diff options
| author | rxliuli <rxliuli@gmail.com> | 2025-11-04 05:03:50 +0800 |
|---|---|---|
| committer | rxliuli <rxliuli@gmail.com> | 2025-11-04 05:03:50 +0800 |
| commit | bce557cc2dc767628bed6aac87301a1be7c5431b (patch) | |
| tree | b51a051228d01fe3306cd7626d4a96768aadb944 /shared/metrics-8/src/recorder | |
init commit
Diffstat (limited to 'shared/metrics-8/src/recorder')
| -rw-r--r-- | shared/metrics-8/src/recorder/composite.ts | 20 | ||||
| -rw-r--r-- | shared/metrics-8/src/recorder/funnelkit.ts | 237 | ||||
| -rw-r--r-- | shared/metrics-8/src/recorder/logging.ts | 21 | ||||
| -rw-r--r-- | shared/metrics-8/src/recorder/metricskit.ts | 239 | ||||
| -rw-r--r-- | shared/metrics-8/src/recorder/void.ts | 17 |
5 files changed, 534 insertions, 0 deletions
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<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); + } +} 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<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; + } +} 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<string>): void { + this.log.info('logged metrics event:', event, topic); + } + + async flush(): Promise<number> { + 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<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; + } +} 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<string>): void { + this.recorded++; + } + + async flush(): Promise<number> { + const { recorded } = this; + this.recorded = 0; + return recorded; + } +} |
