import { type IDisposable, toDisposable } from '#/_base/di/lifecycle'; import { LifecycleScope } from '#/app/scopes'; import { ScopeActivation, registerScopedService } from '#/_base/di/scope'; import { onUnexpectedError } from '#/_base/errors/unexpectedError'; import type { TelemetryContextPatch, TelemetryPrimitive, TelemetryProperties, } from './context'; import { type StrictPropertyCheck, type TelemetryEventName, type TelemetryEventPayload, } from './events'; import { type ITelemetryAppender, ITelemetryService, nullTelemetryAppender, type TelemetryAppenderRecord, } from './telemetry'; type MutableContext = Record; const WIRE_SESSION_ID_PROPERTY = 'sessionId'; function applyPatch(target: MutableContext, patch: TelemetryContextPatch): MutableContext { for (const [key, value] of Object.entries(patch)) { if (value === undefined) { delete target[key]; } else { target[key] = value; } } return target; } export function composeTelemetryProperties( ambient: TelemetryProperties, explicit: TelemetryProperties | undefined, ): TelemetryProperties { const properties: MutableContext = {}; for (const [key, value] of Object.entries(ambient)) { if (key === 'session_id' || value === undefined) { continue; } properties[key] = value; } if (ambient['session_id'] !== undefined) { properties[WIRE_SESSION_ID_PROPERTY] = ambient['session_id']; } if (explicit !== undefined) { for (const [key, value] of Object.entries(explicit)) { if (value !== undefined) { properties[key] = value; } } } return properties; } export interface TelemetryScopeBinding extends IDisposable { readonly telemetry: ITelemetryService; } interface TelemetryAmbientSource { ambient(): TelemetryProperties; } export interface ITelemetryScopeBindingHost { createScopeBinding(seed: TelemetryContextPatch): TelemetryScopeBinding; } export function bindTelemetryScope( parent: ITelemetryService, seed: TelemetryContextPatch, ): TelemetryScopeBinding { const host = parent as ITelemetryService & Partial; if (host.createScopeBinding !== undefined) { return host.createScopeBinding(seed); } return { telemetry: parent.withContext(seed), dispose: () => {} }; } export class TelemetryService implements ITelemetryService, ITelemetryScopeBindingHost, TelemetryAmbientSource { declare readonly _serviceBrand: undefined; private appenders: ITelemetryAppender[] = [nullTelemetryAppender]; private context: MutableContext = {}; private enabled = true; track2 = never>( event: K, properties?: StrictPropertyCheck, E>, ): void { this.dispatch(event, this.ambient(), properties as TelemetryProperties | undefined); } withContext(patch: TelemetryContextPatch): ITelemetryService { return new TelemetrySnapshotView(this, applyPatch(this.ambient(), patch)); } setContext(patch: TelemetryContextPatch): void { applyPatch(this.context, patch); } getContext(): Readonly { return this.ambient(); } createScopeBinding(seed: TelemetryContextPatch): TelemetryScopeBinding { const bound = new BoundTelemetryService(this, this, applyPatch({}, seed)); return { telemetry: bound, dispose: () => bound.dispose() }; } addAppender(appender: ITelemetryAppender): IDisposable { this.appenders.push(appender); return toDisposable(() => this.removeAppender(appender)); } removeAppender(appender: ITelemetryAppender): void { this.appenders = this.appenders.filter((a) => a !== appender); } setEnabled(enabled: boolean): void { this.enabled = enabled; } async flush(): Promise { await Promise.all( this.appenders.map((appender) => Promise.resolve(appender.flush?.()).catch(onUnexpectedError), ), ); } async shutdown(): Promise { await Promise.all( this.appenders.map((appender) => Promise.resolve(appender.shutdown?.()).catch(onUnexpectedError), ), ); } ambient(): TelemetryProperties { return { ...this.context }; } dispatch( event: string, ambient: TelemetryProperties, properties: TelemetryProperties | undefined, ): void { if (!this.enabled) { return; } const record: TelemetryAppenderRecord = { event, context: { ...ambient }, properties: composeTelemetryProperties(ambient, properties), }; for (const appender of this.appenders) { try { appender.track(record); } catch (err) { onUnexpectedError(err); } } } } class BoundTelemetryService implements ITelemetryService, ITelemetryScopeBindingHost, TelemetryAmbientSource { declare readonly _serviceBrand: undefined; private disposed = false; constructor( private readonly root: TelemetryService, private readonly parent: TelemetryAmbientSource, private readonly fragment: MutableContext, ) {} ambient(): TelemetryProperties { const inherited = this.parent.ambient(); if (this.disposed) { return inherited; } return { ...inherited, ...this.fragment }; } track2 = never>( event: K, properties?: StrictPropertyCheck, E>, ): void { this.root.dispatch(event, this.ambient(), properties as TelemetryProperties | undefined); } withContext(patch: TelemetryContextPatch): ITelemetryService { return new TelemetrySnapshotView( this.root, applyPatch(this.ambient(), patch), ); } setContext(patch: TelemetryContextPatch): void { if (!this.disposed) { applyPatch(this.fragment, patch); } } getContext(): Readonly { return this.ambient(); } createScopeBinding(seed: TelemetryContextPatch): TelemetryScopeBinding { const bound = new BoundTelemetryService(this.root, this, applyPatch({}, seed)); return { telemetry: bound, dispose: () => bound.dispose() }; } addAppender(appender: ITelemetryAppender): IDisposable { return this.root.addAppender(appender); } removeAppender(appender: ITelemetryAppender): void { this.root.removeAppender(appender); } setEnabled(enabled: boolean): void { this.root.setEnabled(enabled); } flush(): Promise { return this.root.flush(); } shutdown(): Promise { return this.root.shutdown(); } dispose(): void { this.disposed = true; } } class TelemetrySnapshotView implements ITelemetryService { declare readonly _serviceBrand: undefined; private context: MutableContext; constructor( private readonly root: TelemetryService, context: TelemetryProperties, ) { this.context = { ...context }; } track2 = never>( event: K, properties?: StrictPropertyCheck, E>, ): void { this.root.dispatch(event, this.context, properties as TelemetryProperties | undefined); } withContext(patch: TelemetryContextPatch): ITelemetryService { return new TelemetrySnapshotView(this.root, applyPatch({ ...this.context }, patch)); } setContext(patch: TelemetryContextPatch): void { applyPatch(this.context, patch); } getContext(): Readonly { return { ...this.context }; } addAppender(appender: ITelemetryAppender): IDisposable { return this.root.addAppender(appender); } removeAppender(appender: ITelemetryAppender): void { this.root.removeAppender(appender); } setEnabled(enabled: boolean): void { this.root.setEnabled(enabled); } flush(): Promise { return this.root.flush(); } shutdown(): Promise { return this.root.shutdown(); } } registerScopedService( LifecycleScope.App, ITelemetryService, TelemetryService, ScopeActivation.OnScopeCreated, 'telemetry', );