diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index 0d63db2d3f1e..59fd50c7dd8c 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -7,5 +7,5 @@ /static/src/javascripts/projects/commercial/ @guardian/commercial-dev /commercial/ @guardian/commercial-dev -# Targeted Experiences (TX) -/static/src/javascripts/projects/common/modules/commercial/braze @guardian/tx-engineers +# Value (Supporter Revenue) +/static/src/javascripts/projects/common/modules/commercial/braze @guardian/value diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 21635f59ec18..e748cd52c068 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -1,10 +1,11 @@ -name: "Build" +name: 'Build' on: pull_request: push: # Do not rely on `push` for PR CI - see https://github.com/guardian/mobile-apps-api/pull/2760 branches: - main # Optimal for GHA workflow caching - see https://docs.github.com/en/actions/using-workflows/caching-dependencies-to-speed-up-workflows#restrictions-for-accessing-a-cache + workflow_dispatch: permissions: id-token: write @@ -13,33 +14,78 @@ permissions: # This allows a subsequently queued workflow run to interrupt previous runs concurrency: - group: "${{ github.workflow }} @ ${{ github.event.pull_request.head.label || github.head_ref || github.ref }}" + group: '${{ github.workflow }} @ ${{ github.event.pull_request.head.label || github.head_ref || github.ref }}' cancel-in-progress: true jobs: - build: + client-validate: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v5 + + - run: corepack enable + + - uses: actions/setup-node@v6 + with: + cache: yarn + node-version-file: .nvmrc + + - run: make install + - run: make validate + - run: make test + + client-build: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v5 - run: corepack enable - - uses: actions/setup-node@v4 + - uses: actions/setup-node@v6 with: cache: yarn node-version-file: .nvmrc - - uses: actions/setup-java@v4 + + - run: make install + - run: make compile + + - name: upload frontend-client + uses: actions/upload-artifact@v4 + with: + name: frontend-client + path: | + static/hash + static/target + common/conf/assets + if-no-files-found: error + + build: + needs: [client-validate, client-build] + runs-on: 8core-ubuntu-latest-frontend + steps: + - uses: actions/checkout@v5 + + - run: corepack enable + + - uses: actions/setup-node@v6 + with: + cache: yarn + node-version-file: .nvmrc + + - uses: actions/setup-java@v5 with: distribution: corretto cache: sbt java-version: 11 - - run: make reinstall - - run: make validate - - run: make test - - run: make compile + # Scala tests rely on client build assets + - name: Download frontend-client + uses: actions/download-artifact@v5 + with: + name: frontend-client + path: . - - name: Test, compile + - name: Test, Compile, Package # Australia/Sydney -because it is too easy for devs to forget about timezones run: | java \ @@ -49,12 +95,12 @@ jobs: -XX:+UseParallelGC \ -DAPP_SECRET="fake_secret" \ -Duser.timezone=Australia/Sydney \ - -jar ./bin/sbt-launch.jar clean compile assets scalafmtCheckAll test Universal/packageBin + -jar ./bin/sbt-launch.jar compile assets scalafmtCheckAll test Universal/packageBin - name: Test Summary uses: test-summary/action@v2 with: - paths: "test-results/**/TEST-*.xml" + paths: 'test-results/**/TEST-*.xml' if: always() - uses: guardian/actions-riff-raff@v4.1.2 diff --git a/.github/workflows/sbt-dependency-graph.yaml b/.github/workflows/sbt-dependency-graph.yaml index 809be3caffb9..7cb4c8997138 100644 --- a/.github/workflows/sbt-dependency-graph.yaml +++ b/.github/workflows/sbt-dependency-graph.yaml @@ -10,16 +10,16 @@ jobs: steps: - name: Checkout branch id: checkout - uses: actions/checkout@eef61447b9ff4aafe5dcd4e0bbf5d482be7e7871 # v4.2.1 + uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0 - name: Install Java id: java - uses: actions/setup-java@b36c23c0d998641eff861008f374ee103c25ac73 # v4.2.0 + uses: actions/setup-java@dded0888837ed1f317902acf8a20df0ad188d165 # v4.2.0 with: distribution: corretto java-version: 17 - name: Install sbt id: sbt - uses: sbt/setup-sbt@8a071aa780c993c7a204c785d04d3e8eb64ef272 # v1.1.0 + uses: sbt/setup-sbt@3e125ece5c3e5248e18da9ed8d2cce3d335ec8dd # v1.1.14 - name: Submit dependencies id: submit uses: scalacenter/sbt-dependency-submission@64084844d2b0a9b6c3765f33acde2fbe3f5ae7d3 # v3.1.0 diff --git a/.github/workflows/stale.yml b/.github/workflows/stale.yml index d1dd4ca980b6..a4288318abdd 100644 --- a/.github/workflows/stale.yml +++ b/.github/workflows/stale.yml @@ -11,7 +11,7 @@ jobs: stale: runs-on: ubuntu-latest steps: - - uses: actions/stale@v9 + - uses: actions/stale@v10 id: stale # Read about options here: https://github.com/actions/stale#all-options with: diff --git a/.github/workflows/typescript.yml b/.github/workflows/typescript.yml index 46456c3a860d..148c12e49727 100644 --- a/.github/workflows/typescript.yml +++ b/.github/workflows/typescript.yml @@ -13,10 +13,10 @@ jobs: name: Typescript runs-on: ubuntu-latest steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v5 - run: corepack enable - - uses: actions/setup-node@v4 + - uses: actions/setup-node@v6 with: node-version-file: '.nvmrc' cache: yarn diff --git a/.github/workflows/validate.yml b/.github/workflows/validate.yml index ba17ef518b7b..9e4fb7300a22 100644 --- a/.github/workflows/validate.yml +++ b/.github/workflows/validate.yml @@ -12,10 +12,10 @@ jobs: validate: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v5 - run: corepack enable - - uses: actions/setup-node@v4 + - uses: actions/setup-node@v6 with: node-version-file: '.nvmrc' cache: yarn diff --git a/.nvmrc b/.nvmrc index b8e593f5210c..7d41c735d712 100644 --- a/.nvmrc +++ b/.nvmrc @@ -1 +1 @@ -20.15.1 +22.14.0 diff --git a/README.md b/README.md index 94d6f0f8e5a9..baa69d69aa69 100644 --- a/README.md +++ b/README.md @@ -1,7 +1,3 @@ -## We're hiring! -Ever thought about joining us? -[https://workforus.theguardian.com/careers/product-engineering/](https://workforus.theguardian.com/careers/product-engineering/) - # Frontend The Guardian website frontend. diff --git a/admin/app/AppLoader.scala b/admin/app/AppLoader.scala index 4b1c80964341..34c476b067c6 100644 --- a/admin/app/AppLoader.scala +++ b/admin/app/AppLoader.scala @@ -1,11 +1,8 @@ import app.{FrontendApplicationLoader, FrontendComponents, LifecycleComponent} import com.softwaremill.macwire._ -import dfp._ -import common.dfp._ import common._ import conf.switches.SwitchboardLifecycle import controllers.{AdminControllers, HealthCheck} -import _root_.dfp.DfpDataCacheLifecycle import org.apache.pekko.actor.{ActorSystem => PekkoActorSystem} import concurrent.BlockingOperations import contentapi.{CapiHttpClient, ContentApiClient, HttpClient} @@ -40,34 +37,12 @@ trait AdminServices extends I18nComponents { lazy val contentApiClient = wire[ContentApiClient] lazy val ophanApi = wire[OphanApi] lazy val emailService = wire[EmailService] - lazy val fastlyStatisticService = wire[FastlyStatisticService] - lazy val fastlyCloudwatchLoadJob = wire[FastlyCloudwatchLoadJob] lazy val redirects = wire[RedirectService] lazy val r2PagePressJob = wire[R2PagePressJob] lazy val analyticsSanityCheckJob = wire[AnalyticsSanityCheckJob] lazy val rebuildIndexJob = wire[RebuildIndexJob] - lazy val dfpApi: DfpApi = wire[DfpApi] lazy val blockingOperations: BlockingOperations = wire[BlockingOperations] - lazy val adUnitAgent: AdUnitAgent = wire[AdUnitAgent] - lazy val adUnitService: AdUnitService = wire[AdUnitService] - lazy val advertiserAgent: AdvertiserAgent = wire[AdvertiserAgent] - lazy val creativeTemplateAgent: CreativeTemplateAgent = wire[CreativeTemplateAgent] - lazy val customFieldAgent: CustomFieldAgent = wire[CustomFieldAgent] - lazy val customFieldService: CustomFieldService = wire[CustomFieldService] - lazy val customTargetingAgent: CustomTargetingAgent = wire[CustomTargetingAgent] - lazy val customTargetingService: CustomTargetingService = wire[CustomTargetingService] - lazy val customTargetingKeyValueJob: CustomTargetingKeyValueJob = wire[CustomTargetingKeyValueJob] - lazy val dataMapper: DataMapper = wire[DataMapper] - lazy val dataValidation: DataValidation = wire[DataValidation] - lazy val dfpDataCacheJob: DfpDataCacheJob = wire[DfpDataCacheJob] - lazy val orderAgent: OrderAgent = wire[OrderAgent] - lazy val placementAgent: PlacementAgent = wire[PlacementAgent] - lazy val placementService: PlacementService = wire[PlacementService] - lazy val dfpFacebookIaAdUnitCacheJob: DfpFacebookIaAdUnitCacheJob = wire[DfpFacebookIaAdUnitCacheJob] - lazy val dfpAdUnitCacheJob: DfpAdUnitCacheJob = wire[DfpAdUnitCacheJob] - lazy val dfpMobileAppUnitCacheJob: DfpMobileAppAdUnitCacheJob = wire[DfpMobileAppAdUnitCacheJob] - lazy val dfpTemplateCreativeCacheJob: DfpTemplateCreativeCacheJob = wire[DfpTemplateCreativeCacheJob] lazy val parameterStoreService: ParameterStoreService = wire[ParameterStoreService] lazy val parameterStoreProvider: ParameterStoreProvider = wire[ParameterStoreProvider] } @@ -82,20 +57,12 @@ trait AppComponents extends FrontendComponents with AdminControllers with AdminS wire[SwitchboardLifecycle], wire[CloudWatchMetricsLifecycle], wire[SurgingContentAgentLifecycle], - wire[DfpAgentLifecycle], - wire[DfpDataCacheLifecycle], - wire[CommercialDfpReportingLifecycle], ) lazy val router: Router = wire[Routes] lazy val appIdentity = ApplicationIdentity("admin") - override lazy val appMetrics = ApplicationMetrics( - DfpApiMetrics.DfpSessionErrors, - DfpApiMetrics.DfpApiErrors, - ) - def pekkoActorSystem: PekkoActorSystem override lazy val httpFilters: Seq[EssentialFilter] = diff --git a/admin/app/controllers/AdminControllers.scala b/admin/app/controllers/AdminControllers.scala index 5064cab9154f..ac18d06e9b3f 100644 --- a/admin/app/controllers/AdminControllers.scala +++ b/admin/app/controllers/AdminControllers.scala @@ -1,6 +1,6 @@ package controllers import com.amazonaws.regions.Regions -import com.amazonaws.services.s3.AmazonS3ClientBuilder +import software.amazon.awssdk.services.s3.S3Client import com.softwaremill.macwire._ import common.PekkoAsync import controllers.admin._ @@ -15,6 +15,7 @@ import play.api.mvc.ControllerComponents import services.{OphanApi, ParameterStoreService, RedirectService} import conf.Configuration.aws.mandatoryCredentials import org.apache.pekko.stream.Materializer +import utils.AWSv2 trait AdminControllers { def pekkoAsync: PekkoAsync @@ -26,38 +27,14 @@ trait AdminControllers { def httpConfiguration: HttpConfiguration def controllerComponents: ControllerComponents def assets: Assets - def adUnitAgent: AdUnitAgent - def adUnitService: AdUnitService - def advertiserAgent: AdvertiserAgent - def creativeTemplateAgent: CreativeTemplateAgent - def customFieldAgent: CustomFieldAgent - def customFieldService: CustomFieldService - def customTargetingAgent: CustomTargetingAgent - def customTargetingService: CustomTargetingService - def customTargetingKeyValueJob: CustomTargetingKeyValueJob - def dataMapper: DataMapper - def dataValidation: DataValidation - def dfpDataCacheJob: DfpDataCacheJob - def orderAgent: OrderAgent - def placementAgent: PlacementAgent - def placementService: PlacementService - def dfpApi: DfpApi def parameterStoreService: ParameterStoreService - private lazy val s3Client = AmazonS3ClientBuilder - .standard() - .withRegion(Regions.EU_WEST_1) - .withCredentials( - mandatoryCredentials, - ) - .build() - lazy val auth = new GuardianAuthWithExemptions( controllerComponents, wsClient, toolsDomainPrefix = "frontend", oauthCallbackPath = routes.GuardianAuthWithExemptions.oauthCallback.path, - s3Client, + AWSv2.S3Sync, system = "frontend-admin", extraDoNotAuthenticatePathPrefixes = Seq( // Date: 06 July 2021 @@ -83,12 +60,8 @@ trait AdminControllers { lazy val appConfigController = wire[AppConfigController] lazy val switchboardController = wire[SwitchboardController] lazy val analyticsController = wire[AnalyticsController] - lazy val analyticsConfidenceController = wire[AnalyticsConfidenceController] lazy val metricsController = wire[MetricsController] lazy val commercialController = wire[CommercialController] - lazy val dfpDataController = wire[DfpDataController] - lazy val takeoverWithEmptyMPUsController = wire[TakeoverWithEmptyMPUsController] - lazy val fastlyController = wire[FastlyController] lazy val redirectController = wire[RedirectController] lazy val sportTroubleShooterController = wire[SportTroubleshooterController] lazy val troubleshooterController = wire[TroubleshooterController] diff --git a/admin/app/controllers/admin/AnalyticsConfidenceController.scala b/admin/app/controllers/admin/AnalyticsConfidenceController.scala deleted file mode 100644 index f3f8208fdc15..000000000000 --- a/admin/app/controllers/admin/AnalyticsConfidenceController.scala +++ /dev/null @@ -1,45 +0,0 @@ -package controllers.admin - -import common.{ImplicitControllerExecutionContext, GuLogging} -import model.ApplicationContext -import play.api.mvc.{Action, AnyContent, BaseController, ControllerComponents} -import tools._ - -class AnalyticsConfidenceController(val controllerComponents: ControllerComponents)(implicit - context: ApplicationContext, -) extends BaseController - with GuLogging - with ImplicitControllerExecutionContext { - def renderConfidence(): Action[AnyContent] = - Action.async { implicit request => - for { - ophan <- CloudWatch.ophanConfidence() - google <- CloudWatch.googleConfidence() - } yield { - val ophanAverage = ophan.dataset.flatMap(_.values.headOption).sum / ophan.dataset.length - val googleAverage = google.dataset.flatMap(_.values.headOption).sum / google.dataset.length - - val ophanGraph = new AwsLineChart( - "Ophan confidence", - Seq("Time", "%", "avg."), - ChartFormat(Colour.`tone-comment-1`, Colour.success), - ) { - override lazy val dataset = ophan.dataset.map { point => - point.copy(values = point.values :+ ophanAverage) - } - } - - val googleGraph = new AwsLineChart( - "Google confidence", - Seq("Time", "%", "avg."), - ChartFormat(Colour.`tone-comment-1`, Colour.success), - ) { - override lazy val dataset = google.dataset.map { point => - point.copy(values = point.values :+ googleAverage) - } - } - - Ok(views.html.lineCharts(Seq(ophanGraph, googleGraph))) - } - } -} diff --git a/admin/app/controllers/admin/AnalyticsController.scala b/admin/app/controllers/admin/AnalyticsController.scala index 4225a22f1b8c..1562960e4ba4 100644 --- a/admin/app/controllers/admin/AnalyticsController.scala +++ b/admin/app/controllers/admin/AnalyticsController.scala @@ -3,6 +3,7 @@ package controllers.admin import common.{GuLogging, ImplicitControllerExecutionContext} import model.{ApplicationContext, NoCache} import play.api.mvc.{Action, AnyContent, BaseController, ControllerComponents} +import tools._ import scala.concurrent.Future @@ -10,8 +11,15 @@ class AnalyticsController(val controllerComponents: ControllerComponents)(implic extends BaseController with GuLogging with ImplicitControllerExecutionContext { - def abtests(): Action[AnyContent] = + + def abTests(): Action[AnyContent] = + Action.async { implicit request => + val frameUrl = Store.getAbTestFrameUrl + Future(NoCache(Ok(views.html.abTests(frameUrl)))) + } + + def legacyAbTests(): Action[AnyContent] = Action.async { implicit request => - Future(NoCache(Ok(views.html.abtests()))) + Future(NoCache(Ok(views.html.legacyAbTests()))) } } diff --git a/admin/app/controllers/admin/CommercialController.scala b/admin/app/controllers/admin/CommercialController.scala index 1aa9f724a73b..f3b899f09c12 100644 --- a/admin/app/controllers/admin/CommercialController.scala +++ b/admin/app/controllers/admin/CommercialController.scala @@ -1,37 +1,21 @@ package controllers.admin -import common.dfp.{GuCreativeTemplate, GuCustomField, GuLineItem} +import common.dfp.{GuCustomField, GuLineItem} import common.{ImplicitControllerExecutionContext, JsonComponent, GuLogging} import conf.Configuration -import dfp.{AdvertiserAgent, CreativeTemplateAgent, CustomFieldAgent, DfpApi, DfpDataExtractor, OrderAgent} +import dfp.{DfpDataExtractor} import model._ import services.ophan.SurgingContentAgent import play.api.libs.json.{JsString, Json} import play.api.mvc._ import tools._ +import conf.switches.Switches.{LineItemJobs} import scala.concurrent.duration._ import scala.util.Try -case class CommercialPage() extends StandalonePage { - override val metadata = MetaData.make( - id = "commercial-templates", - section = Some(SectionId.fromId("admin")), - webTitle = "Commercial Templates", - javascriptConfigOverrides = Map( - "keywordIds" -> JsString("live-better"), - "adUnit" -> JsString("/59666047/theguardian.com/global-development/ng"), - ), - ) -} - class CommercialController( val controllerComponents: ControllerComponents, - createTemplateAgent: CreativeTemplateAgent, - advertiserAgent: AdvertiserAgent, - orderAgent: OrderAgent, - customFieldAgent: CustomFieldAgent, - dfpApi: DfpApi, )(implicit context: ApplicationContext) extends BaseController with GuLogging @@ -42,15 +26,10 @@ class CommercialController( NoCache(Ok(views.html.commercial.commercialMenu())) } - def renderFluidAds: Action[AnyContent] = - Action { implicit request => - NoCache(Ok(views.html.commercial.fluidAds())) - } - def renderSpecialAdUnits: Action[AnyContent] = Action { implicit request => - val specialAdUnits = dfpApi.readSpecialAdUnits(Configuration.commercial.dfpAdUnitGuRoot) - Ok(views.html.commercial.specialAdUnits(specialAdUnits)) + val specialAdUnits = Store.getDfpSpecialAdUnits + NoCache(Ok(views.html.commercial.specialAdUnits(specialAdUnits))) } def renderPageskins: Action[AnyContent] = @@ -78,23 +57,10 @@ class CommercialController( NoCache(Ok(views.html.commercial.surveySponsorships(surveyAdUnits))) } - def renderCreativeTemplates: Action[AnyContent] = - Action { implicit request => - val emptyTemplates = createTemplateAgent.get - val creatives = Store.getDfpTemplateCreatives - val templates = emptyTemplates - .foldLeft(Seq.empty[GuCreativeTemplate]) { (soFar, template) => - soFar :+ template.copy(creatives = creatives.filter(_.templateId.get == template.id).sortBy(_.name)) - } - .sortBy(_.name) - NoCache(Ok(views.html.commercial.templates(templates))) - } - def renderCustomFields: Action[AnyContent] = Action { implicit request => - val fields: Seq[GuCustomField] = customFieldAgent.get.data.values.toSeq + val fields: Seq[GuCustomField] = Store.getDfpCustomFields NoCache(Ok(views.html.commercial.customFields(fields))) - } def renderAdTests: Action[AnyContent] = @@ -113,7 +79,7 @@ class CommercialController( } val sortedGroups = { - hasNumericTestValue.toSeq.sortBy { case (testValue, _) => testValue.toInt } ++ + hasNumericTestValue.toSeq.sortBy { case (testValue, _) => testValue.toLong } ++ hasStringTestValue.toSeq.sortBy { case (testValue, _) => testValue } } @@ -129,24 +95,6 @@ class CommercialController( } } - def getCreativesListing(lineitemId: String, section: String): Action[AnyContent] = - Action { implicit request: RequestHeader => - val validSections: List[String] = List("uk", "lifeandstyle", "sport", "science") - - val previewUrls: Seq[String] = - (for { - lineItemId <- Try(lineitemId.toLong).toOption - validSection <- validSections.find(_ == section) - } yield { - dfpApi.getCreativeIds(lineItemId) flatMap (dfpApi - .getPreviewUrl(lineItemId, _, s"https://theguardian.com/$validSection")) - }) getOrElse Nil - - Cached(5.minutes) { - JsonComponent.fromWritable(previewUrls) - } - } - def renderKeyValues(): Action[AnyContent] = Action { implicit request => Ok(views.html.commercial.customTargetingKeyValues(Store.getDfpCustomTargetingKeyValues)) @@ -172,29 +120,17 @@ class CommercialController( val invalidLineItems: Seq[GuLineItem] = Store.getDfpLineItemsReport().invalidLineItems val invalidItemsExtractor = DfpDataExtractor(invalidLineItems, Nil) - val advertisers = advertiserAgent.get - val orders = orderAgent.get - val sonobiAdvertiserId = advertisers.find(_.name.toLowerCase == "sonobi").map(_.id).getOrElse(0L) - val sonobiOrderIds = orders.filter(_.advertiserId == sonobiAdvertiserId).map(_.id) - // Sort line items into groups where possible, and bucket everything else. val pageskins = invalidItemsExtractor.pageSkinSponsorships - val groupedItems = invalidLineItems.groupBy { - case item if sonobiOrderIds.contains(item.orderId) => "sonobi" - case _ => "unknown" - } - - val sonobiItems = groupedItems.getOrElse("sonobi", Seq.empty) val invalidItemsMap = GuLineItem.asMap(invalidLineItems) val unidentifiedLineItems = - invalidItemsMap.keySet -- pageskins.map(_.lineItemId) -- sonobiItems.map(_.id) + invalidItemsMap.keySet -- pageskins.map(_.lineItemId) Ok( views.html.commercial.invalidLineItems( pageskins, - sonobiItems, unidentifiedLineItems.toSeq.map(invalidItemsMap), ), ) diff --git a/admin/app/controllers/admin/TroubleshooterController.scala b/admin/app/controllers/admin/TroubleshooterController.scala index 8a8c6df365b2..0f79bb0e1786 100644 --- a/admin/app/controllers/admin/TroubleshooterController.scala +++ b/admin/app/controllers/admin/TroubleshooterController.scala @@ -1,7 +1,5 @@ package controllers.admin -import com.amazonaws.services.ec2.model.{DescribeInstancesRequest, Filter} -import com.amazonaws.services.ec2.{AmazonEC2, AmazonEC2ClientBuilder} import common.{GuLogging, ImplicitControllerExecutionContext} import conf.Configuration.aws.credentials import contentapi.{CapiHttpClient, ContentApiClient, PreviewContentApi, PreviewSigner} @@ -36,14 +34,6 @@ class TroubleshooterController(wsClient: WSClient, val controllerComponents: Con val contentApi = new ContentApiClient(capiLiveHttpClient) val previewContentApi = new PreviewContentApi(capiPreviewHttpClient) - private lazy val awsEc2Client: Option[AmazonEC2] = credentials.map { credentials => - AmazonEC2ClientBuilder - .standard() - .withCredentials(credentials) - .withRegion(conf.Configuration.aws.region) - .build() - } - def index(): Action[AnyContent] = Action { implicit request => NoCache(Ok(views.html.troubleshooter(LoadBalancer.all.filter(_.testPath.isDefined)))) @@ -63,7 +53,6 @@ class TroubleshooterController(wsClient: WSClient, val controllerComponents: Con .getOrElse(Future.successful(TestFailed("Can find the appropriate loadbalancer"))) val viaWebsite = testOnGuardianSite(pathToTest, id) val directToContentApi = testOnContentApi(pathToTest, id) - val directToRouter = testOnRouter(pathToTest, id) val directToPreviewContentApi = testOnPreviewContentApi(pathToTest, id) val viaPreviewWebsite = testOnPreviewSite(pathToTest, id) @@ -74,7 +63,6 @@ class TroubleshooterController(wsClient: WSClient, val controllerComponents: Con Seq( directToContentApi, directToLoadBalancer, - directToRouter, viaWebsite, directToPreviewContentApi, viaPreviewWebsite, @@ -85,55 +73,6 @@ class TroubleshooterController(wsClient: WSClient, val controllerComponents: Con } } - private def testOnRouter(testPath: String, id: String): Future[EndpointStatus] = { - - def fetchWithRouterUrl(url: String) = { - val result = httpGet("Can fetch directly from Router load balancer", s"http://$url$testpath/") - result.map { result => - if (result.isOk) - result - else - TestFailed( - result.name, - result.messages :+ - "NOTE: if hitting the Router you MUST set Host header to 'www.theguardian.com' or else you will get '403 Forbidden'": _*, - ) - } - } - - val routerUrl = if (appContext.environment.mode == Mode.Prod) { - // Workaround in PROD: - // Getting the private dns of one of the router instances because - // the Router ELB can only be accessed via its public IP/DNS from Fastly or Guardian VPN/office, not from an Admin instance - // However Admin instances can access router instances via private IPs - // This is of course not very fast since it has to make a call to AWS API before to fetch the url - // but the troubleshooter is an admin only tool - val tagsAsFilters = Map( - "Stack" -> "frontend", - "App" -> "router", - "Stage" -> "PROD", - ).map { case (name, value) => - new Filter("tag:" + name).withValues(value) - }.asJavaCollection - val instancesDnsName: Seq[String] = awsEc2Client - .map( - _.describeInstances(new DescribeInstancesRequest().withFilters(tagsAsFilters)).getReservations.asScala - .flatMap(_.getInstances.asScala) - .map(_.getPrivateDnsName), - ) - .toSeq - .flatten - Random.shuffle(instancesDnsName).headOption - } else { - LoadBalancer("frontend-router").flatMap(_.url) - } - - routerUrl - .map(fetchWithRouterUrl) - .getOrElse(Future.successful(TestFailed("Can get Frontend router url"))) - - } - private def testOnLoadBalancer( thisLoadBalancer: LoadBalancer, testPath: String, diff --git a/admin/app/controllers/admin/commercial/DashboardRenderer.scala b/admin/app/controllers/admin/commercial/DashboardRenderer.scala deleted file mode 100644 index 10cd82ae1546..000000000000 --- a/admin/app/controllers/admin/commercial/DashboardRenderer.scala +++ /dev/null @@ -1,55 +0,0 @@ -package controllers.admin.commercial - -import java.util.Locale - -import jobs.CommercialDfpReporting -import jobs.CommercialDfpReporting.DfpReportRow -import model.{ApplicationContext, NoCache} -import play.api.mvc._ - -object DashboardRenderer extends Results { - - def renderDashboard(testName: String, dashboardTitle: String, controlColour: String, variantColour: String)(implicit - request: RequestHeader, - context: ApplicationContext, - ): Result = { - val maybeData = for { - reportId <- CommercialDfpReporting.reportMappings.get(CommercialDfpReporting.teamKPIReport) - report: Seq[DfpReportRow] <- CommercialDfpReporting.getReport(reportId) - } yield { - val keyValueRows: Seq[KeyValueRevenueRow] = report.flatMap { row => - val fields = row.fields - for { - customCriteria: String <- fields.lift(0) - customTargetingId: String <- fields.lift(1) - totalImpressions: Int <- fields.lift(2).map(_.toInt) - totalAverageECPM: Double <- fields.lift(3).map(_.toDouble / 1000000.0d) // convert DFP micropounds to pounds - } yield KeyValueRevenueRow(customCriteria, customTargetingId, totalImpressions, totalAverageECPM) - } - - keyValueRows - } - - val abTestRows = maybeData.getOrElse(Seq.empty) - - val controlDataRow = abTestRows.find(_.customCriteria.startsWith(s"ab=${testName}Control")) - val variantDataRow = abTestRows.find(_.customCriteria.startsWith(s"ab=${testName}Variant")) - - val integerFormatter = java.text.NumberFormat.getIntegerInstance - val currencyFormatter = java.text.NumberFormat.getCurrencyInstance(Locale.UK) - - NoCache( - Ok( - views.html.commercial.revenueDashboard( - controlDataRow, - variantDataRow, - integerFormatter, - currencyFormatter, - dashboardTitle, - controlColour, - variantColour, - ), - ), - ) - } -} diff --git a/admin/app/controllers/admin/commercial/DfpDataController.scala b/admin/app/controllers/admin/commercial/DfpDataController.scala deleted file mode 100644 index fd209d834046..000000000000 --- a/admin/app/controllers/admin/commercial/DfpDataController.scala +++ /dev/null @@ -1,25 +0,0 @@ -package controllers.admin.commercial - -import common.ImplicitControllerExecutionContext -import dfp.DfpDataCacheJob -import model.{ApplicationContext, NoCache} -import play.api.mvc._ - -class DfpDataController(val controllerComponents: ControllerComponents, dfpDataCacheJob: DfpDataCacheJob)(implicit - context: ApplicationContext, -) extends BaseController - with ImplicitControllerExecutionContext { - - def renderCacheFlushPage(): Action[AnyContent] = - Action { implicit request => - NoCache(Ok(views.html.commercial.dfpFlush())) - } - - def flushCache(): Action[AnyContent] = - Action { implicit request => - dfpDataCacheJob.refreshAllDfpData() - NoCache(Redirect(routes.DfpDataController.renderCacheFlushPage())) - .flashing("triggered" -> "true") - } - -} diff --git a/admin/app/controllers/admin/commercial/TakeoverWithEmptyMPUsController.scala b/admin/app/controllers/admin/commercial/TakeoverWithEmptyMPUsController.scala deleted file mode 100644 index 9aeee38eb418..000000000000 --- a/admin/app/controllers/admin/commercial/TakeoverWithEmptyMPUsController.scala +++ /dev/null @@ -1,43 +0,0 @@ -package controllers.admin.commercial - -import common.dfp.TakeoverWithEmptyMPUs -import model.{ApplicationContext, NoCache} -import play.api.i18n.I18nSupport -import play.api.mvc.{Action, AnyContent, BaseController, ControllerComponents} - -class TakeoverWithEmptyMPUsController(val controllerComponents: ControllerComponents)(implicit - context: ApplicationContext, -) extends BaseController - with I18nSupport { - - def viewList(): Action[AnyContent] = - Action { implicit request => - NoCache(Ok(views.html.commercial.takeoverWithEmptyMPUs(TakeoverWithEmptyMPUs.fetchSorted()))) - } - - def viewForm(): Action[AnyContent] = - Action { implicit request => - NoCache(Ok(views.html.commercial.takeoverWithEmptyMPUsCreate(TakeoverWithEmptyMPUs.form))) - } - - def create(): Action[AnyContent] = - Action { implicit request => - TakeoverWithEmptyMPUs.form - .bindFromRequest() - .fold( - formWithErrors => { - NoCache(BadRequest(views.html.commercial.takeoverWithEmptyMPUsCreate(formWithErrors))) - }, - takeover => { - TakeoverWithEmptyMPUs.create(takeover) - NoCache(Redirect(routes.TakeoverWithEmptyMPUsController.viewList())) - }, - ) - } - - def remove(url: String): Action[AnyContent] = - Action { implicit request => - TakeoverWithEmptyMPUs.remove(url) - NoCache(Redirect(routes.TakeoverWithEmptyMPUsController.viewList())) - } -} diff --git a/admin/app/controllers/metrics/FastlyController.scala b/admin/app/controllers/metrics/FastlyController.scala deleted file mode 100644 index 7e61997f1511..000000000000 --- a/admin/app/controllers/metrics/FastlyController.scala +++ /dev/null @@ -1,19 +0,0 @@ -package controllers.admin - -import common.{ImplicitControllerExecutionContext, GuLogging} -import model.ApplicationContext -import play.api.mvc.{Action, AnyContent, BaseController, ControllerComponents} -import tools.CloudWatch - -class FastlyController(val controllerComponents: ControllerComponents)(implicit context: ApplicationContext) - extends BaseController - with GuLogging - with ImplicitControllerExecutionContext { - def renderFastly(): Action[AnyContent] = - Action.async { implicit request => - for { - errors <- CloudWatch.fastlyErrors() - statistics <- CloudWatch.fastlyHitMissStatistics() - } yield Ok(views.html.lineCharts(errors ++ statistics)) - } -} diff --git a/admin/app/controllers/metrics/MetricsController.scala b/admin/app/controllers/metrics/MetricsController.scala index ad21b7c69c0a..d856ab5fdbd4 100644 --- a/admin/app/controllers/metrics/MetricsController.scala +++ b/admin/app/controllers/metrics/MetricsController.scala @@ -18,18 +18,11 @@ class MetricsController( lazy val stage = Configuration.environment.stage.toUpperCase - def renderLoadBalancers(): Action[AnyContent] = - Action.async { implicit request => - for { - graphs <- CloudWatch.dualOkLatencyFullStack() - } yield NoCache(Ok(views.html.lineCharts(graphs))) - } - def renderErrors(): Action[AnyContent] = Action.async { implicit request => for { - errors4xx <- HttpErrors.global4XX() - errors5xx <- HttpErrors.global5XX() + errors4xx <- HttpErrors.legacyElb4XXs() + errors5xx <- HttpErrors.legacyElb5XXs() } yield NoCache(Ok(views.html.lineCharts(Seq(errors4xx, errors5xx)))) } @@ -46,35 +39,4 @@ class MetricsController( httpErrors <- HttpErrors.errors() } yield NoCache(Ok(views.html.lineCharts(httpErrors))) } - - def renderGooglebot404s(): Action[AnyContent] = - Action.async { implicit request => - for { - googleBot404s <- HttpErrors.googlebot404s() - } yield NoCache(Ok(views.html.lineCharts(googleBot404s, Some("GoogleBot 404s")))) - } - - def renderAfg(): Action[AnyContent] = - Action.async { implicit request => - wsClient.url("https://s3-eu-west-1.amazonaws.com/aws-frontend-metrics/frequency/index.html").get() map { - response => - NoCache(Ok(views.html.afg(response.body))) - } - } - - def renderBundleVisualization(): Action[AnyContent] = - Action { implicit request => - NoCache(SeeOther(Static("javascripts/webpack-stats.html"))) - } - - def renderBundleAnalyzer(): Action[AnyContent] = - Action { implicit request => - NoCache(SeeOther(Static("javascripts/bundle-analyzer-report.html"))) - } - - private def toPercentage(graph: AwsLineChart) = - graph.dataset - .map(_.values) - .collect { case Seq(saw, clicked) => if (saw == 0) 0.0 else clicked / saw * 100 } - } diff --git a/admin/app/dfp/AdUnitAgent.scala b/admin/app/dfp/AdUnitAgent.scala deleted file mode 100644 index 956bd070bbfd..000000000000 --- a/admin/app/dfp/AdUnitAgent.scala +++ /dev/null @@ -1,79 +0,0 @@ -package dfp - -import com.google.api.ads.admanager.axis.utils.v202405.StatementBuilder -import common.dfp.GuAdUnit -import conf.Configuration -import ApiHelper.toSeq -import concurrent.BlockingOperations - -import scala.util.Try - -class AdUnitAgent(val blockingOperations: BlockingOperations) extends DataAgent[String, GuAdUnit] { - - override def loadFreshData(): Try[Map[String, GuAdUnit]] = - Try { - val maybeData = for (session <- SessionWrapper()) yield { - - val statementBuilder = new StatementBuilder() - - val dfpAdUnits = session.adUnits(statementBuilder) - - val networkRootId = session.getRootAdUnitId - lazy val guardianRootName = Configuration.commercial.dfpAdUnitGuRoot - - val runOfNetwork = dfpAdUnits - .find(_.getId == networkRootId) - .map(networkAdUnit => { - val id = networkAdUnit.getId - id -> GuAdUnit(id = id, path = Nil, status = networkAdUnit.getStatus.getValue) - }) - .toSeq - - val rootAndDescendantAdUnits = dfpAdUnits filter { adUnit => - Option(adUnit.getParentPath) exists { path => - val isGuRoot = path.length == 1 && adUnit.getName == guardianRootName - val isDescendantOfRoot = path.length > 1 && path(1).getName == guardianRootName - isGuRoot || isDescendantOfRoot - } - } - - val adUnits = rootAndDescendantAdUnits.map { adUnit => - val id = adUnit.getId - val path = toSeq(adUnit.getParentPath).tail.map(_.getName) :+ adUnit.getName - id -> GuAdUnit(id, path, adUnit.getStatus.getValue) - } - - (adUnits ++ runOfNetwork).toMap - } - - maybeData getOrElse Map.empty - } - -} - -class AdUnitService(adUnitAgent: AdUnitAgent) { - - // Retrieves the ad unit object if the id matches and the ad unit is active. - def activeAdUnit(adUnitId: String): Option[GuAdUnit] = { - adUnitAgent.get.data.get(adUnitId).collect { - case adUnit if adUnit.isActive => adUnit - } - } - - def archivedAdUnit(adUnitId: String): Option[GuAdUnit] = { - adUnitAgent.get.data.get(adUnitId).collect { - case adUnit if adUnit.isArchived => adUnit - } - } - - def isArchivedAdUnit(adUnitId: String): Boolean = archivedAdUnit(adUnitId).isDefined - - def inactiveAdUnit(adUnitId: String): Option[GuAdUnit] = { - adUnitAgent.get.data.get(adUnitId).collect { - case adUnit if adUnit.isInactive => adUnit - } - } - - def isInactiveAdUnit(adUnitId: String): Boolean = inactiveAdUnit(adUnitId).isDefined - -} diff --git a/admin/app/dfp/AdvertiserAgent.scala b/admin/app/dfp/AdvertiserAgent.scala deleted file mode 100644 index cf170a11b9e4..000000000000 --- a/admin/app/dfp/AdvertiserAgent.scala +++ /dev/null @@ -1,20 +0,0 @@ -package dfp - -import common.Box -import common.dfp.GuAdvertiser -import concurrent.BlockingOperations - -import scala.concurrent.{ExecutionContext, Future} - -class AdvertiserAgent(blockingOperations: BlockingOperations, dfpApi: DfpApi) { - - private lazy val cache = Box(Seq.empty[GuAdvertiser]) - - def refresh()(implicit executionContext: ExecutionContext): Future[Seq[GuAdvertiser]] = { - blockingOperations.executeBlocking(dfpApi.getAllAdvertisers).flatMap { freshData => - cache.alter(if (freshData.nonEmpty) freshData else _) - } - } - - def get: Seq[GuAdvertiser] = cache.get() -} diff --git a/admin/app/dfp/ApiHelper.scala b/admin/app/dfp/ApiHelper.scala deleted file mode 100644 index 9458356e8211..000000000000 --- a/admin/app/dfp/ApiHelper.scala +++ /dev/null @@ -1,41 +0,0 @@ -package dfp - -import com.google.api.ads.admanager.axis.v202405._ -import common.GuLogging -import org.joda.time.{DateTime => JodaDateTime, DateTimeZone} - -private[dfp] object ApiHelper extends GuLogging { - - def isPageSkin(dfpLineItem: LineItem): Boolean = { - - def hasA1x1Pixel(placeholders: Array[CreativePlaceholder]): Boolean = { - placeholders.exists { - _.getCompanions.exists { companion => - val size = companion.getSize - size.getWidth == 1 && size.getHeight == 1 - } - } - } - - (dfpLineItem.getRoadblockingType == RoadblockingType.CREATIVE_SET) && - hasA1x1Pixel(dfpLineItem.getCreativePlaceholders) - } - - def toJodaTime(time: DateTime): JodaDateTime = { - val date = time.getDate - new JodaDateTime( - date.getYear, - date.getMonth, - date.getDay, - time.getHour, - time.getMinute, - time.getSecond, - DateTimeZone.forID(time.getTimeZoneId), - ) - } - - def toSeq[A](as: Array[A]): Seq[A] = Option(as) map (_.toSeq) getOrElse Nil - - // noinspection IfElseToOption - def optJavaInt(i: java.lang.Integer): Option[Int] = if (i == null) None else Some(i) -} diff --git a/admin/app/dfp/CreativeTemplateAgent.scala b/admin/app/dfp/CreativeTemplateAgent.scala deleted file mode 100644 index 75ea488aaeea..000000000000 --- a/admin/app/dfp/CreativeTemplateAgent.scala +++ /dev/null @@ -1,20 +0,0 @@ -package dfp - -import common.Box -import common.dfp.GuCreativeTemplate -import concurrent.BlockingOperations - -import scala.concurrent.{ExecutionContext, Future} - -class CreativeTemplateAgent(blockingOperations: BlockingOperations, dfpApi: DfpApi) { - - private lazy val cache = Box(Seq.empty[GuCreativeTemplate]) - - def refresh()(implicit executionContext: ExecutionContext): Future[Seq[GuCreativeTemplate]] = { - blockingOperations.executeBlocking(dfpApi.readActiveCreativeTemplates()).flatMap { freshData => - cache.alter(if (freshData.nonEmpty) freshData else _) - } - } - - def get: Seq[GuCreativeTemplate] = cache.get() -} diff --git a/admin/app/dfp/CustomFieldAgent.scala b/admin/app/dfp/CustomFieldAgent.scala deleted file mode 100644 index de35cbe0e6c9..000000000000 --- a/admin/app/dfp/CustomFieldAgent.scala +++ /dev/null @@ -1,34 +0,0 @@ -package dfp - -import com.google.api.ads.admanager.axis.utils.v202405.StatementBuilder -import com.google.api.ads.admanager.axis.v202405.{CustomFieldValue, LineItem, TextValue} -import common.dfp.GuCustomField -import concurrent.BlockingOperations - -import scala.util.Try - -class CustomFieldAgent(val blockingOperations: BlockingOperations) extends DataAgent[String, GuCustomField] { - - override def loadFreshData(): Try[Map[String, GuCustomField]] = - Try { - getAllCustomFields.map(f => f.name -> f).toMap - } - - private def getAllCustomFields: Seq[GuCustomField] = { - val stmtBuilder = new StatementBuilder() - DfpApi.withDfpSession(_.customFields(stmtBuilder).map(DataMapper.toGuCustomField)) - } -} - -class CustomFieldService(customFieldAgent: CustomFieldAgent) { - - def sponsor(lineItem: LineItem): Option[String] = - for { - sponsorField <- customFieldAgent.get.data.get("Sponsor") - customFieldValues <- Option(lineItem.getCustomFieldValues) - sponsor <- customFieldValues.collect { - case fieldValue: CustomFieldValue if fieldValue.getCustomFieldId == sponsorField.id => - fieldValue.getValue.asInstanceOf[TextValue].getValue - }.headOption - } yield sponsor -} diff --git a/admin/app/dfp/CustomTargetingAgent.scala b/admin/app/dfp/CustomTargetingAgent.scala deleted file mode 100644 index 601077bb280e..000000000000 --- a/admin/app/dfp/CustomTargetingAgent.scala +++ /dev/null @@ -1,76 +0,0 @@ -package dfp - -import com.google.api.ads.admanager.axis.utils.v202405.StatementBuilder -import com.google.api.ads.admanager.axis.v202405.{CustomTargetingKey, CustomTargetingValue} -import common.GuLogging -import common.dfp.{GuCustomTargeting, GuCustomTargetingValue} -import concurrent.BlockingOperations - -import scala.util.Try - -class CustomTargetingAgent(val blockingOperations: BlockingOperations) - extends DataAgent[Long, GuCustomTargeting] - with GuLogging { - - def loadFreshData(): Try[Map[Long, GuCustomTargeting]] = - Try { - val maybeData = for (session <- SessionWrapper()) yield { - - val keys: Map[Long, CustomTargetingKey] = - session.customTargetingKeys(new StatementBuilder()).map { key => key.getId.longValue -> key }.toMap - - val statementWithIds = new StatementBuilder() - .where(s"customTargetingKeyId IN (${keys.keys.mkString(",")})") - - val valuesByKey: Map[Long, Seq[CustomTargetingValue]] = - session.customTargetingValues(statementWithIds).groupBy { _.getCustomTargetingKeyId.longValue } - - valuesByKey flatMap { case (keyId: Long, values: Seq[CustomTargetingValue]) => - keys.get(keyId) map { key => - val guValues: Seq[GuCustomTargetingValue] = values map { value => - GuCustomTargetingValue( - id = value.getId.longValue, - name = value.getName, - displayName = value.getDisplayName, - ) - } - - keyId -> GuCustomTargeting( - keyId = keyId, - name = key.getName, - displayName = key.getDisplayName, - values = guValues, - ) - } - } - } - - maybeData getOrElse Map.empty - } -} - -class CustomTargetingService(customTargetingAgent: CustomTargetingAgent) { - - def targetingKey(session: SessionWrapper)(keyId: Long): String = { - lazy val fallback = { - val stmtBuilder = new StatementBuilder() - .where("id = :keyId") - .withBindVariableValue("keyId", keyId) - session.customTargetingKeys(stmtBuilder).headOption.map(_.getName).getOrElse("unknown") - } - - customTargetingAgent.get.data get keyId map (_.name) getOrElse fallback - } - - def targetingValue(session: SessionWrapper)(keyId: Long, valueId: Long): String = { - lazy val fallback = { - val stmtBuilder = new StatementBuilder() - .where("customTargetingKeyId = :keyId AND id = :valueId") - .withBindVariableValue("keyId", keyId) - .withBindVariableValue("valueId", valueId) - session.customTargetingValues(stmtBuilder).headOption.map(_.getName).getOrElse("unknown") - } - - customTargetingAgent.get.data get keyId flatMap { _.values.find(_.id == valueId) } map (_.name) getOrElse fallback - } -} diff --git a/admin/app/dfp/CustomTargetingKeyValueJob.scala b/admin/app/dfp/CustomTargetingKeyValueJob.scala deleted file mode 100644 index 3f524a0dcd35..000000000000 --- a/admin/app/dfp/CustomTargetingKeyValueJob.scala +++ /dev/null @@ -1,20 +0,0 @@ -package dfp - -import play.api.libs.json._ -import tools.Store - -import scala.concurrent.{ExecutionContext, Future} - -// This object is run by the commercial lifecycle and writes a json S3 file that stores -// key value mappings. In contrast, the CustomTargetingAgent is used to resolve key/value ids to string names. -class CustomTargetingKeyValueJob(customTargetingAgent: CustomTargetingAgent) { - - def run()(implicit executionContext: ExecutionContext): Future[Unit] = - Future { - val customTargeting = customTargetingAgent.get.data.values - - if (customTargeting.nonEmpty) { - Store.putDfpCustomTargetingKeyValues(Json.stringify(Json.toJson(customTargeting))) - } - } -} diff --git a/admin/app/dfp/DataAgent.scala b/admin/app/dfp/DataAgent.scala deleted file mode 100644 index 4627fbd83137..000000000000 --- a/admin/app/dfp/DataAgent.scala +++ /dev/null @@ -1,42 +0,0 @@ -package dfp - -import common.{Box, GuLogging} -import concurrent.BlockingOperations - -import scala.concurrent.{ExecutionContext, Future} -import scala.util.{Failure, Success, Try} - -trait DataAgent[K, V] extends GuLogging with implicits.Strings { - - private val initialCache: DataCache[K, V] = DataCache(Map.empty[K, V]) - private lazy val cache = Box(initialCache) - - def blockingOperations: BlockingOperations - - def loadFreshData(): Try[Map[K, V]] - - def refresh()(implicit executionContext: ExecutionContext): Future[DataCache[K, V]] = { - log.info("Refreshing data cache") - val start = System.currentTimeMillis - blockingOperations.executeBlocking(loadFreshData()).map(freshIfExists(start)) - } - - private def freshIfExists(start: Long)(tryFreshData: Try[Map[K, V]]): DataCache[K, V] = { - tryFreshData match { - case Success(freshData) if freshData.nonEmpty => - val duration = System.currentTimeMillis - start - log.info(s"Loading DFP data (${freshData.keys.size} items}) took $duration ms") - val freshCache = DataCache(freshData) - cache.send(freshCache) - freshCache - case Success(_) => - log.error("No fresh data loaded so keeping old data") - cache.get() - case Failure(e) => - log.error("Loading of fresh data has failed.", e) - cache.get() - } - } - - def get: DataCache[K, V] = cache.get() -} diff --git a/admin/app/dfp/DataCache.scala b/admin/app/dfp/DataCache.scala deleted file mode 100644 index dfce89a1f42d..000000000000 --- a/admin/app/dfp/DataCache.scala +++ /dev/null @@ -1,9 +0,0 @@ -package dfp - -import java.time.LocalDateTime - -case class DataCache[K, V](timestamp: LocalDateTime, data: Map[K, V]) - -object DataCache { - def apply[K, V](data: Map[K, V]): DataCache[K, V] = DataCache(LocalDateTime.now(), data) -} diff --git a/admin/app/dfp/DataMapper.scala b/admin/app/dfp/DataMapper.scala deleted file mode 100644 index 8fba37698855..000000000000 --- a/admin/app/dfp/DataMapper.scala +++ /dev/null @@ -1,228 +0,0 @@ -package dfp - -import com.google.api.ads.admanager.axis.v202405._ -import common.dfp._ -import dfp.ApiHelper.{isPageSkin, optJavaInt, toJodaTime, toSeq} - -// These mapping functions use libraries that are only available in admin to create common DFP data models. -class DataMapper( - adUnitService: AdUnitService, - placementService: dfp.PlacementService, - customTargetingService: dfp.CustomTargetingService, - customFieldService: dfp.CustomFieldService, -) { - - def toGuAdUnit(dfpAdUnit: AdUnit): GuAdUnit = { - val ancestors = toSeq(dfpAdUnit.getParentPath) - val ancestorNames = if (ancestors.isEmpty) Nil else ancestors.tail.map(_.getName) - GuAdUnit(dfpAdUnit.getId, ancestorNames :+ dfpAdUnit.getName, dfpAdUnit.getStatus.getValue) - } - - private def toGuTargeting(session: SessionWrapper)(dfpTargeting: Targeting): GuTargeting = { - - def toIncludedGuAdUnits(inventoryTargeting: InventoryTargeting): Seq[GuAdUnit] = { - - // noinspection MapFlatten - val directAdUnits = - toSeq(inventoryTargeting.getTargetedAdUnits).map(_.getAdUnitId).map(adUnitService.activeAdUnit).flatten - - // noinspection MapFlatten - val adUnitsDerivedFromPlacements = { - toSeq(inventoryTargeting.getTargetedPlacementIds).map(placementService.placementAdUnitIds(session)).flatten - } - - (directAdUnits ++ adUnitsDerivedFromPlacements).sortBy(_.path.mkString).distinct - } - - def toExcludedGuAdUnits(inventoryTargeting: InventoryTargeting): Seq[GuAdUnit] = { - toSeq(inventoryTargeting.getExcludedAdUnits).map(_.getAdUnitId).flatMap(adUnitService.activeAdUnit) - } - - def toCustomTargetSets(criteriaSets: CustomCriteriaSet): Seq[CustomTargetSet] = { - - def toCustomTargetSet(criteria: CustomCriteriaSet): CustomTargetSet = { - - def toCustomTarget(criterion: CustomCriteria) = - CustomTarget( - customTargetingService.targetingKey(session)(criterion.getKeyId), - criterion.getOperator.getValue, - criterion.getValueIds.toSeq map (valueId => - customTargetingService.targetingValue(session)(criterion.getKeyId, valueId) - ), - ) - - val targets = criteria.getChildren collect { case criterion: CustomCriteria => - criterion - } map toCustomTarget - CustomTargetSet(criteria.getLogicalOperator.getValue, targets.toIndexedSeq) - } - - criteriaSets.getChildren - .collect { case criteria: CustomCriteriaSet => - criteria - } - .map(toCustomTargetSet) - .toSeq - } - - def geoTargets(locations: GeoTargeting => Array[Location]): Seq[GeoTarget] = { - - def toGeoTarget(dfpLocation: Location) = - GeoTarget( - dfpLocation.getId, - optJavaInt(dfpLocation.getCanonicalParentId), - dfpLocation.getType, - dfpLocation.getDisplayName, - ) - - Option(dfpTargeting.getGeoTargeting) flatMap { geoTargeting => - Option(locations(geoTargeting)) map (_.map(toGeoTarget).toSeq) - } getOrElse Nil - } - val geoTargetsIncluded = geoTargets(_.getTargetedLocations) - val geoTargetsExcluded = geoTargets(_.getExcludedLocations) - - GuTargeting( - adUnitsIncluded = Option(dfpTargeting.getInventoryTargeting) map toIncludedGuAdUnits getOrElse Nil, - adUnitsExcluded = Option(dfpTargeting.getInventoryTargeting) map toExcludedGuAdUnits getOrElse Nil, - geoTargetsIncluded, - geoTargetsExcluded, - customTargetSets = Option(dfpTargeting.getCustomTargeting) map toCustomTargetSets getOrElse Nil, - ) - } - - private def toGuCreativePlaceholders(session: SessionWrapper)(dfpLineItem: LineItem): Seq[GuCreativePlaceholder] = { - - def creativeTargeting(name: String): Option[GuTargeting] = { - for (targeting <- toSeq(dfpLineItem.getCreativeTargetings) find (_.getName == name)) yield { - toGuTargeting(session)(targeting.getTargeting) - } - } - - val placeholders = for (placeholder <- dfpLineItem.getCreativePlaceholders) yield { - val size = placeholder.getSize - val targeting = Option(placeholder.getTargetingName).flatMap(creativeTargeting) - GuCreativePlaceholder(AdSize(size.getWidth, size.getHeight), targeting) - } - - placeholders.toIndexedSeq sortBy { placeholder => - val size = placeholder.size - (size.width, size.height) - } - } - - def toGuLineItem(session: SessionWrapper)(dfpLineItem: LineItem): GuLineItem = - GuLineItem( - id = dfpLineItem.getId, - orderId = dfpLineItem.getOrderId, - name = dfpLineItem.getName, - lineItemType = GuLineItemType.fromDFPLineItemType(dfpLineItem.getLineItemType.getValue), - startTime = toJodaTime(dfpLineItem.getStartDateTime), - endTime = { - if (dfpLineItem.getUnlimitedEndDateTime) None - else Some(toJodaTime(dfpLineItem.getEndDateTime)) - }, - isPageSkin = isPageSkin(dfpLineItem), - sponsor = customFieldService.sponsor(dfpLineItem), - creativePlaceholders = toGuCreativePlaceholders(session)( - dfpLineItem, - ), - targeting = toGuTargeting(session)(dfpLineItem.getTargeting), - status = dfpLineItem.getStatus.toString, - costType = dfpLineItem.getCostType.toString, - lastModified = toJodaTime(dfpLineItem.getLastModifiedDateTime), - ) - - def toGuCreativeTemplate(dfpCreativeTemplate: CreativeTemplate): GuCreativeTemplate = { - - def toParameter(param: CreativeTemplateVariable) = - GuCreativeTemplateParameter( - parameterType = param.getClass.getSimpleName.stripSuffix("CreativeTemplateVariable"), - label = param.getLabel, - isRequired = param.getIsRequired, - description = Option(param.getDescription), - ) - - GuCreativeTemplate( - id = dfpCreativeTemplate.getId, - name = dfpCreativeTemplate.getName, - description = dfpCreativeTemplate.getDescription, - parameters = Option(dfpCreativeTemplate.getVariables) - .map { params => - (params map toParameter).toSeq - } - .getOrElse(Nil), - snippet = dfpCreativeTemplate.getSnippet, - creatives = Nil, - isNative = dfpCreativeTemplate.getIsNativeEligible, - ) - } - - def toGuTemplateCreative(dfpCreative: TemplateCreative): GuCreative = { - - def arg(variableValue: BaseCreativeTemplateVariableValue): (String, String) = { - val exampleAssetUrl = - "https://tpc.googlesyndication.com/pagead/imgad?id=CICAgKCT8L-fJRABGAEyCCXl5VJTW9F8" - val argValue = variableValue match { - case s: StringCreativeTemplateVariableValue => - Option(s.getValue) getOrElse "" - case u: UrlCreativeTemplateVariableValue => - Option(u.getValue) getOrElse "" - case _: AssetCreativeTemplateVariableValue => - exampleAssetUrl - case other => "???" - } - variableValue.getUniqueName -> argValue - } - - GuCreative( - id = dfpCreative.getId, - name = dfpCreative.getName, - lastModified = toJodaTime(dfpCreative.getLastModifiedDateTime), - args = Option(dfpCreative.getCreativeTemplateVariableValues).map(_.map(arg)).map(_.toMap).getOrElse(Map.empty), - templateId = Some(dfpCreative.getCreativeTemplateId), - snippet = None, - previewUrl = Some(dfpCreative.getPreviewUrl), - ) - } - - def toGuOrder(dfpOrder: Order): GuOrder = { - GuOrder( - id = dfpOrder.getId, - name = dfpOrder.getName, - advertiserId = dfpOrder.getAdvertiserId, - ) - } - def toGuAdvertiser(dfpCompany: Company): GuAdvertiser = { - - GuAdvertiser( - id = dfpCompany.getId, - name = dfpCompany.getName, - ) - } -} - -object DataMapper { - def toGuCustomFieldOption(option: CustomFieldOption): GuCustomFieldOption = - GuCustomFieldOption(option.getId, option.getDisplayName) - - def toGuCustomField(dfpCustomField: CustomField): GuCustomField = { - val options: List[GuCustomFieldOption] = { - dfpCustomField match { - case dropdown: DropDownCustomField => dropdown.getOptions.toList - case _ => Nil - } - } map toGuCustomFieldOption - - GuCustomField( - dfpCustomField.getId, - dfpCustomField.getName, - dfpCustomField.getDescription, - dfpCustomField.getIsActive, - dfpCustomField.getEntityType.getValue, - dfpCustomField.getDataType.getValue, - dfpCustomField.getVisibility.getValue, - options, - ) - } -} diff --git a/admin/app/dfp/DataValidation.scala b/admin/app/dfp/DataValidation.scala deleted file mode 100644 index 31418bc2903e..000000000000 --- a/admin/app/dfp/DataValidation.scala +++ /dev/null @@ -1,27 +0,0 @@ -package dfp - -import com.google.api.ads.admanager.axis.v202405._ -import common.dfp._ -import dfp.ApiHelper.toSeq - -class DataValidation(adUnitService: AdUnitService) { - - def isGuLineItemValid(guLineItem: GuLineItem, dfpLineItem: LineItem): Boolean = { - - // Check that all the direct dfp ad units have been accounted for in the targeting. - val guAdUnits = guLineItem.targeting.adUnitsIncluded - - val dfpAdUnitIds = Option(dfpLineItem.getTargeting.getInventoryTargeting) - .map(inventoryTargeting => toSeq(inventoryTargeting.getTargetedAdUnits).map(_.getAdUnitId())) - .getOrElse(Nil) - - // The validation should not account for inactive or archived ad units. - val activeDfpAdUnitIds = dfpAdUnitIds.filterNot { adUnitId => - adUnitService.isArchivedAdUnit(adUnitId) || adUnitService.isInactiveAdUnit(adUnitId) - } - - activeDfpAdUnitIds.forall(adUnitId => { - guAdUnits.exists(_.id == adUnitId) - }) - } -} diff --git a/admin/app/dfp/DfpAdUnitCacheJob.scala b/admin/app/dfp/DfpAdUnitCacheJob.scala deleted file mode 100644 index 64567715abbf..000000000000 --- a/admin/app/dfp/DfpAdUnitCacheJob.scala +++ /dev/null @@ -1,43 +0,0 @@ -package dfp - -import common.{PekkoAsync, GuLogging} -import conf.Configuration -import tools.Store - -import scala.concurrent.{ExecutionContext, Future} - -class DfpAdUnitCacher(val rootAdUnit: Any, val filename: String, dfpApi: DfpApi) extends GuLogging { - - def run(pekkoAsync: PekkoAsync)(implicit executionContext: ExecutionContext): Future[Unit] = - Future { - pekkoAsync { - val adUnits = dfpApi.readActiveAdUnits(rootAdUnit.toString) - if (adUnits.nonEmpty) { - val rows = adUnits.map(adUnit => s"${adUnit.id},${adUnit.path.mkString(",")}") - val list = rows.mkString("\n") - Store.putDfpAdUnitList(filename, list) - } - } - } -} - -class DfpAdUnitCacheJob(dfpApi: DfpApi) - extends DfpAdUnitCacher( - Configuration.commercial.dfpAdUnitGuRoot, - Configuration.commercial.dfpActiveAdUnitListKey, - dfpApi, - ) - -class DfpFacebookIaAdUnitCacheJob(dfpApi: DfpApi) - extends DfpAdUnitCacher( - Configuration.commercial.dfpFacebookIaAdUnitRoot, - Configuration.commercial.dfpFacebookIaAdUnitListKey, - dfpApi, - ) - -class DfpMobileAppAdUnitCacheJob(dfpApi: DfpApi) - extends DfpAdUnitCacher( - Configuration.commercial.dfpMobileAppsAdUnitRoot, - Configuration.commercial.dfpMobileAppsAdUnitListKey, - dfpApi, - ) diff --git a/admin/app/dfp/DfpApi.scala b/admin/app/dfp/DfpApi.scala deleted file mode 100644 index 1d8aa714baee..000000000000 --- a/admin/app/dfp/DfpApi.scala +++ /dev/null @@ -1,200 +0,0 @@ -package dfp - -// StatementBuilder query language is PQL defined here: -// https://developers.google.com/ad-manager/api/pqlreference -import com.google.api.ads.admanager.axis.utils.v202405.StatementBuilder -import com.google.api.ads.admanager.axis.v202405._ -import com.madgag.scala.collection.decorators.MapDecorator -import common.GuLogging -import common.dfp._ -import org.joda.time.DateTime - -case class DfpLineItems(validItems: Seq[GuLineItem], invalidItems: Seq[GuLineItem]) - -class DfpApi(dataMapper: DataMapper, dataValidation: DataValidation) extends GuLogging { - import dfp.DfpApi._ - - private def readLineItems( - stmtBuilder: StatementBuilder, - postFilter: LineItem => Boolean = _ => true, - ): DfpLineItems = { - - val lineItems = withDfpSession(session => { - session - .lineItems(stmtBuilder) - .filter(postFilter) - .map(dfpLineItem => { - (dataMapper.toGuLineItem(session)(dfpLineItem), dfpLineItem) - }) - }) - - // Note that this will call getTargeting on each - // item, potentially making one API call per lineitem. - val validatedLineItems = lineItems - .groupBy(Function.tupled(dataValidation.isGuLineItemValid)) - .mapV(_.map(_._1)) - - DfpLineItems( - validItems = validatedLineItems.getOrElse(true, Nil), - invalidItems = validatedLineItems.getOrElse(false, Nil), - ) - } - - def getAllOrders: Seq[GuOrder] = { - val stmtBuilder = new StatementBuilder() - withDfpSession(_.orders(stmtBuilder).map(dataMapper.toGuOrder)) - } - - def getAllAdvertisers: Seq[GuAdvertiser] = { - val stmtBuilder = new StatementBuilder() - .where("type = :type") - .withBindVariableValue("type", CompanyType.ADVERTISER.toString) - .orderBy("id ASC") - - withDfpSession(_.companies(stmtBuilder).map(dataMapper.toGuAdvertiser)) - } - - def readCurrentLineItems: DfpLineItems = { - - val stmtBuilder = new StatementBuilder() - .where("status = :readyStatus OR status = :deliveringStatus") - .withBindVariableValue("readyStatus", ComputedStatus.READY.toString) - .withBindVariableValue("deliveringStatus", ComputedStatus.DELIVERING.toString) - .orderBy("id ASC") - - readLineItems(stmtBuilder) - } - - def readLineItemsModifiedSince(threshold: DateTime): DfpLineItems = { - - val stmtBuilder = new StatementBuilder() - .where("lastModifiedDateTime > :threshold") - .withBindVariableValue("threshold", threshold.getMillis) - - readLineItems(stmtBuilder) - } - - def readSponsorshipLineItemIds(): Seq[Long] = { - - // The advertiser ID for "Amazon Transparent Ad Marketplace" - val amazonAdvertiserId = 4751525411L - - val stmtBuilder = new StatementBuilder() - .where( - "(status = :readyStatus OR status = :deliveringStatus) AND lineItemType = :sponsorshipType AND advertiserId != :amazonAdvertiserId", - ) - .withBindVariableValue("readyStatus", ComputedStatus.READY.toString) - .withBindVariableValue("deliveringStatus", ComputedStatus.DELIVERING.toString) - .withBindVariableValue("sponsorshipType", LineItemType.SPONSORSHIP.toString) - .withBindVariableValue("amazonAdvertiserId", amazonAdvertiserId.toString) - .orderBy("id ASC") - - // Lets avoid Prebid lineitems - val IsPrebid = "(?i).*?prebid.*".r - - val lineItems = readLineItems( - stmtBuilder, - lineItem => { - lineItem.getName match { - case IsPrebid() => false - case _ => true - } - }, - ) - (lineItems.validItems.map(_.id) ++ lineItems.invalidItems.map(_.id)).sorted - } - - def readActiveCreativeTemplates(): Seq[GuCreativeTemplate] = { - - val stmtBuilder = new StatementBuilder() - .where("status = :active and type = :userDefined") - .withBindVariableValue("active", CreativeTemplateStatus._ACTIVE) - .withBindVariableValue("userDefined", CreativeTemplateType._USER_DEFINED) - - withDfpSession { - _.creativeTemplates(stmtBuilder) map dataMapper.toGuCreativeTemplate filterNot (_.isForApps) - } - } - - def readTemplateCreativesModifiedSince(threshold: DateTime): Seq[GuCreative] = { - - val stmtBuilder = new StatementBuilder() - .where("lastModifiedDateTime > :threshold") - .withBindVariableValue("threshold", threshold.getMillis) - - withDfpSession { - _.creatives.get(stmtBuilder) collect { case creative: TemplateCreative => - creative - } map dataMapper.toGuTemplateCreative - } - } - - private def readDescendantAdUnits(rootName: String, stmtBuilder: StatementBuilder): Seq[GuAdUnit] = { - withDfpSession { session => - session.adUnits(stmtBuilder) filter { adUnit => - def isRoot(path: Array[AdUnitParent]) = path.length == 1 && adUnit.getName == rootName - def isDescendant(path: Array[AdUnitParent]) = path.length > 1 && path(1).getName == rootName - - Option(adUnit.getParentPath) exists { path => isRoot(path) || isDescendant(path) } - } map dataMapper.toGuAdUnit sortBy (_.id) - } - } - - def readActiveAdUnits(rootName: String): Seq[GuAdUnit] = { - - val stmtBuilder = new StatementBuilder() - .where("status = :status") - .withBindVariableValue("status", InventoryStatus._ACTIVE) - - readDescendantAdUnits(rootName, stmtBuilder) - } - - def readSpecialAdUnits(rootName: String): Seq[(String, String)] = { - - val statementBuilder = new StatementBuilder() - .where("status = :status") - .where("explicitlyTargeted = :targeting") - .withBindVariableValue("status", InventoryStatus._ACTIVE) - .withBindVariableValue("targeting", true) - - readDescendantAdUnits(rootName, statementBuilder) map { adUnit => - (adUnit.id, adUnit.path.mkString("/")) - } sortBy (_._2) - } - - def getCreativeIds(lineItemId: Long): Seq[Long] = { - val stmtBuilder = new StatementBuilder() - .where("status = :status AND lineItemId = :lineItemId") - .withBindVariableValue("status", LineItemCreativeAssociationStatus._ACTIVE) - .withBindVariableValue("lineItemId", lineItemId) - - withDfpSession { session => - session.lineItemCreativeAssociations.get(stmtBuilder) map (id => Long2long(id.getCreativeId)) - } - } - - def getPreviewUrl(lineItemId: Long, creativeId: Long, url: String): Option[String] = - for { - session <- SessionWrapper() - previewUrl <- session.lineItemCreativeAssociations.getPreviewUrl(lineItemId, creativeId, url) - } yield previewUrl - - def getReportQuery(reportId: Long): Option[ReportQuery] = - for { - session <- SessionWrapper() - query <- session.getReportQuery(reportId) - } yield query - - def runReportJob(report: ReportQuery): Seq[String] = { - withDfpSession { session => - session.runReportJob(report) - } - } -} - -object DfpApi { - def withDfpSession[T](block: SessionWrapper => Seq[T]): Seq[T] = { - val results = for (session <- SessionWrapper()) yield block(session) - results getOrElse Nil - } -} diff --git a/admin/app/dfp/DfpDataCacheJob.scala b/admin/app/dfp/DfpDataCacheJob.scala deleted file mode 100644 index 4747744c9b04..000000000000 --- a/admin/app/dfp/DfpDataCacheJob.scala +++ /dev/null @@ -1,179 +0,0 @@ -package dfp - -import common.dfp._ -import common.GuLogging -import org.joda.time.DateTime -import play.api.libs.json.Json.{toJson, _} -import tools.Store - -import scala.concurrent.{ExecutionContext, Future} - -class DfpDataCacheJob( - adUnitAgent: AdUnitAgent, - customFieldAgent: CustomFieldAgent, - customTargetingAgent: CustomTargetingAgent, - placementAgent: PlacementAgent, - dfpApi: DfpApi, -) extends GuLogging { - - case class LineItemLoadSummary(validLineItems: Seq[GuLineItem], invalidLineItems: Seq[GuLineItem]) - - def run()(implicit executionContext: ExecutionContext): Future[Unit] = - Future { - log.info("Refreshing data cache") - val start = System.currentTimeMillis - val data = loadLineItems() - val sponsorshipLineItemIds = dfpApi.readSponsorshipLineItemIds() - val currentLineItems = loadCurrentLineItems() - val duration = System.currentTimeMillis - start - log.info(s"Loading DFP data took $duration ms") - write(data) - Store.putNonRefreshableLineItemIds(sponsorshipLineItemIds) - writeLiveBlogTopSponsorships(currentLineItems) - writeSurveySponsorships(currentLineItems) - } - - /* - for initialization and total refresh of data, - so would be used for first read and for emergency data update. - */ - def refreshAllDfpData()(implicit executionContext: ExecutionContext): Unit = { - - for { - _ <- adUnitAgent.refresh() - _ <- customFieldAgent.refresh() - _ <- customTargetingAgent.refresh() - _ <- placementAgent.refresh() - } { - loadLineItems() - } - } - - private def loadCurrentLineItems(): DfpDataExtractor = { - val currentLineItems = dfpApi.readCurrentLineItems - - val loadSummary = LineItemLoadSummary( - validLineItems = currentLineItems.validItems, - invalidLineItems = currentLineItems.invalidItems, - ) - - DfpDataExtractor(loadSummary.validLineItems, loadSummary.invalidLineItems) - } - - private def loadLineItems(): DfpDataExtractor = { - - def fetchCachedLineItems(): DfpLineItems = { - val lineItemReport = Store.getDfpLineItemsReport() - - DfpLineItems(validItems = lineItemReport.lineItems, invalidItems = lineItemReport.invalidLineItems) - } - - val start = System.currentTimeMillis - - val loadSummary = loadLineItems( - fetchCachedLineItems(), - dfpApi.readLineItemsModifiedSince, - dfpApi.readCurrentLineItems, - ) - - val loadDuration = System.currentTimeMillis - start - log.info(s"Loading line items took $loadDuration ms") - - DfpDataExtractor(loadSummary.validLineItems, loadSummary.invalidLineItems) - } - - def report(ids: Iterable[Long]): String = if (ids.isEmpty) "None" else ids.mkString(", ") - - def loadLineItems( - cachedLineItems: => DfpLineItems, - lineItemsModifiedSince: DateTime => DfpLineItems, - allActiveLineItems: => DfpLineItems, - ): LineItemLoadSummary = { - - // If the cache is empty, run a full query to generate a complete LineItemLoadSummary, using allActiveLineItems. - if (cachedLineItems.validItems.isEmpty) { - // Create a full summary object from scratch, using a query that collects all line items from dfp. - LineItemLoadSummary( - validLineItems = allActiveLineItems.validItems, - invalidLineItems = allActiveLineItems.invalidItems, - ) - } else { - - // Calculate the most recent modified timestamp of the existing cache items, - // and find line items modified since that timestamp. - val threshold = cachedLineItems.validItems.map(_.lastModified).maxBy(_.getMillis) - val recentlyModified = lineItemsModifiedSince(threshold) - - // Update existing items with a patch of new items. - def updateCachedContent(existingItems: Seq[GuLineItem], newItems: Seq[GuLineItem]): Seq[GuLineItem] = { - - // Create a combined map of all the line items, preferring newer items over old ones (equality is based on id). - val updatedLineItemMap = GuLineItem.asMap(existingItems) ++ GuLineItem.asMap(newItems) - - // These are the existing, cached keys. - val existingKeys = existingItems.map(_.id).toSet - - val (active, inactive) = newItems partition (Seq("READY", "DELIVERING", "DELIVERY_EXTENDED") contains _.status) - val activeKeys = active.map(_.id).toSet - val inactiveKeys = inactive.map(_.id).toSet - - val added = activeKeys -- existingKeys - val modified = activeKeys intersect existingKeys - val removed = inactiveKeys intersect existingKeys - - // New cache contents. - val updatedKeys = existingKeys ++ added -- removed - - log.info(s"Cached line item count was ${cachedLineItems.validItems.size}") - log.info(s"Last modified time of cached line items: $threshold") - - log.info(s"Added: ${report(added)}") - log.info(s"Modified: ${report(modified)}") - log.info(s"Removed: ${report(inactiveKeys)}") - log.info(s"Cached line item count now ${updatedKeys.size}") - - updatedKeys.toSeq.sorted.map(updatedLineItemMap) - } - - LineItemLoadSummary( - validLineItems = updateCachedContent(cachedLineItems.validItems, recentlyModified.validItems), - invalidLineItems = updateCachedContent(cachedLineItems.invalidItems, recentlyModified.invalidItems), - ) - } - } - - private def write(data: DfpDataExtractor): Unit = { - - if (data.hasValidLineItems) { - val now = printLondonTime(DateTime.now()) - - val pageSkinSponsorships = data.pageSkinSponsorships - Store.putDfpPageSkinAdUnits(stringify(toJson(PageSkinSponsorshipReport(now, pageSkinSponsorships)))) - - Store.putDfpLineItemsReport(stringify(toJson(LineItemReport(now, data.lineItems, data.invalidLineItems)))) - } - } - - private def writeLiveBlogTopSponsorships(data: DfpDataExtractor): Unit = { - if (data.hasValidLineItems) { - val now = printLondonTime(DateTime.now()) - - val sponsorships = data.liveBlogTopSponsorships - Store.putLiveBlogTopSponsorships( - stringify(toJson(LiveBlogTopSponsorshipReport(Some(now), sponsorships))), - ) - } - } - - private def writeSurveySponsorships(data: DfpDataExtractor): Unit = { - if (data.hasValidLineItems) { - val now = printLondonTime(DateTime.now()) - - val sponsorships = data.surveySponsorships - Store.putSurveySponsorships( - stringify(toJson(SurveySponsorshipReport(Some(now), sponsorships))), - ) - } - } - -} diff --git a/admin/app/dfp/DfpDataCacheLifecycle.scala b/admin/app/dfp/DfpDataCacheLifecycle.scala deleted file mode 100644 index 22a5e4f70664..000000000000 --- a/admin/app/dfp/DfpDataCacheLifecycle.scala +++ /dev/null @@ -1,127 +0,0 @@ -package dfp - -import app.LifecycleComponent -import common.dfp.{GuAdUnit, GuCreativeTemplate, GuCustomField, GuCustomTargeting} -import common._ -import play.api.inject.ApplicationLifecycle - -import scala.concurrent.{ExecutionContext, Future} - -class DfpDataCacheLifecycle( - appLifecycle: ApplicationLifecycle, - jobScheduler: JobScheduler, - creativeTemplateAgent: CreativeTemplateAgent, - adUnitAgent: AdUnitAgent, - advertiserAgent: AdvertiserAgent, - customFieldAgent: CustomFieldAgent, - orderAgent: OrderAgent, - placementAgent: PlacementAgent, - customTargetingAgent: CustomTargetingAgent, - dfpDataCacheJob: DfpDataCacheJob, - customTargetingKeyValueJob: CustomTargetingKeyValueJob, - dfpAdUnitCacheJob: DfpAdUnitCacheJob, - dfpMobileAppAdUnitCacheJob: DfpMobileAppAdUnitCacheJob, - dfpFacebookIaAdUnitCacheJob: DfpFacebookIaAdUnitCacheJob, - dfpTemplateCreativeCacheJob: DfpTemplateCreativeCacheJob, - pekkoAsync: PekkoAsync, -)(implicit ec: ExecutionContext) - extends LifecycleComponent { - - appLifecycle.addStopHook { () => - Future { - jobs foreach { job => - jobScheduler.deschedule(job.name) - } - } - } - - trait Job[T] { - val name: String - val interval: Int - def run(): Future[T] - } - - val jobs = Set( - new Job[DataCache[String, GuAdUnit]] { - val name = "DFP-AdUnits-Update" - val interval = 30 - def run() = adUnitAgent.refresh() - }, - new Job[DataCache[String, GuCustomField]] { - val name = "DFP-CustomFields-Update" - val interval = 30 - def run() = customFieldAgent.refresh() - }, - new Job[DataCache[Long, GuCustomTargeting]] { - val name = "DFP-CustomTargeting-Update" - val interval = 30 - def run() = customTargetingAgent.refresh() - }, - new Job[Unit] { - val name: String = "DFP-CustomTargeting-Store" - val interval: Int = 15 - def run() = customTargetingKeyValueJob.run() - }, - new Job[DataCache[Long, Seq[String]]] { - val name = "DFP-Placements-Update" - val interval = 30 - def run() = placementAgent.refresh() - }, - new Job[Unit] { - val name: String = "DFP-Cache" - val interval: Int = 2 - def run(): Future[Unit] = dfpDataCacheJob.run() - }, - new Job[Unit] { - val name: String = "DFP-Ad-Units-Update" - val interval: Int = 60 - def run(): Future[Unit] = dfpAdUnitCacheJob.run(pekkoAsync) - }, - new Job[Unit] { - val name: String = "DFP-Mobile-Apps-Ad-Units-Update" - val interval: Int = 60 - def run(): Future[Unit] = dfpMobileAppAdUnitCacheJob.run(pekkoAsync) - }, - new Job[Unit] { - val name: String = "DFP-Facebook-IA-Ad-Units-Update" - val interval: Int = 60 - def run(): Future[Unit] = dfpFacebookIaAdUnitCacheJob.run(pekkoAsync) - }, - new Job[Seq[GuCreativeTemplate]] { - val name: String = "DFP-Creative-Templates-Update" - val interval: Int = 15 - def run() = creativeTemplateAgent.refresh() - }, - new Job[Unit] { - val name: String = "DFP-Template-Creatives-Cache" - val interval: Int = 2 - def run() = dfpTemplateCreativeCacheJob.run() - }, - new Job[Unit] { - val name = "DFP-Order-Advertiser-Update" - val interval: Int = 300 - def run() = { - Future.sequence(Seq(advertiserAgent.refresh(), orderAgent.refresh())).map(_ => ()) - } - }, - ) - - override def start(): Unit = { - jobs foreach { job => - jobScheduler.deschedule(job.name) - jobScheduler.scheduleEveryNMinutes(job.name, job.interval) { - job.run().map(_ => ()) - } - } - - pekkoAsync.after1s { - dfpDataCacheJob.refreshAllDfpData() - creativeTemplateAgent.refresh() - dfpTemplateCreativeCacheJob.run() - customTargetingKeyValueJob.run() - advertiserAgent.refresh() - orderAgent.refresh() - customFieldAgent.refresh() - } - } -} diff --git a/admin/app/dfp/DfpDataExtractor.scala b/admin/app/dfp/DfpDataExtractor.scala index 4c6a7a74e118..a89f077dc25a 100644 --- a/admin/app/dfp/DfpDataExtractor.scala +++ b/admin/app/dfp/DfpDataExtractor.scala @@ -7,37 +7,6 @@ case class DfpDataExtractor(lineItems: Seq[GuLineItem], invalidLineItems: Seq[Gu val hasValidLineItems: Boolean = lineItems.nonEmpty - val liveBlogTopSponsorships: Seq[LiveBlogTopSponsorship] = { - lineItems - .filter(lineItem => lineItem.targetsLiveBlogTop && lineItem.isCurrent) - .foldLeft(Seq.empty[LiveBlogTopSponsorship]) { (soFar, lineItem) => - soFar :+ LiveBlogTopSponsorship( - lineItemName = lineItem.name, - lineItemId = lineItem.id, - adTest = lineItem.targeting.adTestValue, - editions = editionsTargeted(lineItem), - sections = lineItem.liveBlogTopTargetedSections, - keywords = lineItem.targeting.keywordValues, - targetsAdTest = lineItem.targeting.hasAdTestTargetting, - ) - } - } - - val surveySponsorships: Seq[SurveySponsorship] = { - lineItems - .filter(lineItem => lineItem.targetsSurvey && lineItem.isCurrent) - .foldLeft(Seq.empty[SurveySponsorship]) { (soFar, lineItem) => - soFar :+ SurveySponsorship( - lineItemName = lineItem.name, - lineItemId = lineItem.id, - adUnits = lineItem.targeting.adUnitsIncluded map (_.path mkString "/"), - countries = countriesTargeted(lineItem), - adTest = lineItem.targeting.adTestValue, - targetsAdTest = lineItem.targeting.hasAdTestTargetting, - ) - } - } - val pageSkinSponsorships: Seq[PageSkinSponsorship] = { lineItems withFilter { lineItem => lineItem.isPageSkin && lineItem.isCurrent @@ -56,11 +25,6 @@ case class DfpDataExtractor(lineItems: Seq[GuLineItem], invalidLineItems: Seq[Gu } } - def dateSort(lineItems: => Seq[GuLineItem]): Seq[GuLineItem] = - lineItems sortBy { lineItem => - (lineItem.startTime.getMillis, lineItem.endTime.map(_.getMillis).getOrElse(0L)) - } - def editionsTargeted(lineItem: GuLineItem): Seq[Edition] = { for { targetSet <- lineItem.targeting.customTargetSets diff --git a/admin/app/dfp/DfpTemplateCreativeCacheJob.scala b/admin/app/dfp/DfpTemplateCreativeCacheJob.scala deleted file mode 100644 index 4709df018546..000000000000 --- a/admin/app/dfp/DfpTemplateCreativeCacheJob.scala +++ /dev/null @@ -1,20 +0,0 @@ -package dfp - -import common.dfp.GuCreative -import org.joda.time.DateTime.now -import play.api.libs.json.Json -import tools.Store - -import scala.concurrent.{ExecutionContext, Future} - -class DfpTemplateCreativeCacheJob(dfpApi: DfpApi) { - - def run()(implicit executionContext: ExecutionContext): Future[Unit] = - Future { - val cached = Store.getDfpTemplateCreatives - val threshold = GuCreative.lastModified(cached) getOrElse now.minusMonths(1) - val recentlyModified = dfpApi.readTemplateCreativesModifiedSince(threshold) - val merged = GuCreative.merge(cached, recentlyModified) - Store.putDfpTemplateCreatives(Json.stringify(Json.toJson(merged))) - } -} diff --git a/admin/app/dfp/OrderAgent.scala b/admin/app/dfp/OrderAgent.scala deleted file mode 100644 index c31537e6093a..000000000000 --- a/admin/app/dfp/OrderAgent.scala +++ /dev/null @@ -1,20 +0,0 @@ -package dfp - -import common.Box -import common.dfp.GuOrder -import concurrent.BlockingOperations - -import scala.concurrent.{ExecutionContext, Future} - -class OrderAgent(blockingOperations: BlockingOperations, dfpApi: DfpApi) { - - private lazy val cache = Box(Seq.empty[GuOrder]) - - def refresh()(implicit executionContext: ExecutionContext): Future[Seq[GuOrder]] = { - blockingOperations.executeBlocking(dfpApi.getAllOrders).flatMap { freshData => - cache.alter(if (freshData.nonEmpty) freshData else _) - } - } - - def get: Seq[GuOrder] = cache.get() -} diff --git a/admin/app/dfp/PlacementAgent.scala b/admin/app/dfp/PlacementAgent.scala deleted file mode 100644 index 7764346b3390..000000000000 --- a/admin/app/dfp/PlacementAgent.scala +++ /dev/null @@ -1,34 +0,0 @@ -package dfp - -import com.google.api.ads.admanager.axis.utils.v202405.StatementBuilder -import common.dfp.GuAdUnit -import concurrent.BlockingOperations - -import scala.util.Try - -class PlacementAgent(val blockingOperations: BlockingOperations) extends DataAgent[Long, Seq[String]] { - - override def loadFreshData(): Try[Map[Long, Seq[String]]] = - Try { - val maybeData = for (session <- SessionWrapper()) yield { - val placements = session.placements(new StatementBuilder()) - placements.map { placement => - placement.getId.toLong -> placement.getTargetedAdUnitIds.toSeq - }.toMap - } - maybeData getOrElse Map.empty - } -} - -class PlacementService(placementAgent: PlacementAgent, adUnitService: AdUnitService) { - - def placementAdUnitIds(session: SessionWrapper)(placementId: Long): Seq[GuAdUnit] = { - lazy val fallback = { - val stmtBuilder = new StatementBuilder().where("id = :id").withBindVariableValue("id", placementId) - session.placements(stmtBuilder) flatMap (_.getTargetedAdUnitIds.toSeq) - } - val adUnitIds = placementAgent.get.data getOrElse (placementId, fallback) - adUnitIds.flatMap(adUnitService.activeAdUnit) - } - -} diff --git a/admin/app/dfp/Reader.scala b/admin/app/dfp/Reader.scala deleted file mode 100644 index 522b03a67cea..000000000000 --- a/admin/app/dfp/Reader.scala +++ /dev/null @@ -1,28 +0,0 @@ -package dfp - -import com.google.api.ads.admanager.axis.utils.v202405.StatementBuilder -import com.google.api.ads.admanager.axis.utils.v202405.StatementBuilder.SUGGESTED_PAGE_LIMIT -import com.google.api.ads.admanager.axis.v202405._ - -import scala.annotation.tailrec - -object Reader { - - def read[T](statementBuilder: StatementBuilder)(readPage: Statement => (Array[T], Int)): Seq[T] = { - - @tailrec - def read(soFar: Seq[T]): Seq[T] = { - val (pageOfResults, totalResultSetSize) = readPage(statementBuilder.toStatement) - val resultsSoFar = Option(pageOfResults).map(soFar ++ _).getOrElse(soFar) - if (resultsSoFar.size >= totalResultSetSize) { - resultsSoFar - } else { - statementBuilder.increaseOffsetBy(SUGGESTED_PAGE_LIMIT) - read(resultsSoFar) - } - } - - statementBuilder.limit(SUGGESTED_PAGE_LIMIT) - read(Nil) - } -} diff --git a/admin/app/dfp/ServicesWrapper.scala b/admin/app/dfp/ServicesWrapper.scala deleted file mode 100644 index 371a3812fd9f..000000000000 --- a/admin/app/dfp/ServicesWrapper.scala +++ /dev/null @@ -1,36 +0,0 @@ -package dfp - -import com.google.api.ads.admanager.axis.factory.AdManagerServices -import com.google.api.ads.admanager.axis.v202405._ -import com.google.api.ads.admanager.lib.client.AdManagerSession - -private[dfp] class ServicesWrapper(session: AdManagerSession) { - - private val dfpServices = new AdManagerServices - - lazy val lineItemService = dfpServices.get(session, classOf[LineItemServiceInterface]) - - lazy val licaService = dfpServices.get(session, classOf[LineItemCreativeAssociationServiceInterface]) - - lazy val customFieldsService = dfpServices.get(session, classOf[CustomFieldServiceInterface]) - - lazy val customTargetingService = dfpServices.get(session, classOf[CustomTargetingServiceInterface]) - - lazy val inventoryService = dfpServices.get(session, classOf[InventoryServiceInterface]) - - lazy val suggestedAdUnitService = dfpServices.get(session, classOf[SuggestedAdUnitServiceInterface]) - - lazy val placementService = dfpServices.get(session, classOf[PlacementServiceInterface]) - - lazy val creativeTemplateService = dfpServices.get(session, classOf[CreativeTemplateServiceInterface]) - - lazy val creativeService = dfpServices.get(session, classOf[CreativeServiceInterface]) - - lazy val networkService = dfpServices.get(session, classOf[NetworkServiceInterface]) - - lazy val orderService = dfpServices.get(session, classOf[OrderServiceInterface]) - - lazy val companyService = dfpServices.get(session, classOf[CompanyServiceInterface]) - - lazy val reportService = dfpServices.get(session, classOf[ReportServiceInterface]) -} diff --git a/admin/app/dfp/SessionLogger.scala b/admin/app/dfp/SessionLogger.scala deleted file mode 100644 index 05e869582048..000000000000 --- a/admin/app/dfp/SessionLogger.scala +++ /dev/null @@ -1,95 +0,0 @@ -package dfp - -import com.google.api.ads.admanager.axis.utils.v202405.StatementBuilder -import com.google.api.ads.admanager.axis.v202405._ -import common.GuLogging - -import scala.util.control.NonFatal -import common.DfpApiMetrics.DfpApiErrors - -private[dfp] object SessionLogger extends GuLogging { - - def logAroundRead[T](typesToRead: String, stmtBuilder: StatementBuilder)(read: => Seq[T]): Seq[T] = { - logAroundSeq(typesToRead, opName = "reading", Some(stmtBuilder.toStatement))(read) - } - - def logAroundReadSingle[T](typesToRead: String)(read: => T): Option[T] = { - logAround(typesToRead, "reading")(read)((_: T) => 1) - } - - def logAroundCreate[T](typesToCreate: String)(create: => Seq[T]): Seq[T] = { - logAroundSeq(typesToCreate, opName = "creating")(create) - } - - def logAroundPerform(typesName: String, opName: String, statement: Statement)(op: => Int): Int = { - logAround(typesName, opName, Some(statement))(op)(identity) getOrElse 0 - } - - private def logAroundSeq[T](typesName: String, opName: String, statement: Option[Statement] = None)( - op: => Seq[T], - ): Seq[T] = { - logAround(typesName, opName, statement)(op)(_.size) getOrElse Nil - } - - private def logAround[T](typesName: String, opName: String, statement: Option[Statement] = None)( - op: => T, - )(numAffected: T => Int): Option[T] = { - - def logApiException(e: ApiException, baseMessage: String): Unit = { - e.getErrors foreach { err => - val reasonMsg = err match { - case freqCapErr: FrequencyCapError => s", with the reason '${freqCapErr.getReason}'" - case notNullErr: NotNullError => s", with the reason '${notNullErr.getReason}'" - case _ => "" - } - val path = err.getFieldPath - val trigger = err.getTrigger - val msg = s"'${err.getErrorString}'$reasonMsg" - log.error( - s"$baseMessage failed: API exception in field '$path', " + - s"caused by an invalid value '$trigger', " + - s"with the error message $msg", - e, - ) - } - } - - val maybeQryLogMessage = statement map { stmt => - val qry = stmt.getQuery - val params = stmt.getValues.map { param => - val k = param.getKey - val rawValue = param.getValue - k -> ( - rawValue match { - case t: TextValue => s""""${t.getValue}"""" - case n: NumberValue => n.getValue - case b: BooleanValue => b.getValue - case other => other.toString - } - ) - }.toMap - val paramStr = if (params.isEmpty) "" else s"and params ${params.toString}" - s"""with statement "$qry" $paramStr""" - } - val baseMessage = s"$opName $typesName" - val msgPrefix = maybeQryLogMessage map (qryLogMsg => s"$baseMessage $qryLogMsg") getOrElse baseMessage - - try { - log.info(s"$msgPrefix ...") - val start = System.currentTimeMillis() - val result = op - val duration = System.currentTimeMillis() - start - log.info(s"Successful $opName of ${numAffected(result)} $typesName in $duration ms") - Some(result) - } catch { - case e: ApiException => - logApiException(e, msgPrefix); - DfpApiErrors.increment(); - None - case NonFatal(e) => - log.error(s"$msgPrefix failed", e); - DfpApiErrors.increment(); - None - } - } -} diff --git a/admin/app/dfp/SessionWrapper.scala b/admin/app/dfp/SessionWrapper.scala deleted file mode 100644 index 42896ede872d..000000000000 --- a/admin/app/dfp/SessionWrapper.scala +++ /dev/null @@ -1,232 +0,0 @@ -package dfp - -import com.google.api.ads.common.lib.auth.OfflineCredentials -import com.google.api.ads.common.lib.auth.OfflineCredentials.Api -import com.google.api.ads.admanager.axis.utils.v202405.{ReportDownloader, StatementBuilder} -import com.google.api.ads.admanager.axis.v202405._ -import com.google.api.ads.admanager.lib.client.AdManagerSession -import com.google.common.io.CharSource -import common.GuLogging -import conf.{AdminConfiguration, Configuration} -import dfp.Reader.read -import dfp.SessionLogger.{logAroundCreate, logAroundPerform, logAroundRead, logAroundReadSingle} -import scala.jdk.CollectionConverters._ - -import scala.util.control.NonFatal -import common.DfpApiMetrics.DfpSessionErrors - -private[dfp] class SessionWrapper(dfpSession: AdManagerSession) { - - private val services = new ServicesWrapper(dfpSession) - - def lineItems(stmtBuilder: StatementBuilder): Seq[LineItem] = { - logAroundRead("line items", stmtBuilder) { - read(stmtBuilder) { statement => - val page = services.lineItemService.getLineItemsByStatement(statement) - (page.getResults, page.getTotalResultSetSize) - } - } - } - - def orders(stmtBuilder: StatementBuilder): Seq[Order] = { - logAroundRead("orders", stmtBuilder) { - read(stmtBuilder) { statement => - val page = services.orderService.getOrdersByStatement(statement) - (page.getResults, page.getTotalResultSetSize) - } - } - } - - def companies(stmtBuilder: StatementBuilder): Seq[Company] = { - logAroundRead("companies", stmtBuilder) { - read(stmtBuilder) { statement => - val page = services.companyService.getCompaniesByStatement(statement) - (page.getResults, page.getTotalResultSetSize) - } - } - } - - def customFields(stmtBuilder: StatementBuilder): Seq[CustomField] = { - logAroundRead("custom fields", stmtBuilder) { - read(stmtBuilder) { statement => - val page = services.customFieldsService.getCustomFieldsByStatement(statement) - (page.getResults, page.getTotalResultSetSize) - } - } - } - - def customTargetingKeys(stmtBuilder: StatementBuilder): Seq[CustomTargetingKey] = { - logAroundRead("custom targeting keys", stmtBuilder) { - read(stmtBuilder) { statement => - val page = services.customTargetingService.getCustomTargetingKeysByStatement(statement) - (page.getResults, page.getTotalResultSetSize) - } - } - } - - def customTargetingValues(stmtBuilder: StatementBuilder): Seq[CustomTargetingValue] = { - logAroundRead("custom targeting values", stmtBuilder) { - read(stmtBuilder) { statement => - val page = services.customTargetingService.getCustomTargetingValuesByStatement(statement) - (page.getResults, page.getTotalResultSetSize) - } - } - } - - def adUnits(stmtBuilder: StatementBuilder): Seq[AdUnit] = { - logAroundRead("ad units", stmtBuilder) { - read(stmtBuilder) { statement => - val page = services.inventoryService.getAdUnitsByStatement(statement) - (page.getResults, page.getTotalResultSetSize) - } - } - } - - def placements(stmtBuilder: StatementBuilder): Seq[Placement] = { - logAroundRead("placements", stmtBuilder) { - read(stmtBuilder) { statement => - val page = services.placementService.getPlacementsByStatement(statement) - (page.getResults, page.getTotalResultSetSize) - } - } - } - - def creativeTemplates(stmtBuilder: StatementBuilder): Seq[CreativeTemplate] = { - logAroundRead("creative templates", stmtBuilder) { - read(stmtBuilder) { statement => - val page = services.creativeTemplateService.getCreativeTemplatesByStatement(statement) - (page.getResults, page.getTotalResultSetSize) - } - } - } - - def getRootAdUnitId: String = { - services.networkService.getCurrentNetwork.getEffectiveRootAdUnitId - } - - def getReportQuery(reportId: Long): Option[ReportQuery] = { - // Retrieve the saved query. - val stmtBuilder = new StatementBuilder() - .where("id = :id") - .limit(1) - .withBindVariableValue("id", reportId) - - val page: SavedQueryPage = services.reportService.getSavedQueriesByStatement(stmtBuilder.toStatement) - // page.getResults() may return null. - val savedQuery: Option[SavedQuery] = Option(page.getResults()).flatMap(_.toList.headOption) - - /* - * if this is null it means that the report is incompatible with the API version we're using. - * Eg. check this for supported date-range types: - * https://developers.google.com/doubleclick-publishers/docs/reference/v201711/ReportService.ReportQuery#daterangetype - * And supported filter types: - * https://developers.google.com/doubleclick-publishers/docs/reference/v201711/ReportService.ReportQuery#statement` - * Also see https://developers.google.com/doubleclick-publishers/docs/reporting - */ - savedQuery.flatMap(qry => Option(qry.getReportQuery)) - } - - def runReportJob(report: ReportQuery): Seq[String] = { - - val reportJob = new ReportJob() - reportJob.setReportQuery(report) - - val runningJob = services.reportService.runReportJob(reportJob) - - val reportDownloader = new ReportDownloader(services.reportService, runningJob.getId) - reportDownloader.waitForReportReady() - - // Download the report. - val options: ReportDownloadOptions = new ReportDownloadOptions() - options.setExportFormat(ExportFormat.CSV_DUMP) - options.setUseGzipCompression(true) - val charSource: CharSource = reportDownloader.getReportAsCharSource(options) - charSource.readLines().asScala.toSeq - } - - object lineItemCreativeAssociations { - - private val licaService = services.licaService - private val typeName = "licas" - - def get(stmtBuilder: StatementBuilder): Seq[LineItemCreativeAssociation] = { - logAroundRead(typeName, stmtBuilder) { - read(stmtBuilder) { statement => - val page = licaService.getLineItemCreativeAssociationsByStatement(statement) - (page.getResults, page.getTotalResultSetSize) - } - } - } - - def getPreviewUrl(lineItemId: Long, creativeId: Long, url: String): Option[String] = - logAroundReadSingle(typeName) { - licaService.getPreviewUrl(lineItemId, creativeId, url) - } - - def create(licas: Seq[LineItemCreativeAssociation]): Seq[LineItemCreativeAssociation] = { - logAroundCreate(typeName) { - licaService.createLineItemCreativeAssociations(licas.toArray).toIndexedSeq - } - } - - def deactivate(filterStatement: Statement): Int = { - logAroundPerform(typeName, "deactivating", filterStatement) { - val action = new DeactivateLineItemCreativeAssociations() - val result = licaService.performLineItemCreativeAssociationAction(action, filterStatement) - result.getNumChanges - } - } - } - - object creatives { - - private val creativeService = services.creativeService - private val typeName = "creatives" - - def get(stmtBuilder: StatementBuilder): Seq[Creative] = { - logAroundRead(typeName, stmtBuilder) { - read(stmtBuilder) { statement => - val page = creativeService.getCreativesByStatement(statement) - (page.getResults, page.getTotalResultSetSize) - } - } - } - - def create(creatives: Seq[Creative]): Seq[Creative] = { - logAroundCreate(typeName) { - creativeService.createCreatives(creatives.toArray).toIndexedSeq - } - } - } -} - -object SessionWrapper extends GuLogging { - - def apply(networkId: Option[String] = None): Option[SessionWrapper] = { - val dfpSession = - try { - for { - serviceAccountKeyFile <- AdminConfiguration.dfpApi.serviceAccountKeyFile - appName <- AdminConfiguration.dfpApi.appName - } yield { - val credential = new OfflineCredentials.Builder() - .forApi(Api.AD_MANAGER) - .withJsonKeyFilePath(serviceAccountKeyFile.toString()) - .build() - .generateCredential() - new AdManagerSession.Builder() - .withOAuth2Credential(credential) - .withApplicationName(appName) - .withNetworkCode(networkId.getOrElse(Configuration.commercial.dfpAccountId)) - .build() - } - } catch { - case NonFatal(e) => - log.error(s"Building DFP session failed.", e) - DfpSessionErrors.increment(); - None - } - - dfpSession map (new SessionWrapper(_)) - } -} diff --git a/admin/app/dfp/package.scala b/admin/app/dfp/package.scala index 5538e5742e1b..4a48993eacda 100644 --- a/admin/app/dfp/package.scala +++ b/admin/app/dfp/package.scala @@ -1,7 +1,7 @@ import org.joda.time.format.{DateTimeFormat, DateTimeFormatter} import org.joda.time.{DateTime, DateTimeZone} -package object dfp { +package object gam { private def timeFormatter: DateTimeFormatter = { DateTimeFormat.forPattern("d MMM YYYY HH:mm:ss z") diff --git a/admin/app/football/model/PrevResult.scala b/admin/app/football/model/PrevResult.scala index 44c6fed3e719..ddd7fb976010 100644 --- a/admin/app/football/model/PrevResult.scala +++ b/admin/app/football/model/PrevResult.scala @@ -18,6 +18,7 @@ case class PrevResult(date: ZonedDateTime, self: MatchDayTeam, foe: MatchDayTeam val lost = scores.exists { case (selfScore, foeScore) => selfScore < foeScore } } +// TODO: check if this class is used anywhere object PrevResult { def apply(result: Result, thisTeamId: String): PrevResult = { if (thisTeamId == result.homeTeam.id) PrevResult(result.date, result.homeTeam, result.awayTeam, wasHome = true) diff --git a/admin/app/jobs/CommercialDfpReporting.scala b/admin/app/jobs/CommercialDfpReporting.scala deleted file mode 100644 index 8d54e533077e..000000000000 --- a/admin/app/jobs/CommercialDfpReporting.scala +++ /dev/null @@ -1,112 +0,0 @@ -package jobs - -import java.time.{LocalDate, LocalDateTime} - -import app.LifecycleComponent -import com.google.api.ads.admanager.axis.v202405.Column.{AD_SERVER_IMPRESSIONS, AD_SERVER_WITHOUT_CPD_AVERAGE_ECPM} -import com.google.api.ads.admanager.axis.v202405.DateRangeType.CUSTOM_DATE -import com.google.api.ads.admanager.axis.v202405.Dimension.{CUSTOM_CRITERIA, DATE} -import com.google.api.ads.admanager.axis.v202405._ -import common.{PekkoAsync, Box, JobScheduler, GuLogging} -import dfp.DfpApi -import play.api.inject.ApplicationLifecycle - -import scala.concurrent.{ExecutionContext, Future} - -object CommercialDfpReporting extends GuLogging { - - case class DfpReportRow(value: String) { - val fields = value.split(",").toSeq - } - - case class DfpReport(rows: Seq[DfpReportRow], lastUpdated: LocalDateTime) - - private val dfpReports = Box[Map[Long, Seq[DfpReportRow]]](Map.empty) - private val dfpCustomReports = Box[Map[String, DfpReport]](Map.empty) - - val teamKPIReport = "All ab-test impressions and CPM" - val prebidBidderPerformance = "Prebid Bidder Performance" - - // These IDs correspond to queries saved in DFP's web console. - val reportMappings = Map( - teamKPIReport -> 10060521970L, // This report is accessible by the DFP user: "NGW DFP Production" - ) - - private def prebidBidderPerformanceQry = { - def toGoogleDate(date: LocalDate) = new Date(date.getYear, date.getMonthValue, date.getDayOfMonth) - val weekAgo = LocalDate.now.minusWeeks(1) - val qry = new ReportQuery() - qry.setDateRangeType(CUSTOM_DATE) - qry.setStartDate(toGoogleDate(weekAgo.minusDays(1))) - qry.setEndDate(toGoogleDate(LocalDate.now)) - qry.setDimensions(Array(DATE, CUSTOM_CRITERIA)) - qry.setColumns(Array(AD_SERVER_IMPRESSIONS, AD_SERVER_WITHOUT_CPD_AVERAGE_ECPM)) - qry - } - - def update(dfpApi: DfpApi)(implicit executionContext: ExecutionContext): Future[Unit] = - Future { - for { - (_, reportId) <- reportMappings.toSeq - } { - val maybeReport: Option[Seq[DfpReportRow]] = dfpApi - .getReportQuery(reportId) - .map(reportId => { - // exclude the CSV header - dfpApi.runReportJob(reportId).tail.map(DfpReportRow) - }) - - maybeReport.foreach { report: Seq[DfpReportRow] => - dfpReports.send(currentMap => { - currentMap + (reportId -> report) - }) - } - } - - dfpCustomReports.send { prev => - val curr = prev + { - prebidBidderPerformance -> - DfpReport( - rows = dfpApi.runReportJob(prebidBidderPerformanceQry).filter(_.contains("hb_bidder=")).map(DfpReportRow), - lastUpdated = LocalDateTime.now, - ) - } - curr foreach { case (key, report) => - log.info(s"Updated report '$key' with ${report.rows.size} rows") - } - curr - } - } - - def getReport(reportId: Long): Option[Seq[DfpReportRow]] = dfpReports.get().get(reportId) - def getCustomReport(reportName: String): Option[DfpReport] = dfpCustomReports.get().get(reportName) -} - -class CommercialDfpReportingLifecycle( - appLifecycle: ApplicationLifecycle, - jobs: JobScheduler, - pekkoAsync: PekkoAsync, - dfpApi: DfpApi, -)(implicit ec: ExecutionContext) - extends LifecycleComponent - with GuLogging { - - appLifecycle.addStopHook { () => - Future { - jobs.deschedule("CommercialDfpReportingJob") - } - } - - override def start(): Unit = { - jobs.deschedule("CommercialDfpReportingJob") - - CommercialDfpReporting.update(dfpApi)(ec) - - // 30 minutes between each log write. - jobs.scheduleEveryNMinutes("CommercialDfpReportingJob", 30) { - log.logger.info(s"Fetching commercial dfp report from dfp api") - CommercialDfpReporting.update(dfpApi)(ec) - } - } - -} diff --git a/admin/app/jobs/FastlyCloudwatchLoadJob.scala b/admin/app/jobs/FastlyCloudwatchLoadJob.scala deleted file mode 100644 index 29e1d751038e..000000000000 --- a/admin/app/jobs/FastlyCloudwatchLoadJob.scala +++ /dev/null @@ -1,71 +0,0 @@ -package jobs - -import com.amazonaws.services.cloudwatch.model.StandardUnit -import com.madgag.scala.collection.decorators.MapDecorator -import common.GuLogging -import metrics.SamplerMetric -import model.diagnostics.CloudWatch -import services.{FastlyStatistic, FastlyStatisticService} - -import scala.collection.mutable -import conf.Configuration -import org.joda.time.DateTime - -import scala.concurrent.ExecutionContext - -class FastlyCloudwatchLoadJob(fastlyStatisticService: FastlyStatisticService) extends GuLogging { - // Samples in CloudWatch are additive so we want to limit duplicate reporting. - // We do not want to corrupt the past either, so set a default value (the most - // recent 15 minutes of results are unstable). - - // This key is (service, name, region) - val latestTimestampsSent = - mutable.Map[(String, String, String), Long]().withDefaultValue(DateTime.now().minusMinutes(15).getMillis()) - - // Be very explicit about which metrics we want. It is not necessary to cloudwatch everything. - val allFastlyMetrics: List[SamplerMetric] = List( - SamplerMetric("usa-hits", StandardUnit.Count), - SamplerMetric("usa-miss", StandardUnit.Count), - SamplerMetric("usa-errors", StandardUnit.Count), - SamplerMetric("europe-hits", StandardUnit.Count), - SamplerMetric("europe-miss", StandardUnit.Count), - SamplerMetric("europe-errors", StandardUnit.Count), - SamplerMetric("ausnz-hits", StandardUnit.Count), - SamplerMetric("ausnz-miss", StandardUnit.Count), - SamplerMetric("ausnz-errors", StandardUnit.Count), - ) - - private def updateMetricFromStatistic(stat: FastlyStatistic): Unit = { - val maybeMetric: Option[SamplerMetric] = allFastlyMetrics.find { metric => - metric.name == s"${stat.region}-${stat.name}" - } - - maybeMetric.foreach { metric => - metric.recordSample(stat.value.toDouble, new DateTime(stat.timestamp)) - } - } - - def run()(implicit executionContext: ExecutionContext): Unit = { - log.info("Loading statistics from Fastly to CloudWatch.") - fastlyStatisticService.fetch().map { statistics => - val fresh: List[FastlyStatistic] = statistics filter { statistic => - latestTimestampsSent(statistic.key) < statistic.timestamp - } - - log.info("Uploading %d new metric data points" format fresh.size) - - if (Configuration.environment.isProd) { - fresh.foreach { updateMetricFromStatistic } - CloudWatch.putMetrics("Fastly", allFastlyMetrics, List.empty) - } else { - log.info("DISABLED: Metrics uploaded in PROD only to limit duplication.") - } - - val groups = fresh groupBy { _.key } - val timestampsSent = groups mapV { _ map { _.timestamp } } - timestampsSent mapV { _.max } foreach { case (key, value) => - latestTimestampsSent.update(key, value) - } - } - } -} diff --git a/admin/app/model/AdminLifecycle.scala b/admin/app/model/AdminLifecycle.scala index 73c204d11e24..a10ef9312b4b 100644 --- a/admin/app/model/AdminLifecycle.scala +++ b/admin/app/model/AdminLifecycle.scala @@ -21,7 +21,6 @@ class AdminLifecycle( jobs: JobScheduler, pekkoAsync: PekkoAsync, emailService: EmailService, - fastlyCloudwatchLoadJob: FastlyCloudwatchLoadJob, r2PagePressJob: R2PagePressJob, analyticsSanityCheckJob: AnalyticsSanityCheckJob, rebuildIndexJob: RebuildIndexJob, @@ -34,7 +33,7 @@ class AdminLifecycle( descheduleJobs() CloudWatch.shutdown() emailService.shutdown() - deleteTmpFiles() + // deleteTmpFiles() } } @@ -56,11 +55,6 @@ class AdminLifecycle( LoadBalancer.refresh() } - // every 2 minutes starting 5 seconds past the minute (e.g 13:02:05, 13:04:05) - jobs.schedule("FastlyCloudwatchLoadJob", "5 0/2 * * * ?") { - fastlyCloudwatchLoadJob.run() - } - jobs.scheduleEvery("R2PagePressJob", r2PagePressRateInSeconds.seconds) { r2PagePressJob.run() } @@ -110,7 +104,6 @@ class AdminLifecycle( private def descheduleJobs(): Unit = { jobs.deschedule("AdminLoadJob") jobs.deschedule("LoadBalancerLoadJob") - jobs.deschedule("FastlyCloudwatchLoadJob") jobs.deschedule("R2PagePressJob") jobs.deschedule("AnalyticsSanityCheckJob") jobs.deschedule("RebuildIndexJob") @@ -122,8 +115,6 @@ class AdminLifecycle( jobs.deschedule("AssetMetricsCache") } - private def deleteTmpFiles(): Unit = AdminConfiguration.dfpApi.serviceAccountKeyFile.map(deleteIfExists) - override def start(): Unit = { descheduleJobs() scheduleJobs() diff --git a/admin/app/services/Fastly.scala b/admin/app/services/Fastly.scala deleted file mode 100644 index a9532cc8e5c4..000000000000 --- a/admin/app/services/Fastly.scala +++ /dev/null @@ -1,87 +0,0 @@ -package services - -import common.GuLogging -import conf.AdminConfiguration.fastly -import com.amazonaws.services.cloudwatch.model.{Dimension, MetricDatum} - -import java.util.Date -import play.api.libs.ws.WSClient -import play.api.libs.json.{JsValue, Json, OFormat} - -import scala.concurrent.{ExecutionContext, Future} -import scala.concurrent.duration._ - -case class FastlyStatistic(service: String, region: String, timestamp: Long, name: String, value: String) { - lazy val key: (String, String, String) = (service, name, region) - - lazy val metric = new MetricDatum() - .withMetricName(name) - .withDimensions( - new Dimension().withName("service").withValue(service), - new Dimension().withName("region").withValue(region), - ) - .withTimestamp(new Date(timestamp)) - .withValue(value.toDouble) -} - -class FastlyStatisticService(wsClient: WSClient) extends GuLogging { - - private case class FastlyApiStat( - hits: Int, - miss: Int, - errors: Int, - service_id: String, - start_time: Long, - ) - - private implicit val FastlyApiStatFormat: OFormat[FastlyApiStat] = Json.format[FastlyApiStat] - - private val regions = List("usa", "europe", "ausnz") - - def fetch()(implicit executionContext: ExecutionContext): Future[List[FastlyStatistic]] = { - - val futureResponses: Future[List[JsValue]] = Future - .sequence { - regions map { region => - val request = wsClient - .url( - s"https://api.fastly.com/stats/service/$%7Bfastly.serviceId%7D?by=minute&from=45+minutes+ago&to=15+minutes+ago®ion=$region", - ) - .withHttpHeaders("Fastly-Key" -> fastly.key) - .withRequestTimeout(20.seconds) - - val response: Future[Option[JsValue]] = - request.get().map { resp => Some(resp.json) }.recover { case e: Throwable => - log.error(s"Error with request to api.fastly.com: ${e.getMessage}") - None - } - response - } - - } - .map(_.flatten) - - futureResponses map { responses => - responses flatMap { json => - val samples: List[FastlyApiStat] = (json \ "data").validate[List[FastlyApiStat]].getOrElse(Nil) - val region: String = (json \ "meta" \ "region").as[String] - - log.info(s"Loaded ${samples.size} Fastly statistics results for region: $region") - - samples flatMap { sample: FastlyApiStat => - val service: String = sample.service_id - val timestamp: Long = sample.start_time * 1000 - val statistics: List[(String, String)] = List( - ("hits", sample.hits.toString), - ("miss", sample.miss.toString), - ("errors", sample.errors.toString), - ) - - statistics map { case (name, stat) => - FastlyStatistic(service, region, timestamp, name, stat) - } - } - } - } - } -} diff --git a/admin/app/tools/CloudWatch.scala b/admin/app/tools/CloudWatch.scala index 3a58f6946151..4fe0e23d4f20 100644 --- a/admin/app/tools/CloudWatch.scala +++ b/admin/app/tools/CloudWatch.scala @@ -10,15 +10,9 @@ import com.amazonaws.services.cloudwatch.model._ import common.GuLogging import conf.Configuration import conf.Configuration._ -import org.joda.time.DateTime -import scala.jdk.CollectionConverters._ import scala.concurrent.{ExecutionContext, Future} -case class MaximumMetric(metric: GetMetricStatisticsResult) { - lazy val max: Double = metric.getDatapoints.asScala.headOption.map(_.getMaximum.doubleValue()).getOrElse(0.0) -} - object CloudWatch extends GuLogging { def shutdown(): Unit = { euWestClient.shutdown() @@ -39,6 +33,9 @@ object CloudWatch extends GuLogging { // some metrics are only available in the 'default' region lazy val defaultClient: AmazonCloudWatchAsync = defaultClientBuilder.build() + val v1LoadBalancerNamespace = "AWS/ELB" + val v2LoadBalancerNamespace = "AWS/ApplicationELB" + val primaryLoadBalancers: Seq[LoadBalancer] = Seq( LoadBalancer("frontend-router"), LoadBalancer("frontend-article"), @@ -57,47 +54,8 @@ object CloudWatch extends GuLogging { LoadBalancer("frontend-rss"), ).flatten - private val chartColours = Map( - ("frontend-router", ChartFormat(Colour.`tone-news-1`)), - ("frontend-article", ChartFormat(Colour.`tone-news-1`)), - ("frontend-facia", ChartFormat(Colour.`tone-news-1`)), - ("frontend-applications", ChartFormat(Colour.`tone-news-1`)), - ("frontend-discussion", ChartFormat(Colour.`tone-news-2`)), - ("frontend-identity", ChartFormat(Colour.`tone-news-2`)), - ("frontend-sport", ChartFormat(Colour.`tone-news-2`)), - ("frontend-commercial", ChartFormat(Colour.`tone-news-2`)), - ("frontend-onward", ChartFormat(Colour.`tone-news-2`)), - ("frontend-r2football", ChartFormat(Colour.`tone-news-2`)), - ("frontend-diagnostics", ChartFormat(Colour.`tone-news-2`)), - ("frontend-archive", ChartFormat(Colour.`tone-news-2`)), - ("frontend-rss", ChartFormat(Colour.`tone-news-2`)), - ).withDefaultValue(ChartFormat.SingleLineBlack) - val loadBalancers = primaryLoadBalancers ++ secondaryLoadBalancers - private val fastlyMetrics = List( - ("Fastly Errors (Europe) - errors per minute, average", "europe-errors"), - ("Fastly Errors (USA) - errors per minute, average", "usa-errors"), - ) - - private val fastlyHitMissMetrics = List( - ("Fastly Hits and Misses (Europe) - per minute, average", "europe"), - ("Fastly Hits and Misses (USA) - per minute, average", "usa"), - ) - - val assetsFiles = Seq( - "app.js", - "commercial.js", - "facia.js", - "content.css", - "head.commercial.css", - "head.content.css", - "head.facia.css", - "head.football.css", - "head.identity.css", - "head.index.css", - ) - def withErrorLogging[A](future: Future[A])(implicit executionContext: ExecutionContext): Future[A] = { future.failed.foreach { exception: Throwable => log.error(s"CloudWatch error: ${exception.getMessage}", exception) @@ -105,239 +63,6 @@ object CloudWatch extends GuLogging { future } - def fetchLatencyMetric( - loadBalancer: LoadBalancer, - )(implicit executionContext: ExecutionContext): Future[GetMetricStatisticsResult] = - withErrorLogging( - euWestClient.getMetricStatisticsFuture( - new GetMetricStatisticsRequest() - .withStartTime(new DateTime().minusHours(2).toDate) - .withEndTime(new DateTime().toDate) - .withPeriod(60) - .withUnit(StandardUnit.Seconds) - .withStatistics("Average") - .withNamespace("AWS/ELB") - .withMetricName("Latency") - .withDimensions(new Dimension().withName("LoadBalancerName").withValue(loadBalancer.id)), - ), - ) - - private def latency( - loadBalancers: Seq[LoadBalancer], - )(implicit executionContext: ExecutionContext): Future[Seq[AwsLineChart]] = { - Future.traverse(loadBalancers) { loadBalancer => - fetchLatencyMetric(loadBalancer).map { metricsResult => - new AwsLineChart( - loadBalancer.name, - Seq("Time", "latency (ms)"), - chartColours(loadBalancer.project), - metricsResult, - ) - } - } - } - - def fullStackLatency()(implicit executionContext: ExecutionContext): Future[Seq[AwsLineChart]] = - latency(primaryLoadBalancers ++ secondaryLoadBalancers) - - def fetchOkMetric( - loadBalancer: LoadBalancer, - )(implicit executionContext: ExecutionContext): Future[GetMetricStatisticsResult] = - withErrorLogging( - euWestClient.getMetricStatisticsFuture( - new GetMetricStatisticsRequest() - .withStartTime(new DateTime().minusHours(2).toDate) - .withEndTime(new DateTime().toDate) - .withPeriod(60) - .withStatistics("Sum") - .withNamespace("AWS/ELB") - .withMetricName("HTTPCode_Backend_2XX") - .withDimensions(new Dimension().withName("LoadBalancerName").withValue(loadBalancer.id)), - ), - ) - - def dualOkLatency( - loadBalancers: Seq[LoadBalancer], - )(implicit executionContext: ExecutionContext): Future[Seq[AwsDualYLineChart]] = { - Future.traverse(loadBalancers) { loadBalancer => - for { - oks <- fetchOkMetric(loadBalancer) - latency <- fetchLatencyMetric(loadBalancer) - healthyHosts <- fetchHealthyHostMetric(loadBalancer) - } yield { - val chartTitle = s"${loadBalancer.name} - ${healthyHosts.getDatapoints.asScala.last.getMaximum.toInt} instances" - new AwsDualYLineChart( - chartTitle, - ("Time", "2xx/minute", "latency (secs)"), - ChartFormat(Colour.`tone-news-1`, Colour.`tone-comment-1`), - oks, - latency, - ) - } - } - } - - def dualOkLatencyFullStack()(implicit executionContext: ExecutionContext): Future[Seq[AwsDualYLineChart]] = - dualOkLatency(primaryLoadBalancers ++ secondaryLoadBalancers) - - def fetchHealthyHostMetric( - loadBalancer: LoadBalancer, - )(implicit executionContext: ExecutionContext): Future[GetMetricStatisticsResult] = - withErrorLogging( - euWestClient.getMetricStatisticsFuture( - new GetMetricStatisticsRequest() - .withStartTime(new DateTime().minusHours(2).toDate) - .withEndTime(new DateTime().toDate) - .withPeriod(60) - .withStatistics("Maximum") - .withNamespace("AWS/ELB") - .withMetricName("HealthyHostCount") - .withDimensions(new Dimension().withName("LoadBalancerName").withValue(loadBalancer.id)), - ), - ) - - def fastlyErrors()(implicit executionContext: ExecutionContext): Future[List[AwsLineChart]] = - Future.traverse(fastlyMetrics) { case (graphTitle, metric) => - withErrorLogging( - euWestClient.getMetricStatisticsFuture( - new GetMetricStatisticsRequest() - .withStartTime(new DateTime().minusHours(6).toDate) - .withEndTime(new DateTime().toDate) - .withPeriod(120) - .withStatistics("Average") - .withNamespace("Fastly") - .withDimensions(stage) - .withMetricName(metric), - ), - ) map { metricsResult => - new AwsLineChart(graphTitle, Seq("Time", metric), ChartFormat(Colour.`tone-features-2`), metricsResult) - } - } - - def fastlyHitMissStatistics()(implicit executionContext: ExecutionContext): Future[List[AwsLineChart]] = - Future.traverse(fastlyHitMissMetrics) { case (graphTitle, region) => - for { - hits <- withErrorLogging( - euWestClient.getMetricStatisticsFuture( - new GetMetricStatisticsRequest() - .withStartTime(new DateTime().minusHours(6).toDate) - .withEndTime(new DateTime().toDate) - .withPeriod(120) - .withStatistics("Average") - .withNamespace("Fastly") - .withMetricName(s"$region-hits") - .withDimensions(stage), - ), - ) - - misses <- withErrorLogging( - euWestClient.getMetricStatisticsFuture( - new GetMetricStatisticsRequest() - .withStartTime(new DateTime().minusHours(6).toDate) - .withEndTime(new DateTime().toDate) - .withPeriod(120) - .withStatistics("Average") - .withNamespace("Fastly") - .withMetricName(s"$region-miss") - .withDimensions(stage), - ), - ) - } yield new AwsLineChart( - graphTitle, - Seq("Time", "Hits", "Misses"), - ChartFormat(Colour.success, Colour.error), - hits, - misses, - ) - } - - def confidenceGraph(metricName: String)(implicit executionContext: ExecutionContext): Future[AwsLineChart] = - for { - percentConversion <- withErrorLogging( - euWestClient.getMetricStatisticsFuture( - new GetMetricStatisticsRequest() - .withStartTime(new DateTime().minusWeeks(2).toDate) - .withEndTime(new DateTime().toDate) - .withPeriod(900) - .withStatistics("Average") - .withNamespace("Analytics") - .withMetricName(metricName) - .withDimensions(stage), - ), - ) - } yield new AwsLineChart(metricName, Seq("Time", "%"), ChartFormat.SingleLineBlue, percentConversion) - - def ophanConfidence()(implicit executionContext: ExecutionContext): Future[AwsLineChart] = - confidenceGraph("ophan-percent-conversion") - - def googleConfidence()(implicit executionContext: ExecutionContext): Future[AwsLineChart] = - confidenceGraph("google-percent-conversion") - - def routerBackend50x()(implicit executionContext: ExecutionContext): Future[AwsLineChart] = { - val dimension = new Dimension() - .withName("LoadBalancerName") - .withValue(LoadBalancer("frontend-router").fold("unknown")(_.id)) - for { - metric <- withErrorLogging( - euWestClient.getMetricStatisticsFuture( - new GetMetricStatisticsRequest() - .withStartTime(new DateTime().minusHours(2).toDate) - .withEndTime(new DateTime().toDate) - .withPeriod(60) - .withStatistics("Sum") - .withNamespace("AWS/ELB") - .withMetricName("HTTPCode_Backend_5XX") - .withDimensions(dimension), - ), - ) - } yield new AwsLineChart("Router 50x", Seq("Time", "50x/min"), ChartFormat.SingleLineRed, metric) - } - - object headlineTests { - - private def get( - metricName: String, - )(implicit executionContext: ExecutionContext): Future[GetMetricStatisticsResult] = - euWestClient.getMetricStatisticsFuture( - new GetMetricStatisticsRequest() - .withStartTime(new DateTime().minusHours(6).toDate) - .withEndTime(new DateTime().toDate) - .withPeriod(60) - .withStatistics("Sum") - .withNamespace("Diagnostics") - .withMetricName(metricName) - .withDimensions(stage), - ) - - def control()(implicit executionContext: ExecutionContext): Future[AwsLineChart] = - withErrorLogging( - for { - viewed <- get("headlines-control-seen") - clicked <- get("headlines-control-clicked") - } yield new AwsLineChart( - "Control Group", - Seq("", "Saw the headline", "Clicked the headline"), - ChartFormat.DoubleLineBlueRed, - viewed, - clicked, - ), - ) - - def variant()(implicit executionContext: ExecutionContext): Future[AwsLineChart] = - withErrorLogging( - for { - viewed <- get("headlines-variant-seen") - clicked <- get("headlines-variant-clicked") - } yield new AwsLineChart( - "Test Group", - Seq("cccc", "Saw the headline", "Clicked the headline"), - ChartFormat.DoubleLineBlueRed, - viewed, - clicked, - ), - ) - } - def AbMetricNames()(implicit executionContext: ExecutionContext): Future[ListMetricsResult] = { withErrorLogging( euWestClient.listMetricsFuture( @@ -347,68 +72,4 @@ object CloudWatch extends GuLogging { ), ) } - - def eventualAdResponseConfidenceGraph()(implicit executionContext: ExecutionContext): Future[AwsLineChart] = { - - def getMetric(metricName: String): Future[GetMetricStatisticsResult] = { - val now = DateTime.now() - withErrorLogging( - euWestClient.getMetricStatisticsFuture( - new GetMetricStatisticsRequest() - .withNamespace("Diagnostics") - .withMetricName(metricName) - .withStartTime(now.minusWeeks(2).toDate) - .withEndTime(now.toDate) - .withPeriod(900) - .withStatistics("Sum") - .withDimensions(stage), - ), - ) - } - - def compare( - pvCount: GetMetricStatisticsResult, - pvWithAdCount: GetMetricStatisticsResult, - ): GetMetricStatisticsResult = { - - val pvWithAdCountMap = pvWithAdCount.getDatapoints.asScala.map { point => - point.getTimestamp -> point.getSum.toDouble - }.toMap - - val confidenceValues = pvCount.getDatapoints.asScala.foldLeft(List.empty[Datapoint]) { - case (soFar, pvCountValue) => - val confidenceValue = pvWithAdCountMap - .get(pvCountValue.getTimestamp) - .map { pvWithAdCountValue => - pvWithAdCountValue * 100 / pvCountValue.getSum.toDouble - } - .getOrElse(0d) - soFar :+ new Datapoint().withTimestamp(pvCountValue.getTimestamp).withSum(confidenceValue) - } - - new GetMetricStatisticsResult().withDatapoints(confidenceValues.asJava) - } - - for { - pageViewCount <- getMetric("kpis-page-views") - pageViewWithAdCount <- getMetric("first-ad-rendered") - } yield { - val confidenceMetric = compare(pageViewCount, pageViewWithAdCount) - val averageMetric = { - val dataPoints = confidenceMetric.getDatapoints - val average = dataPoints.asScala.map(_.getSum.toDouble).sum / dataPoints.asScala.length - val averageDataPoints = dataPoints.asScala map { point => - new Datapoint().withTimestamp(point.getTimestamp).withSum(average) - } - new GetMetricStatisticsResult().withDatapoints(averageDataPoints.asJava) - } - new AwsLineChart( - name = "Ad Response Confidence", - labels = Seq("Time", "%", "avg."), - ChartFormat(Colour.`tone-comment-2`, Colour.success), - charts = confidenceMetric, - averageMetric, - ) - } - } } diff --git a/admin/app/tools/DfpLink.scala b/admin/app/tools/DfpLink.scala index 419c386f347f..2d5964c95884 100644 --- a/admin/app/tools/DfpLink.scala +++ b/admin/app/tools/DfpLink.scala @@ -9,14 +9,6 @@ object DfpLink { s"https://www.google.com/dfp/$dfpAccountId#delivery/LineItemDetail/lineItemId=$lineItemId" } - def creativeTemplate(templateId: Long): String = { - s"https://www.google.com/dfp/$dfpAccountId#delivery/CreateCreativeTemplate/creativeTemplateId=$templateId" - } - - def creative(creativeId: Long): String = { - s"https://www.google.com/dfp/$dfpAccountId#delivery/CreativeDetail/creativeId=$creativeId" - } - def adUnit(adUnitId: String): String = { s"https://www.google.com/dfp/59666047?#inventory/inventory/adSlotId=$adUnitId" } diff --git a/admin/app/tools/LoadBalancer.scala b/admin/app/tools/LoadBalancer.scala index fe380fec634d..a9703f31f57b 100644 --- a/admin/app/tools/LoadBalancer.scala +++ b/admin/app/tools/LoadBalancer.scala @@ -11,6 +11,8 @@ case class LoadBalancer( project: String, url: Option[String] = None, testPath: Option[String] = None, + // Application load balancers (v2) have target groups, classic load balancers (v1) do not + targetGroup: Option[String] = None, ) object LoadBalancer extends GuLogging { @@ -20,20 +22,63 @@ object LoadBalancer extends GuLogging { private val loadBalancers = Seq( LoadBalancer("frontend-PROD-router-ELB", "Router", "frontend-router"), LoadBalancer( - "frontend-PROD-article-ELB", + "app/fronte-LoadB-xXnA5yhOxB7G/cf797b52302fc833", "Article", "frontend-article", testPath = Some("/uk-news/2014/jan/21/drax-protesters-convictions-quashed-police-spy-mark-kennedy"), + targetGroup = Some("targetgroup/fronte-Targe-YYBO08CFBLH9/19192d72461d4042"), + ), + LoadBalancer( + "app/fronte-LoadB-qTAetyigfHhb/f4371301ea282f8a", + "Front", + "frontend-facia", + testPath = Some("/uk"), + targetGroup = Some("targetgroup/fronte-Targe-RG4PYAXIXEAH/c4104696046f2543"), + ), + LoadBalancer( + "app/fronte-LoadB-jjbgLSz4Ttk7/0e30c8ef528bd918", + "Applications", + "frontend-applications", + testPath = Some("/books"), + targetGroup = Some("targetgroup/fronte-Targe-J5GTY7IUW6U4/5fa6083486dbd785"), + ), + LoadBalancer( + "app/fronte-LoadB-xmdWiUUHyRUS/122c59735e8374bb", + "Discussion", + "frontend-discussion", + targetGroup = Some("targetgroup/fronte-Targe-JGOOGIGPNWQJ/187642c8eda54a4a"), ), - LoadBalancer("frontend-PROD-facia-ELB", "Front", "frontend-facia", testPath = Some("/uk")), - LoadBalancer("frontend-PROD-applications-ELB", "Applications", "frontend-applications", testPath = Some("/books")), - LoadBalancer("frontend-PROD-discussion-ELB", "Discussion", "frontend-discussion"), LoadBalancer("frontend-PROD-identity-ELB", "Identity", "frontend-identity"), - LoadBalancer("frontend-PROD-sport-ELB", "Sport", "frontend-sport"), - LoadBalancer("frontend-PROD-commercial-ELB", "Commercial", "frontend-commercial"), - LoadBalancer("frontend-PROD-onward-ELB", "Onward", "frontend-onward"), - LoadBalancer("frontend-PROD-archive-ELB", "Archive", "frontend-archive"), - LoadBalancer("frontend-PROD-rss-ELB", "Rss", "frontend-rss"), + LoadBalancer( + "app/fronte-LoadB-t2NTzJp2RZFf/4119950dc35e5cb4", + "Sport", + "frontend-sport", + targetGroup = Some("targetgroup/fronte-Targe-LJMDWMGH5FPD/e777dd4276b0bf29"), + ), + LoadBalancer( + "app/fronte-LoadB-4KxztKWTJxEu/faee39a2eecb4c1a", + "Commercial", + "frontend-commercial", + targetGroup = Some("targetgroup/fronte-Targe-C8VZWOPZ3TTS/271f997aea40fb19"), + ), + LoadBalancer( + "app/fronte-LoadB-NpLaks0rT7va/e5a6b5bea5119952", + "Onward", + "frontend-onward", + targetGroup = Some("targetgroup/fronte-Targe-N0YDVRHJB7IM/99164208e6758b4e"), + ), + LoadBalancer( + "app/fronte-LoadB-wSjta29AZxoG/32048dda4b467613", + "Archive", + "frontend-archive", + targetGroup = Some("targetgroup/fronte-Targe-CVM11DC1XUEX/5980205ce24de6bf"), + ), + LoadBalancer( + "app/fronte-LoadB-lVDfejahHxTX/5cfe31b29fa71749", + "Rss", + "frontend-rss", + targetGroup = Some("targetgroup/fronte-Targe-1UWQX08K530W/4da53e8e56d13ab8"), + ), ) private val agent = Box(loadBalancers) diff --git a/admin/app/tools/Store.scala b/admin/app/tools/Store.scala index 9f4bac998a46..5c42faaf62e6 100644 --- a/admin/app/tools/Store.scala +++ b/admin/app/tools/Store.scala @@ -3,6 +3,7 @@ package tools import common.GuLogging import common.dfp._ import conf.Configuration.commercial._ +import conf.Configuration.abTesting._ import conf.{AdminConfiguration, Configuration} import implicits.Dates import org.joda.time.DateTime @@ -19,34 +20,13 @@ trait Store extends GuLogging with Dates { def getSwitches: Option[String] = S3.get(switchesKey) def getSwitchesWithLastModified: Option[(String, DateTime)] = S3.getWithLastModified(switchesKey) def getSwitchesLastModified: Option[DateTime] = S3.getLastModified(switchesKey) - def putSwitches(config: String): Unit = { S3.putPublic(switchesKey, config, "text/plain") } + def putSwitches(config: String): Unit = { S3.putPrivate(switchesKey, config, "text/plain") } def getTopStories: Option[String] = S3.get(topStoriesKey) def putTopStories(config: String): Unit = { S3.putPublic(topStoriesKey, config, "application/json") } - def putLiveBlogTopSponsorships(sponsorshipsJson: String): Unit = { - S3.putPublic(dfpLiveBlogTopSponsorshipDataKey, sponsorshipsJson, defaultJsonEncoding) - } - def putSurveySponsorships(adUnitJson: String): Unit = { - S3.putPublic(dfpSurveySponsorshipDataKey, adUnitJson, defaultJsonEncoding) - } - def putDfpPageSkinAdUnits(adUnitJson: String): Unit = { - S3.putPublic(dfpPageSkinnedAdUnitsKey, adUnitJson, defaultJsonEncoding) - } def putDfpLineItemsReport(everything: String): Unit = { - S3.putPublic(dfpLineItemsKey, everything, defaultJsonEncoding) - } - def putDfpAdUnitList(filename: String, adUnits: String): Unit = { - S3.putPublic(filename, adUnits, "text/plain") - } - def putDfpTemplateCreatives(creatives: String): Unit = { - S3.putPublic(dfpTemplateCreativesKey, creatives, defaultJsonEncoding) - } - def putDfpCustomTargetingKeyValues(keyValues: String): Unit = { - S3.putPublic(dfpCustomTargetingKey, keyValues, defaultJsonEncoding) - } - def putNonRefreshableLineItemIds(lineItemIds: Seq[Long]): Unit = { - S3.putPublic(dfpNonRefreshableLineItemIdsKey, Json.stringify(toJson(lineItemIds)), defaultJsonEncoding) + S3.putPrivate(dfpLineItemsKey, everything, defaultJsonEncoding) } val now: String = DateTime.now().toHttpDateTimeString @@ -83,13 +63,6 @@ trait Store extends GuLogging with Dates { maybeLineItems getOrElse LineItemReport("Empty Report", Nil, Nil) } - def getDfpTemplateCreatives: Seq[GuCreative] = { - val creatives = for (doc <- S3.get(dfpTemplateCreativesKey)) yield { - Json.parse(doc).as[Seq[GuCreative]] - } - creatives getOrElse Nil - } - def getDfpCustomTargetingKeyValues: Seq[GuCustomTargeting] = { val targeting = for (doc <- S3.get(dfpCustomTargetingKey)) yield { val json = Json.parse(doc) @@ -101,18 +74,22 @@ trait Store extends GuLogging with Dates { targeting getOrElse Nil } - object commercial { - - def getTakeoversWithEmptyMPUs(): Seq[TakeoverWithEmptyMPUs] = { - S3.get(takeoversWithEmptyMPUsKey) map { - Json.parse(_).as[Seq[TakeoverWithEmptyMPUs]] - } getOrElse Nil + def getDfpCustomFields: Seq[GuCustomField] = { + val customFields = for (doc <- S3.get(dfpCustomFieldsKey)) yield { + Json.parse(doc).as[Seq[GuCustomField]] } + customFields getOrElse Nil + } + + def getAbTestFrameUrl: Option[String] = { + S3.getPresignedUrl(uiHtmlObjectKey) + } - def putTakeoversWithEmptyMPUs(takeovers: Seq[TakeoverWithEmptyMPUs]): Unit = { - val content = Json.stringify(toJson(takeovers)) - S3.putPrivate(takeoversWithEmptyMPUsKey, content, "application/json") + def getDfpSpecialAdUnits: Seq[(String, String)] = { + val specialAdUnits = for (doc <- S3.get(dfpSpecialAdUnitsKey)) yield { + Json.parse(doc).as[Seq[(String, String)]] } + specialAdUnits getOrElse Nil } } diff --git a/admin/app/tools/errors.scala b/admin/app/tools/errors.scala index 75cbb5e41a8d..99541a0fe128 100644 --- a/admin/app/tools/errors.scala +++ b/admin/app/tools/errors.scala @@ -4,56 +4,37 @@ import CloudWatch._ import com.amazonaws.services.cloudwatch.model.{Dimension, GetMetricStatisticsRequest} import org.joda.time.DateTime import awswrappers.cloudwatch._ -import conf.Configuration._ import scala.concurrent.{ExecutionContext, Future} object HttpErrors { - def global4XX()(implicit executionContext: ExecutionContext): Future[AwsLineChart] = - euWestClient.getMetricStatisticsFuture(metric("HTTPCode_Backend_4XX")) map { metric => - new AwsLineChart("Global 4XX", Seq("Time", "4xx/min"), ChartFormat.SingleLineBlue, metric) - } - private val stage = new Dimension().withName("Stage").withValue(environment.stage) + val v1Metric4XX = "HTTPCode_Backend_4XX" + val v2Metric4XX = "HTTPCode_Target_4XX_Count" + + val v1Metric5XX = "HTTPCode_Backend_5XX" + val v2Metric5XX = "HTTPCode_Target_5XX_Count" - def googlebot404s()(implicit executionContext: ExecutionContext): Future[Seq[AwsLineChart]] = + def legacyElb4XXs()(implicit executionContext: ExecutionContext): Future[AwsLineChart] = withErrorLogging( - Future.sequence( - Seq( - euWestClient.getMetricStatisticsFuture( - metric("googlebot-404s") - .withStartTime(new DateTime().minusHours(12).toDate) - .withNamespace("ArchiveMetrics") - .withDimensions(stage), - ) map { metric => - new AwsLineChart("12 hours", Seq("Time", "404/min"), ChartFormat(Colour.`tone-live-1`), metric) - }, - euWestClient.getMetricStatisticsFuture( - metric("googlebot-404s") - .withNamespace("ArchiveMetrics") - .withDimensions(stage) - .withPeriod(900) - .withStartTime(new DateTime().minusDays(14).toDate), - ) map { metric => - new AwsLineChart("2 weeks", Seq("Time", "404/15min"), ChartFormat(Colour.`tone-live-2`), metric) - }, - ), - ), - ) + euWestClient.getMetricStatisticsFuture(metric(v1Metric4XX, v1LoadBalancerNamespace)), + ) map { metric => + new AwsLineChart("Legacy ELB 4XXs", Seq("Time", "4xx/min"), ChartFormat.SingleLineBlue, metric) + } - def global5XX()(implicit executionContext: ExecutionContext): Future[AwsLineChart] = + def legacyElb5XXs()(implicit executionContext: ExecutionContext): Future[AwsLineChart] = withErrorLogging( euWestClient.getMetricStatisticsFuture( - metric("HTTPCode_Backend_5XX"), + metric(v1Metric5XX, v1LoadBalancerNamespace), ) map { metric => - new AwsLineChart("Global 5XX", Seq("Time", "5XX/ min"), ChartFormat.SingleLineRed, metric) + new AwsLineChart("Legacy ELB 5XXs", Seq("Time", "5XX/ min"), ChartFormat.SingleLineRed, metric) }, ) def notFound()(implicit executionContext: ExecutionContext): Future[Seq[AwsLineChart]] = withErrorLogging(Future.traverse(primaryLoadBalancers ++ secondaryLoadBalancers) { loadBalancer => euWestClient.getMetricStatisticsFuture( - metric("HTTPCode_Backend_4XX", Some(loadBalancer.id)), + loadBalancerMetric(v1Metric4XX, v2Metric4XX, loadBalancer), ) map { metric => new AwsLineChart(loadBalancer.name, Seq("Time", "4XX/ min"), ChartFormat.SingleLineBlue, metric) } @@ -62,25 +43,35 @@ object HttpErrors { def errors()(implicit executionContext: ExecutionContext): Future[Seq[AwsLineChart]] = withErrorLogging(Future.traverse(primaryLoadBalancers ++ secondaryLoadBalancers) { loadBalancer => euWestClient.getMetricStatisticsFuture( - metric("HTTPCode_Backend_5XX", Some(loadBalancer.id)), + loadBalancerMetric(v1Metric5XX, v2Metric5XX, loadBalancer), ) map { metric => new AwsLineChart(loadBalancer.name, Seq("Time", "5XX/ min"), ChartFormat.SingleLineRed, metric) } }) - def metric(metricName: String, loadBalancer: Option[String] = None)(implicit - executionContext: ExecutionContext, - ): GetMetricStatisticsRequest = { - val metric = new GetMetricStatisticsRequest() - .withStartTime(new DateTime().minusHours(2).toDate) - .withEndTime(new DateTime().toDate) - .withPeriod(60) - .withStatistics("Sum") - .withNamespace("AWS/ELB") - .withMetricName(metricName) + def metric(metricName: String, namespace: String): GetMetricStatisticsRequest = new GetMetricStatisticsRequest() + .withStartTime(new DateTime().minusHours(2).toDate) + .withEndTime(new DateTime().toDate) + .withPeriod(60) + .withStatistics("Sum") + .withNamespace(namespace) + .withMetricName(metricName) - loadBalancer - .map(lb => metric.withDimensions(new Dimension().withName("LoadBalancerName").withValue(lb))) - .getOrElse(metric) + def loadBalancerMetric( + v1MetricName: String, + v2MetricName: String, + loadBalancer: LoadBalancer, + ): GetMetricStatisticsRequest = { + loadBalancer.targetGroup match { + case None => + metric(v1MetricName, v1LoadBalancerNamespace).withDimensions( + new Dimension().withName("LoadBalancerName").withValue(loadBalancer.id), + ) + case Some(_) => + metric(v2MetricName, v2LoadBalancerNamespace).withDimensions( + new Dimension().withName("LoadBalancer").withValue(loadBalancer.id), + ) + } } + } diff --git a/admin/app/views/abTests.scala.html b/admin/app/views/abTests.scala.html new file mode 100644 index 000000000000..ee509237e605 --- /dev/null +++ b/admin/app/views/abTests.scala.html @@ -0,0 +1,7 @@ +@(frameUrl: Option[String])(implicit request: RequestHeader, context: model.ApplicationContext) + +@import views.support.CamelCase + +@admin_embed("A/B Tests", isAuthed = true, hasCharts = true) { + +} diff --git a/admin/app/views/admin.scala.html b/admin/app/views/admin.scala.html index 3a94f0ce1a90..f4f7c11a86ee 100644 --- a/admin/app/views/admin.scala.html +++ b/admin/app/views/admin.scala.html @@ -14,8 +14,8 @@

