From bce557cc2dc767628bed6aac87301a1be7c5431b Mon Sep 17 00:00:00 2001 From: rxliuli Date: Tue, 4 Nov 2025 05:03:50 +0800 Subject: init commit --- shared/metrics-8/src/constants.ts | 19 + shared/metrics-8/src/impression-provider.ts | 27 + .../metrics-8/src/impression-snapshot-provider.ts | 27 + shared/metrics-8/src/impressions/constants.ts | 1 + shared/metrics-8/src/impressions/index.ts | 252 +++++++++ shared/metrics-8/src/index.ts | 578 +++++++++++++++++++++ shared/metrics-8/src/recorder/composite.ts | 20 + shared/metrics-8/src/recorder/funnelkit.ts | 237 +++++++++ shared/metrics-8/src/recorder/logging.ts | 21 + shared/metrics-8/src/recorder/metricskit.ts | 239 +++++++++ shared/metrics-8/src/recorder/void.ts | 17 + .../metrics-8/src/utils/get-event-field-topic.ts | 11 + .../src/utils/metrics-dev-console/constants.ts | 7 + .../utils/metrics-dev-console/setup-metrics-dev.ts | 55 ++ 14 files changed, 1511 insertions(+) create mode 100644 shared/metrics-8/src/constants.ts create mode 100644 shared/metrics-8/src/impression-provider.ts create mode 100644 shared/metrics-8/src/impression-snapshot-provider.ts create mode 100644 shared/metrics-8/src/impressions/constants.ts create mode 100644 shared/metrics-8/src/impressions/index.ts create mode 100644 shared/metrics-8/src/index.ts create mode 100644 shared/metrics-8/src/recorder/composite.ts create mode 100644 shared/metrics-8/src/recorder/funnelkit.ts create mode 100644 shared/metrics-8/src/recorder/logging.ts create mode 100644 shared/metrics-8/src/recorder/metricskit.ts create mode 100644 shared/metrics-8/src/recorder/void.ts create mode 100644 shared/metrics-8/src/utils/get-event-field-topic.ts create mode 100644 shared/metrics-8/src/utils/metrics-dev-console/constants.ts create mode 100644 shared/metrics-8/src/utils/metrics-dev-console/setup-metrics-dev.ts (limited to 'shared/metrics-8/src') diff --git a/shared/metrics-8/src/constants.ts b/shared/metrics-8/src/constants.ts new file mode 100644 index 0000000..6eff451 --- /dev/null +++ b/shared/metrics-8/src/constants.ts @@ -0,0 +1,19 @@ +/** + * A list of event types we use across all onyx apps for metrics. + */ +export const METRICS_EVENT_TYPES = [ + // The following types come from the jet enum `MetricsEventType` + // https://github.pie.apple.com/app-store/jet-js/blob/505144151e875c1bcbacd898216127fbc14c1562/packages/environment/src/types/metrics.ts#L198-L205 + // and the events could be handled by MetricsKit + // https://github.pie.apple.com/amp-ae/mt-metricskit/tree/dev/packages/processors/mt-metricskit-processor-clickstream/src/metrics/event_handlers + 'account', // For GDPR + 'click', + 'dialog', + 'enter', + 'exit', + 'impressions', + 'media', + 'page', + 'pageRender', + 'search', +] as const; diff --git a/shared/metrics-8/src/impression-provider.ts b/shared/metrics-8/src/impression-provider.ts new file mode 100644 index 0000000..852d418 --- /dev/null +++ b/shared/metrics-8/src/impression-provider.ts @@ -0,0 +1,27 @@ +import type { + MetricsFieldsBuilder, + MetricsFieldsContext, + MetricsFieldsProvider, +} from '@jet/engine'; +import { IMPRESSION_CONTEXT_NAME } from './impressions/constants'; +import type { Impressions } from './impressions'; + +export class ImpressionFieldProvider implements MetricsFieldsProvider { + constructor(private readonly appContext: Map) { + this.appContext = appContext; + } + + addMetricsFields( + builder: MetricsFieldsBuilder, + _metricsContext: MetricsFieldsContext, + ) { + const impressionInstance = this.appContext.get( + IMPRESSION_CONTEXT_NAME, + ) as Impressions; + + if (impressionInstance?.settings?.captureType === 'jet') { + let impressions = impressionInstance.consumeImpressions(); + builder.addValue(impressions, 'impressions'); + } + } +} diff --git a/shared/metrics-8/src/impression-snapshot-provider.ts b/shared/metrics-8/src/impression-snapshot-provider.ts new file mode 100644 index 0000000..c7261f9 --- /dev/null +++ b/shared/metrics-8/src/impression-snapshot-provider.ts @@ -0,0 +1,27 @@ +import type { + MetricsFieldsBuilder, + MetricsFieldsContext, + MetricsFieldsProvider, +} from '@jet/engine'; +import { IMPRESSION_CONTEXT_NAME } from './impressions/constants'; +import type { Impressions } from './impressions'; + +export class ImpressionSnapshotFieldProvider implements MetricsFieldsProvider { + constructor(private readonly appContext: Map) { + this.appContext = appContext; + } + + addMetricsFields( + builder: MetricsFieldsBuilder, + _metricsContext: MetricsFieldsContext, + ) { + const impressionInstance = this.appContext.get( + IMPRESSION_CONTEXT_NAME, + ) as Impressions; + + if (impressionInstance?.settings?.captureType === 'jet') { + let impressions = impressionInstance.captureSnapshotImpression(); + builder.addValue(impressions, 'impressions'); + } + } +} diff --git a/shared/metrics-8/src/impressions/constants.ts b/shared/metrics-8/src/impressions/constants.ts new file mode 100644 index 0000000..0638f9e --- /dev/null +++ b/shared/metrics-8/src/impressions/constants.ts @@ -0,0 +1 @@ +export const IMPRESSION_CONTEXT_NAME = 'metrics:impression' as const; diff --git a/shared/metrics-8/src/impressions/index.ts b/shared/metrics-8/src/impressions/index.ts new file mode 100644 index 0000000..b904feb --- /dev/null +++ b/shared/metrics-8/src/impressions/index.ts @@ -0,0 +1,252 @@ +import type { Logger, LoggerFactory } from '@amp/web-apps-logger/src/types'; +import { IMPRESSION_CONTEXT_NAME } from './constants'; +import { createSvelteImpressionAction } from './utils/svelte/impressions-svelte-action'; +import type { + AppImpressionModel, + ImpressionSettings, + ImpressionsInstance, +} from './types'; +import type { + ImpressionObserver, + newInstanceWithMetricsConfig, +} from '@amp-metrics/mt-impressions-observer'; +import type { ClickstreamProcessor } from '@amp-metrics/mt-metricskit-processor-clickstream'; + +/** + * Adapter class to handle interactions with + * metricsKit impression observer. + */ +export class Impressions implements ImpressionsInstance { + private readonly logger: Logger; + private impressionObserverInstance: ImpressionObserver | undefined; + private hasInitialized: boolean = false; + private impressionDataMap: Map = new Map(); + private currentSnapshot: Record[] = []; + private readonly impressionSettings: ImpressionSettings | undefined; + + constructor( + loggerFactory: LoggerFactory, + context: Map, + settings?: ImpressionSettings, + ) { + this.logger = loggerFactory.loggerFor(IMPRESSION_CONTEXT_NAME); + this.impressionSettings = settings; + + context.set(IMPRESSION_CONTEXT_NAME, this); + } + + async init( + makeImpressionObserver: typeof newInstanceWithMetricsConfig, + clickStreamInstance: ClickstreamProcessor, + ) { + if (this.hasInitialized) { + this.logger.warn( + 'Ignoring, Impressions.init() can only be called once', + ); + return; + } + + const options = { root: document, rootMargin: '0px' }; + const config = clickStreamInstance.config; + const impressionObserver: ImpressionObserver = + await makeImpressionObserver(config, options); + + impressionObserver.setDelegate({ + extractImpressionInfo: (domNode: HTMLElement) => { + const dataMap = this.impressionDataMap; + const nodeMetricData = dataMap.get(domNode); + if (nodeMetricData) { + const impressionData = nodeMetricData.impressionMetrics; + impressionData.location = + clickStreamInstance.utils.eventFields.buildLocationStructure( + domNode, + (node: HTMLElement) => { + const metrics = dataMap.get(node); + if (metrics?.location) { + return metrics.location; + } + return; + }, + ); + return impressionData; + } else { + this.logger.warn('no impression data found for', domNode); + } + }, + }); + + this.impressionObserverInstance = impressionObserver; + this.impressionDataMap.forEach((_value, node) => { + this.logger.debug('observing deffered node', node); + this.impressionObserverInstance?.observe(node); + }); + this.hasInitialized = true; + + this.logger.debug('impressions initialized'); + } + + get settings() { + return this.impressionSettings; + } + + isEnabled(event: 'click' | 'exit' | 'impressions'): boolean { + if (this.impressionSettings?.captureType === 'jet') { + return ( + this.impressionSettings?.metricsKitEvents?.includes(event) ?? + false + ); + } + return true; + } + + consumeImpressions(): Record[] | undefined { + if (this.hasInitialized) { + this.logger.debug('consuming impression metrics'); + return this.impressionObserverInstance?.consumeImpressions(); + } + this.logger.warn('impressions not avaiable yet'); + return; + } + + captureSnapshotImpression(): Record[] | undefined { + const snapshot = + this.impressionObserverInstance?.snapshotImpressions() ?? []; + + // if the current page already transitioned. fallback to the snapshot we captured before transition + if (snapshot.length === 0) { + return this.getSnapshotImpression(); + } + + return snapshot; + } + + getSnapshotImpression(): Record[] | undefined { + if (this.hasInitialized) { + return this.currentSnapshot; + } + this.logger.warn('impressions not avaiable yet'); + return; + } + + setCurrentSnapshot(): void { + if (this.hasInitialized) { + this.logger.debug('capturing impression snapshot'); + this.currentSnapshot = + this.impressionObserverInstance?.snapshotImpressions() ?? []; + } else { + this.logger.warn('impressions not avaiable yet'); + } + } + + get nodeList() { + const impressionClass = this; + + return new Proxy(impressionClass.impressionDataMap, { + get(target, prop, receiver) { + const orginalFn = Reflect.get(target, prop, receiver); + + // overriding 'set' to also be able to observe + if (prop === 'set') { + return ( + node: HTMLElement, + value: Record, + ) => { + if (impressionClass.hasInitialized) { + impressionClass.logger.debug( + 'observing', + node, + value, + ); + + impressionClass.impressionObserverInstance?.observe( + node, + ); + } + + return orginalFn.bind(target)(node, value); + }; + } + + // overriding 'delete' to also be able to unobserve + if (prop === 'delete') { + return (node: HTMLElement) => { + if (impressionClass.hasInitialized) { + impressionClass.logger.debug('unobserve', node); + impressionClass.impressionObserverInstance?.unobserve( + node, + ); + } + + return orginalFn.bind(target)(node); + }; + } + + return orginalFn.bind(target); + }, + set(target, prop, value) { + return Reflect.set(target, prop, value); + }, + }); + } +} + +/** + * Server Noop for above + */ +class ServerNoopImpressions implements ImpressionsInstance { + readonly nodeList: WeakMap; + constructor() { + this.nodeList = new WeakMap(); + } + setCurrentSnapshot(): void {} +} + +/** + * Gets the current Impression instance from the Svelte context. + * + * @return The current instance of Impression + */ +export function generateBrowserImpressionsContextGetter( + getContext: (context: string) => unknown, +): () => AppImpressionModel { + return function getImpressions(): AppImpressionModel { + const impressions = getContext(IMPRESSION_CONTEXT_NAME) as + | Impressions + | undefined; + + if (!impressions) { + const noopImpressions = new ServerNoopImpressions(); + return { + captureImpressions: (_node: any, _impressionsData: any) => { + return { + destroy() {}, + }; + }, + impressions: noopImpressions, + }; + } + + return { + captureImpressions: createSvelteImpressionAction(impressions), + impressions, + }; + }; +} + +/** + * Server No-op for generateImpressionsContextGetter + * + */ +export function generateServerImpressionsContextGetter( + _getContext: (context: string) => unknown, +): () => AppImpressionModel { + const impressions = new ServerNoopImpressions(); + return () => ({ + captureImpressions: (_node: any, _impressionsData: any) => { + return { + destroy() {}, + }; + }, + impressions, + }); +} diff --git a/shared/metrics-8/src/index.ts b/shared/metrics-8/src/index.ts new file mode 100644 index 0000000..59510b0 --- /dev/null +++ b/shared/metrics-8/src/index.ts @@ -0,0 +1,578 @@ +import type { Logger, LoggerFactory } from '@amp/web-apps-logger'; +import { getPWADisplayMode, PWADisplayMode } from '@amp/web-apps-utils/src'; +import type { + LintedMetricsEvent, + MetricsData, + MetricsFields, +} from '@jet/environment/types/metrics'; +import type { PageMetrics } from '@jet/environment/types/metrics'; + +import type { Opt } from '@jet/environment'; + +import { + MetricsFieldsAggregator, + type MetricsFieldsContext, + type MetricsFieldsProvider, + MetricsPipeline, + PageMetricsPresenter, + type MetricsEventRecorder, +} from '@jet/engine'; + +import { + CompositeEventRecorder, + type FunnelKitConfig, + FunnelKitRecorder, + LoggingEventRecorder, + type MetricKitConfig, + MetricsKitRecorder, + VoidEventRecorder, +} from './recorder'; + +import type { + MetricsEnterEventType, + MetricsExitEventType, + SystemLoggerLevel, +} from './types'; + +import type { + EnvironmentDelegates, + WebDelegates as WebDelegatesInstance, +} from '@amp-metrics/mt-metricskit-delegates-web'; +import type { ClickstreamProcessor as ClickstreamProcessorInstance } from '@amp-metrics/mt-metricskit-processor-clickstream'; +import { Impressions } from './impressions'; +import { buildMakeAjaxRequest } from './utils/metrics-dev-console/metrics-dev-network'; +import { ImpressionFieldProvider } from './impression-provider'; +import { ImpressionSnapshotFieldProvider } from './impression-snapshot-provider'; +import type { ImpressionSettings } from './impressions/types'; + +const CONTEXT_NAME = 'metrics'; + +export type MetricsProvider = { + provider: MetricsFieldsProvider; + request: string; +}; + +export interface MetricSettings { + shouldEnableImpressions?: () => boolean; + shouldEnableFunnelKit: () => boolean; + getConsumerId: () => Promise; + suppressMetricsKit?: boolean; + impressions?: ImpressionSettings; +} + +interface InitializedMetrics { + clickstream: ClickstreamProcessorInstance; + webDelegate: WebDelegatesInstance; +} + +interface Config { + baseFields: { + appName: string; + delegateApp: string; + appVersion: string; + resourceRevNum: string; + storageObject?: 'sessionStorage' | 'localStorage'; + }; + clickstream: MetricKitConfig; + + /** + * `FunnelKit` configuration + * + * Can be `undefined` to disable the `FunnelKit` recorder entirely + */ + funnel?: FunnelKitConfig; + + initialURL?: string | null; +} + +type ClickstreamProcessorClass = typeof ClickstreamProcessorInstance; +type WebDelegatesClass = typeof WebDelegatesInstance; + +export class Metrics { + private readonly log: Logger; + private impressions: InstanceType | undefined; + + // Properties asynchronously set in the `init` function + private ClickstreamProcessor!: ClickstreamProcessorClass; + private WebDelegates!: WebDelegatesClass; + + private readonly metricsKitRecorder?: MetricsKitRecorder; + private readonly funnelKitRecorder?: FunnelKitRecorder; + private firstEnterRecorded: boolean = false; + private funnelKit: ClickstreamProcessorInstance | undefined; + private config: Config; + + public readonly metricsPipeline: MetricsPipeline; + public currentPageMetrics: Opt; + + static load( + loggerFactory: LoggerFactory, + context: Map, + processEvent: (fields: MetricsFields) => Promise, + config: Config, + listofMetricProviders: MetricsProvider[], + settings: MetricSettings, + ): Metrics { + const { + getConsumerId, + shouldEnableFunnelKit, + suppressMetricsKit = false, + } = settings; + + const log = loggerFactory.loggerFor('Metrics'); + + // server + if (typeof window === 'undefined' || suppressMetricsKit) { + const recorder = new VoidEventRecorder(); + const metricsPipeline = new MetricsPipeline({ + aggregator: new MetricsFieldsAggregator(), + linter: { + async processEvent( + fields: MetricsFields, + ): Promise { + return { fields }; + }, + }, + recorder, + }); + + return new Metrics(log, metricsPipeline, config); + } + + config.initialURL = window.location.href; + + const aggregator = setupAggregators(listofMetricProviders, context); + + let impressions: InstanceType | undefined = + undefined; + if (settings.shouldEnableImpressions?.() ?? false) { + impressions = new Impressions( + loggerFactory, + context, + settings?.impressions, + ); + } + + const metricsKitRecorder = new MetricsKitRecorder( + loggerFactory, + config.clickstream, + impressions, + ); + + const recorders: MetricsEventRecorder[] = [ + new LoggingEventRecorder(loggerFactory), + metricsKitRecorder, + ]; + + const funnelKitRecorder = config.funnel + ? new FunnelKitRecorder(loggerFactory, config.funnel, impressions) + : undefined; + if (funnelKitRecorder) { + recorders.push(funnelKitRecorder); + } + + let recorder = new CompositeEventRecorder(recorders); + + const metricsPipeline = new MetricsPipeline({ + aggregator, + linter: { + processEvent: async (fields: MetricsFields) => { + const lintedEvent = await processEvent(fields); + + // `dsId` is added by the LintMetricsEventIntentController in music-ui-js, but is not needed and erroneous for web + // https://github.pie.apple.com/music/music-ui-js/blob/50cbae83deccffad37e5b617394ea30b7e082660/src/metrics/LintMetricsEventIntentController.ts#L19-L22 + if (lintedEvent.fields?.dsId) { + delete lintedEvent.fields.dsId; + } + + // Consumer ID needs to be added at the time of processEvent because the ConsumerID is available after Sign In and not before sign In + // Using it through the delegates does not have ability to fetch it dynamically + const consumerId = await getConsumerId(); + if (consumerId) { + lintedEvent.fields.consumerId = consumerId; + } + + return lintedEvent; + }, + }, + recorder, + }); + + const metricsInstance = new Metrics( + log, + metricsPipeline, + config, + metricsKitRecorder, + funnelKitRecorder, + impressions, + ); + metricsInstance.watchEnterAndExit(); + + (async () => { + try { + const metricsDependencies = [ + import('@amp-metrics/mt-metricskit-processor-clickstream'), + import('@amp-metrics/mt-metricskit-delegates-web'), + impressions + ? import('@amp-metrics/mt-impressions-observer') + : undefined, + ] as const; + + const [ + { ClickstreamProcessor }, + { WebDelegates }, + impressionsDependency, + ] = await Promise.all(metricsDependencies); + + metricsInstance.onDependenciesLoaded( + ClickstreamProcessor, + WebDelegates, + ); + + const { clickstream, webDelegate } = setupMtkit( + ClickstreamProcessor, + WebDelegates, + config, + ); + + if (impressions && impressionsDependency) { + const { newInstanceWithMetricsConfig } = + impressionsDependency; + impressions.init(newInstanceWithMetricsConfig, clickstream); + } + + const eventRecorder = webDelegate.eventRecorder; + metricsKitRecorder.setupEventRecorder( + eventRecorder, + clickstream, + ); + + if (shouldEnableFunnelKit()) { + metricsInstance.enableFunnelKit(); + } + log.info('Metricskit loaded'); + } catch (e) { + log.warn('Metricskit failed to load', e); + } + })(); + + // Save Metrics Instance on Context before Returning + context.set(CONTEXT_NAME, metricsInstance); + + return metricsInstance; + } + + private constructor( + log: Logger, + metricsPipeline: MetricsPipeline, + config: Config, + metricsKitRecorder?: MetricsKitRecorder, + funnelKitRecorder?: FunnelKitRecorder, + impressions?: InstanceType, + ) { + this.log = log; + this.metricsPipeline = metricsPipeline; + this.metricsKitRecorder = metricsKitRecorder; + this.funnelKitRecorder = funnelKitRecorder; + this.config = config; + this.impressions = impressions; + } + + /** + * Metrics code that should get called before a page changes. + */ + willPageTransition(): void { + this.impressions?.setCurrentSnapshot(); + } + + async didEnterPage< + T extends { pageMetrics: PageMetrics; canonicalURL: string }, + >(page: T | null): Promise { + if (this.currentPageMetrics) { + await this.currentPageMetrics.didLeavePage(); + this.currentPageMetrics = null; + } + + if (page?.pageMetrics) { + this.currentPageMetrics = new PageMetricsPresenter( + this.metricsPipeline, + ); + this.currentPageMetrics.pageMetrics = page.pageMetrics; + await this.currentPageMetrics.didEnterPage(); + } else { + this.log.warn('No pageMetrics', page); + } + + if (!this.firstEnterRecorded) { + const event = document.referrer?.length > 0 ? 'link' : 'launch'; + this.enter(event, { openUrl: page?.canonicalURL }); + this.firstEnterRecorded = true; + } + } + + async enter(type: MetricsEnterEventType, fields?: Opt) { + let openUrl: string = window.location.href; + let pwaDisplayMode: PWADisplayMode | null = null; + + if (fields?.openUrl) { + openUrl = fields?.openUrl as string; + } + + if (type === 'launch' && this.config.initialURL) { + openUrl = this.config.initialURL; + // Clearing the initial URL as we don't need this post launch event + this.config.initialURL = null; + pwaDisplayMode = getPWADisplayMode(); + } + + this.recordCustomEvent({ + eventType: 'enter', + extRefUrl: document.referrer ?? '', + refUrl: document.referrer ?? '', + openUrl, + type, + // only add buildFlavor property if coming from the PWA (represented by 'standalone' in the manifest.json) or android app + ...(pwaDisplayMode === PWADisplayMode.STANDALONE || + pwaDisplayMode === PWADisplayMode.TWA + ? { buildFlavor: pwaDisplayMode } + : {}), + }); + } + + async exit(type: MetricsExitEventType, _fields?: Opt) { + this.recordCustomEvent({ + eventType: 'exit', + type, + }); + } + + async pageTransition() { + this.log.info('triggered metrics for page transition'); + if (this.impressions) { + this.impressions.setCurrentSnapshot(); + } + } + + private watchEnterAndExit() { + document.addEventListener( + 'visibilitychange', + this.onVisibilityChange.bind(this), + ); + } + + async onVisibilityChange() { + if (document.visibilityState === 'visible') { + this.enter('taskSwitch'); + } else { + this.exit('taskSwitch'); + } + } + + async processEvent(metricsFields: MetricsFields) { + const metricsData: MetricsData = { + excludingFields: [], + includingFields: [], + shouldFlush: false, + fields: metricsFields, + }; + const context: MetricsFieldsContext = {}; + await this.metricsPipeline.process(metricsData, context); + } + + async recordCustomEvent(fields?: Opt) { + await this.processEvent({ + ...this.currentPageMetrics?.pageMetrics?.pageFields, + ...fields, + }); + } + + /** + * Sets up FunnelKit for clickstream events + */ + private setupFunnelKit(): void { + if (!this.config.funnel) { + this.log.warn( + 'Tried to set up `FunnelKit` but no config was provided', + ); + return; + } + + const { topic } = this.config.funnel; + const { clickstream, webDelegate } = setupStarkit( + this.ClickstreamProcessor, + this.WebDelegates, + this.config.funnel, + this.config.baseFields, + ); + clickstream.config.setDebugSource(null); + + // Disable PII fields and cookies for the funnel topic + webDelegate.eventRecorder.setProperties?.(topic, { + anonymous: true, + }); + + this.funnelKitRecorder?.setupEventRecorder(clickstream); + this.funnelKit = clickstream; + } + + private onDependenciesLoaded( + ClickstreamProcessor: ClickstreamProcessorClass, + webDelegate: WebDelegatesClass, + ): void { + this.ClickstreamProcessor = ClickstreamProcessor; + this.WebDelegates = webDelegate; + } + + disableMetrics(): void { + this.metricsKitRecorder?.disable(); + } + + enableMetrics(): void { + this.metricsKitRecorder?.enable(); + } + + enableFunnelKit(): void { + if (!this.funnelKit) { + this.setupFunnelKit(); + } + this.funnelKitRecorder?.enableFunnelKit(); + } + + disableFunnelKit(): void { + this.funnelKitRecorder?.disableFunnelKit(); + } +} + +/** + * Shared setup for *kit, namely MetricsKit and FunnelKit + */ +function setupStarkit( + ClickstreamProcessor: ClickstreamProcessorClass, + WebDelegates: WebDelegatesClass, + setupConfig: FunnelKitConfig | MetricKitConfig, + config: Config['baseFields'], +): InitializedMetrics { + const { topic } = setupConfig; + const webDelegate = new WebDelegates(topic); + + if (import.meta.env.APP_SCOPE === 'internal') { + try { + // Temporary setup to get Network Dependency + const networkCopy = { + ...Object.getPrototypeOf(webDelegate.config.network), + }; + + const makeAjaxRequest = buildMakeAjaxRequest(networkCopy, topic); + + webDelegate.setNetwork({ + makeAjaxRequest, + }); + } catch (e) { + console.warn('failed to setup flush logger'); + } + } + + const clickstream = new ClickstreamProcessor(webDelegate); + + const systemLoggerLevel: SystemLoggerLevel = 'none'; + clickstream.system.logger.setLevel(systemLoggerLevel); + clickstream.init(); + + setupMtkitDelegates(clickstream, setupConfig, config); + return { clickstream, webDelegate }; +} + +/** + * MetricsKit setup for main clickstream events + */ +function setupMtkit( + ClickstreamProcessor: ClickstreamProcessorClass, + webDelegates: WebDelegatesClass, + config: Config, +): InitializedMetrics { + const mtkit = setupStarkit( + ClickstreamProcessor, + webDelegates, + config.clickstream, + config.baseFields, + ); + return mtkit; +} + +function setupMtkitDelegates( + mtkit: ClickstreamProcessorInstance, + setupConfig: FunnelKitConfig | MetricKitConfig, + config: Config['baseFields'], +): void { + const { appName, delegateApp, appVersion, resourceRevNum, storageObject } = + config; + const additionalDelegates: EnvironmentDelegates = { + app: () => appName, + appVersion: () => appVersion, + delegateApp: () => delegateApp, + resourceRevNum: () => resourceRevNum, + }; + + if (storageObject === 'sessionStorage') { + additionalDelegates['localStorageObject'] = () => { + return sessionStorage; + }; + } + + mtkit.system.environment.setDelegate(additionalDelegates); + + if (Array.isArray(setupConfig.constraintProfiles)) { + mtkit.config.setDelegate({ + constraintProfiles: () => setupConfig.constraintProfiles, + }); + } +} + +function setupAggregators( + metricsFieldsProviders: MetricsProvider[], + context: Map, +): MetricsFieldsAggregator { + const aggregator = MetricsFieldsAggregator.makeDefaultAggregator(); + + aggregator.addOptInProvider( + new ImpressionFieldProvider(context), + 'impressions', + ); + + aggregator.addOptInProvider( + new ImpressionSnapshotFieldProvider(context), + 'impressionsSnapshot', + ); + + metricsFieldsProviders.forEach((metricsFields) => { + aggregator.addOptOutProvider( + metricsFields.provider, + metricsFields.request, + ); + }); + + return aggregator; +} + +/** + * Gets the current Metrics instance from the Svelte context. + * + * @return metrics The current instance of Metrics + */ + +export function generateMetricsContextGetter( + getContext: (context: string) => unknown, +): () => Metrics { + return function getMetrics(): Metrics { + const metrics = getContext(CONTEXT_NAME) as Metrics | undefined; + + if (!metrics) { + throw new Error('getMetrics called before Metrics.load'); + } + + return metrics; + }; +} + +export * from './impressions/index'; +export * from './impressions/utils/svelte/impressions-svelte-action'; diff --git a/shared/metrics-8/src/recorder/composite.ts b/shared/metrics-8/src/recorder/composite.ts new file mode 100644 index 0000000..6302921 --- /dev/null +++ b/shared/metrics-8/src/recorder/composite.ts @@ -0,0 +1,20 @@ +import type { MetricsEventRecorder } from '@jet/engine'; +import type { LintedMetricsEvent } from '@jet/environment/types/metrics'; +import type { Opt } from '@jet/environment/types/optional'; + +export class CompositeEventRecorder implements MetricsEventRecorder { + constructor(private readonly eventRecorders: MetricsEventRecorder[]) {} + + record(event: LintedMetricsEvent, topic: Opt): void { + for (const eventRecorder of this.eventRecorders) { + eventRecorder.record(event, topic); + } + } + + async flush(): Promise { + const flushed: number[] = await Promise.all( + this.eventRecorders.map((recorder) => recorder.flush()), + ); + return Math.max(...flushed); + } +} diff --git a/shared/metrics-8/src/recorder/funnelkit.ts b/shared/metrics-8/src/recorder/funnelkit.ts new file mode 100644 index 0000000..7f3fa84 --- /dev/null +++ b/shared/metrics-8/src/recorder/funnelkit.ts @@ -0,0 +1,237 @@ +import type { MetricsEventRecorder } from '@jet/engine'; +import type { LintedMetricsEvent } from '@jet/environment/types/metrics'; +import type { Opt } from '@jet/environment/types/optional'; +import type { Logger, LoggerFactory } from '@amp/web-apps-logger'; +import type { ClickstreamProcessor as ClickstreamProcessorInstance } from '@amp-metrics/mt-metricskit-processor-clickstream'; +import type { Impressions } from '../impressions'; +import { sendToMetricsDevConsole } from '../utils/metrics-dev-console/setup-metrics-dev'; +import { getEventFieldsWithTopic } from '../utils/get-event-field-topic'; +import { eventType } from '../utils/metrics-dev-console/constants'; + +interface DeferredEvent { + event: LintedMetricsEvent; + topic: Opt; +} + +export interface FunnelKitConfig { + constraintProfiles: string[]; + topic: string; +} + +/** + * These fields are considered PII and should be ignored by FunnelKit. + * `consumerId` is added via the `processEvent` based on when it is available (see jet/metrics/index.ts) + * However it should be ignored when sent to the FunnelKit topic. + */ +const IGNORED_FIELDS = ['consumerId']; + +export class FunnelKitRecorder implements MetricsEventRecorder { + private readonly log: Logger; + private funnelKit: ClickstreamProcessorInstance | undefined; + private funnelKitEnabled: boolean = false; + private recordedEventsCount: number; + private config: FunnelKitConfig; + private readonly impressions: InstanceType | undefined; + + /** + * Queues events prior to the mt-event-queue recorder being available + */ + private readonly deferredEvents: DeferredEvent[]; + + constructor( + loggerFactory: LoggerFactory, + config: FunnelKitConfig, + impressions: InstanceType | undefined, + ) { + this.log = loggerFactory.loggerFor('FunnelKitRecorder'); + this.deferredEvents = []; + this.recordedEventsCount = 0; + this.config = config; + this.impressions = impressions; + } + + async record( + event: LintedMetricsEvent, + eventTopic: Opt, + ): Promise { + let topic = eventTopic ?? this.config.topic; + + // TV always uses the config topic + // TODO: rdar://151772731 (Align funnel metrics between Music + TV) + if (this.config.topic === 'xp_amp_tv_unidentified') { + topic = this.config.topic; + } + + if (!this.funnelKitEnabled) { + this.log.info('FunnelKit not enabled', event, topic); + return; + } + + if (this.funnelKit) { + const eventHandler = event.fields.eventType as string; + const { pageId, pageType, pageContext } = event.fields; + if (!eventHandler) { + this.log.warn('No `eventType` found on event', event, topic); + } else if (!this.impressions && eventHandler === 'impressions') { + this.log.info( + 'Supressing impression event. Impressions not enabled', + ); + return; + } + + // when the user leaves a page to report the accumulated impressions for that page + if ( + (this.impressions?.isEnabled('exit') && + eventHandler === 'exit') || + (this.impressions?.isEnabled('click') && + event.fields.actionType === 'navigate') + ) { + // create + capture impressions + const accumulatedImpressions = + this.impressions.consumeImpressions(); + const metricsData = this.funnelKit?.eventHandlers[ + 'impressions' + ]?.metricsData(pageId, pageType, pageContext, { + impressions: accumulatedImpressions, + }); + + metricsData + ?.recordEvent(topic) + .then((data) => { + this.log.info( + 'impressions event captured', + data, + topic, + ); + sendToMetricsDevConsole( + data as { [key: string]: unknown }, + topic, + ); + }) + .catch((e) => { + this.log.warn( + 'failed to capture impression metrics', + e, + topic, + ); + }); + } + + let impressionsData: Record = {}; + // snapshot impressions to include in click events + if ( + (this.impressions?.isEnabled('click') && + eventHandler === 'click') || + (this.impressions?.isEnabled('impressions') && + eventHandler === 'impressions') + ) { + const snapshotImpressions = + this.impressions.captureSnapshotImpression(); + impressionsData = snapshotImpressions + ? { + impressions: snapshotImpressions, + } + : {}; + } + + const eventFields = getEventFieldsWithTopic(event, topic); + // Handle transaction events differently per Ember implementation + // https://github.pie.apple.com/amp-ui/ember-metrics/blob/7eb762601db5e37cb428d7a4e6f24e22d0529515/addon/services/metrics.js#L347-L349 + const metricsDataArgs = + eventHandler === 'transaction' + ? [eventFields] + : [pageId, pageType, pageContext, eventFields]; + + try { + const baseFields = await this.funnelKit.eventHandlers[ + eventHandler + ] + ?.metricsData( + // @ts-expect-error TypeScript doesn't handle spreading the argument array well + ...metricsDataArgs, + ) + .toJSON(); + + const metricsData = { + ...baseFields, + ...eventFields, + ...impressionsData, + }; + IGNORED_FIELDS.forEach( + (ignoredField) => delete metricsData[ignoredField], + ); + this.log.info('FunnelKit event data', metricsData, topic); + + try { + const data = + await this.funnelKit.system.eventRecorder.recordEvent( + topic, + metricsData, + ); + sendToMetricsDevConsole(data, topic); + } catch (e) { + this.log.info( + 'FunnelKit failed to capture', + metricsData, + topic, + ); + } + + // on exit events we should flush all metrics + if (eventHandler === 'exit') { + this.funnelKit?.system.eventRecorder.flushUnreportedEvents?.( + true, + ); + + sendToMetricsDevConsole( + { metricsDevType: eventType.FLUSH, status: 'SUCCESS' }, + topic, + ); + } + + this.recordedEventsCount++; + } catch (e) { + this.log.error('FunnelKit failed to capture metric', e, topic); + } + } else { + this.deferredEvents.push({ event, topic }); + } + } + + async flush(): Promise { + if (!this.funnelKitEnabled) { + return 0; + } + + await this.funnelKit?.system.eventRecorder.flushUnreportedEvents(false); + const count = this.recordedEventsCount; + this.recordedEventsCount = 0; + return count; + } + + setupEventRecorder(funnelKit: ClickstreamProcessorInstance): void { + this.funnelKit = funnelKit; + this.deferredEvents.forEach(({ event, topic }) => + this.record(event, topic), + ); + this.deferredEvents.length = 0; + } + + enableFunnelKit(): void { + if (this.funnelKitEnabled) { + return; + } + + this.log.info('Enabling FunnelKit'); + this.funnelKitEnabled = true; + } + + disableFunnelKit(): void { + if (!this.funnelKitEnabled) { + return; + } + + this.log.info('Disabling FunnelKit'); + this.funnelKitEnabled = false; + } +} diff --git a/shared/metrics-8/src/recorder/logging.ts b/shared/metrics-8/src/recorder/logging.ts new file mode 100644 index 0000000..baa0373 --- /dev/null +++ b/shared/metrics-8/src/recorder/logging.ts @@ -0,0 +1,21 @@ +import type { MetricsEventRecorder } from '@jet/engine'; +import type { LintedMetricsEvent } from '@jet/environment/types/metrics'; +import type { Opt } from '@jet/environment/types/optional'; +import type { Logger, LoggerFactory } from '@amp/web-apps-logger'; + +export class LoggingEventRecorder implements MetricsEventRecorder { + private readonly log: Logger; + + constructor(loggerFactory: LoggerFactory) { + this.log = loggerFactory.loggerFor('LoggingEventRecorder'); + } + + record(event: LintedMetricsEvent, topic: Opt): void { + this.log.info('logged metrics event:', event, topic); + } + + async flush(): Promise { + this.log.info('flushed metrics'); + return 0; + } +} diff --git a/shared/metrics-8/src/recorder/metricskit.ts b/shared/metrics-8/src/recorder/metricskit.ts new file mode 100644 index 0000000..9d724c9 --- /dev/null +++ b/shared/metrics-8/src/recorder/metricskit.ts @@ -0,0 +1,239 @@ +import type { MetricsEventRecorder } from '@jet/engine'; +import type { LintedMetricsEvent } from '@jet/environment/types/metrics'; +import type { Opt } from '@jet/environment/types/optional'; +import type { Logger, LoggerFactory } from '@amp/web-apps-logger'; + +import { METRICS_EVENT_TYPES } from '../constants'; + +import type { WebDelegates as WebDelegatesInstance } from '@amp-metrics/mt-metricskit-delegates-web'; +import type { ClickstreamProcessor as ClickstreamProcessorInstance } from '@amp-metrics/mt-metricskit-processor-clickstream'; +import type { Impressions } from '../impressions'; +import { sendToMetricsDevConsole } from '../utils/metrics-dev-console/setup-metrics-dev'; +import { getEventFieldsWithTopic } from '../utils/get-event-field-topic'; +import { eventType } from '../utils/metrics-dev-console/constants'; + +interface DeferredEvent { + event: LintedMetricsEvent; + topic: Opt; +} + +type EventRecorder = WebDelegatesInstance['eventRecorder']; + +type MetricEventType = (typeof METRICS_EVENT_TYPES)[number]; + +export interface MetricKitConfig { + constraintProfiles: string[]; + topic: string; +} + +export class MetricsKitRecorder implements MetricsEventRecorder { + private readonly log: Logger; + private eventRecorder: EventRecorder | undefined; + private mtkit: ClickstreamProcessorInstance | undefined; + private recordedEventsCount: number; + private config: MetricKitConfig; + private readonly impressions: InstanceType | undefined; + private enabled: boolean = true; + + /** + * Queues events prior to the mt-event-queue recorder being available + */ + private readonly deferredEvents: DeferredEvent[]; + + constructor( + loggerFactory: LoggerFactory, + config: MetricKitConfig, + impressions: InstanceType | undefined, + ) { + this.log = loggerFactory.loggerFor('MetricsKitRecorder'); + this.deferredEvents = []; + this.recordedEventsCount = 0; + this.config = config; + this.impressions = impressions; + } + + record(event: LintedMetricsEvent, topic: Opt): void { + topic = topic ?? this.config.topic; + if (this.isDisabled()) { + this.log.info( + `topic ${this.config.topic} is disabled following event not captured:`, + event, + ); + return; + } + + if (this.eventRecorder) { + const eventHandler = event.fields.eventType as MetricEventType; + const { pageId, pageType, pageContext } = event.fields; + if (!eventHandler) { + this.log.warn('No `eventType` found on event', event, topic); + return; + } else if (!METRICS_EVENT_TYPES.includes(eventHandler)) { + this.log.warn( + 'Invalid `eventType` found on event', + event, + topic, + ); + return; + } else if (!this.impressions && eventHandler === 'impressions') { + this.log.info( + 'Supressing impression event. Impressions not enabled', + ); + return; + } + + // when the user leaves a page to report the accumulated impressions for that page + if ( + (this.impressions?.isEnabled('exit') && + eventHandler === 'exit') || + (this.impressions?.isEnabled('click') && + event.fields.actionType === 'navigate') + ) { + // create + capture impressions + const accumulatedImpressions = + this.impressions.consumeImpressions(); + + const metricsData = this.mtkit?.eventHandlers[ + 'impressions' + ]?.metricsData(pageId, pageType, pageContext, { + impressions: accumulatedImpressions, + }); + + metricsData + ?.recordEvent(topic) + .then((data) => { + this.log.info( + 'impressions event captured', + data, + topic, + ); + sendToMetricsDevConsole( + data as { [key: string]: unknown }, + topic ?? '', + ); + }) + .catch((e) => { + this.log.warn( + 'failed to capture impression metrics', + e, + topic, + ); + }); + } + + let impressionsData = {}; + // snapshot impressions to include in click events + if ( + (this.impressions?.isEnabled('click') && + eventHandler === 'click') || + (this.impressions?.isEnabled('impressions') && + eventHandler === 'impressions') + ) { + const snapshotImpressions = + this.impressions.captureSnapshotImpression(); + impressionsData = { + impressions: snapshotImpressions, + }; + } + + const eventFields = getEventFieldsWithTopic(event, topic); + // click events are the only ones with different method signature + // https://github.pie.apple.com/amp-metrics/mt-metricskit/blob/7.3.5/src/metrics/event_handlers/click.js#L133 + const metricsDataArgs = + eventHandler === 'click' // TODO rdar://102438307 (JMOTW Clickstream – Pass targetElement to click events) + ? [ + pageId, + pageType, + pageContext, + null, + { ...eventFields, ...impressionsData }, + ] + : [pageId, pageType, pageContext, eventFields]; + + if (eventHandler === 'impressions') { + metricsDataArgs.push(impressionsData); + } + + let metricsData = this.mtkit?.eventHandlers[ + eventHandler + ]?.metricsData( + // @ts-expect-error TypeScript doesn't handle spreading the argument array well + ...metricsDataArgs, + ); + + metricsData + ?.recordEvent(topic) + .then((data) => { + this.log.info('MetricsKit event data', data, topic); + sendToMetricsDevConsole( + data as { [key: string]: unknown }, + topic ?? '', + ); + }) + .catch((e) => { + this.log.error( + 'MetricsKit failed to capture metric', + e, + topic, + ); + }); + + this.recordedEventsCount++; + + // on exit events we should flush all metrics + if (eventHandler === 'exit') { + this.eventRecorder?.flushUnreportedEvents?.(true); + sendToMetricsDevConsole( + { metricsDevType: eventType.FLUSH, status: 'SUCCESS' }, + topic, + ); + } + } else { + this.deferredEvents.push({ event, topic }); + } + } + + async flush(): Promise { + await this.eventRecorder?.flushUnreportedEvents?.(false); + const count = this.recordedEventsCount; + this.recordedEventsCount = 0; + return count; + } + + setupEventRecorder( + eventRecorder: EventRecorder, + mtkit: ClickstreamProcessorInstance, + ): void { + this.eventRecorder = eventRecorder; + this.mtkit = mtkit; + this.deferredEvents.forEach(({ event, topic }) => + this.record(event, topic), + ); + this.deferredEvents.length = 0; + } + + isDisabled(): boolean { + return !this.enabled; + } + + enable(): void { + if (this.enabled) { + this.log.info( + `Clickstream topic ${this.config.topic} already enabled`, + ); + return; + } + + this.log.info(`Enabling clickstream topic ${this.config.topic}`); + this.enabled = true; + } + + disable(): void { + if (this.isDisabled()) { + return; + } + + this.log.info(`Disabling clickstream topic ${this.config.topic}`); + this.enabled = false; + } +} diff --git a/shared/metrics-8/src/recorder/void.ts b/shared/metrics-8/src/recorder/void.ts new file mode 100644 index 0000000..475c759 --- /dev/null +++ b/shared/metrics-8/src/recorder/void.ts @@ -0,0 +1,17 @@ +import type { MetricsEventRecorder } from '@jet/engine'; +import type { LintedMetricsEvent } from '@jet/environment/types/metrics'; +import type { Opt } from '@jet/environment/types/optional'; + +export class VoidEventRecorder implements MetricsEventRecorder { + private recorded: number = 0; + + record(_event: LintedMetricsEvent, _topic: Opt): void { + this.recorded++; + } + + async flush(): Promise { + const { recorded } = this; + this.recorded = 0; + return recorded; + } +} diff --git a/shared/metrics-8/src/utils/get-event-field-topic.ts b/shared/metrics-8/src/utils/get-event-field-topic.ts new file mode 100644 index 0000000..96bb125 --- /dev/null +++ b/shared/metrics-8/src/utils/get-event-field-topic.ts @@ -0,0 +1,11 @@ +import type { LintedMetricsEvent } from '@jet/environment'; +import type { MetricsFields } from '~/types'; + +export function getEventFieldsWithTopic( + event: LintedMetricsEvent, + topic: string, +) { + return 'topic' in event.fields + ? event.fields + : ({ ...event.fields, topic } as MetricsFields); +} diff --git a/shared/metrics-8/src/utils/metrics-dev-console/constants.ts b/shared/metrics-8/src/utils/metrics-dev-console/constants.ts new file mode 100644 index 0000000..7193da8 --- /dev/null +++ b/shared/metrics-8/src/utils/metrics-dev-console/constants.ts @@ -0,0 +1,7 @@ +/** + * Event type constants for metrics development console + */ +export const eventType = { + RECORD: 'record', + FLUSH: 'flush', +} as const; diff --git a/shared/metrics-8/src/utils/metrics-dev-console/setup-metrics-dev.ts b/shared/metrics-8/src/utils/metrics-dev-console/setup-metrics-dev.ts new file mode 100644 index 0000000..fb7def6 --- /dev/null +++ b/shared/metrics-8/src/utils/metrics-dev-console/setup-metrics-dev.ts @@ -0,0 +1,55 @@ +import { isFlushEvent, makeFlushEvent } from './events/flush-event'; +import { makeRecordEvent } from './events/record-event'; +import type { MetricsOptions, FlushEvent, MetricsObject } from './type'; + +/** + * Updates the metrics console by dispatching appropriate events + */ +const updateMetricsConsole = ( + topic: string, + metricsData: MetricsOptions | FlushEvent, +): void => { + let event = null; + const { metricsDevType, ...data } = metricsData ?? ({} as MetricsObject); + + if (isFlushEvent(metricsData)) { + event = makeFlushEvent(metricsData, topic); + } else if (metricsData) { + event = makeRecordEvent(data, topic); + } + + if (event) { + try { + window.dispatchEvent(event); + } catch (e) { + console.error('metric console failed', e); + } + } +}; + +const isMetricsDevConsoleEnabled = () => { + return ( + typeof window !== 'undefined' && + window.localStorage?.getItem('metrics-dev') === 'true' + ); +}; + +/** + * Sends metrics data to the development console if enabled + * @param metricsData - The metrics data to send + * @param topic - The topic/category for the metrics + */ +export const sendToMetricsDevConsole = ( + metricsData: MetricsOptions, + topic: string, +): void => { + if (import.meta.env.APP_SCOPE === 'internal') { + if (isMetricsDevConsoleEnabled()) { + try { + updateMetricsConsole(topic, metricsData); + } catch (error) { + console.warn('Failed to send metrics to dev console:', error); + } + } + } +}; -- cgit v1.2.3