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,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;
}
}