first commit
This commit is contained in:
@@ -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';
|
||||
Reference in New Issue
Block a user