/ services / analytics / index.ts
index.ts
  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   */
 19  export 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   */
 33  export 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   */
 45  export 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
 61  type LogEventMetadata = { [key: string]: boolean | number | undefined }
 62  
 63  type QueuedEvent = {
 64    eventName: string
 65    metadata: LogEventMetadata
 66    async: boolean
 67  }
 68  
 69  /**
 70   * Sink interface for the analytics backend
 71   */
 72  export 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
 81  const eventQueue: QueuedEvent[] = []
 82  
 83  // Sink - initialized during app startup
 84  let 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   */
 95  export 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   */
133  export 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   */
154  export 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   */
170  export function _resetForTesting(): void {
171    sink = null
172    eventQueue.length = 0
173  }