Files
apps.apple.com-archive/shared/metrics-8/src/index.ts
T
2025-11-06 18:53:16 +08:00

579 lines
18 KiB
TypeScript

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';