diff options
Diffstat (limited to 'shared/metrics-8/src/index.ts')
| -rw-r--r-- | shared/metrics-8/src/index.ts | 578 |
1 files changed, 578 insertions, 0 deletions
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<string>; + 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<typeof Impressions> | 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<PageMetricsPresenter>; + + static load( + loggerFactory: LoggerFactory, + context: Map<string, unknown>, + processEvent: (fields: MetricsFields) => Promise<LintedMetricsEvent>, + 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<LintedMetricsEvent> { + return { fields }; + }, + }, + recorder, + }); + + return new Metrics(log, metricsPipeline, config); + } + + config.initialURL = window.location.href; + + const aggregator = setupAggregators(listofMetricProviders, context); + + let impressions: InstanceType<typeof Impressions> | 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<typeof Impressions>, + ) { + 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<void> { + 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<MetricsFields>) { + 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<MetricsFields>) { + 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<MetricsFields>) { + 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<string, unknown>, +): 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'; |
