import MetricService from "./metric.service"; import { v4 as uuid } from "uuid"; import _ from "lodash"; import axios from "axios"; import { FeatureConfigService } from "./FeatureConfig.service"; import { ParameterInfo } from "@ogury/feature-config-sdk"; import { createDeliveryCampaignsClient } from "../grpc/base-client"; import { ChoiceRequest } from "../models"; import { config } from "../utils/config"; import logger from "../utils/logger"; import constants from "../utils/constants"; import { delivery } from "../protos/protos"; import { OgyCache, OgyCacheBatchValueGetterResult } from "@ogury/ogy-cache"; import { MonitoringSection } from "../utils/monitoring-section-decorator"; const THRESHOLD_CACHE_TTL = 60 * 1000; const PRIO_SCORE_CACHE_TTL = 2 * 60 * 1000; const GET_THRESHOLDS_METRIC = "speed_get_thresholds"; const GET_PRIO_SCORES_METRIC = "speed_get_prio_scores"; const CACHE_REQUESTS_METRIC = "speed_cache_requests_total"; const CACHE_SIZE_METRIC = "speed_cache_size_total"; type ThresholdCacheEntry = { speed_threshold: number | null; delay: number | null; }; export class ThresholdIsolatedCacheKey { keyString: string; constructor( readonly campaignId: string, readonly experimentName: string, readonly popIndex: number ) { this.keyString = `${this.campaignId}:${this.experimentName}:${this.popIndex}`; } toString() { return this.keyString; } } type PrioScoreCacheEntry = { priority_score: number; delay?: number; }; export class PrioScoreCacheKey { keyString: string; constructor( readonly appApiKey: string | number | null | undefined, readonly publisherId: number | null | undefined, readonly country: string | null | undefined, readonly environmentType: string | null | undefined, readonly format: string | null | undefined ) { this.keyString = [ this.appApiKey, this.publisherId, this.country, this.environmentType, this.format, ] .map((v) => `${v}`) .join(":"); } toString() { return this.keyString; } } export class PrioScoreIsolatedCacheKey { keyString: string; constructor( readonly prioScoreCacheKey: PrioScoreCacheKey, readonly experimentName: string, readonly popIndex: number ) { this.keyString = `${this.prioScoreCacheKey.keyString}:${this.experimentName}:${this.popIndex}`; } toString() { return this.keyString; } } type PrioScoreSpeedResponse = { priority_score: number; delay?: number; }; type PrioScoreIsolatedSpeedResponse = { experiment_name?: string; pops_metadata: PrioScoreSpeedResponse[]; }; export default class SpeedService { private campaignsClient: delivery.Campaigns; private metricService: MetricService; private featureConfig: FeatureConfigService; campaignCache: OgyCache; isolatedCampaignCache: OgyCache; prioScoreCache: OgyCache; isolatedPrioScoreCache: OgyCache; public constructor(campaignsClient?: delivery.Campaigns) { this.campaignsClient = campaignsClient || createDeliveryCampaignsClient(config.MS_SPEED_GRPC_HOST); this.metricService = MetricService.getInstance(); this.featureConfig = FeatureConfigService.getInstance(); this.metricService.addSection(GET_THRESHOLDS_METRIC, ["isolated"]); this.metricService.addSection(GET_PRIO_SCORES_METRIC, ["isolated"]); this.metricService.addGauge(CACHE_REQUESTS_METRIC, "Speed cache hits, misses and stales", [ "isolated", "score_type", "response_type", ]); this.metricService.addGauge(CACHE_SIZE_METRIC, "Speed cache size", ["isolated", "score_type"]); const baseCacheConfig = { maxItemsPerBatch: null, getterTimeout: 15000, staleAfter: { value: THRESHOLD_CACHE_TTL, jitter: THRESHOLD_CACHE_TTL * 0.1 }, expireAfter: { value: THRESHOLD_CACHE_TTL * 10, jitter: THRESHOLD_CACHE_TTL * 0.1 }, cacheNulls: true, logError: (err: Error, msg?: string) => logger.error(err, msg), }; this.campaignCache = new OgyCache(this.loadCampaignThresholds.bind(this), baseCacheConfig); this.isolatedCampaignCache = new OgyCache( this.loadIsolatedCampaignThresholds.bind(this), baseCacheConfig ); const prioScoreCacheConfig = { ...baseCacheConfig, maxItemsPerBatch: 1, staleAfter: { value: PRIO_SCORE_CACHE_TTL, jitter: PRIO_SCORE_CACHE_TTL * 0.1 }, expireAfter: { value: PRIO_SCORE_CACHE_TTL * 30, jitter: PRIO_SCORE_CACHE_TTL * 0.2 }, }; this.prioScoreCache = new OgyCache(this.loadSupplyPrioScore.bind(this), prioScoreCacheConfig); this.isolatedPrioScoreCache = new OgyCache( this.loadIsolatedSupplyPrioScores.bind(this), prioScoreCacheConfig ); if (config.OGURY_ENV !== "test") { setInterval(() => { this.updateCacheMetrics(this.campaignCache, false, "campaign_threshold"); this.updateCacheMetrics(this.isolatedCampaignCache, true, "campaign_threshold"); this.updateCacheMetrics(this.prioScoreCache, false, "supply_prio_score"); this.updateCacheMetrics(this.isolatedPrioScoreCache, true, "supply_prio_score"); }, 30000); } } public addThresholdMetadata(request: ChoiceRequest) { if (["staging", "devc", "local"].includes(config.OGURY_ENV)) { request.campaigns.forEach((c) => { c.metadata = { delay: 0, speed_threshold: 0, ...c.metadata, }; }); return; } const isolatedParam = this.getIsolatedParam(request); request.campaigns.forEach((c) => { const cacheEntry = this.getThresholdCache(c.id, isolatedParam); if (cacheEntry) { Object.assign(c.metadata, cacheEntry); } }); } public addPrioScoreMetadata(request: ChoiceRequest) { if (["staging", "devc", "local"].includes(config.OGURY_ENV)) { request.metadata.supply_obj_prio_score = 0; return; } const isolatedParam = this.getIsolatedParam(request); const cacheEntry = this.getPrioScoreCache(request, isolatedParam); if (cacheEntry) { request.metadata.supply_obj_prio_score = cacheEntry.priority_score; request.metadata.supply_obj_delay = cacheEntry.delay; } } getIsolatedParam(request: ChoiceRequest): ParameterInfo | undefined { const paramInfo = this.featureConfig.getParameterInfo( request, constants.SPEED_ISOLATION_PARAMETER, "false" ); return paramInfo?.value === "true" ? paramInfo : undefined; } public getThresholdCache( campaignId: string, isolatedParam?: ParameterInfo ): ThresholdCacheEntry | null { let cacheEntry: ThresholdCacheEntry | null; if (isolatedParam) { const key = new ThresholdIsolatedCacheKey( campaignId, isolatedParam.experimentName!, isolatedParam.popIndex ); cacheEntry = this.isolatedCampaignCache.getNoWait(key); if (cacheEntry) { return cacheEntry; } } return this.campaignCache.getNoWait(campaignId); } public getPrioScoreCache( request: ChoiceRequest, isolatedParam?: ParameterInfo ): PrioScoreCacheEntry | null { let cacheEntry: PrioScoreCacheEntry | null; const key = new PrioScoreCacheKey( request.app?.app_api_key, request.app?.app_publisher_id, request.context?.country, request.environment_type, request.app?.ad_unit_type ); if (isolatedParam) { const isolatedKey = new PrioScoreIsolatedCacheKey( key, isolatedParam.experimentName!, isolatedParam.popIndex ); cacheEntry = this.isolatedPrioScoreCache.getNoWait(isolatedKey); if (cacheEntry) { return cacheEntry; } } return this.prioScoreCache.getNoWait(key); } async loadCampaignThresholds( ids: string[] ): Promise> { const req = this.buildMsSpeedThresholdRequest(ids); const response = await this.getThresholdsFromMsSpeed(req); logger.debug(() => ({ message: `ms-speed response for campaign thresholds`, ids, response, })); return response.campaigns.map((campaign) => { const cacheEntry = this.buildThresholdCacheEntry(campaign.metadata); return [campaign.id!, cacheEntry]; }); } async loadIsolatedCampaignThresholds( keys: ThresholdIsolatedCacheKey[] ): Promise> { const ids = keys.map((k) => k.campaignId); const req = this.buildMsSpeedThresholdRequest(ids); const response = await this.getIsolatedThresholdsFromMsSpeed(req); logger.debug(() => ({ message: `ms-speed response for campaign isolated thresholds`, ids, response, })); if (!response.experiment_name) { throw new Error("No experiment when loading isolated speed"); } const result: OgyCacheBatchValueGetterResult = []; for (const campaign of response.campaigns) { campaign.pops_metadata?.forEach((metadata, index) => { const cacheEntry = this.buildThresholdCacheEntry(metadata); if (this.isThresholdCacheEntryIncomplete(cacheEntry)) { return; } const cacheKey = new ThresholdIsolatedCacheKey( campaign.id!, response.experiment_name, index ); result.push([cacheKey, cacheEntry]); }); } return result; } private isThresholdCacheEntryIncomplete(speedCacheEntry: ThresholdCacheEntry): boolean { return speedCacheEntry.delay == null || speedCacheEntry.speed_threshold == null; } buildThresholdCacheEntry( metadata: delivery.ISpeedCampaignMetadata | null | undefined ): ThresholdCacheEntry { const threshold = parseFloat(metadata?.threshold ?? ""); const delay = parseFloat(metadata?.delay ?? ""); return { speed_threshold: Number.isFinite(threshold) ? threshold : null, delay: Number.isFinite(delay) ? delay : null, }; } private buildMsSpeedThresholdRequest(campaignIds: string[]): delivery.Request { const campaignList: delivery.Campaign[] = campaignIds.map((cId) => delivery.Campaign.create({ id: cId }) ); return delivery.Request.create({ id: uuid(), campaigns: campaignList, }); } @MonitoringSection(GET_THRESHOLDS_METRIC, { isolated: "false" }) private async getThresholdsFromMsSpeed(req: delivery.Request): Promise { return this.campaignsClient.getSpeed(req); } @MonitoringSection(GET_THRESHOLDS_METRIC, { isolated: "true" }) private async getIsolatedThresholdsFromMsSpeed( req: delivery.Request ): Promise { return this.campaignsClient.getIsolatedSpeed(req); } async loadSupplyPrioScore( keys: PrioScoreCacheKey[] ): Promise> { const responses = await Promise.all(keys.map((k) => this.getPrioScoreFromMsSpeed(k))); const result: OgyCacheBatchValueGetterResult = []; for (let i = 0; i < keys.length; ++i) { const k = keys[i]; const resp = responses[i]; result.push([k, this.buildPrioScoreCacheEntry(resp)]); } return result; } async loadIsolatedSupplyPrioScores( keys: PrioScoreIsolatedCacheKey[] ): Promise> { const responses = await Promise.all( keys.map((k) => this.getIsolatedPrioScoresFromMsSpeed(k.prioScoreCacheKey)) ); const result: OgyCacheBatchValueGetterResult = []; for (let i = 0; i < keys.length; ++i) { const k = keys[i]; const resp = responses[i]; resp.pops_metadata.forEach((metadata, index) => { const cacheKey = new PrioScoreIsolatedCacheKey( k.prioScoreCacheKey, resp.experiment_name!, index ); result.push([cacheKey, this.buildPrioScoreCacheEntry(metadata)]); }); } return result; } buildPrioScoreCacheEntry(metadata: PrioScoreSpeedResponse | null): PrioScoreCacheEntry | null { const score = parseFloat(metadata?.priority_score as any); const delay = parseFloat(metadata?.delay as any); if (!Number.isFinite(score)) { return null; } return { priority_score: score, delay: Number.isFinite(delay) ? delay : undefined, }; } @MonitoringSection(GET_PRIO_SCORES_METRIC, { isolated: "false" }) private async getPrioScoreFromMsSpeed(key: PrioScoreCacheKey): Promise { return this.callSpeedHttp("/speed/supply", key); } @MonitoringSection(GET_PRIO_SCORES_METRIC, { isolated: "true" }) private async getIsolatedPrioScoresFromMsSpeed( key: PrioScoreCacheKey ): Promise { return this.callSpeedHttp("/isolated-speed/supply", key); } async callSpeedHttp(url: string, key: PrioScoreCacheKey): Promise { const data = _.mapValues({ id: uuid(), app_api_key: `${key.appApiKey}`, publisher_id: key.publisherId, country: key.country, environment_type: key.environmentType, format: key.format, }); const response = await axios({ method: "post", baseURL: `http://${config.MS_SPEED_HTTP_HOST}`, url, data: _.pickBy(data, (v) => !!v), }); return response.data; } updateCacheMetrics(cache: OgyCache, isolated: boolean, score_type: string) { const stats = cache.stats; const requestMetrics: [number, string][] = [ [stats.itemRequests.hitsFresh, "hit"], [stats.itemRequests.hitsStale, "stale"], [stats.itemRequests.misses, "miss"], ]; for (const [num, response_type] of requestMetrics) { this.metricService.setGauge(CACHE_REQUESTS_METRIC, num, { response_type, isolated: `${isolated}`, score_type, }); } this.metricService.setGauge(CACHE_SIZE_METRIC, stats.cacheSize, { isolated: `${isolated}`, score_type, }); } }