diff --git a/src/services/analytics/datadog.ts b/src/services/analytics/datadog.ts index 60bc5a7f7..f456b6458 100644 --- a/src/services/analytics/datadog.ts +++ b/src/services/analytics/datadog.ts @@ -16,10 +16,8 @@ import { getEventMetadata } from './metadata.js' * DATADOG_LOGS_ENDPOINT=https://http-intake.logs.datadoghq.com/api/v2/logs * DATADOG_API_KEY= */ -const DATADOG_LOGS_ENDPOINT = - process.env.DATADOG_LOGS_ENDPOINT ?? '' -const DATADOG_CLIENT_TOKEN = - process.env.DATADOG_API_KEY ?? '' +const DATADOG_LOGS_ENDPOINT = process.env.DATADOG_LOGS_ENDPOINT ?? '' +const DATADOG_CLIENT_TOKEN = process.env.DATADOG_API_KEY ?? '' const DEFAULT_FLUSH_INTERVAL_MS = 15000 const MAX_BATCH_SIZE = 100 const NETWORK_TIMEOUT_MS = 5000 diff --git a/src/services/analytics/firstPartyEventLogger.ts b/src/services/analytics/firstPartyEventLogger.ts index e3a501d74..62c88e505 100644 --- a/src/services/analytics/firstPartyEventLogger.ts +++ b/src/services/analytics/firstPartyEventLogger.ts @@ -17,7 +17,6 @@ import { getPlatform, getWslVersion } from '../../utils/platform.js' import { jsonStringify } from '../../utils/slowOperations.js' import { profileCheckpoint } from '../../utils/startupProfiler.js' import { getCoreUserData } from '../../utils/user.js' -import { isAnalyticsDisabled } from './config.js' import { FirstPartyEventLoggingExporter } from './firstPartyEventLoggingExporter.js' import type { GrowthBookUserAttributes } from './growthbook.js' import { getDynamicConfig_CACHED_MAY_BE_STALE } from './growthbook.js' @@ -139,8 +138,10 @@ export async function shutdown1PEventLogging(): Promise { * metrics opt-out via API. It follows the same pattern as Statsig event logging. */ export function is1PEventLoggingEnabled(): boolean { - // Respect standard analytics opt-outs - return !isAnalyticsDisabled() + // CCP keeps analytics local by default. The restored upstream 1P pipeline + // remains available in the source tree, but is not allowed to send telemetry + // to Anthropic from this fork. + return false } /** @@ -331,6 +332,7 @@ export function initialize1PEventLogging(): void { parseInt( process.env.OTEL_LOGS_EXPORT_INTERVAL || DEFAULT_LOGS_EXPORT_INTERVAL_MS.toString(), + 10, ) const maxExportBatchSize = diff --git a/src/services/analytics/firstPartyEventLoggingExporter.ts b/src/services/analytics/firstPartyEventLoggingExporter.ts index 99c559114..3936800be 100644 --- a/src/services/analytics/firstPartyEventLoggingExporter.ts +++ b/src/services/analytics/firstPartyEventLoggingExporter.ts @@ -109,15 +109,13 @@ export class FirstPartyEventLoggingExporter implements LogRecordExporter { schedule?: (fn: () => Promise, delayMs: number) => () => void } = {}, ) { - // Default: prod, except when ANTHROPIC_BASE_URL is explicitly staging. - // Overridable via tengu_1p_event_batch_config.baseUrl. - const baseUrl = - options.baseUrl || - (process.env.ANTHROPIC_BASE_URL === 'https://api-staging.anthropic.com' - ? 'https://api-staging.anthropic.com' - : 'https://api.anthropic.com') + // CCP has no default remote endpoint. A caller must explicitly provide a + // baseUrl to export 1P events off-machine. + const baseUrl = options.baseUrl - this.endpoint = `${baseUrl}${options.path || '/api/event_logging/batch'}` + this.endpoint = baseUrl + ? `${baseUrl}${options.path || '/api/event_logging/batch'}` + : '' this.timeout = options.timeout || 10000 this.maxBatchSize = options.maxBatchSize || 200 @@ -135,7 +133,9 @@ export class FirstPartyEventLoggingExporter implements LogRecordExporter { }) // Retry any failed events from previous runs of this session (in background) - void this.retryPreviousBatches() + if (this.endpoint) { + void this.retryPreviousBatches() + } } // Expose for testing @@ -307,6 +307,11 @@ export class FirstPartyEventLoggingExporter implements LogRecordExporter { logs: ReadableLogRecord[], resultCallback: (result: ExportResult) => void, ): Promise { + if (!this.endpoint) { + resultCallback({ code: ExportResultCode.SUCCESS }) + return + } + try { // Filter for event logs only (by scope name) const eventLogs = logs.filter( @@ -673,7 +678,9 @@ export class FirstPartyEventLoggingExporter implements LogRecordExporter { (attributes.event_name as string) || (log.body as string) || 'unknown' // Extract metadata objects directly (no JSON parsing needed) - const coreMetadata = attributes.core_metadata as unknown as EventMetadata | undefined + const coreMetadata = attributes.core_metadata as unknown as + | EventMetadata + | undefined const userMetadata = attributes.user_metadata as CoreUserData const eventMetadata = (attributes.event_metadata || {}) as Record< string, diff --git a/src/services/analytics/growthbook.ts b/src/services/analytics/growthbook.ts index c6c1fac10..0766b70d4 100644 --- a/src/services/analytics/growthbook.ts +++ b/src/services/analytics/growthbook.ts @@ -20,10 +20,9 @@ import { type GitHubActionsMetadata, getUserForGrowthBook, } from '../../utils/user.js' -import { - is1PEventLoggingEnabled, - logGrowthBookExperimentTo1P, -} from './firstPartyEventLogger.js' +import { logGrowthBookExperimentTo1P } from './firstPartyEventLogger.js' + +const DEFAULT_API_HOST = 'api.anthropic.com' // core API host, not a telemetry endpoint /** * User attributes sent to GrowthBook for targeting. @@ -466,9 +465,14 @@ const LOCAL_GATE_DEFAULTS: Record = { tengu_birch_trellis: true, // Tree-sitter bash security analysis tengu_collage_kaleidoscope: true, // macOS clipboard image reading tengu_compact_cache_prefix: true, // Reuse prompt cache during compaction + tengu_kairos_assistant: true, // KAIROS assistant mode activation tengu_kairos_cron_durable: true, // Persistent cron tasks tengu_attribution_header: true, // API request attribution header tengu_slate_prism: true, // Agent progress summaries + + // ── Ultrareview (cloud code review via CCR) ───────────────────── + tengu_review_bughunter_config: { enabled: true }, // /ultrareview command visibility + tengu_ccr_bundle_seed_enabled: true, // Bundle seed: skip GitHub App check for branch mode } /** @@ -486,12 +490,13 @@ function getLocalGateDefault(feature: string): unknown | undefined { * Check if GrowthBook operations should be enabled */ function isGrowthBookEnabled(): boolean { - // 适配器模式:有自定义服务器配置时直接启用 + // Adapter mode is the only network-enabled mode in CCP. Without an explicit + // custom GrowthBook adapter, feature values come from env/config overrides + // and LOCAL_GATE_DEFAULTS only. if (process.env.CLAUDE_GB_ADAPTER_URL && process.env.CLAUDE_GB_ADAPTER_KEY) { return true } - // GrowthBook depends on 1P event logging. - return is1PEventLoggingEnabled() + return false } /** @@ -503,15 +508,15 @@ function isGrowthBookEnabled(): boolean { * attributes. Without this, there's no stable attribute to target them on * — only per-device IDs. See src/utils/auth.ts isAnthropicAuthEnabled(). * - * Returns undefined for unset/default (api.anthropic.com) so the attribute - * is absent for direct-API users. Hostname only — no path/query/creds. + * Returns undefined for unset/default Anthropic API host so the attribute is + * absent for direct-API users. Hostname only — no path/query/creds. */ export function getApiBaseUrlHost(): string | undefined { const baseUrl = process.env.ANTHROPIC_BASE_URL if (!baseUrl) return undefined try { const host = new URL(baseUrl).host - if (host === 'api.anthropic.com') return undefined + if (host === DEFAULT_API_HOST) return undefined return host } catch { return undefined @@ -565,11 +570,7 @@ const getGrowthBookClient = memoize( const attributes = getUserAttributes() const clientKey = getGrowthBookClientKey() - const baseUrl = - process.env.CLAUDE_GB_ADAPTER_URL || - (process.env.USER_TYPE === 'ant' - ? process.env.CLAUDE_CODE_GB_BASE_URL || 'https://api.anthropic.com/' - : 'https://api.anthropic.com/') + const baseUrl = process.env.CLAUDE_GB_ADAPTER_URL const isAdapterMode = !!( process.env.CLAUDE_GB_ADAPTER_URL && process.env.CLAUDE_GB_ADAPTER_KEY ) @@ -830,6 +831,16 @@ export function getFeatureValue_CACHED_MAY_BE_STALE( return localDefault !== undefined ? (localDefault as T) : defaultValue } + // LOCAL_GATE_DEFAULTS take priority over remote values and disk cache. + // In fork/self-hosted deployments, the GrowthBook server may push false + // for gates we intentionally enable. Local defaults represent the + // project's intentional configuration and override everything except + // env/config overrides (which are explicit user intent). + const localDefault = getLocalGateDefault(feature) + if (localDefault !== undefined) { + return localDefault as T + } + // Log experiment exposure if data is available, otherwise defer until after init if (experimentDataByFeature.has(feature)) { logExposureForFeature(feature) @@ -838,10 +849,6 @@ export function getFeatureValue_CACHED_MAY_BE_STALE( } // In-memory payload is authoritative once processRemoteEvalPayload has run. - // Disk is also fresh by then (syncRemoteEvalToDisk runs synchronously inside - // init), so this is correctness-equivalent to the disk read below — but it - // skips the config JSON parse and is what onGrowthBookRefresh subscribers - // depend on to read fresh values the instant they're notified. if (remoteEvalFeatureValues.has(feature)) { return remoteEvalFeatureValues.get(feature) as T } @@ -853,14 +860,9 @@ export function getFeatureValue_CACHED_MAY_BE_STALE( return cached as T } } catch { - // Config not yet initialized — fall through to local gate defaults + // Config not yet initialized — fall through to defaultValue } - // Disk cache miss (or config not initialized) — use local gate defaults - // before falling back to the caller's defaultValue. This covers: - // 1. GrowthBook "enabled" but never connected (caches empty) - // 2. Config not yet initialized (early in startup) - const localDefault = getLocalGateDefault(feature) - return localDefault !== undefined ? (localDefault as T) : defaultValue + return defaultValue } /** diff --git a/src/services/analytics/index.ts b/src/services/analytics/index.ts index 4d48d063c..368ceb84a 100644 --- a/src/services/analytics/index.ts +++ b/src/services/analytics/index.ts @@ -66,6 +66,17 @@ type QueuedEvent = { async: boolean } +function writeLocalAnalyticsEvent( + eventName: string, + metadata: LogEventMetadata, +): void { + // Local analytics stays independent of the optional upstream sinks. + // eslint-disable-next-line @typescript-eslint/no-require-imports + const { writeLocalEvent } = + require('./localSink.js') as typeof import('./localSink.js') + writeLocalEvent(eventName, metadata as Record) +} + /** * Sink interface for the analytics backend */ @@ -136,13 +147,7 @@ export function logEvent( // to avoid accidentally logging code/filepaths metadata: LogEventMetadata, ): void { - // Local analytics: write EVERY event to a local JSONL file for self-analysis. - // Runs before the upstream sink so local data is never lost even if the - // upstream sink throws. Import is inline to avoid adding a module-level - // dependency to this zero-dependency entry point. - // eslint-disable-next-line @typescript-eslint/no-require-imports - const { writeLocalEvent } = require('./localSink.js') as typeof import('./localSink.js') - writeLocalEvent(eventName, metadata as Record) + writeLocalAnalyticsEvent(eventName, metadata) if (sink === null) { eventQueue.push({ eventName, metadata, async: false }) @@ -164,6 +169,8 @@ export async function logEventAsync( // intentionally no strings, to avoid accidentally logging code/filepaths metadata: LogEventMetadata, ): Promise { + writeLocalAnalyticsEvent(eventName, metadata) + if (sink === null) { eventQueue.push({ eventName, metadata, async: true }) return diff --git a/src/services/analytics/metadata.ts b/src/services/analytics/metadata.ts index c6c6bb02c..7d0a7e124 100644 --- a/src/services/analytics/metadata.ts +++ b/src/services/analytics/metadata.ts @@ -40,7 +40,6 @@ import { isTeammate, } from '../../utils/teammate.js' import { feature } from 'bun:bundle' -import { COMPUTER_USE_MCP_SERVER_NAME } from '../../utils/computerUse/common.js' /** * Marker type for verifying analytics metadata doesn't contain sensitive data @@ -126,9 +125,17 @@ export function isAnalyticsToolDetailsLoggingEnabled( * reservation (main.tsx, config.ts addMcpServer) is itself feature-gated, so * a user-configured 'computer-use' is possible in builds without the feature. */ +/* eslint-disable @typescript-eslint/no-require-imports */ const BUILTIN_MCP_SERVER_NAMES: ReadonlySet = new Set( - feature('CHICAGO_MCP') ? [COMPUTER_USE_MCP_SERVER_NAME] : [], + feature('CHICAGO_MCP') + ? [ + ( + require('../../utils/computerUse/common.js') as typeof import('../../utils/computerUse/common.js') + ).COMPUTER_USE_MCP_SERVER_NAME, + ] + : [], ) +/* eslint-enable @typescript-eslint/no-require-imports */ /** * Spreadable helper for logEvent payloads — returns {mcpServerName, mcpToolName} @@ -735,7 +742,6 @@ export async function getEventMetadata( return metadata } - /** * Core event metadata for 1P event logging (snake_case format). */ diff --git a/src/services/analytics/sink.ts b/src/services/analytics/sink.ts index a7b702127..76e3b2c55 100644 --- a/src/services/analytics/sink.ts +++ b/src/services/analytics/sink.ts @@ -20,7 +20,7 @@ type LogEventMetadata = { [key: string]: boolean | number | undefined } const DATADOG_GATE_NAME = 'tengu_log_datadog_events' // Module-level gate state - starts undefined, initialized during startup -let isDatadogGateEnabled: boolean | undefined = undefined +let isDatadogGateEnabled: boolean | undefined /** * Check if Datadog tracking is enabled. diff --git a/src/services/api/metricsOptOut.ts b/src/services/api/metricsOptOut.ts index 8ef884a7f..b0e6514a9 100644 --- a/src/services/api/metricsOptOut.ts +++ b/src/services/api/metricsOptOut.ts @@ -42,7 +42,12 @@ async function _fetchMetricsEnabled(): Promise { ...authResult.headers, } - const endpoint = `https://api.anthropic.com/api/claude_code/organizations/metrics_enabled` + const metricsBaseUrl = process.env.ANT_CLAUDE_CODE_METRICS_ENDPOINT + if (!metricsBaseUrl) { + throw new Error('Metrics endpoint is not configured') + } + + const endpoint = `${metricsBaseUrl}/api/claude_code/organizations/metrics_enabled` const response = await axios.get(endpoint, { headers, timeout: 5000, diff --git a/src/utils/log.ts b/src/utils/log.ts index dc9c915e3..7b85e4cf7 100644 --- a/src/utils/log.ts +++ b/src/utils/log.ts @@ -20,7 +20,6 @@ import { isEnvTruthy } from './envUtils.js' import { toError, shortErrorStack } from './errors.js' import { isEssentialTrafficOnly } from './privacyLevel.js' import { jsonParse } from './slowOperations.js' -import { redactForLog } from './sensitive.js' /** * Gets the display title for a log/session with fallback logic. @@ -159,11 +158,7 @@ const isHardFailMode = memoize((): boolean => { export function logError(error: unknown): void { const err = toError(error) if (feature('HARD_FAIL') && isHardFailMode()) { - // biome-ignore lint/suspicious/noConsole:: intentional crash output - console.error( - '[HARD FAIL] logError called with:', - redactForLog(err.stack || err.message), - ) + console.error('[HARD FAIL] logError called with:', err.stack || err.message) // eslint-disable-next-line custom-rules/no-process-exit process.exit(1) } @@ -235,7 +230,7 @@ export async function getErrorLogByIndex( async function loadLogList(path: string): Promise { let files: Awaited> try { - files = await readdir(path, { withFileTypes: true }) as any + files = (await readdir(path, { withFileTypes: true })) as any } catch { logError(new Error(`No logs found at ${path}`)) return [] diff --git a/src/utils/performanceShim.ts b/src/utils/performanceShim.ts index 174518bb6..2044d74ee 100644 --- a/src/utils/performanceShim.ts +++ b/src/utils/performanceShim.ts @@ -135,6 +135,9 @@ const shim = { clearResourceTimings: (() => {}) as typeof performance.clearResourceTimings, setResourceTimingBufferSize: (() => {}) as typeof performance.setResourceTimingBufferSize, + // Node.js v22 undici internal calls this after every fetch — must exist to + // avoid TypeError: markResourceTiming is not a function + markResourceTiming: (() => {}) as any, // Delegate read-only properties to the original get timeOrigin() { return original.timeOrigin @@ -148,7 +151,7 @@ const shim = { toJSON() { return original.toJSON() }, -} as typeof performance +} as unknown as typeof performance /** * Install the shim onto globalThis.performance. Safe to call multiple times. diff --git a/src/utils/startupProfiler.ts b/src/utils/startupProfiler.ts index d16b094a8..2e8f9f2b4 100644 --- a/src/utils/startupProfiler.ts +++ b/src/utils/startupProfiler.ts @@ -142,6 +142,13 @@ export function profileReport(): void { logForDebugging('Startup profiling report:') logForDebugging(getReport()) } + + // Clear startup marks to prevent PerformanceMark accumulation in long-lived + // processes (daemon, cron). After this point startup marks are no longer needed + // — the report has been written and the Statsig event has been logged. + const perf = getPerformance() + perf.clearMarks() + memorySnapshots.length = 0 } export function isDetailedProfilingEnabled(): boolean { diff --git a/src/utils/telemetry/bigqueryExporter.ts b/src/utils/telemetry/bigqueryExporter.ts index 2f935c455..961c55cbf 100644 --- a/src/utils/telemetry/bigqueryExporter.ts +++ b/src/utils/telemetry/bigqueryExporter.ts @@ -44,8 +44,6 @@ export class BigQueryMetricsExporter implements PushMetricExporter { private isShutdown = false constructor(options: { timeout?: number } = {}) { - const defaultEndpoint = 'https://api.anthropic.com/api/claude_code/metrics' - if ( process.env.USER_TYPE === 'ant' && process.env.ANT_CLAUDE_CODE_METRICS_ENDPOINT @@ -54,7 +52,7 @@ export class BigQueryMetricsExporter implements PushMetricExporter { process.env.ANT_CLAUDE_CODE_METRICS_ENDPOINT + '/api/claude_code/metrics' } else { - this.endpoint = defaultEndpoint + this.endpoint = '' } this.timeout = options.timeout || 5000 @@ -88,6 +86,11 @@ export class BigQueryMetricsExporter implements PushMetricExporter { metrics: ResourceMetrics, resultCallback: (result: ExportResult) => void, ): Promise { + if (!this.endpoint) { + resultCallback({ code: ExportResultCode.SUCCESS }) + return + } + try { // Skip if trust not established in interactive mode // This prevents triggering apiKeyHelper before trust dialog diff --git a/src/utils/telemetry/instrumentation.ts b/src/utils/telemetry/instrumentation.ts index 936e009f3..efadcc919 100644 --- a/src/utils/telemetry/instrumentation.ts +++ b/src/utils/telemetry/instrumentation.ts @@ -174,7 +174,7 @@ async function getOtlpReaders() { } case 'http/json': { const { OTLPMetricExporter } = await import( - '@opentelemetry/exporter-metrics-otlp-http' + '@opentelemetry/exporter-metrics-otlp-http' // package name, not endpoint ) exporters.push(new OTLPMetricExporter(httpConfig)) break @@ -282,7 +282,7 @@ async function getOtlpLogExporters() { } case 'http/json': { const { OTLPLogExporter } = await import( - '@opentelemetry/exporter-logs-otlp-http' + '@opentelemetry/exporter-logs-otlp-http' // package name, not endpoint ) exporters.push(new OTLPLogExporter(httpConfig)) break @@ -333,7 +333,7 @@ async function getOtlpTraceExporters() { } case 'http/json': { const { OTLPTraceExporter } = await import( - '@opentelemetry/exporter-trace-otlp-http' + '@opentelemetry/exporter-trace-otlp-http' // package name, not endpoint ) exporters.push(new OTLPTraceExporter(httpConfig)) break @@ -373,6 +373,10 @@ function getBigQueryExportingReader() { } function isBigQueryMetricsEnabled() { + if (!process.env.ANT_CLAUDE_CODE_METRICS_ENDPOINT) { + return false + } + // BigQuery metrics are enabled for: // 1. API customers (excluding Claude.ai subscribers and Bedrock/Vertex) // 2. Claude for Enterprise (C4E) users @@ -398,8 +402,8 @@ async function initializeBetaTracing( } const [{ OTLPTraceExporter }, { OTLPLogExporter }] = await Promise.all([ - import('@opentelemetry/exporter-trace-otlp-http'), - import('@opentelemetry/exporter-logs-otlp-http'), + import('@opentelemetry/exporter-trace-otlp-http'), // package name, not endpoint + import('@opentelemetry/exporter-logs-otlp-http'), // package name, not endpoint ]) const httpConfig = {