Simplify inert analytics compatibility layer

This commit is contained in:
2026-04-04 03:01:57 +08:00
parent dccd151718
commit 1b4603ed3b
5 changed files with 26 additions and 122 deletions

View File

@@ -2,7 +2,7 @@
* Shared analytics configuration * Shared analytics configuration
* *
* Common logic for determining when analytics should be disabled * Common logic for determining when analytics should be disabled
* across all analytics systems (Datadog, 1P) * across the remaining local analytics compatibility surfaces.
*/ */
import { isEnvTruthy } from '../../utils/envUtils.js' import { isEnvTruthy } from '../../utils/envUtils.js'
@@ -31,7 +31,7 @@ export function isAnalyticsDisabled(): boolean {
* *
* Unlike isAnalyticsDisabled(), this does NOT block on 3P providers * Unlike isAnalyticsDisabled(), this does NOT block on 3P providers
* (Bedrock/Vertex/Foundry). The survey is a local UI prompt with no * (Bedrock/Vertex/Foundry). The survey is a local UI prompt with no
* transcript data — enterprise customers capture responses via OTEL. * transcript upload in this fork.
*/ */
export function isFeedbackSurveyDisabled(): boolean { export function isFeedbackSurveyDisabled(): boolean {
return process.env.NODE_ENV === 'test' || isTelemetryDisabled() return process.env.NODE_ENV === 'test' || isTelemetryDisabled()

View File

@@ -1,11 +1,9 @@
/** /**
* Analytics service - public API for event logging * Analytics service - public API for event logging
* *
* This module serves as the main entry point for analytics events in Claude CLI. * The open build intentionally ships without product telemetry. We keep this
* * module as a compatibility boundary so existing call sites can remain
* DESIGN: This module has NO dependencies to avoid import cycles. * unchanged while all analytics become inert.
* Events are queued until attachAnalyticsSink() is called during app initialization.
* The sink handles routing to Datadog and 1P event logging.
*/ */
/** /**
@@ -27,15 +25,14 @@ export type AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS = never
*/ */
export type AnalyticsMetadata_I_VERIFIED_THIS_IS_PII_TAGGED = never export type AnalyticsMetadata_I_VERIFIED_THIS_IS_PII_TAGGED = never
// Internal type for logEvent metadata in the local no-op sink. export function stripProtoFields<V>(
type LogEventMetadata = { [key: string]: boolean | number | undefined } metadata: Record<string, V>,
): Record<string, V> {
type QueuedEvent = { return metadata
eventName: string
metadata: LogEventMetadata
async: boolean
} }
type LogEventMetadata = { [key: string]: boolean | number | undefined }
/** /**
* Sink interface for the analytics backend * Sink interface for the analytics backend
*/ */
@@ -47,97 +44,26 @@ export type AnalyticsSink = {
) => Promise<void> ) => Promise<void>
} }
// Event queue for events logged before sink is attached export function attachAnalyticsSink(_newSink: AnalyticsSink): void {}
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) * 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( export function logEvent(
eventName: string, _eventName: string,
// intentionally no strings unless AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, _metadata: LogEventMetadata,
// to avoid accidentally logging code/filepaths ): void {}
metadata: LogEventMetadata,
): void {
if (sink === null) {
eventQueue.push({ eventName, metadata, async: false })
return
}
sink.logEvent(eventName, metadata)
}
/** /**
* Log an event to analytics backends (asynchronous) * 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( export async function logEventAsync(
eventName: string, _eventName: string,
// intentionally no strings, to avoid accidentally logging code/filepaths _metadata: LogEventMetadata,
metadata: LogEventMetadata, ): Promise<void> {}
): Promise<void> {
if (sink === null) {
eventQueue.push({ eventName, metadata, async: true })
return
}
await sink.logEventAsync(eventName, metadata)
}
/** /**
* Reset analytics state for testing purposes only. * Reset analytics state for testing purposes only.
* @internal * @internal
*/ */
export function _resetForTesting(): void { export function _resetForTesting(): void {}
sink = null
eventQueue.length = 0
}

View File

@@ -1,31 +1,10 @@
/** /**
* Analytics sink implementation * Analytics sink implementation
* *
* This open build keeps the analytics sink boundary for compatibility, but * Telemetry sinks are disabled in this build. The exported functions remain so
* drops all queued analytics events locally instead of routing them onward. * startup code does not need to special-case the open build.
*/ */
import { attachAnalyticsSink } from './index.js' export function initializeAnalyticsSink(): void {
type LogEventMetadata = { [key: string]: boolean | number | undefined }
function logEventImpl(
_eventName: string,
_metadata: LogEventMetadata,
): void {
return return
} }
function logEventAsyncImpl(
_eventName: string,
_metadata: LogEventMetadata,
): Promise<void> {
return Promise.resolve()
}
export function initializeAnalyticsSink(): void {
attachAnalyticsSink({
logEvent: logEventImpl,
logEventAsync: logEventAsyncImpl,
})
}

View File

@@ -368,7 +368,7 @@ export async function setup(
) // Start team memory sync watcher ) // Start team memory sync watcher
} }
} }
initSinks() // Attach error log + analytics sinks and drain queued events initSinks() // Attach error log sink and analytics compatibility stubs
// Session-success-rate denominator. Emit immediately after the analytics // Session-success-rate denominator. Emit immediately after the analytics
// sink is attached — before any parsing, fetching, or I/O that could throw. // sink is attached — before any parsing, fetching, or I/O that could throw.

View File

@@ -2,10 +2,9 @@ import { initializeAnalyticsSink } from '../services/analytics/sink.js'
import { initializeErrorLogSink } from './errorLogSink.js' import { initializeErrorLogSink } from './errorLogSink.js'
/** /**
* Attach error log and analytics sinks, draining any events queued before * Attach error log and analytics compatibility sinks. Both inits are
* attachment. Both inits are idempotent. Called from setup() for the default * idempotent. Called from setup() for the default command; other entrypoints
* command; other entrypoints (subcommands, daemon, bridge) call this directly * (subcommands, daemon, bridge) call this directly since they bypass setup().
* since they bypass setup().
* *
* Leaf module — kept out of setup.ts to avoid the setup → commands → bridge * Leaf module — kept out of setup.ts to avoid the setup → commands → bridge
* → setup import cycle. * → setup import cycle.