source dump of claude code
at main 173 lines 5.5 kB view raw
1/** 2 * Analytics service - public API for event logging 3 * 4 * This module serves as the main entry point for analytics events in Claude CLI. 5 * 6 * DESIGN: This module has NO dependencies to avoid import cycles. 7 * Events are queued until attachAnalyticsSink() is called during app initialization. 8 * The sink handles routing to Datadog and 1P event logging. 9 */ 10 11/** 12 * Marker type for verifying analytics metadata doesn't contain sensitive data 13 * 14 * This type forces explicit verification that string values being logged 15 * don't contain code snippets, file paths, or other sensitive information. 16 * 17 * Usage: `myString as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS` 18 */ 19export type AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS = never 20 21/** 22 * Marker type for values routed to PII-tagged proto columns via `_PROTO_*` 23 * payload keys. The destination BQ column has privileged access controls, 24 * so unredacted values are acceptable — unlike general-access backends. 25 * 26 * sink.ts strips `_PROTO_*` keys before Datadog fanout; only the 1P 27 * exporter (firstPartyEventLoggingExporter) sees them and hoists them to the 28 * top-level proto field. A single stripProtoFields call guards all non-1P 29 * sinks — no per-sink filtering to forget. 30 * 31 * Usage: `rawName as AnalyticsMetadata_I_VERIFIED_THIS_IS_PII_TAGGED` 32 */ 33export type AnalyticsMetadata_I_VERIFIED_THIS_IS_PII_TAGGED = never 34 35/** 36 * Strip `_PROTO_*` keys from a payload destined for general-access storage. 37 * Used by: 38 * - sink.ts: before Datadog fanout (never sees PII-tagged values) 39 * - firstPartyEventLoggingExporter: defensive strip of additional_metadata 40 * after hoisting known _PROTO_* keys to proto fields — prevents a future 41 * unrecognized _PROTO_foo from silently landing in the BQ JSON blob. 42 * 43 * Returns the input unchanged (same reference) when no _PROTO_ keys present. 44 */ 45export function stripProtoFields<V>( 46 metadata: Record<string, V>, 47): Record<string, V> { 48 let result: Record<string, V> | undefined 49 for (const key in metadata) { 50 if (key.startsWith('_PROTO_')) { 51 if (result === undefined) { 52 result = { ...metadata } 53 } 54 delete result[key] 55 } 56 } 57 return result ?? metadata 58} 59 60// Internal type for logEvent metadata - different from the enriched EventMetadata in metadata.ts 61type LogEventMetadata = { [key: string]: boolean | number | undefined } 62 63type QueuedEvent = { 64 eventName: string 65 metadata: LogEventMetadata 66 async: boolean 67} 68 69/** 70 * Sink interface for the analytics backend 71 */ 72export type AnalyticsSink = { 73 logEvent: (eventName: string, metadata: LogEventMetadata) => void 74 logEventAsync: ( 75 eventName: string, 76 metadata: LogEventMetadata, 77 ) => Promise<void> 78} 79 80// Event queue for events logged before sink is attached 81const eventQueue: QueuedEvent[] = [] 82 83// Sink - initialized during app startup 84let sink: AnalyticsSink | null = null 85 86/** 87 * Attach the analytics sink that will receive all events. 88 * Queued events are drained asynchronously via queueMicrotask to avoid 89 * adding latency to the startup path. 90 * 91 * Idempotent: if a sink is already attached, this is a no-op. This allows 92 * calling from both the preAction hook (for subcommands) and setup() (for 93 * the default command) without coordination. 94 */ 95export function attachAnalyticsSink(newSink: AnalyticsSink): void { 96 if (sink !== null) { 97 return 98 } 99 sink = newSink 100 101 // Drain the queue asynchronously to avoid blocking startup 102 if (eventQueue.length > 0) { 103 const queuedEvents = [...eventQueue] 104 eventQueue.length = 0 105 106 // Log queue size for ants to help debug analytics initialization timing 107 if (process.env.USER_TYPE === 'ant') { 108 sink.logEvent('analytics_sink_attached', { 109 queued_event_count: queuedEvents.length, 110 }) 111 } 112 113 queueMicrotask(() => { 114 for (const event of queuedEvents) { 115 if (event.async) { 116 void sink!.logEventAsync(event.eventName, event.metadata) 117 } else { 118 sink!.logEvent(event.eventName, event.metadata) 119 } 120 } 121 }) 122 } 123} 124 125/** 126 * Log an event to analytics backends (synchronous) 127 * 128 * Events may be sampled based on the 'tengu_event_sampling_config' dynamic config. 129 * When sampled, the sample_rate is added to the event metadata. 130 * 131 * If no sink is attached, events are queued and drained when the sink attaches. 132 */ 133export function logEvent( 134 eventName: string, 135 // intentionally no strings unless AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, 136 // to avoid accidentally logging code/filepaths 137 metadata: LogEventMetadata, 138): void { 139 if (sink === null) { 140 eventQueue.push({ eventName, metadata, async: false }) 141 return 142 } 143 sink.logEvent(eventName, metadata) 144} 145 146/** 147 * Log an event to analytics backends (asynchronous) 148 * 149 * Events may be sampled based on the 'tengu_event_sampling_config' dynamic config. 150 * When sampled, the sample_rate is added to the event metadata. 151 * 152 * If no sink is attached, events are queued and drained when the sink attaches. 153 */ 154export async function logEventAsync( 155 eventName: string, 156 // intentionally no strings, to avoid accidentally logging code/filepaths 157 metadata: LogEventMetadata, 158): Promise<void> { 159 if (sink === null) { 160 eventQueue.push({ eventName, metadata, async: true }) 161 return 162 } 163 await sink.logEventAsync(eventName, metadata) 164} 165 166/** 167 * Reset analytics state for testing purposes only. 168 * @internal 169 */ 170export function _resetForTesting(): void { 171 sink = null 172 eventQueue.length = 0 173}