Analytics

@@ -55,21 +55,13 @@

Metrics

diff --git a/admin/app/views/admin_embed.scala.html b/admin/app/views/admin_embed.scala.html new file mode 100644 index 000000000000..355f49ac648e --- /dev/null +++ b/admin/app/views/admin_embed.scala.html @@ -0,0 +1,73 @@ +@(title: String, + isAuthed: Boolean = false, + hasCharts: Boolean = false, + autoRefresh: Boolean = false, + loadJquery: Boolean = true, + container: Option[String] = None )(content: Html)(implicit request: RequestHeader, context: model.ApplicationContext) + +@import controllers.admin.routes.UncachedAssets +@import controllers.admin.routes.UncachedWebAssets +@import templates.inlineJS.blocking.js._ +@import play.api.Mode.Dev +@import conf.Static +@import conf.Configuration + + + + + + @title + + + @if(autoRefresh){ + + } + + @if(context.environment.mode == Dev){ + + } else { + + } + + + + + + + + + + + + + + + + + + + @admin_head() +
+ + @content + +
+ + + + + + + + @if(loadJquery){ + + } + + + + diff --git a/admin/app/views/afg.scala.html b/admin/app/views/afg.scala.html deleted file mode 100644 index 714e935fd634..000000000000 --- a/admin/app/views/afg.scala.html +++ /dev/null @@ -1,10 +0,0 @@ -@(body: String)(implicit request: RequestHeader, context: model.ApplicationContext) - -@admin_main("Dashboard", isAuthed = true, hasCharts = true) { - -

In memoriam Assets frequency graph

- -
- @Html(body) -
-} diff --git a/admin/app/views/commercial/commercialMenu.scala.html b/admin/app/views/commercial/commercialMenu.scala.html index 5d106dcaadaf..a32351c3b8c0 100644 --- a/admin/app/views/commercial/commercialMenu.scala.html +++ b/admin/app/views/commercial/commercialMenu.scala.html @@ -23,7 +23,6 @@

