| /** | |
| * Analytics service - public API for event logging | |
| * | |
| * This module serves as the main entry point for analytics events in Claude CLI. | |
| * | |
| * DESIGN: This module has NO dependencies to avoid import cycles. | |
| * Events are queued until attachAnalyticsSink() is called during app initialization. | |
| * The sink handles routing to Datadog and 1P event logging. | |
| */ | |
| /** | |
| * Marker type for verifying analytics metadata doesn't contain sensitive data | |
| * | |
| * This type forces explicit verification that string values being logged | |
| * don't contain code snippets, file paths, or other sensitive information. | |
| * | |
| * Usage: `myString as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS` | |
| */ | |
| export type AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS = never | |
| /** | |
| * Marker type for values routed to PII-tagged proto columns via `_PROTO_*` | |
| * payload keys. The destination BQ column has privileged access controls, | |
| * so unredacted values are acceptable — unlike general-access backends. | |
| * | |
| * sink.ts strips `_PROTO_*` keys before Datadog fanout; only the 1P | |
| * exporter (firstPartyEventLoggingExporter) sees them and hoists them to the | |
| * top-level proto field. A single stripProtoFields call guards all non-1P | |
| * sinks — no per-sink filtering to forget. | |
| * | |
| * Usage: `rawName as AnalyticsMetadata_I_VERIFIED_THIS_IS_PII_TAGGED` | |
| */ | |
| export type AnalyticsMetadata_I_VERIFIED_THIS_IS_PII_TAGGED = never | |
| /** | |
| * Strip `_PROTO_*` keys from a payload destined for general-access storage. | |
| * Used by: | |
| * - sink.ts: before Datadog fanout (never sees PII-tagged values) | |
| * - firstPartyEventLoggingExporter: defensive strip of additional_metadata | |
| * after hoisting known _PROTO_* keys to proto fields — prevents a future | |
| * unrecognized _PROTO_foo from silently landing in the BQ JSON blob. | |
| * | |
| * Returns the input unchanged (same reference) when no _PROTO_ keys present. | |
| */ | |
| export function stripProtoFields<V>( | |
| metadata: Record<string, V>, | |
| ): Record<string, V> { | |
| let result: Record<string, V> | undefined | |
| for (const key in metadata) { | |
| if (key.startsWith('_PROTO_')) { | |
| if (result === undefined) { | |
| result = { ...metadata } | |
| } | |
| delete result[key] | |
| } | |
| } | |
| return result ?? metadata | |
| } | |
| // Internal type for logEvent metadata - different from the enriched EventMetadata in metadata.ts | |
| type LogEventMetadata = { [key: string]: boolean | number | undefined } | |
| type QueuedEvent = { | |
| eventName: string | |
| metadata: LogEventMetadata | |
| async: boolean | |
| } | |
| /** | |
| * Sink interface for the analytics backend | |
| */ | |
| export type AnalyticsSink = { | |
| logEvent: (eventName: string, metadata: LogEventMetadata) => void | |
| logEventAsync: ( | |
| eventName: string, | |
| metadata: LogEventMetadata, | |
| ) => Promise<void> | |
| } | |
| // Event queue for events logged before sink is attached | |
| const eventQueue: QueuedEvent[] = [] | |
| // Sink - initialized during app startup | |
| let sink: AnalyticsSink | null = null | |
| /** | |
| * Attach the analytics sink that will receive all events. | |
| * Queued events are drained asynchronously via queueMicrotask to avoid | |
| * adding latency to the startup path. | |
| * | |
| * Idempotent: if a sink is already attached, this is a no-op. This allows | |
| * calling from both the preAction hook (for subcommands) and setup() (for | |
| * the default command) without coordination. | |
| */ | |
| export function attachAnalyticsSink(newSink: AnalyticsSink): void { | |
| if (sink !== null) { | |
| return | |
| } | |
| sink = newSink | |
| // Drain the queue asynchronously to avoid blocking startup | |
| if (eventQueue.length > 0) { | |
| const queuedEvents = [...eventQueue] | |
| eventQueue.length = 0 | |
| // Log queue size for ants to help debug analytics initialization timing | |
| if (process.env.USER_TYPE === 'ant') { | |
| sink.logEvent('analytics_sink_attached', { | |
| queued_event_count: queuedEvents.length, | |
| }) | |
| } | |
| queueMicrotask(() => { | |
| for (const event of queuedEvents) { | |
| if (event.async) { | |
| void sink!.logEventAsync(event.eventName, event.metadata) | |
| } else { | |
| sink!.logEvent(event.eventName, event.metadata) | |
| } | |
| } | |
| }) | |
| } | |
| } | |
| /** | |
| * Log an event to analytics backends (synchronous) | |
| * | |
| * Events may be sampled based on the 'tengu_event_sampling_config' dynamic config. | |
| * When sampled, the sample_rate is added to the event metadata. | |
| * | |
| * If no sink is attached, events are queued and drained when the sink attaches. | |
| */ | |
| export function logEvent( | |
| eventName: string, | |
| // intentionally no strings unless AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, | |
| // to avoid accidentally logging code/filepaths | |
| metadata: LogEventMetadata, | |
| ): void { | |
| if (sink === null) { | |
| eventQueue.push({ eventName, metadata, async: false }) | |
| return | |
| } | |
| sink.logEvent(eventName, metadata) | |
| } | |
| /** | |
| * Log an event to analytics backends (asynchronous) | |
| * | |
| * Events may be sampled based on the 'tengu_event_sampling_config' dynamic config. | |
| * When sampled, the sample_rate is added to the event metadata. | |
| * | |
| * If no sink is attached, events are queued and drained when the sink attaches. | |
| */ | |
| export async function logEventAsync( | |
| eventName: string, | |
| // intentionally no strings, to avoid accidentally logging code/filepaths | |
| metadata: LogEventMetadata, | |
| ): Promise<void> { | |
| if (sink === null) { | |
| eventQueue.push({ eventName, metadata, async: true }) | |
| return | |
| } | |
| await sink.logEventAsync(eventName, metadata) | |
| } | |
| /** | |
| * Reset analytics state for testing purposes only. | |
| * @internal | |
| */ | |
| export function _resetForTesting(): void { | |
| sink = null | |
| eventQueue.length = 0 | |
| } | |