init commit

This commit is contained in:
rxliuli
2025-11-04 05:03:50 +08:00
commit bce557cc2d
1396 changed files with 172991 additions and 0 deletions

View File

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

View File

@@ -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<string, unknown>) {
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');
}
}
}

View File

@@ -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<string, unknown>) {
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');
}
}
}

View File

@@ -0,0 +1 @@
export const IMPRESSION_CONTEXT_NAME = 'metrics:impression' as const;

View File

@@ -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<HTMLElement, any> = new Map();
private currentSnapshot: Record<string, unknown>[] = [];
private readonly impressionSettings: ImpressionSettings | undefined;
constructor(
loggerFactory: LoggerFactory,
context: Map<string, unknown>,
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<string, unknown>[] | undefined {
if (this.hasInitialized) {
this.logger.debug('consuming impression metrics');
return this.impressionObserverInstance?.consumeImpressions();
}
this.logger.warn('impressions not avaiable yet');
return;
}
captureSnapshotImpression(): Record<string, unknown>[] | 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<string, unknown>[] | 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<string, unknown>,
) => {
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<any, any>;
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,
});
}

View File

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

View File

@@ -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<string>): void {
for (const eventRecorder of this.eventRecorders) {
eventRecorder.record(event, topic);
}
}
async flush(): Promise<number> {
const flushed: number[] = await Promise.all(
this.eventRecorders.map((recorder) => recorder.flush()),
);
return Math.max(...flushed);
}
}

View File

@@ -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<string>;
}
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<typeof Impressions> | undefined;
/**
* Queues events prior to the mt-event-queue recorder being available
*/
private readonly deferredEvents: DeferredEvent[];
constructor(
loggerFactory: LoggerFactory,
config: FunnelKitConfig,
impressions: InstanceType<typeof Impressions> | undefined,
) {
this.log = loggerFactory.loggerFor('FunnelKitRecorder');
this.deferredEvents = [];
this.recordedEventsCount = 0;
this.config = config;
this.impressions = impressions;
}
async record(
event: LintedMetricsEvent,
eventTopic: Opt<string>,
): Promise<void> {
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<string, unknown> = {};
// 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<number> {
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;
}
}

View File

@@ -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<string>): void {
this.log.info('logged metrics event:', event, topic);
}
async flush(): Promise<number> {
this.log.info('flushed metrics');
return 0;
}
}

View File

@@ -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<string>;
}
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<typeof Impressions> | 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<typeof Impressions> | undefined,
) {
this.log = loggerFactory.loggerFor('MetricsKitRecorder');
this.deferredEvents = [];
this.recordedEventsCount = 0;
this.config = config;
this.impressions = impressions;
}
record(event: LintedMetricsEvent, topic: Opt<string>): 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<number> {
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;
}
}

View File

@@ -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<string>): void {
this.recorded++;
}
async flush(): Promise<number> {
const { recorded } = this;
this.recorded = 0;
return recorded;
}
}

View File

@@ -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);
}

View File

@@ -0,0 +1,7 @@
/**
* Event type constants for metrics development console
*/
export const eventType = {
RECORD: 'record',
FLUSH: 'flush',
} as const;

View File

@@ -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);
}
}
}
};