Targeting

  • Surging Content
  • Ad Tests
  • Key Values
  • -
  • Refresh all cached DFP data
  • Invalid Line Items
  • Custom Fields
  • @@ -37,9 +36,7 @@

    Display

    Preview ad formats, merchandising components and paid-for content.
    @@ -51,7 +48,6 @@

    Ad Ops

    Tools for the Ad Ops team.
    diff --git a/admin/app/views/commercial/dfpFlush.scala.html b/admin/app/views/commercial/dfpFlush.scala.html deleted file mode 100644 index 44328d89979a..000000000000 --- a/admin/app/views/commercial/dfpFlush.scala.html +++ /dev/null @@ -1,21 +0,0 @@ -@()(implicit flash: Flash, request: RequestHeader, context: model.ApplicationContext) - -@link(cssClass: String) = { - - Flush! -} - -@admin_main("DFP Data Cache Flush", isAuthed = true) { - -

    DFP Data Cache Flush

    - -

    Click this button to refresh all cached DFP data

    - - @if(flash.get("triggered") == Some("true")) { - @link("btn btn-danger disabled") -

    Data is being refreshed now.

    - } else { - @link("btn btn-danger") - } - -} diff --git a/admin/app/views/commercial/fluidAds.scala.html b/admin/app/views/commercial/fluidAds.scala.html deleted file mode 100644 index dfb0f48dbe3e..000000000000 --- a/admin/app/views/commercial/fluidAds.scala.html +++ /dev/null @@ -1,61 +0,0 @@ -@()(implicit request: RequestHeader, context: model.ApplicationContext) - -@admin_main("Commercial", isAuthed = true, hasCharts = true) { - - - -

    Commercial

    - -

    Responsive advertising

    - -

    These are links to the responsive ads that we have created so far. All are served from DFP and are hidden behind cookies.

    -

    Click here to clear the cookie.

    - - -} diff --git a/admin/app/views/commercial/fragments/slot.scala.html b/admin/app/views/commercial/fragments/slot.scala.html index 99c77e5a9f48..6d59634b6376 100644 --- a/admin/app/views/commercial/fragments/slot.scala.html +++ b/admin/app/views/commercial/fragments/slot.scala.html @@ -1,6 +1,6 @@ @import common.dfp.LineItemReport @(slotReport: LineItemReport) -@import _root_.dfp.printUniversalTime +@import _root_.gam.printUniversalTime @import common.dfp.GuLineItem @import tools.DfpLink @import views.commercial.LineItemSupport.targetedAdUnits diff --git a/admin/app/views/commercial/invalidLineItems.scala.html b/admin/app/views/commercial/invalidLineItems.scala.html index 5a74f6cae3b9..1ab2eee6e33b 100644 --- a/admin/app/views/commercial/invalidLineItems.scala.html +++ b/admin/app/views/commercial/invalidLineItems.scala.html @@ -3,7 +3,6 @@ @import common.dfp.{GuLineItem} @(invalidPageskins: Seq[PageSkinSponsorship], - sonobiItems: Seq[GuLineItem], unknownInvalidLineItems: Seq[GuLineItem])(implicit request: RequestHeader, context: model.ApplicationContext) @admin_main("Line Item Problems", isAuthed = true, hasCharts = false) { @@ -68,30 +67,4 @@

    Unidentified Line Items

    } - -

    Sonobi Line Items

    - -

    - These line items are used by the Sonobi SSP to pass winning bids through DFP. They can be ignored. -

    - - @if(sonobiItems.isEmpty) {None} else { - - - - - - - - - @for(lineItem <- sonobiItems) { - - - - - } - -
    Line Item NameDFP link
    @{lineItem.name}@{lineItem.id}
    - } - } diff --git a/admin/app/views/commercial/surgingpages.scala.html b/admin/app/views/commercial/surgingpages.scala.html index 803c0e129b92..bd3e8f6f345d 100644 --- a/admin/app/views/commercial/surgingpages.scala.html +++ b/admin/app/views/commercial/surgingpages.scala.html @@ -1,5 +1,5 @@ @(surgingContent: services.ophan.SurgingContent)(implicit request: RequestHeader, context: model.ApplicationContext) -@import _root_.dfp.printLondonTime +@import _root_.gam.printLondonTime @admin_main("Commercial", isAuthed = true, hasCharts = true) { diff --git a/admin/app/views/commercial/surveySponsorships.scala.html b/admin/app/views/commercial/surveySponsorships.scala.html index 5b02602958fd..eb6753725a33 100644 --- a/admin/app/views/commercial/surveySponsorships.scala.html +++ b/admin/app/views/commercial/surveySponsorships.scala.html @@ -20,19 +20,22 @@

    Survey Sponsorships

    Last updated: @if(report.updatedTimeStamp) { @{report.updatedTimeStamp} } else { never }

    -

    Pages will show a survey slot if you set up a line item in GAM with the following parameters:

    +

    Pages can show a survey slot if you set up a line item in GAM with the following parameters:

      -
    1. Is a Sponsorship
    2. Targets the
      survey
      slot
    3. -
    4. Targets the
      theguardian.com
      except
      front
      adUnit
    5. -
    6. Targets the
      theguardian.com
      except
      front
      content type
    7. Targets the
      desktop
      breakpoint
    8. -
    9. Targets the
      connected TV
      device category in GAM
    ANY OTHER TARGETING WILL CAUSE THE SLOT TO APPEAR UNINTENTIONALLY

    If you are unsure please contact the commercial dev team first.

    +

    Limitations

    +

    Regardless of the targeting applied to the line item, survey slots:

    + +

    Sponsorships

    Line items that match the above targeting:

    @if(report.sponsorships.isEmpty) {

    None

    } else { diff --git a/admin/app/views/commercial/takeoverWithEmptyMPUs.scala.html b/admin/app/views/commercial/takeoverWithEmptyMPUs.scala.html deleted file mode 100644 index 73ed9d7396c2..000000000000 --- a/admin/app/views/commercial/takeoverWithEmptyMPUs.scala.html +++ /dev/null @@ -1,27 +0,0 @@ -@import common.dfp.TakeoverWithEmptyMPUs -@(takeovers: Seq[TakeoverWithEmptyMPUs])(implicit request: RequestHeader, context: model.ApplicationContext) -@import TakeoverWithEmptyMPUs.timeViewFormatter - -@admin_main("Takeovers with Empty MPUs", isAuthed = true) { - -

    Takeovers with Empty MPUs

    -

    This list shows URLs where a takeover is taking place and container content should automatically reflow to take the place of MPUs.

    -
    - -
    - @helper.form(action = controllers.admin.commercial.routes.TakeoverWithEmptyMPUsController.viewForm()) { - - } -} diff --git a/admin/app/views/commercial/takeoverWithEmptyMPUsCreate.scala.html b/admin/app/views/commercial/takeoverWithEmptyMPUsCreate.scala.html deleted file mode 100644 index d9c2ed69baff..000000000000 --- a/admin/app/views/commercial/takeoverWithEmptyMPUsCreate.scala.html +++ /dev/null @@ -1,37 +0,0 @@ -@import common.Edition -@import common.dfp.TakeoverWithEmptyMPUs -@import helper._ -@(takeoverForm: Form[TakeoverWithEmptyMPUs])( - implicit messages: Messages, - request: RequestHeader, - context: model.ApplicationContext -) - -@admin_main("Create takeover with Empty MPUs", isAuthed = true) { - -

    New Takeover with Empty MPUs

    - @form(action = controllers.admin.commercial.routes.TakeoverWithEmptyMPUsController.create()) { -

    Fill in the details of a front takeover in which MPUs should not appear on the page.

    - @if(takeoverForm.hasGlobalErrors) { - - } - @inputText(takeoverForm("url"), Symbol("_label")-> "Paste URL here:", Symbol("size") -> 100, Symbol("_help") -> "") - @select( - takeoverForm("editions"), - options = for(e <- Edition.allEditions) yield { e.id -> e.displayName }, - Symbol("multiple") -> true, - Symbol("_label") -> "Editions this applies to (one or multiple):" - ) - @input(takeoverForm("startTime"), Symbol("_label") -> "Takeover starts (UTC):", Symbol("_help") -> "") { (id, name, value, args) => - - } - @input(takeoverForm("endTime"), Symbol("_label") -> "Takeover ends (UTC):", Symbol("_help") -> "") { (id, name, value, args) => - - } - - } -} diff --git a/admin/app/views/commercial/templates.scala.html b/admin/app/views/commercial/templates.scala.html deleted file mode 100644 index 9718f033a0ec..000000000000 --- a/admin/app/views/commercial/templates.scala.html +++ /dev/null @@ -1,57 +0,0 @@ -@(templates: Seq[common.dfp.GuCreativeTemplate])(implicit request: RequestHeader, context: model.ApplicationContext) -@import model.{MetaData, SectionId, SimplePage} -@import tools.DfpLink - -@mainLegacy( - SimplePage(MetaData.make( - id = "commercial-templates", - section = Some(SectionId.fromId("admin")), - webTitle = "Commercial Templates" - )) -) { } { - -
    -
    - -
    -

    Creative Templates

    -

    This dashboard is to help debug DFP creative templates.
    - All unarchived custom creative templates are shown; native templates are indicated with a *

    -
    - -
    -

    Creative Templates: Contents

    - -
    - -
      - @for(template <- templates) { -
    • -
      -

      - @template.name (@template.id)@if(template.isNative){*} -

      -

      @{template.description}

      - @if(template.creatives.isEmpty){ -

      This template is not in use.

      - } - @if(template.creatives.nonEmpty){ -

      Creatives built from this template (see preview here):

      -
        - @for(creative <- template.creatives){ -
      • @{creative.name} (@{creative.id})
      • - } -
      - } -
      -
    • - } -
    - -
    -
    -} diff --git a/admin/app/views/abtests.scala.html b/admin/app/views/legacyAbTests.scala.html similarity index 100% rename from admin/app/views/abtests.scala.html rename to admin/app/views/legacyAbTests.scala.html diff --git a/admin/conf/logback.xml b/admin/conf/logback.xml index ce08bf16805c..ca6735645aab 100644 --- a/admin/conf/logback.xml +++ b/admin/conf/logback.xml @@ -7,7 +7,9 @@ logs/frontend-admin.log.%d{yyyy-MM-dd}.%i.gz - 7512MB256MB + 7 + 512MB + 256MB @@ -23,9 +25,6 @@ - - - diff --git a/admin/conf/routes b/admin/conf/routes index 5dc33f8f1730..ed3117cfcbdc 100644 --- a/admin/conf/routes +++ b/admin/conf/routes @@ -58,8 +58,8 @@ GET /dev/switchboard POST /dev/switchboard controllers.admin.SwitchboardController.save() # Analytics -GET /analytics/abtests controllers.admin.AnalyticsController.abtests() -GET /analytics/confidence controllers.admin.AnalyticsConfidenceController.renderConfidence() +GET /analytics/abtests controllers.admin.AnalyticsController.legacyAbTests() +GET /analytics/ab-testing controllers.admin.AnalyticsController.abTests() # Commercial GET /commercial controllers.admin.CommercialController.renderCommercialMenu() @@ -68,21 +68,12 @@ GET /commercial/pageskins GET /commercial/surging controllers.admin.CommercialController.renderSurgingContent() GET /commercial/liveblog-top controllers.admin.CommercialController.renderLiveBlogTopSponsorships() GET /commercial/survey controllers.admin.CommercialController.renderSurveySponsorships() -GET /commercial/templates controllers.admin.CommercialController.renderCreativeTemplates() -GET /commercial/fluid250 controllers.admin.CommercialController.renderFluidAds() GET /commercial/adtests controllers.admin.CommercialController.renderAdTests() GET /commercial/keyvalues controllers.admin.CommercialController.renderKeyValues() GET /commercial/keyvalues/csv/*key controllers.admin.CommercialController.renderKeyValuesCsv(key) -GET /commercial/dfp/flush/view controllers.admin.commercial.DfpDataController.renderCacheFlushPage() -GET /commercial/dfp/flush controllers.admin.commercial.DfpDataController.flushCache() -GET /commercial/adops/takeovers-empty-mpus controllers.admin.commercial.TakeoverWithEmptyMPUsController.viewList() -GET /commercial/adops/takeovers-empty-mpus/create controllers.admin.commercial.TakeoverWithEmptyMPUsController.viewForm() -POST /commercial/adops/takeovers-empty-mpus/create controllers.admin.commercial.TakeoverWithEmptyMPUsController.create() -POST /commercial/adops/takeovers-empty-mpus/remove controllers.admin.commercial.TakeoverWithEmptyMPUsController.remove(t) GET /commercial/invalid-lineitems controllers.admin.CommercialController.renderInvalidItems() GET /commercial/custom-fields controllers.admin.CommercialController.renderCustomFields() GET /commercial/adgrabber/order/:orderId controllers.admin.CommercialController.getLineItemsForOrder(orderId: String) -GET /commercial/adgrabber/previewUrls/:lineItemId/:section controllers.admin.CommercialController.getCreativesListing(lineItemId: String, section: String) GET /commercial/adops/ads-txt controllers.admin.commercial.AdsDotTextEditController.renderAdsDotText() POST /commercial/adops/ads-txt controllers.admin.commercial.AdsDotTextEditController.postAdsDotText() GET /commercial/adops/app-ads-txt controllers.admin.commercial.AdsDotTextEditController.renderAppAdsDotText() @@ -94,15 +85,9 @@ GET /config GET /config/parameter/*key controllers.AppConfigController.findParameter(key: String) # Metrics -GET /metrics/loadbalancers controllers.admin.MetricsController.renderLoadBalancers() -GET /metrics/fastly controllers.admin.FastlyController.renderFastly() GET /metrics/errors controllers.admin.MetricsController.renderErrors() GET /metrics/errors/4xx controllers.admin.MetricsController.render4XX() GET /metrics/errors/5xx controllers.admin.MetricsController.render5XX() -GET /metrics/googlebot/404 controllers.admin.MetricsController.renderGooglebot404s() -GET /metrics/afg controllers.admin.MetricsController.renderAfg() -GET /metrics/webpack-bundle-visualization controllers.admin.MetricsController.renderBundleVisualization() -GET /metrics/webpack-bundle-analyzer controllers.admin.MetricsController.renderBundleAnalyzer() # Redirects GET /redirects controllers.admin.RedirectController.redirect() diff --git a/admin/test/dfp/DfpApiValidationTest.scala b/admin/test/dfp/DfpApiValidationTest.scala deleted file mode 100644 index 6f439265b1e7..000000000000 --- a/admin/test/dfp/DfpApiValidationTest.scala +++ /dev/null @@ -1,73 +0,0 @@ -package dfp - -import concurrent.BlockingOperations -import common.dfp.{GuAdUnit, GuLineItem, GuTargeting, Sponsorship} -import com.google.api.ads.admanager.axis.v202405._ -import org.joda.time.DateTime -import org.apache.pekko.actor.ActorSystem -import org.scalatest.flatspec.AnyFlatSpec -import org.scalatest.matchers.should.Matchers - -class DfpApiValidationTest extends AnyFlatSpec with Matchers { - - private def lineItem(adUnitIds: Seq[String]): GuLineItem = { - val adUnits = adUnitIds.map(adUnitId => { - GuAdUnit(id = adUnitId, path = Nil, status = GuAdUnit.ACTIVE) - }) - - GuLineItem( - id = 0L, - orderId = 0L, - name = "test line item", - Sponsorship, - startTime = DateTime.now.withTimeAtStartOfDay, - endTime = None, - isPageSkin = false, - sponsor = None, - status = "COMPLETED", - costType = "CPM", - creativePlaceholders = Nil, - targeting = GuTargeting( - adUnitsIncluded = adUnits, - adUnitsExcluded = Nil, - geoTargetsIncluded = Nil, - geoTargetsExcluded = Nil, - customTargetSets = Nil, - ), - lastModified = DateTime.now.withTimeAtStartOfDay, - ) - } - - private def makeDfpLineItem(adUnitIds: Seq[String]): LineItem = { - val dfpLineItem = new LineItem() - val targeting = new Targeting() - val inventoryTargeting = new InventoryTargeting() - - val adUnitTargeting = adUnitIds.map(adUnit => { - val adUnitTarget = new AdUnitTargeting() - adUnitTarget.setAdUnitId(adUnit) - adUnitTarget - }) - - inventoryTargeting.setTargetedAdUnits(adUnitTargeting.toArray) - targeting.setInventoryTargeting(inventoryTargeting) - dfpLineItem.setTargeting(targeting) - dfpLineItem - } - - val dataValidation = new DataValidation(new AdUnitService(new AdUnitAgent(new BlockingOperations(ActorSystem())))) - - "isGuLineItemValid" should "return false when the adunit targeting does not match the dfp line item" in { - val guLineItem = lineItem(List("1", "2", "3")) - val dfpLineItem = makeDfpLineItem(List("1", "2", "3", "4")) - - dataValidation.isGuLineItemValid(guLineItem, dfpLineItem) shouldBe false - } - - "isGuLineItemValid" should "return true when the adunit targeting does match the dfp line item" in { - val guLineItem = lineItem(List("1", "2", "3")) - val dfpLineItem = makeDfpLineItem(List("1", "2", "3")) - - dataValidation.isGuLineItemValid(guLineItem, dfpLineItem) shouldBe true - } -} diff --git a/admin/test/dfp/DfpDataCacheJobTest.scala b/admin/test/dfp/DfpDataCacheJobTest.scala deleted file mode 100644 index a5a0bceb5b04..000000000000 --- a/admin/test/dfp/DfpDataCacheJobTest.scala +++ /dev/null @@ -1,128 +0,0 @@ -package dfp - -import common.dfp.{GuLineItem, GuTargeting, Sponsorship} -import org.joda.time.DateTime -import org.scalatest._ -import org.scalatest.flatspec.AnyFlatSpec -import org.scalatest.matchers.should.Matchers -import org.scalatestplus.mockito.MockitoSugar -import test._ - -class DfpDataCacheJobTest - extends AnyFlatSpec - with Matchers - with SingleServerSuite - with BeforeAndAfterAll - with WithMaterializer - with WithTestWsClient - with MockitoSugar - with WithTestContentApiClient { - - val dfpDataCacheJob = new DfpDataCacheJob( - mock[AdUnitAgent], - mock[CustomFieldAgent], - mock[CustomTargetingAgent], - mock[PlacementAgent], - mock[DfpApi], - ) - - private def lineItem(id: Long, name: String, completed: Boolean = false): GuLineItem = { - GuLineItem( - id, - 0L, - name, - Sponsorship, - startTime = DateTime.now.withTimeAtStartOfDay, - endTime = None, - isPageSkin = false, - sponsor = None, - status = if (completed) "COMPLETED" else "READY", - costType = "CPM", - creativePlaceholders = Nil, - targeting = GuTargeting( - adUnitsIncluded = Nil, - adUnitsExcluded = Nil, - geoTargetsIncluded = Nil, - geoTargetsExcluded = Nil, - customTargetSets = Nil, - ), - lastModified = DateTime.now.withTimeAtStartOfDay, - ) - } - - private val cachedLineItems = DfpLineItems( - validItems = Seq(lineItem(1, "a-cache"), lineItem(2, "b-cache"), lineItem(3, "c-cache")), - invalidItems = Seq.empty, - ) - - private val allReadyOrDeliveringLineItems = DfpLineItems(Seq.empty, Seq.empty) - - "loadLineItems" should "dedupe line items that have changed in an unknown way" in { - def lineItemsModifiedSince(threshold: DateTime): DfpLineItems = - DfpLineItems( - validItems = Seq( - lineItem(1, "a-fresh"), - lineItem(2, "b-fresh"), - lineItem(3, "c-fresh"), - ), - invalidItems = Seq.empty, - ) - - val lineItems = dfpDataCacheJob.loadLineItems( - cachedLineItems, - lineItemsModifiedSince, - allReadyOrDeliveringLineItems, - ) - - lineItems.validLineItems.size shouldBe 3 - lineItems.validLineItems shouldBe Seq(lineItem(1, "a-fresh"), lineItem(2, "b-fresh"), lineItem(3, "c-fresh")) - lineItems.invalidLineItems shouldBe empty - } - - it should "dedupe line items that have changed in a known way" in { - def lineItemsModifiedSince(threshold: DateTime): DfpLineItems = - DfpLineItems( - validItems = Seq( - lineItem(1, "d"), - lineItem(2, "e"), - lineItem(4, "f"), - ), - invalidItems = Seq.empty, - ) - - val lineItems = dfpDataCacheJob.loadLineItems( - cachedLineItems, - lineItemsModifiedSince, - allReadyOrDeliveringLineItems, - ) - - lineItems.validLineItems.size shouldBe 4 - lineItems.validLineItems shouldBe Seq( - lineItem(1, "d"), - lineItem(2, "e"), - lineItem(3, "c-cache"), - lineItem(4, "f"), - ) - } - - it should "omit line items whose state has changed to no longer be ready or delivering" in { - def lineItemsModifiedSince(threshold: DateTime): DfpLineItems = - DfpLineItems( - validItems = Seq( - lineItem(1, "a", completed = true), - lineItem(2, "e"), - lineItem(4, "f"), - ), - invalidItems = Seq.empty, - ) - - val lineItems = dfpDataCacheJob.loadLineItems( - cachedLineItems, - lineItemsModifiedSince, - allReadyOrDeliveringLineItems, - ) - - lineItems.validLineItems.size shouldBe 3 - lineItems.validLineItems shouldBe Seq(lineItem(2, "e"), lineItem(3, "c-cache"), lineItem(4, "f")) - } -} diff --git a/admin/test/dfp/ReaderTest.scala b/admin/test/dfp/ReaderTest.scala deleted file mode 100644 index 80068ee030f5..000000000000 --- a/admin/test/dfp/ReaderTest.scala +++ /dev/null @@ -1,31 +0,0 @@ -package dfp - -import com.google.api.ads.admanager.axis.utils.v202405.StatementBuilder -import dfp.Reader.read -import org.scalatest.flatspec.AnyFlatSpec -import org.scalatest.matchers.should.Matchers - -class ReaderTest extends AnyFlatSpec with Matchers { - - "load" should "load a single page of results" in { - val stmtBuilder = new StatementBuilder() - val result = read[Int](stmtBuilder) { statement => - (Array(1, 2, 3, 4, 5), 5) - } - result shouldBe Seq(1, 2, 3, 4, 5) - } - - it should "load multiple pages of results" in { - val stmtBuilder = new StatementBuilder() - val result = read[Int](stmtBuilder) { statement => - ((1 to 10).toArray, 30) - } - result shouldBe Seq.fill[Seq[Int]](3)((1 to 10)).flatten - } - - it should "cope with a null result" in { - val stmtBuilder = new StatementBuilder() - val result = read[Int](stmtBuilder) { statement => (null, 0) } - result shouldBe empty - } -} diff --git a/applications/app/controllers/ApplicationsControllers.scala b/applications/app/controllers/ApplicationsControllers.scala index 9a06f1589c07..d03bae65094c 100644 --- a/applications/app/controllers/ApplicationsControllers.scala +++ b/applications/app/controllers/ApplicationsControllers.scala @@ -39,6 +39,7 @@ trait ApplicationsControllers { lazy val siteVerificationController = wire[SiteVerificationController] lazy val youtubeController = wire[YoutubeController] lazy val nx1ConfigController = wire[Nx1ConfigController] + lazy val diagnosticsController = wire[DiagnosticsController] // A fake geolocation controller to test it locally lazy val geolocationController = wire[FakeGeolocationController] diff --git a/applications/app/controllers/CrosswordsController.scala b/applications/app/controllers/CrosswordsController.scala index 2bfd618daf4c..bd6bcf881cc5 100644 --- a/applications/app/controllers/CrosswordsController.scala +++ b/applications/app/controllers/CrosswordsController.scala @@ -10,7 +10,7 @@ import com.gu.contentapi.client.model.v1.{ import common.{Edition, GuLogging, ImplicitControllerExecutionContext} import conf.Static import contentapi.ContentApiClient -import com.gu.contentapi.client.model.SearchQuery +import com.gu.contentapi.client.model.{ContentApiError, SearchQuery} import crosswords.{ AccessibleCrosswordPage, AccessibleCrosswordRows, @@ -37,6 +37,7 @@ import renderers.DotcomRenderingService import services.dotcomrendering.{CrosswordsPicker, RemoteRender} import services.{IndexPage, IndexPageItem} +import scala.collection.immutable import scala.concurrent.Future import scala.concurrent.duration._ @@ -64,9 +65,15 @@ trait CrosswordController extends BaseController with GuLogging with ImplicitCon crossword <- content.crossword } yield f(crossword, content) maybeCrossword getOrElse Future.successful(noResults()) - } recover { case t: Throwable => - logErrorWithRequestId(s"Error retrieving $crosswordType crossword id $id from API", t) - noResults() + } recover { + case capiError: ContentApiError if capiError.httpStatus == 404 => { + logInfoWithRequestId(s"The $crosswordType crossword with id $id was not found in CAPI") + noResults() + } + case t: Throwable => { + logErrorWithRequestId(s"Error retrieving $crosswordType crossword id $id from API", t) + noResults() + } } } @@ -343,13 +350,21 @@ class CrosswordEditionsController( "crosswords/series/cryptic", "crosswords/series/prize", "crosswords/series/weekend-crossword", + "crosswords/series/sunday-quick", "crosswords/series/quick-cryptic", "crosswords/series/everyman", "crosswords/series/speedy", "crosswords/series/quiptic", ).mkString("|") - private def parseCrosswords(response: SearchResponse): EditionsCrosswordRenderingDataModel = - EditionsCrosswordRenderingDataModel(response.results.flatMap(_.crossword)) - + private def parseCrosswords(response: SearchResponse): EditionsCrosswordRenderingDataModel = { + val collectedItems = response.results.collect { + case content if content.crossword.isDefined => + CrosswordData.fromCrossword(content.crossword.get, content) + } + val crosswordDataItems: immutable.Seq[CrosswordData] = collectedItems.toList + EditionsCrosswordRenderingDataModel( + crosswordDataItems, + ) + } } diff --git a/applications/app/controllers/DiagnosticsController.scala b/applications/app/controllers/DiagnosticsController.scala new file mode 100644 index 000000000000..cd2b153bd733 --- /dev/null +++ b/applications/app/controllers/DiagnosticsController.scala @@ -0,0 +1,21 @@ +package controllers + +import model.Cached.RevalidatableResult +import model.{ApplicationContext, Cached, DiagnosticsPageMetadata} +import pages.TagIndexHtmlPage +import play.api.mvc.{Action, AnyContent, BaseController, ControllerComponents} + +/** Browser diagnostics was introduced as a temporary page on 15/09/2025 If you still see this comment in 2026, please + * notify @cemms1 or feel free to remove See https://github.com/guardian/frontend/pull/28220 + */ +class DiagnosticsController(val controllerComponents: ControllerComponents)(implicit context: ApplicationContext) + extends BaseController + with common.ImplicitControllerExecutionContext { + + def renderDiagnosticsPage(): Action[AnyContent] = + Action { implicit request => + Cached(300) { + RevalidatableResult.Ok(TagIndexHtmlPage.html(new DiagnosticsPageMetadata())) + } + } +} diff --git a/applications/app/controllers/GalleryController.scala b/applications/app/controllers/GalleryController.scala index 7f9833631703..b9b1703410f4 100644 --- a/applications/app/controllers/GalleryController.scala +++ b/applications/app/controllers/GalleryController.scala @@ -56,12 +56,16 @@ class GalleryController( ) = { val pageType = PageType(model, request, context) - remoteRenderer.getGallery( - wsClient, - model, - pageType, - blocks, - ) + if (request.isApps) { + remoteRenderer.getAppsGallery(wsClient, model, pageType, blocks) + } else { + remoteRenderer.getGallery( + wsClient, + model, + pageType, + blocks, + ) + } } def lightboxJson(path: String): Action[AnyContent] = diff --git a/applications/app/controllers/InteractiveController.scala b/applications/app/controllers/InteractiveController.scala index 86010cf512ea..136419d4923c 100644 --- a/applications/app/controllers/InteractiveController.scala +++ b/applications/app/controllers/InteractiveController.scala @@ -126,12 +126,12 @@ class InteractiveController( case Right((page, blocks)) => { val tier = InteractivePicker.getRenderingTier(path) (requestFormat, tier) match { - case (AppsFormat, DotcomRendering) => renderApps(page, blocks) - case (AmpFormat, DotcomRendering) => renderAmp(page, blocks) - case (JsonFormat, DotcomRendering) => renderJson(page, blocks) - case (HtmlFormat, PressedInteractive) => servePressedPage(path) - case (HtmlFormat, DotcomRendering) => renderHtml(page, blocks) - case _ => renderNonDCR(page) + case (AppsFormat, DotcomRendering) => renderApps(page, blocks) + case (AmpFormat, DotcomRendering) if page.interactive.content.shouldAmplify => renderAmp(page, blocks) + case (HtmlFormat | AmpFormat, DotcomRendering) => renderHtml(page, blocks) + case (JsonFormat, DotcomRendering) => renderJson(page, blocks) + case (HtmlFormat, PressedInteractive) => servePressedPage(path) + case _ => renderNonDCR(page) } } case Left(result) => Future.successful(result) diff --git a/applications/app/controllers/NewspaperController.scala b/applications/app/controllers/NewspaperController.scala index 8f61cf1406d4..51005c8fe1b3 100644 --- a/applications/app/controllers/NewspaperController.scala +++ b/applications/app/controllers/NewspaperController.scala @@ -34,15 +34,6 @@ class NewspaperController( } - def latestObserverNewspaper(): Action[AnyContent] = { - // A request was made by Central Production on the 12th July 2022 to redirect this page to - // /observer rather than create a generated page here. - // Issue: https://github.com/guardian/frontend/issues/25223 - Action { implicit request => - Cached(300)(WithoutRevalidationResult(MovedPermanently("/observer"))) - } - } - def newspaperForDate(path: String, day: String, month: String, year: String): Action[AnyContent] = Action.async { implicit request => val metadata = path match { diff --git a/applications/app/controllers/OptInController.scala b/applications/app/controllers/OptInController.scala index c33feab601ca..e661b8659bf3 100644 --- a/applications/app/controllers/OptInController.scala +++ b/applications/app/controllers/OptInController.scala @@ -25,7 +25,8 @@ class OptInController(val controllerComponents: ControllerComponents) extends Ba case "delete" => optDelete(feature) } def optIn(cookieName: String): Result = SeeOther("/").withCookies(Cookie(cookieName, "true", maxAge = Some(lifetime))) - def optOut(cookieName: String): Result = SeeOther("/").discardingCookies(DiscardingCookie(cookieName)) + def optOut(cookieName: String): Result = + SeeOther("/").withCookies(Cookie(cookieName, "false", maxAge = Some(lifetime))) def optDelete(cookieName: String): Result = SeeOther("/").discardingCookies(DiscardingCookie(cookieName)) def reset(): Action[AnyContent] = diff --git a/applications/app/controllers/TagIndexController.scala b/applications/app/controllers/TagIndexController.scala index a3ee703daa1e..644f8f0ca7d5 100644 --- a/applications/app/controllers/TagIndexController.scala +++ b/applications/app/controllers/TagIndexController.scala @@ -18,7 +18,7 @@ class TagIndexController(val controllerComponents: ControllerComponents)(implici Action { implicit request => TagIndexesS3.getIndex(keywordType, page) match { case Left(TagIndexNotFound) => - logErrorWithRequestId(s"404 error serving tag index page for $keywordType $page") + logInfoWithRequestId(s"404 error serving tag index page for $keywordType $page") NotFound case Left(TagIndexReadError(error)) => diff --git a/applications/app/model/DiagnosticsPageMetadata.scala b/applications/app/model/DiagnosticsPageMetadata.scala new file mode 100644 index 000000000000..e38f1d680d7c --- /dev/null +++ b/applications/app/model/DiagnosticsPageMetadata.scala @@ -0,0 +1,13 @@ +package model + +import play.api.libs.json.JsBoolean + +class DiagnosticsPageMetadata extends StandalonePage { + override val metadata = MetaData.make( + id = "Browser Diagnostics", + section = Some(SectionId.fromId("Index")), + webTitle = "Browser Diagnostics", + javascriptConfigOverrides = Map("isDiagnosticsPage" -> JsBoolean(true)), + shouldGoogleIndex = false, + ) +} diff --git a/applications/app/pages/TagIndexHtmlPage.scala b/applications/app/pages/TagIndexHtmlPage.scala index 0af38725ae8b..e98d43bf3dd2 100644 --- a/applications/app/pages/TagIndexHtmlPage.scala +++ b/applications/app/pages/TagIndexHtmlPage.scala @@ -6,6 +6,7 @@ import html.{HtmlPage, Styles} import model.{ ApplicationContext, ContributorsListing, + DiagnosticsPageMetadata, PreferencesMetaData, StandalonePage, SubjectsListing, @@ -20,7 +21,8 @@ import views.html.fragments.page.head.stylesheets.{criticalStyleInline, critical import views.html.fragments.page.head._ import views.html.fragments.page.{devTakeShot, htmlTag} import views.html.preferences.index -import html.HtmlPageHelpers.{ContentCSSFile} +import views.html.browserDiagnostics.diagnosticsPage +import html.HtmlPageHelpers.ContentCSSFile object TagIndexHtmlPage extends HtmlPage[StandalonePage] { @@ -39,10 +41,11 @@ object TagIndexHtmlPage extends HtmlPage[StandalonePage] { implicit val p: StandalonePage = page val content: Html = page match { - case p: TagIndexPage => tagIndexBody(p) - case p: PreferencesMetaData => index(p) - case p: ContributorsListing => tagIndexListingBody("contributors", p.metadata.webTitle, p.listings) - case p: SubjectsListing => tagIndexListingBody("subjects", p.metadata.webTitle, p.listings) + case p: TagIndexPage => tagIndexBody(p) + case p: PreferencesMetaData => index(p) + case p: ContributorsListing => tagIndexListingBody("contributors", p.metadata.webTitle, p.listings) + case p: SubjectsListing => tagIndexListingBody("subjects", p.metadata.webTitle, p.listings) + case p: DiagnosticsPageMetadata => diagnosticsPage(p) case unsupported => throw new RuntimeException( diff --git a/applications/app/services/NewspaperQuery.scala b/applications/app/services/NewspaperQuery.scala index 5a3ba584b829..a3d8b392db4b 100644 --- a/applications/app/services/NewspaperQuery.scala +++ b/applications/app/services/NewspaperQuery.scala @@ -31,11 +31,6 @@ class NewspaperQuery(contentApiClient: ContentApiClient) extends Dates with GuLo bookSectionContainers("theguardian/mainsection", getLatestGuardianPageFor(now), "theguardian") } - def fetchLatestObserverNewspaper()(implicit executionContext: ExecutionContext): Future[List[FaciaContainer]] = { - val now = DateTime.now(DateTimeZone.UTC) - bookSectionContainers("theobserver/news", getPastSundayDateFor(now), "theobserver") - } - def fetchNewspaperForDate(path: String, day: String, month: String, year: String)(implicit executionContext: ExecutionContext, ): Future[List[FaciaContainer]] = { @@ -207,6 +202,7 @@ class NewspaperQuery(contentApiClient: ContentApiClient) extends Dates with GuLo byline = None, kicker = None, brandingByEdition = Map.empty, + mediaAtom = None, ) LinkSnap.make(fapiSnap) } diff --git a/applications/app/services/TagPagePicker.scala b/applications/app/services/TagPagePicker.scala index 3a96103aa103..64c27f7ff325 100644 --- a/applications/app/services/TagPagePicker.scala +++ b/applications/app/services/TagPagePicker.scala @@ -10,9 +10,7 @@ import services.IndexPage object TagPagePicker extends GuLogging { def getTier(tagPage: IndexPage)(implicit request: RequestHeader): RenderType = { - lazy val isSwitchedOn = DCRTagPages.isSwitchedOn; - - val checks = dcrChecks(tagPage) + lazy val isSwitchedOn = DCRTagPages.isSwitchedOn val tier = decideTier( request.isRss, @@ -20,31 +18,19 @@ object TagPagePicker extends GuLogging { request.forceDCROff, request.forceDCR, isSwitchedOn, - dcrCouldRender(checks), ) - logTier(tagPage, isSwitchedOn, dcrCouldRender(checks), checks, tier) + logTier(tagPage, isSwitchedOn, tier) tier } - private def dcrCouldRender(checks: Map[String, Boolean]): Boolean = { - checks.values.forall(identity) - } - - private def dcrChecks(tagPage: IndexPage): Map[String, Boolean] = { - Map( - ("isNotTagCombiner", !tagPage.page.isInstanceOf[TagCombiner]), - ) - } - private def decideTier( isRss: Boolean, isJson: Boolean, forceDCROff: Boolean, forceDCR: Boolean, isSwitchedOn: Boolean, - dcrCouldRender: Boolean, ): RenderType = { if (isRss) LocalRender else if (isJson) { @@ -53,28 +39,22 @@ object TagPagePicker extends GuLogging { else LocalRender } else if (forceDCROff) LocalRender else if (forceDCR) RemoteRender - else if (dcrCouldRender && isSwitchedOn) RemoteRender + else if (isSwitchedOn) RemoteRender else LocalRender } private def logTier( tagPage: IndexPage, isSwitchedOn: Boolean, - dcrCouldRender: Boolean, - checks: Map[String, Boolean], tier: RenderType, )(implicit request: RequestHeader): Unit = { val tierReadable = if (tier == RemoteRender) "dotcomcomponents" else "web" - val checksToString = checks.map { case (key, value) => - (key, value.toString) - } val properties = Map( "isSwitchedOn" -> isSwitchedOn.toString, - "dcrCouldRender" -> dcrCouldRender.toString, "isTagPage" -> "true", "tier" -> tierReadable, - ) ++ checksToString + ) DotcomFrontsLogger.logger.logRequest(s"tag front executing in $tierReadable", properties, tagPage) } diff --git a/applications/app/services/dotcomrendering/GalleryPicker.scala b/applications/app/services/dotcomrendering/GalleryPicker.scala index 717f99f2df21..0b0861575fee 100644 --- a/applications/app/services/dotcomrendering/GalleryPicker.scala +++ b/applications/app/services/dotcomrendering/GalleryPicker.scala @@ -1,6 +1,7 @@ package services.dotcomrendering import common.GuLogging +import conf.switches.Switches.DCARGalleyPages import model.Cors.RichRequestHeader import model.GalleryPage import play.api.mvc.RequestHeader @@ -12,8 +13,20 @@ object GalleryPicker extends GuLogging { )(implicit request: RequestHeader, ): RenderType = { - DotcomponentsLogger.logger.logRequest(s"path executing in web", Map.empty, galleryPage.gallery) - LocalRender + val tier = { + if (request.forceDCROff) LocalRender + else if (request.forceDCR) RemoteRender + else if (DCARGalleyPages.isSwitchedOn) RemoteRender + else LocalRender + } + + if (tier == RemoteRender) { + DotcomponentsLogger.logger.logRequest(s"path executing in dotcomponents", Map.empty, galleryPage.gallery) + } else { + DotcomponentsLogger.logger.logRequest(s"path executing in web", Map.empty, galleryPage.gallery) + } + + tier } } diff --git a/applications/app/views/browserDiagnostics/diagnosticsPage.scala.html b/applications/app/views/browserDiagnostics/diagnosticsPage.scala.html new file mode 100644 index 000000000000..173e63711ef0 --- /dev/null +++ b/applications/app/views/browserDiagnostics/diagnosticsPage.scala.html @@ -0,0 +1,7 @@ +@(metaData: model.DiagnosticsPageMetadata)(implicit request: RequestHeader, context: model.ApplicationContext) + +@import views.html.fragments.containers.facia_cards.containerScaffold + +@containerScaffold("User benefits cookies", "user-benefits-cookies") { +
    Loading…
    +} diff --git a/applications/app/views/fragments/crosswords/crosswordContent.scala.html b/applications/app/views/fragments/crosswords/crosswordContent.scala.html index 2eee10bf2172..e5384db55fa1 100644 --- a/applications/app/views/fragments/crosswords/crosswordContent.scala.html +++ b/applications/app/views/fragments/crosswords/crosswordContent.scala.html @@ -46,11 +46,6 @@

    Down

    - @if(crosswordPage.item.trail.isCommentable) { - - } }
    diff --git a/applications/app/views/fragments/galleryHeader.scala.html b/applications/app/views/fragments/galleryHeader.scala.html index 4be88f075aa8..48ec58171893 100644 --- a/applications/app/views/fragments/galleryHeader.scala.html +++ b/applications/app/views/fragments/galleryHeader.scala.html @@ -32,8 +32,11 @@

    @Html(gallery.item.trail.headline)

    {