summaryrefslogtreecommitdiff
path: root/shared/metrics-8/src
diff options
context:
space:
mode:
Diffstat (limited to 'shared/metrics-8/src')
-rw-r--r--shared/metrics-8/src/constants.ts19
-rw-r--r--shared/metrics-8/src/impression-provider.ts27
-rw-r--r--shared/metrics-8/src/impression-snapshot-provider.ts27
-rw-r--r--shared/metrics-8/src/impressions/constants.ts1
-rw-r--r--shared/metrics-8/src/impressions/index.ts252
-rw-r--r--shared/metrics-8/src/index.ts578
-rw-r--r--shared/metrics-8/src/recorder/composite.ts20
-rw-r--r--shared/metrics-8/src/recorder/funnelkit.ts237
-rw-r--r--shared/metrics-8/src/recorder/logging.ts21
-rw-r--r--shared/metrics-8/src/recorder/metricskit.ts239
-rw-r--r--shared/metrics-8/src/recorder/void.ts17
-rw-r--r--shared/metrics-8/src/utils/get-event-field-topic.ts11
-rw-r--r--shared/metrics-8/src/utils/metrics-dev-console/constants.ts7
-rw-r--r--shared/metrics-8/src/utils/metrics-dev-console/setup-metrics-dev.ts55
14 files changed, 1511 insertions, 0 deletions
diff --git a/shared/metrics-8/src/constants.ts b/shared/metrics-8/src/constants.ts
new file mode 100644
index 0000000..6eff451
--- /dev/null
+++ b/shared/metrics-8/src/constants.ts
@@ -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;
diff --git a/shared/metrics-8/src/impression-provider.ts b/shared/metrics-8/src/impression-provider.ts
new file mode 100644
index 0000000..852d418
--- /dev/null
+++ b/shared/metrics-8/src/impression-provider.ts
@@ -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');
+ }
+ }
+}
diff --git a/shared/metrics-8/src/impression-snapshot-provider.ts b/shared/metrics-8/src/impression-snapshot-provider.ts
new file mode 100644
index 0000000..c7261f9
--- /dev/null
+++ b/shared/metrics-8/src/impression-snapshot-provider.ts
@@ -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');
+ }
+ }
+}
diff --git a/shared/metrics-8/src/impressions/constants.ts b/shared/metrics-8/src/impressions/constants.ts
new file mode 100644
index 0000000..0638f9e
--- /dev/null
+++ b/shared/metrics-8/src/impressions/constants.ts
@@ -0,0 +1 @@
+export const IMPRESSION_CONTEXT_NAME = 'metrics:impression' as const;
diff --git a/shared/metrics-8/src/impressions/index.ts b/shared/metrics-8/src/impressions/index.ts
new file mode 100644
index 0000000..b904feb
--- /dev/null
+++ b/shared/metrics-8/src/impressions/index.ts
@@ -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,
+ });
+}
diff --git a/shared/metrics-8/src/index.ts b/shared/metrics-8/src/index.ts
new file mode 100644
index 0000000..59510b0
--- /dev/null
+++ b/shared/metrics-8/src/index.ts
@@ -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';
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;
+ }
+}
diff --git a/shared/metrics-8/src/utils/get-event-field-topic.ts b/shared/metrics-8/src/utils/get-event-field-topic.ts
new file mode 100644
index 0000000..96bb125
--- /dev/null
+++ b/shared/metrics-8/src/utils/get-event-field-topic.ts
@@ -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);
+}
diff --git a/shared/metrics-8/src/utils/metrics-dev-console/constants.ts b/shared/metrics-8/src/utils/metrics-dev-console/constants.ts
new file mode 100644
index 0000000..7193da8
--- /dev/null
+++ b/shared/metrics-8/src/utils/metrics-dev-console/constants.ts
@@ -0,0 +1,7 @@
+/**
+ * Event type constants for metrics development console
+ */
+export const eventType = {
+ RECORD: 'record',
+ FLUSH: 'flush',
+} as const;
diff --git a/shared/metrics-8/src/utils/metrics-dev-console/setup-metrics-dev.ts b/shared/metrics-8/src/utils/metrics-dev-console/setup-metrics-dev.ts
new file mode 100644
index 0000000..fb7def6
--- /dev/null
+++ b/shared/metrics-8/src/utils/metrics-dev-console/setup-metrics-dev.ts
@@ -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);
+ }
+ }
+ }
+};