mirror of
https://github.com/rxliuli/apps.apple.com.git
synced 2025-11-10 00:30:32 +00:00
init commit
This commit is contained in:
19
shared/metrics-8/src/constants.ts
Normal file
19
shared/metrics-8/src/constants.ts
Normal 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;
|
||||
27
shared/metrics-8/src/impression-provider.ts
Normal file
27
shared/metrics-8/src/impression-provider.ts
Normal 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');
|
||||
}
|
||||
}
|
||||
}
|
||||
27
shared/metrics-8/src/impression-snapshot-provider.ts
Normal file
27
shared/metrics-8/src/impression-snapshot-provider.ts
Normal 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');
|
||||
}
|
||||
}
|
||||
}
|
||||
1
shared/metrics-8/src/impressions/constants.ts
Normal file
1
shared/metrics-8/src/impressions/constants.ts
Normal file
@@ -0,0 +1 @@
|
||||
export const IMPRESSION_CONTEXT_NAME = 'metrics:impression' as const;
|
||||
252
shared/metrics-8/src/impressions/index.ts
Normal file
252
shared/metrics-8/src/impressions/index.ts
Normal 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,
|
||||
});
|
||||
}
|
||||
578
shared/metrics-8/src/index.ts
Normal file
578
shared/metrics-8/src/index.ts
Normal 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';
|
||||
20
shared/metrics-8/src/recorder/composite.ts
Normal file
20
shared/metrics-8/src/recorder/composite.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
237
shared/metrics-8/src/recorder/funnelkit.ts
Normal file
237
shared/metrics-8/src/recorder/funnelkit.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
21
shared/metrics-8/src/recorder/logging.ts
Normal file
21
shared/metrics-8/src/recorder/logging.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
239
shared/metrics-8/src/recorder/metricskit.ts
Normal file
239
shared/metrics-8/src/recorder/metricskit.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
17
shared/metrics-8/src/recorder/void.ts
Normal file
17
shared/metrics-8/src/recorder/void.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
11
shared/metrics-8/src/utils/get-event-field-topic.ts
Normal file
11
shared/metrics-8/src/utils/get-event-field-topic.ts
Normal 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);
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
/**
|
||||
* Event type constants for metrics development console
|
||||
*/
|
||||
export const eventType = {
|
||||
RECORD: 'record',
|
||||
FLUSH: 'flush',
|
||||
} as const;
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
Reference in New Issue
Block a user