Remove dead analytics and telemetry scaffolding
This commit is contained in:
@@ -48,8 +48,9 @@ Removed in this repository:
|
||||
- Datadog analytics and Anthropic 1P event-logging egress.
|
||||
- GrowthBook remote evaluation/network fetches; local env/config overrides and cached values remain available for compatibility.
|
||||
- OpenTelemetry initialization and event export paths.
|
||||
- Extra dead telemetry scaffolding tied to the removed egress paths, including startup/session analytics fanout, logout telemetry flush, and remote GrowthBook metadata collectors.
|
||||
|
||||
Still present:
|
||||
|
||||
- Normal Claude API requests are still part of product functionality; this fork only removes extra local metadata injection, not core model/network access.
|
||||
- Compatibility scaffolding for analytics, GrowthBook, and telemetry still exists in the tree as local no-op or cache-only code.
|
||||
- Minimal compatibility helpers for analytics and GrowthBook still exist in the tree as local no-op or cache-only code.
|
||||
|
||||
@@ -4,7 +4,6 @@ import { Text } from '../../ink.js';
|
||||
import { refreshGrowthBookAfterAuthChange } from '../../services/analytics/growthbook.js';
|
||||
import { getGroveNoticeConfig, getGroveSettings } from '../../services/api/grove.js';
|
||||
import { clearPolicyLimitsCache } from '../../services/policyLimits/index.js';
|
||||
// flushTelemetry is loaded lazily to avoid pulling in ~1.1MB of OpenTelemetry at startup
|
||||
import { clearRemoteManagedSettingsCache } from '../../services/remoteManagedSettings/index.js';
|
||||
import { getClaudeAIOAuthTokens, removeApiKey } from '../../utils/auth.js';
|
||||
import { clearBetasCaches } from '../../utils/betas.js';
|
||||
@@ -16,11 +15,6 @@ import { resetUserCache } from '../../utils/user.js';
|
||||
export async function performLogout({
|
||||
clearOnboarding = false
|
||||
}): Promise<void> {
|
||||
// Flush telemetry BEFORE clearing credentials to prevent org data leakage
|
||||
const {
|
||||
flushTelemetry
|
||||
} = await import('../../utils/telemetry/instrumentation.js');
|
||||
await flushTelemetry();
|
||||
await removeApiKey();
|
||||
|
||||
// Wipe all secure storage data on logout
|
||||
|
||||
@@ -1,11 +1,8 @@
|
||||
import { profileCheckpoint } from '../utils/startupProfiler.js'
|
||||
import '../bootstrap/state.js'
|
||||
import '../utils/config.js'
|
||||
import type { Attributes, MetricOptions } from '@opentelemetry/api'
|
||||
import memoize from 'lodash-es/memoize.js'
|
||||
import { getIsNonInteractiveSession } from 'src/bootstrap/state.js'
|
||||
import type { AttributedCounter } from '../bootstrap/state.js'
|
||||
import { getSessionCounter, setMeter } from '../bootstrap/state.js'
|
||||
import { shutdownLspServerManager } from '../services/lsp/manager.js'
|
||||
import { populateOAuthAccountInfoIfNeeded } from '../services/oauth/client.js'
|
||||
import {
|
||||
@@ -41,19 +38,9 @@ import {
|
||||
ensureScratchpadDir,
|
||||
isScratchpadEnabled,
|
||||
} from '../utils/permissions/filesystem.js'
|
||||
// initializeTelemetry is loaded lazily via import() in setMeterState() to defer
|
||||
// ~400KB of OpenTelemetry + protobuf modules until telemetry is actually initialized.
|
||||
// gRPC exporters (~700KB via @grpc/grpc-js) are further lazy-loaded within instrumentation.ts.
|
||||
import { configureGlobalAgents } from '../utils/proxy.js'
|
||||
import { isBetaTracingEnabled } from '../utils/telemetry/betaSessionTracing.js'
|
||||
import { getTelemetryAttributes } from '../utils/telemetryAttributes.js'
|
||||
import { setShellIfWindows } from '../utils/windowsPaths.js'
|
||||
|
||||
// initialize1PEventLogging is dynamically imported to defer OpenTelemetry sdk-logs/resources
|
||||
|
||||
// Track if telemetry has been initialized to prevent double initialization
|
||||
let telemetryInitialized = false
|
||||
|
||||
export const init = memoize(async (): Promise<void> => {
|
||||
const initStartTime = Date.now()
|
||||
logForDiagnosticsNoPII('info', 'init_started')
|
||||
@@ -222,23 +209,3 @@ export const init = memoize(async (): Promise<void> => {
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
/**
|
||||
* Initialize telemetry after trust has been granted.
|
||||
* For remote-settings-eligible users, waits for settings to load (non-blocking),
|
||||
* then re-applies env vars (to include remote settings) before initializing telemetry.
|
||||
* For non-eligible users, initializes telemetry immediately.
|
||||
* This should only be called once, after the trust dialog has been accepted.
|
||||
*/
|
||||
export function initializeTelemetryAfterTrust(): void {
|
||||
return
|
||||
}
|
||||
|
||||
async function doInitializeTelemetry(): Promise<void> {
|
||||
void telemetryInitialized
|
||||
return
|
||||
}
|
||||
|
||||
async function setMeterState(): Promise<void> {
|
||||
return
|
||||
}
|
||||
|
||||
@@ -7,7 +7,6 @@ import { type ChannelEntry, getAllowedChannels, setAllowedChannels, setHasDevCha
|
||||
import type { Command } from './commands.js';
|
||||
import { createStatsStore, type StatsStore } from './context/stats.js';
|
||||
import { getSystemContext } from './context.js';
|
||||
import { initializeTelemetryAfterTrust } from './entrypoints/init.js';
|
||||
import { isSynchronizedOutputSupported } from './ink/terminal.js';
|
||||
import type { RenderOptions, Root, TextProps } from './ink.js';
|
||||
import { KeybindingSetup } from './keybindings/KeybindingProviderSetup.js';
|
||||
@@ -183,11 +182,6 @@ export async function showSetupScreens(root: Root, permissionMode: PermissionMod
|
||||
// This includes potentially dangerous environment variables from untrusted sources
|
||||
applyConfigEnvironmentVariables();
|
||||
|
||||
// Initialize telemetry after env vars are applied so OTEL endpoint env vars and
|
||||
// otelHeadersHelper (which requires trust to execute) are available.
|
||||
// Defer to next tick so the OTel dynamic import resolves after first render
|
||||
// instead of during the pre-render microtask queue.
|
||||
setImmediate(() => initializeTelemetryAfterTrust());
|
||||
if (await isQualifiedForGrove()) {
|
||||
const {
|
||||
GroveDialog
|
||||
|
||||
85
src/main.tsx
85
src/main.tsx
@@ -29,7 +29,7 @@ import React from 'react';
|
||||
import { getOauthConfig } from './constants/oauth.js';
|
||||
import { getRemoteSessionUrl } from './constants/product.js';
|
||||
import { getSystemContext, getUserContext } from './context.js';
|
||||
import { init, initializeTelemetryAfterTrust } from './entrypoints/init.js';
|
||||
import { init } from './entrypoints/init.js';
|
||||
import { addToHistory } from './history.js';
|
||||
import type { Root } from './ink.js';
|
||||
import { launchRepl } from './replLauncher.js';
|
||||
@@ -49,7 +49,7 @@ import { isAgentSwarmsEnabled } from './utils/agentSwarmsEnabled.js';
|
||||
import { count, uniq } from './utils/array.js';
|
||||
import { installAsciicastRecorder } from './utils/asciicast.js';
|
||||
import { getSubscriptionType, isClaudeAISubscriber, prefetchAwsCredentialsAndBedRockInfoIfSafe, prefetchGcpCredentialsIfSafe, validateForceLoginOrg } from './utils/auth.js';
|
||||
import { checkHasTrustDialogAccepted, getGlobalConfig, getRemoteControlAtStartup, isAutoUpdaterDisabled, saveGlobalConfig } from './utils/config.js';
|
||||
import { checkHasTrustDialogAccepted, getGlobalConfig, getRemoteControlAtStartup, saveGlobalConfig } from './utils/config.js';
|
||||
import { seedEarlyInput, stopCapturingEarlyInput } from './utils/earlyInput.js';
|
||||
import { getInitialEffortSetting, parseEffortValue } from './utils/effort.js';
|
||||
import { getInitialFastModeSetting, isFastModeEnabled, prefetchFastModeStatus, resolveFastModeStatusFromCache } from './utils/fastMode.js';
|
||||
@@ -80,10 +80,8 @@ const coordinatorModeModule = feature('COORDINATOR_MODE') ? require('./coordinat
|
||||
const assistantModule = feature('KAIROS') ? require('./assistant/index.js') as typeof import('./assistant/index.js') : null;
|
||||
const kairosGate = feature('KAIROS') ? require('./assistant/gate.js') as typeof import('./assistant/gate.js') : null;
|
||||
import { relative, resolve } from 'path';
|
||||
import { isAnalyticsDisabled } from 'src/services/analytics/config.js';
|
||||
import { getFeatureValue_CACHED_MAY_BE_STALE } from 'src/services/analytics/growthbook.js';
|
||||
import { type AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, logEvent } from 'src/services/analytics/index.js';
|
||||
import { initializeAnalyticsGates } from 'src/services/analytics/sink.js';
|
||||
import { getOriginalCwd, setAdditionalDirectoriesForClaudeMd, setIsRemoteMode, setMainLoopModelOverride, setMainThreadAgentType, setTeleportedSessionInfo } from './bootstrap/state.js';
|
||||
import { filterCommandsForRemoteMode, getCommands } from './commands.js';
|
||||
import type { StatsStore } from './context/stats.js';
|
||||
@@ -103,15 +101,13 @@ import type { Message as MessageType } from './types/message.js';
|
||||
import { assertMinVersion } from './utils/autoUpdater.js';
|
||||
import { CLAUDE_IN_CHROME_SKILL_HINT, CLAUDE_IN_CHROME_SKILL_HINT_WITH_WEBBROWSER } from './utils/claudeInChrome/prompt.js';
|
||||
import { setupClaudeInChrome, shouldAutoEnableClaudeInChrome, shouldEnableClaudeInChrome } from './utils/claudeInChrome/setup.js';
|
||||
import { getContextWindowForModel } from './utils/context.js';
|
||||
import { loadConversationForResume } from './utils/conversationRecovery.js';
|
||||
import { buildDeepLinkBanner } from './utils/deepLink/banner.js';
|
||||
import { hasNodeOption, isBareMode, isEnvTruthy, isInProtectedNamespace } from './utils/envUtils.js';
|
||||
import { isBareMode, isEnvTruthy, isInProtectedNamespace } from './utils/envUtils.js';
|
||||
import { refreshExampleCommands } from './utils/exampleCommands.js';
|
||||
import type { FpsMetrics } from './utils/fpsTracker.js';
|
||||
import { getWorktreePaths } from './utils/getWorktreePaths.js';
|
||||
import { findGitRoot, getBranch, getIsGit, getWorktreeCount } from './utils/git.js';
|
||||
import { getGhAuthStatus } from './utils/github/ghAuthStatus.js';
|
||||
import { findGitRoot, getBranch } from './utils/git.js';
|
||||
import { safeParseJSON } from './utils/json.js';
|
||||
import { logError } from './utils/log.js';
|
||||
import { getModelDeprecationWarning } from './utils/model/deprecation.js';
|
||||
@@ -121,9 +117,7 @@ import { PERMISSION_MODES } from './utils/permissions/PermissionMode.js';
|
||||
import { checkAndDisableBypassPermissions, getAutoModeEnabledStateIfCached, initializeToolPermissionContext, initialPermissionModeFromCLI, isDefaultPermissionModeAuto, parseToolListFromCLI, removeDangerousPermissions, stripDangerousPermissionsForAutoMode, verifyAutoModeGateAccess } from './utils/permissions/permissionSetup.js';
|
||||
import { cleanupOrphanedPluginVersionsInBackground } from './utils/plugins/cacheUtils.js';
|
||||
import { initializeVersionedPlugins } from './utils/plugins/installedPluginsManager.js';
|
||||
import { getManagedPluginNames } from './utils/plugins/managedPlugins.js';
|
||||
import { getGlobExclusionsForPluginCache } from './utils/plugins/orphanedPluginFilter.js';
|
||||
import { getPluginSeedDirs } from './utils/plugins/pluginDirectories.js';
|
||||
import { countFilesRoundedRg } from './utils/ripgrep.js';
|
||||
import { processSessionStartHooks, processSetupHooks } from './utils/sessionStart.js';
|
||||
import { cacheSessionTitle, getSessionIdFromLog, loadTranscriptFromFile, saveAgentSetting, saveMode, searchSessionsByCustomTitle, sessionIdExists } from './utils/sessionStorage.js';
|
||||
@@ -132,8 +126,6 @@ import { getInitialSettings, getManagedSettingsKeysForLogging, getSettingsForSou
|
||||
import { resetSettingsCache } from './utils/settings/settingsCache.js';
|
||||
import type { ValidationError } from './utils/settings/validation.js';
|
||||
import { DEFAULT_TASKS_MODE_TASK_LIST_ID, TASK_STATUSES } from './utils/tasks.js';
|
||||
import { logPluginLoadErrors, logPluginsEnabledForSession } from './utils/telemetry/pluginTelemetry.js';
|
||||
import { logSkillsLoaded } from './utils/telemetry/skillLoadedEvent.js';
|
||||
import { generateTempFilePath } from './utils/tempfile.js';
|
||||
import { validateUuid } from './utils/uuid.js';
|
||||
// Plugin startup checks are now handled non-blockingly in REPL.tsx
|
||||
@@ -196,7 +188,7 @@ import { filterAllowedSdkBetas } from './utils/betas.js';
|
||||
import { isInBundledMode, isRunningWithBun } from './utils/bundledMode.js';
|
||||
import { logForDiagnosticsNoPII } from './utils/diagLogs.js';
|
||||
import { filterExistingPaths, getKnownPathsForRepo } from './utils/githubRepoPathMapping.js';
|
||||
import { clearPluginCache, loadAllPluginsCacheOnly } from './utils/plugins/pluginLoader.js';
|
||||
import { clearPluginCache } from './utils/plugins/pluginLoader.js';
|
||||
import { migrateChangelogFromConfig } from './utils/releaseNotes.js';
|
||||
import { SandboxManager } from './utils/sandbox/sandbox-adapter.js';
|
||||
import { fetchSession, prepareApiRequest } from './utils/teleport/api.js';
|
||||
@@ -270,56 +262,6 @@ if ("external" !== 'ant' && isBeingDebugged()) {
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
/**
|
||||
* Per-session skill/plugin telemetry. Called from both the interactive path
|
||||
* and the headless -p path (before runHeadless) — both go through
|
||||
* main.tsx but branch before the interactive startup path, so it needs two
|
||||
* call sites here rather than one here + one in QueryEngine.
|
||||
*/
|
||||
function logSessionTelemetry(): void {
|
||||
const model = parseUserSpecifiedModel(getInitialMainLoopModel() ?? getDefaultMainLoopModel());
|
||||
void logSkillsLoaded(getCwd(), getContextWindowForModel(model, getSdkBetas()));
|
||||
void loadAllPluginsCacheOnly().then(({
|
||||
enabled,
|
||||
errors
|
||||
}) => {
|
||||
const managedNames = getManagedPluginNames();
|
||||
logPluginsEnabledForSession(enabled, managedNames, getPluginSeedDirs());
|
||||
logPluginLoadErrors(errors, managedNames);
|
||||
}).catch(err => logError(err));
|
||||
}
|
||||
function getCertEnvVarTelemetry(): Record<string, boolean> {
|
||||
const result: Record<string, boolean> = {};
|
||||
if (process.env.NODE_EXTRA_CA_CERTS) {
|
||||
result.has_node_extra_ca_certs = true;
|
||||
}
|
||||
if (process.env.CLAUDE_CODE_CLIENT_CERT) {
|
||||
result.has_client_cert = true;
|
||||
}
|
||||
if (hasNodeOption('--use-system-ca')) {
|
||||
result.has_use_system_ca = true;
|
||||
}
|
||||
if (hasNodeOption('--use-openssl-ca')) {
|
||||
result.has_use_openssl_ca = true;
|
||||
}
|
||||
return result;
|
||||
}
|
||||
async function logStartupTelemetry(): Promise<void> {
|
||||
if (isAnalyticsDisabled()) return;
|
||||
const [isGit, worktreeCount, ghAuthStatus] = await Promise.all([getIsGit(), getWorktreeCount(), getGhAuthStatus()]);
|
||||
logEvent('tengu_startup_telemetry', {
|
||||
is_git: isGit,
|
||||
worktree_count: worktreeCount,
|
||||
gh_auth_status: ghAuthStatus as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
|
||||
sandbox_enabled: SandboxManager.isSandboxingEnabled(),
|
||||
are_unsandboxed_commands_allowed: SandboxManager.areUnsandboxedCommandsAllowed(),
|
||||
is_auto_bash_allowed_if_sandbox_enabled: SandboxManager.isAutoAllowBashIfSandboxedEnabled(),
|
||||
auto_updater_disabled: isAutoUpdaterDisabled(),
|
||||
prefers_reduced_motion: getInitialSettings().prefersReducedMotion ?? false,
|
||||
...getCertEnvVarTelemetry()
|
||||
});
|
||||
}
|
||||
|
||||
// @[MODEL LAUNCH]: Consider any migrations you may need for model strings. See migrateSonnet1mToSonnet45.ts for an example.
|
||||
// Bump this when adding a new sync migration so existing users re-run the set.
|
||||
const CURRENT_MIGRATION_VERSION = 11;
|
||||
@@ -413,8 +355,7 @@ export function startDeferredPrefetches(): void {
|
||||
}
|
||||
void countFilesRoundedRg(getCwd(), AbortSignal.timeout(3000), []);
|
||||
|
||||
// Analytics and feature flag initialization
|
||||
void initializeAnalyticsGates();
|
||||
// Feature flag initialization
|
||||
void prefetchOfficialMcpUrls();
|
||||
void refreshModelCapabilities();
|
||||
|
||||
@@ -2587,15 +2528,10 @@ async function run(): Promise<CommanderCommand> {
|
||||
setHasFormattedOutput(true);
|
||||
}
|
||||
|
||||
// Apply full environment variables in print mode since trust dialog is bypassed
|
||||
// This includes potentially dangerous environment variables from untrusted sources
|
||||
// Apply full environment variables in print mode since trust dialog is bypassed.
|
||||
// but print mode is considered trusted (as documented in help text)
|
||||
applyConfigEnvironmentVariables();
|
||||
|
||||
// Initialize telemetry after env vars are applied so OTEL endpoint env vars and
|
||||
// otelHeadersHelper (which requires trust to execute) are available.
|
||||
initializeTelemetryAfterTrust();
|
||||
|
||||
// Kick SessionStart hooks now so the subprocess spawn overlaps with
|
||||
// MCP connect + plugin init + print.ts import below. loadInitialMessages
|
||||
// joins this at print.ts:4397. Guarded same as loadInitialMessages —
|
||||
@@ -2820,7 +2756,6 @@ async function run(): Promise<CommanderCommand> {
|
||||
void import('./utils/sdkHeapDumpMonitor.js').then(m => m.startSdkMemoryMonitor());
|
||||
}
|
||||
}
|
||||
logSessionTelemetry();
|
||||
profileCheckpoint('before_print_import');
|
||||
const {
|
||||
runHeadless
|
||||
@@ -3043,15 +2978,11 @@ async function run(): Promise<CommanderCommand> {
|
||||
|
||||
// Increment numStartups synchronously — first-render readers like
|
||||
// shouldShowEffortCallout (via useState initializer) need the updated
|
||||
// value before setImmediate fires. Defer only telemetry.
|
||||
// value immediately.
|
||||
saveGlobalConfig(current => ({
|
||||
...current,
|
||||
numStartups: (current.numStartups ?? 0) + 1
|
||||
}));
|
||||
setImmediate(() => {
|
||||
void logStartupTelemetry();
|
||||
logSessionTelemetry();
|
||||
});
|
||||
|
||||
// Set up per-turn session environment data uploader (ant-only build).
|
||||
// Default-enabled for all ant users when working in an Anthropic-owned
|
||||
|
||||
@@ -1,20 +1,9 @@
|
||||
/**
|
||||
* Datadog analytics egress is disabled in this build.
|
||||
*
|
||||
* The exported functions remain so existing call sites do not need to branch.
|
||||
* Only shutdown compatibility remains for existing cleanup paths.
|
||||
*/
|
||||
|
||||
export async function initializeDatadog(): Promise<boolean> {
|
||||
return false
|
||||
}
|
||||
|
||||
export async function shutdownDatadog(): Promise<void> {
|
||||
return
|
||||
}
|
||||
|
||||
export async function trackDatadogEvent(
|
||||
_eventName: string,
|
||||
_properties: { [key: string]: boolean | number | undefined },
|
||||
): Promise<void> {
|
||||
return
|
||||
}
|
||||
|
||||
@@ -1,58 +1,16 @@
|
||||
/**
|
||||
* Anthropic 1P event logging egress is disabled in this build.
|
||||
*
|
||||
* The module keeps its public API so the rest of the app can call into it
|
||||
* without conditional imports.
|
||||
* Only the shutdown and feedback call sites still need a local stub.
|
||||
*/
|
||||
|
||||
import type { GrowthBookUserAttributes } from './growthbook.js'
|
||||
|
||||
export type EventSamplingConfig = {
|
||||
[eventName: string]: {
|
||||
sample_rate: number
|
||||
}
|
||||
}
|
||||
|
||||
export function getEventSamplingConfig(): EventSamplingConfig {
|
||||
return {}
|
||||
}
|
||||
|
||||
export function shouldSampleEvent(_eventName: string): number | null {
|
||||
return null
|
||||
}
|
||||
|
||||
export async function shutdown1PEventLogging(): Promise<void> {
|
||||
return
|
||||
}
|
||||
|
||||
export function is1PEventLoggingEnabled(): boolean {
|
||||
return false
|
||||
}
|
||||
|
||||
export function logEventTo1P(
|
||||
_eventName: string,
|
||||
_metadata: Record<string, number | boolean | undefined> = {},
|
||||
): void {
|
||||
return
|
||||
}
|
||||
|
||||
export type GrowthBookExperimentData = {
|
||||
experimentId: string
|
||||
variationId: number
|
||||
userAttributes?: GrowthBookUserAttributes
|
||||
experimentMetadata?: Record<string, unknown>
|
||||
}
|
||||
|
||||
export function logGrowthBookExperimentTo1P(
|
||||
_data: GrowthBookExperimentData,
|
||||
): void {
|
||||
return
|
||||
}
|
||||
|
||||
export function initialize1PEventLogging(): void {
|
||||
return
|
||||
}
|
||||
|
||||
export async function reinitialize1PEventLoggingIfConfigChanged(): Promise<void> {
|
||||
return
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -19,45 +19,15 @@
|
||||
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.
|
||||
* Marker type for values that previously flowed to privileged `_PROTO_*`
|
||||
* columns. The export remains so existing call sites keep their explicit
|
||||
* privacy annotations even though external analytics export is disabled.
|
||||
*
|
||||
* 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
|
||||
// Internal type for logEvent metadata in the local no-op sink.
|
||||
type LogEventMetadata = { [key: string]: boolean | number | undefined }
|
||||
|
||||
type QueuedEvent = {
|
||||
|
||||
@@ -1,72 +1,13 @@
|
||||
// biome-ignore-all assist/source/organizeImports: ANT-ONLY import markers must not be reordered
|
||||
/**
|
||||
* Shared event metadata enrichment for analytics systems
|
||||
*
|
||||
* This module provides a single source of truth for collecting and formatting
|
||||
* event metadata across all analytics systems (Datadog, 1P).
|
||||
*/
|
||||
|
||||
import { extname } from 'path'
|
||||
import memoize from 'lodash-es/memoize.js'
|
||||
import { env, getHostPlatformForAnalytics } from '../../utils/env.js'
|
||||
import { envDynamic } from '../../utils/envDynamic.js'
|
||||
import { getModelBetas } from '../../utils/betas.js'
|
||||
import { getMainLoopModel } from '../../utils/model/model.js'
|
||||
import {
|
||||
getSessionId,
|
||||
getIsInteractive,
|
||||
getKairosActive,
|
||||
getClientType,
|
||||
getParentSessionId as getParentSessionIdFromState,
|
||||
} from '../../bootstrap/state.js'
|
||||
import { isEnvTruthy } from '../../utils/envUtils.js'
|
||||
import { isOfficialMcpUrl } from '../mcp/officialRegistry.js'
|
||||
import { isClaudeAISubscriber, getSubscriptionType } from '../../utils/auth.js'
|
||||
import { getRepoRemoteHash } from '../../utils/git.js'
|
||||
import {
|
||||
getWslVersion,
|
||||
getLinuxDistroInfo,
|
||||
detectVcs,
|
||||
} from '../../utils/platform.js'
|
||||
import type { CoreUserData } from 'src/utils/user.js'
|
||||
import { getAgentContext } from '../../utils/agentContext.js'
|
||||
import type { EnvironmentMetadata } from '../../types/generated/events_mono/claude_code/v1/claude_code_internal_event.js'
|
||||
import type { PublicApiAuth } from '../../types/generated/events_mono/common/v1/auth.js'
|
||||
import { jsonStringify } from '../../utils/slowOperations.js'
|
||||
import {
|
||||
getAgentId,
|
||||
getParentSessionId as getTeammateParentSessionId,
|
||||
getTeamName,
|
||||
isTeammate,
|
||||
} from '../../utils/teammate.js'
|
||||
import { feature } from 'bun:bundle'
|
||||
import { type AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS } from './index.js'
|
||||
|
||||
/**
|
||||
* 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.
|
||||
*
|
||||
* The metadata is expected to be JSON-serializable.
|
||||
*
|
||||
* Usage: `myString as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS`
|
||||
*
|
||||
* The type is `never` which means it can never actually hold a value - this is
|
||||
* intentional as it's only used for type-casting to document developer intent.
|
||||
* Local-only analytics helpers retained for compatibility after telemetry
|
||||
* export removal. These helpers only sanitize or classify values in-process.
|
||||
*/
|
||||
export type AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS = never
|
||||
|
||||
/**
|
||||
* Sanitizes tool names for analytics logging to avoid PII exposure.
|
||||
*
|
||||
* MCP tool names follow the format `mcp__<server>__<tool>` and can reveal
|
||||
* user-specific server configurations, which is considered PII-medium.
|
||||
* This function redacts MCP tool names while preserving built-in tool names
|
||||
* (Bash, Read, Write, etc.) which are safe to log.
|
||||
*
|
||||
* @param toolName - The tool name to sanitize
|
||||
* @returns The original name for built-in tools, or 'mcp_tool' for MCP tools
|
||||
*/
|
||||
export type { AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS }
|
||||
|
||||
export function sanitizeToolNameForAnalytics(
|
||||
toolName: string,
|
||||
): AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS {
|
||||
@@ -76,103 +17,17 @@ export function sanitizeToolNameForAnalytics(
|
||||
return toolName as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if detailed tool name logging is enabled for OTLP events.
|
||||
* When enabled, MCP server/tool names and Skill names are logged.
|
||||
* Disabled by default to protect PII (user-specific server configurations).
|
||||
*
|
||||
* Enable with OTEL_LOG_TOOL_DETAILS=1
|
||||
*/
|
||||
export function isToolDetailsLoggingEnabled(): boolean {
|
||||
return isEnvTruthy(process.env.OTEL_LOG_TOOL_DETAILS)
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if detailed tool name logging (MCP server/tool names) is enabled
|
||||
* for analytics events.
|
||||
*
|
||||
* Per go/taxonomy, MCP names are medium PII. We log them for:
|
||||
* - Cowork (entrypoint=local-agent) — no ZDR concept, log all MCPs
|
||||
* - claude.ai-proxied connectors — always official (from claude.ai's list)
|
||||
* - Servers whose URL matches the official MCP registry — directory
|
||||
* connectors added via `claude mcp add`, not customer-specific config
|
||||
*
|
||||
* Custom/user-configured MCPs stay sanitized (toolName='mcp_tool').
|
||||
*/
|
||||
export function isAnalyticsToolDetailsLoggingEnabled(
|
||||
mcpServerType: string | undefined,
|
||||
mcpServerBaseUrl: string | undefined,
|
||||
): boolean {
|
||||
if (process.env.CLAUDE_CODE_ENTRYPOINT === 'local-agent') {
|
||||
return true
|
||||
}
|
||||
if (mcpServerType === 'claudeai-proxy') {
|
||||
return true
|
||||
}
|
||||
if (mcpServerBaseUrl && isOfficialMcpUrl(mcpServerBaseUrl)) {
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
/**
|
||||
* Built-in first-party MCP servers whose names are fixed reserved strings,
|
||||
* not user-configured — so logging them is not PII. Checked in addition to
|
||||
* isAnalyticsToolDetailsLoggingEnabled's transport/URL gates, which a stdio
|
||||
* built-in would otherwise fail.
|
||||
*
|
||||
* Feature-gated so the set is empty when the feature is off: the name
|
||||
* 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<string> = new Set(
|
||||
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}
|
||||
* if the gate passes, empty object otherwise. Consolidates the identical IIFE
|
||||
* pattern at each tengu_tool_use_* call site.
|
||||
*/
|
||||
export function mcpToolDetailsForAnalytics(
|
||||
toolName: string,
|
||||
mcpServerType: string | undefined,
|
||||
mcpServerBaseUrl: string | undefined,
|
||||
): {
|
||||
export function mcpToolDetailsForAnalytics(): {
|
||||
mcpServerName?: AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS
|
||||
mcpToolName?: AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS
|
||||
} {
|
||||
const details = extractMcpToolDetails(toolName)
|
||||
if (!details) {
|
||||
return {}
|
||||
}
|
||||
if (
|
||||
!BUILTIN_MCP_SERVER_NAMES.has(details.serverName) &&
|
||||
!isAnalyticsToolDetailsLoggingEnabled(mcpServerType, mcpServerBaseUrl)
|
||||
) {
|
||||
return {}
|
||||
}
|
||||
return {
|
||||
mcpServerName: details.serverName,
|
||||
mcpToolName: details.mcpToolName,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract MCP server and tool names from a full MCP tool name.
|
||||
* MCP tool names follow the format: mcp__<server>__<tool>
|
||||
*
|
||||
* @param toolName - The full tool name (e.g., 'mcp__slack__read_channel')
|
||||
* @returns Object with serverName and toolName, or undefined if not an MCP tool
|
||||
*/
|
||||
export function extractMcpToolDetails(toolName: string):
|
||||
| {
|
||||
serverName: AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS
|
||||
@@ -183,16 +38,13 @@ export function extractMcpToolDetails(toolName: string):
|
||||
return undefined
|
||||
}
|
||||
|
||||
// Format: mcp__<server>__<tool>
|
||||
const parts = toolName.split('__')
|
||||
if (parts.length < 3) {
|
||||
return undefined
|
||||
}
|
||||
|
||||
const serverName = parts[1]
|
||||
// Tool name may contain __ so rejoin remaining parts
|
||||
const mcpToolName = parts.slice(2).join('__')
|
||||
|
||||
if (!serverName || !mcpToolName) {
|
||||
return undefined
|
||||
}
|
||||
@@ -205,13 +57,6 @@ export function extractMcpToolDetails(toolName: string):
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract skill name from Skill tool input.
|
||||
*
|
||||
* @param toolName - The tool name (should be 'Skill')
|
||||
* @param input - The tool input containing the skill name
|
||||
* @returns The skill name if this is a Skill tool call, undefined otherwise
|
||||
*/
|
||||
export function extractSkillName(
|
||||
toolName: string,
|
||||
input: unknown,
|
||||
@@ -233,93 +78,14 @@ export function extractSkillName(
|
||||
return undefined
|
||||
}
|
||||
|
||||
const TOOL_INPUT_STRING_TRUNCATE_AT = 512
|
||||
const TOOL_INPUT_STRING_TRUNCATE_TO = 128
|
||||
const TOOL_INPUT_MAX_JSON_CHARS = 4 * 1024
|
||||
const TOOL_INPUT_MAX_COLLECTION_ITEMS = 20
|
||||
const TOOL_INPUT_MAX_DEPTH = 2
|
||||
|
||||
function truncateToolInputValue(value: unknown, depth = 0): unknown {
|
||||
if (typeof value === 'string') {
|
||||
if (value.length > TOOL_INPUT_STRING_TRUNCATE_AT) {
|
||||
return `${value.slice(0, TOOL_INPUT_STRING_TRUNCATE_TO)}…[${value.length} chars]`
|
||||
}
|
||||
return value
|
||||
}
|
||||
if (
|
||||
typeof value === 'number' ||
|
||||
typeof value === 'boolean' ||
|
||||
value === null ||
|
||||
value === undefined
|
||||
) {
|
||||
return value
|
||||
}
|
||||
if (depth >= TOOL_INPUT_MAX_DEPTH) {
|
||||
return '<nested>'
|
||||
}
|
||||
if (Array.isArray(value)) {
|
||||
const mapped = value
|
||||
.slice(0, TOOL_INPUT_MAX_COLLECTION_ITEMS)
|
||||
.map(v => truncateToolInputValue(v, depth + 1))
|
||||
if (value.length > TOOL_INPUT_MAX_COLLECTION_ITEMS) {
|
||||
mapped.push(`…[${value.length} items]`)
|
||||
}
|
||||
return mapped
|
||||
}
|
||||
if (typeof value === 'object') {
|
||||
const entries = Object.entries(value as Record<string, unknown>)
|
||||
// Skip internal marker keys (e.g. _simulatedSedEdit re-introduced by
|
||||
// SedEditPermissionRequest) so they don't leak into telemetry.
|
||||
.filter(([k]) => !k.startsWith('_'))
|
||||
const mapped = entries
|
||||
.slice(0, TOOL_INPUT_MAX_COLLECTION_ITEMS)
|
||||
.map(([k, v]) => [k, truncateToolInputValue(v, depth + 1)])
|
||||
if (entries.length > TOOL_INPUT_MAX_COLLECTION_ITEMS) {
|
||||
mapped.push(['…', `${entries.length} keys`])
|
||||
}
|
||||
return Object.fromEntries(mapped)
|
||||
}
|
||||
return String(value)
|
||||
}
|
||||
|
||||
/**
|
||||
* Serialize a tool's input arguments for the OTel tool_result event.
|
||||
* Truncates long strings and deep nesting to keep the output bounded while
|
||||
* preserving forensically useful fields like file paths, URLs, and MCP args.
|
||||
* Returns undefined when OTEL_LOG_TOOL_DETAILS is not enabled.
|
||||
*/
|
||||
export function extractToolInputForTelemetry(
|
||||
input: unknown,
|
||||
_input: unknown,
|
||||
): string | undefined {
|
||||
if (!isToolDetailsLoggingEnabled()) {
|
||||
return undefined
|
||||
}
|
||||
const truncated = truncateToolInputValue(input)
|
||||
let json = jsonStringify(truncated)
|
||||
if (json.length > TOOL_INPUT_MAX_JSON_CHARS) {
|
||||
json = json.slice(0, TOOL_INPUT_MAX_JSON_CHARS) + '…[truncated]'
|
||||
}
|
||||
return json
|
||||
}
|
||||
|
||||
/**
|
||||
* Maximum length for file extensions to be logged.
|
||||
* Extensions longer than this are considered potentially sensitive
|
||||
* (e.g., hash-based filenames like "key-hash-abcd-123-456") and
|
||||
* will be replaced with 'other'.
|
||||
*/
|
||||
const MAX_FILE_EXTENSION_LENGTH = 10
|
||||
|
||||
/**
|
||||
* Extracts and sanitizes a file extension for analytics logging.
|
||||
*
|
||||
* Uses Node's path.extname for reliable cross-platform extension extraction.
|
||||
* Returns 'other' for extensions exceeding MAX_FILE_EXTENSION_LENGTH to avoid
|
||||
* logging potentially sensitive data (like hash-based filenames).
|
||||
*
|
||||
* @param filePath - The file path to extract the extension from
|
||||
* @returns The sanitized extension, 'other' for long extensions, or undefined if no extension
|
||||
*/
|
||||
export function getFileExtensionForAnalytics(
|
||||
filePath: string,
|
||||
): AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS | undefined {
|
||||
@@ -328,7 +94,7 @@ export function getFileExtensionForAnalytics(
|
||||
return undefined
|
||||
}
|
||||
|
||||
const extension = ext.slice(1) // remove leading dot
|
||||
const extension = ext.slice(1)
|
||||
if (extension.length > MAX_FILE_EXTENSION_LENGTH) {
|
||||
return 'other' as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS
|
||||
}
|
||||
@@ -336,7 +102,6 @@ export function getFileExtensionForAnalytics(
|
||||
return extension as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS
|
||||
}
|
||||
|
||||
/** Allow list of commands we extract file extensions from. */
|
||||
const FILE_COMMANDS = new Set([
|
||||
'rm',
|
||||
'mv',
|
||||
@@ -357,23 +122,16 @@ const FILE_COMMANDS = new Set([
|
||||
'sed',
|
||||
])
|
||||
|
||||
/** Regex to split bash commands on compound operators (&&, ||, ;, |). */
|
||||
const COMPOUND_OPERATOR_REGEX = /\s*(?:&&|\|\||[;|])\s*/
|
||||
|
||||
/** Regex to split on whitespace. */
|
||||
const WHITESPACE_REGEX = /\s+/
|
||||
|
||||
/**
|
||||
* Extracts file extensions from a bash command for analytics.
|
||||
* Best-effort: splits on operators and whitespace, extracts extensions
|
||||
* from non-flag args of allowed commands. No heavy shell parsing needed
|
||||
* because grep patterns and sed scripts rarely resemble file extensions.
|
||||
*/
|
||||
export function getFileExtensionsFromBashCommand(
|
||||
command: string,
|
||||
simulatedSedEditFilePath?: string,
|
||||
): AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS | undefined {
|
||||
if (!command.includes('.') && !simulatedSedEditFilePath) return undefined
|
||||
if (!command.includes('.') && !simulatedSedEditFilePath) {
|
||||
return undefined
|
||||
}
|
||||
|
||||
let result: string | undefined
|
||||
const seen = new Set<string>()
|
||||
@@ -398,7 +156,7 @@ export function getFileExtensionsFromBashCommand(
|
||||
|
||||
for (let i = 1; i < tokens.length; i++) {
|
||||
const arg = tokens[i]!
|
||||
if (arg.charCodeAt(0) === 45 /* - */) continue
|
||||
if (arg.charCodeAt(0) === 45) continue
|
||||
const ext = getFileExtensionForAnalytics(arg)
|
||||
if (ext && !seen.has(ext)) {
|
||||
seen.add(ext)
|
||||
@@ -407,567 +165,8 @@ export function getFileExtensionsFromBashCommand(
|
||||
}
|
||||
}
|
||||
|
||||
if (!result) return undefined
|
||||
return result as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS
|
||||
}
|
||||
|
||||
/**
|
||||
* Environment context metadata
|
||||
*/
|
||||
export type EnvContext = {
|
||||
platform: string
|
||||
platformRaw: string
|
||||
arch: string
|
||||
nodeVersion: string
|
||||
terminal: string | null
|
||||
packageManagers: string
|
||||
runtimes: string
|
||||
isRunningWithBun: boolean
|
||||
isCi: boolean
|
||||
isClaubbit: boolean
|
||||
isClaudeCodeRemote: boolean
|
||||
isLocalAgentMode: boolean
|
||||
isConductor: boolean
|
||||
remoteEnvironmentType?: string
|
||||
coworkerType?: string
|
||||
claudeCodeContainerId?: string
|
||||
claudeCodeRemoteSessionId?: string
|
||||
tags?: string
|
||||
isGithubAction: boolean
|
||||
isClaudeCodeAction: boolean
|
||||
isClaudeAiAuth: boolean
|
||||
version: string
|
||||
versionBase?: string
|
||||
buildTime: string
|
||||
deploymentEnvironment: string
|
||||
githubEventName?: string
|
||||
githubActionsRunnerEnvironment?: string
|
||||
githubActionsRunnerOs?: string
|
||||
githubActionRef?: string
|
||||
wslVersion?: string
|
||||
linuxDistroId?: string
|
||||
linuxDistroVersion?: string
|
||||
linuxKernel?: string
|
||||
vcs?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Process metrics included with all analytics events.
|
||||
*/
|
||||
export type ProcessMetrics = {
|
||||
uptime: number
|
||||
rss: number
|
||||
heapTotal: number
|
||||
heapUsed: number
|
||||
external: number
|
||||
arrayBuffers: number
|
||||
constrainedMemory: number | undefined
|
||||
cpuUsage: NodeJS.CpuUsage
|
||||
cpuPercent: number | undefined
|
||||
}
|
||||
|
||||
/**
|
||||
* Core event metadata shared across all analytics systems
|
||||
*/
|
||||
export type EventMetadata = {
|
||||
model: string
|
||||
sessionId: string
|
||||
userType: string
|
||||
betas?: string
|
||||
envContext: EnvContext
|
||||
entrypoint?: string
|
||||
agentSdkVersion?: string
|
||||
isInteractive: string
|
||||
clientType: string
|
||||
processMetrics?: ProcessMetrics
|
||||
sweBenchRunId: string
|
||||
sweBenchInstanceId: string
|
||||
sweBenchTaskId: string
|
||||
// Swarm/team agent identification for analytics attribution
|
||||
agentId?: string // CLAUDE_CODE_AGENT_ID (format: agentName@teamName) or subagent UUID
|
||||
parentSessionId?: string // CLAUDE_CODE_PARENT_SESSION_ID (team lead's session)
|
||||
agentType?: 'teammate' | 'subagent' | 'standalone' // Distinguishes swarm teammates, Agent tool subagents, and standalone agents
|
||||
teamName?: string // Team name for swarm agents (from env var or AsyncLocalStorage)
|
||||
subscriptionType?: string // OAuth subscription tier (max, pro, enterprise, team)
|
||||
rh?: string // Hashed repo remote URL (first 16 chars of SHA256), for joining with server-side data
|
||||
kairosActive?: true // KAIROS assistant mode active (ant-only; set in main.tsx after gate check)
|
||||
skillMode?: 'discovery' | 'coach' | 'discovery_and_coach' // Which skill surfacing mechanism(s) are gated on (ant-only; for BQ session segmentation)
|
||||
observerMode?: 'backseat' | 'skillcoach' | 'both' // Which observer classifiers are gated on (ant-only; for BQ cohort splits on tengu_backseat_* events)
|
||||
}
|
||||
|
||||
/**
|
||||
* Options for enriching event metadata
|
||||
*/
|
||||
export type EnrichMetadataOptions = {
|
||||
// Model to use, falls back to getMainLoopModel() if not provided
|
||||
model?: unknown
|
||||
// Explicit betas string (already joined)
|
||||
betas?: unknown
|
||||
// Additional metadata to include (optional)
|
||||
additionalMetadata?: Record<string, unknown>
|
||||
}
|
||||
|
||||
/**
|
||||
* Get agent identification for analytics.
|
||||
* Priority: AsyncLocalStorage context (subagents) > env vars (swarm teammates)
|
||||
*/
|
||||
function getAgentIdentification(): {
|
||||
agentId?: string
|
||||
parentSessionId?: string
|
||||
agentType?: 'teammate' | 'subagent' | 'standalone'
|
||||
teamName?: string
|
||||
} {
|
||||
// Check AsyncLocalStorage first (for subagents running in same process)
|
||||
const agentContext = getAgentContext()
|
||||
if (agentContext) {
|
||||
const result: ReturnType<typeof getAgentIdentification> = {
|
||||
agentId: agentContext.agentId,
|
||||
parentSessionId: agentContext.parentSessionId,
|
||||
agentType: agentContext.agentType,
|
||||
}
|
||||
if (agentContext.agentType === 'teammate') {
|
||||
result.teamName = agentContext.teamName
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
// Fall back to swarm helpers (for swarm agents)
|
||||
const agentId = getAgentId()
|
||||
const parentSessionId = getTeammateParentSessionId()
|
||||
const teamName = getTeamName()
|
||||
const isSwarmAgent = isTeammate()
|
||||
// For standalone agents (have agent ID but not a teammate), set agentType to 'standalone'
|
||||
const agentType = isSwarmAgent
|
||||
? ('teammate' as const)
|
||||
: agentId
|
||||
? ('standalone' as const)
|
||||
: undefined
|
||||
if (agentId || agentType || parentSessionId || teamName) {
|
||||
return {
|
||||
...(agentId ? { agentId } : {}),
|
||||
...(agentType ? { agentType } : {}),
|
||||
...(parentSessionId ? { parentSessionId } : {}),
|
||||
...(teamName ? { teamName } : {}),
|
||||
}
|
||||
}
|
||||
|
||||
// Check bootstrap state for parent session ID (e.g., plan mode -> implementation)
|
||||
const stateParentSessionId = getParentSessionIdFromState()
|
||||
if (stateParentSessionId) {
|
||||
return { parentSessionId: stateParentSessionId }
|
||||
}
|
||||
|
||||
return {}
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract base version from full version string. "2.0.36-dev.20251107.t174150.sha2709699" → "2.0.36-dev"
|
||||
*/
|
||||
const getVersionBase = memoize((): string | undefined => {
|
||||
const match = MACRO.VERSION.match(/^\d+\.\d+\.\d+(?:-[a-z]+)?/)
|
||||
return match ? match[0] : undefined
|
||||
})
|
||||
|
||||
/**
|
||||
* Builds the environment context object
|
||||
*/
|
||||
const buildEnvContext = memoize(async (): Promise<EnvContext> => {
|
||||
const [packageManagers, runtimes, linuxDistroInfo, vcs] = await Promise.all([
|
||||
env.getPackageManagers(),
|
||||
env.getRuntimes(),
|
||||
getLinuxDistroInfo(),
|
||||
detectVcs(),
|
||||
])
|
||||
|
||||
return {
|
||||
platform: getHostPlatformForAnalytics(),
|
||||
// Raw process.platform so freebsd/openbsd/aix/sunos are visible in BQ.
|
||||
// getHostPlatformForAnalytics() buckets those into 'linux'; here we want
|
||||
// the truth. CLAUDE_CODE_HOST_PLATFORM still overrides for container/remote.
|
||||
platformRaw: process.env.CLAUDE_CODE_HOST_PLATFORM || process.platform,
|
||||
arch: env.arch,
|
||||
nodeVersion: env.nodeVersion,
|
||||
terminal: envDynamic.terminal,
|
||||
packageManagers: packageManagers.join(','),
|
||||
runtimes: runtimes.join(','),
|
||||
isRunningWithBun: env.isRunningWithBun(),
|
||||
isCi: isEnvTruthy(process.env.CI),
|
||||
isClaubbit: isEnvTruthy(process.env.CLAUBBIT),
|
||||
isClaudeCodeRemote: isEnvTruthy(process.env.CLAUDE_CODE_REMOTE),
|
||||
isLocalAgentMode: process.env.CLAUDE_CODE_ENTRYPOINT === 'local-agent',
|
||||
isConductor: env.isConductor(),
|
||||
...(process.env.CLAUDE_CODE_REMOTE_ENVIRONMENT_TYPE && {
|
||||
remoteEnvironmentType: process.env.CLAUDE_CODE_REMOTE_ENVIRONMENT_TYPE,
|
||||
}),
|
||||
// Gated by feature flag to prevent leaking "coworkerType" string in external builds
|
||||
...(feature('COWORKER_TYPE_TELEMETRY')
|
||||
? process.env.CLAUDE_CODE_COWORKER_TYPE
|
||||
? { coworkerType: process.env.CLAUDE_CODE_COWORKER_TYPE }
|
||||
: {}
|
||||
: {}),
|
||||
...(process.env.CLAUDE_CODE_CONTAINER_ID && {
|
||||
claudeCodeContainerId: process.env.CLAUDE_CODE_CONTAINER_ID,
|
||||
}),
|
||||
...(process.env.CLAUDE_CODE_REMOTE_SESSION_ID && {
|
||||
claudeCodeRemoteSessionId: process.env.CLAUDE_CODE_REMOTE_SESSION_ID,
|
||||
}),
|
||||
...(process.env.CLAUDE_CODE_TAGS && {
|
||||
tags: process.env.CLAUDE_CODE_TAGS,
|
||||
}),
|
||||
isGithubAction: isEnvTruthy(process.env.GITHUB_ACTIONS),
|
||||
isClaudeCodeAction: isEnvTruthy(process.env.CLAUDE_CODE_ACTION),
|
||||
isClaudeAiAuth: isClaudeAISubscriber(),
|
||||
version: MACRO.VERSION,
|
||||
versionBase: getVersionBase(),
|
||||
buildTime: MACRO.BUILD_TIME,
|
||||
deploymentEnvironment: env.detectDeploymentEnvironment(),
|
||||
...(isEnvTruthy(process.env.GITHUB_ACTIONS) && {
|
||||
githubEventName: process.env.GITHUB_EVENT_NAME,
|
||||
githubActionsRunnerEnvironment: process.env.RUNNER_ENVIRONMENT,
|
||||
githubActionsRunnerOs: process.env.RUNNER_OS,
|
||||
githubActionRef: process.env.GITHUB_ACTION_PATH?.includes(
|
||||
'claude-code-action/',
|
||||
)
|
||||
? process.env.GITHUB_ACTION_PATH.split('claude-code-action/')[1]
|
||||
: undefined,
|
||||
}),
|
||||
...(getWslVersion() && { wslVersion: getWslVersion() }),
|
||||
...(linuxDistroInfo ?? {}),
|
||||
...(vcs.length > 0 ? { vcs: vcs.join(',') } : {}),
|
||||
}
|
||||
})
|
||||
|
||||
// --
|
||||
// CPU% delta tracking — inherently process-global, same pattern as logBatch/flushTimer in datadog.ts
|
||||
let prevCpuUsage: NodeJS.CpuUsage | null = null
|
||||
let prevWallTimeMs: number | null = null
|
||||
|
||||
/**
|
||||
* Builds process metrics object for all users.
|
||||
*/
|
||||
function buildProcessMetrics(): ProcessMetrics | undefined {
|
||||
try {
|
||||
const mem = process.memoryUsage()
|
||||
const cpu = process.cpuUsage()
|
||||
const now = Date.now()
|
||||
|
||||
let cpuPercent: number | undefined
|
||||
if (prevCpuUsage && prevWallTimeMs) {
|
||||
const wallDeltaMs = now - prevWallTimeMs
|
||||
if (wallDeltaMs > 0) {
|
||||
const userDeltaUs = cpu.user - prevCpuUsage.user
|
||||
const systemDeltaUs = cpu.system - prevCpuUsage.system
|
||||
cpuPercent =
|
||||
((userDeltaUs + systemDeltaUs) / (wallDeltaMs * 1000)) * 100
|
||||
}
|
||||
}
|
||||
prevCpuUsage = cpu
|
||||
prevWallTimeMs = now
|
||||
|
||||
return {
|
||||
uptime: process.uptime(),
|
||||
rss: mem.rss,
|
||||
heapTotal: mem.heapTotal,
|
||||
heapUsed: mem.heapUsed,
|
||||
external: mem.external,
|
||||
arrayBuffers: mem.arrayBuffers,
|
||||
// eslint-disable-next-line eslint-plugin-n/no-unsupported-features/node-builtins
|
||||
constrainedMemory: process.constrainedMemory(),
|
||||
cpuUsage: cpu,
|
||||
cpuPercent,
|
||||
}
|
||||
} catch {
|
||||
if (!result) {
|
||||
return undefined
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get core event metadata shared across all analytics systems.
|
||||
*
|
||||
* This function collects environment, runtime, and context information
|
||||
* that should be included with all analytics events.
|
||||
*
|
||||
* @param options - Configuration options
|
||||
* @returns Promise resolving to enriched metadata object
|
||||
*/
|
||||
export async function getEventMetadata(
|
||||
options: EnrichMetadataOptions = {},
|
||||
): Promise<EventMetadata> {
|
||||
const model = options.model ? String(options.model) : getMainLoopModel()
|
||||
const betas =
|
||||
typeof options.betas === 'string'
|
||||
? options.betas
|
||||
: getModelBetas(model).join(',')
|
||||
const [envContext, repoRemoteHash] = await Promise.all([
|
||||
buildEnvContext(),
|
||||
getRepoRemoteHash(),
|
||||
])
|
||||
const processMetrics = buildProcessMetrics()
|
||||
|
||||
const metadata: EventMetadata = {
|
||||
model,
|
||||
sessionId: getSessionId(),
|
||||
userType: process.env.USER_TYPE || '',
|
||||
...(betas.length > 0 ? { betas: betas } : {}),
|
||||
envContext,
|
||||
...(process.env.CLAUDE_CODE_ENTRYPOINT && {
|
||||
entrypoint: process.env.CLAUDE_CODE_ENTRYPOINT,
|
||||
}),
|
||||
...(process.env.CLAUDE_AGENT_SDK_VERSION && {
|
||||
agentSdkVersion: process.env.CLAUDE_AGENT_SDK_VERSION,
|
||||
}),
|
||||
isInteractive: String(getIsInteractive()),
|
||||
clientType: getClientType(),
|
||||
...(processMetrics && { processMetrics }),
|
||||
sweBenchRunId: process.env.SWE_BENCH_RUN_ID || '',
|
||||
sweBenchInstanceId: process.env.SWE_BENCH_INSTANCE_ID || '',
|
||||
sweBenchTaskId: process.env.SWE_BENCH_TASK_ID || '',
|
||||
// Swarm/team agent identification
|
||||
// Priority: AsyncLocalStorage context (subagents) > env vars (swarm teammates)
|
||||
...getAgentIdentification(),
|
||||
// Subscription tier for DAU-by-tier analytics
|
||||
...(getSubscriptionType() && {
|
||||
subscriptionType: getSubscriptionType()!,
|
||||
}),
|
||||
// Assistant mode tag — lives outside memoized buildEnvContext() because
|
||||
// setKairosActive() runs at main.tsx:~1648, after the first event may
|
||||
// have already fired and memoized the env. Read fresh per-event instead.
|
||||
...(feature('KAIROS') && getKairosActive()
|
||||
? { kairosActive: true as const }
|
||||
: {}),
|
||||
// Repo remote hash for joining with server-side repo bundle data
|
||||
...(repoRemoteHash && { rh: repoRemoteHash }),
|
||||
}
|
||||
|
||||
return metadata
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Core event metadata for 1P event logging (snake_case format).
|
||||
*/
|
||||
export type FirstPartyEventLoggingCoreMetadata = {
|
||||
session_id: string
|
||||
model: string
|
||||
user_type: string
|
||||
betas?: string
|
||||
entrypoint?: string
|
||||
agent_sdk_version?: string
|
||||
is_interactive: boolean
|
||||
client_type: string
|
||||
swe_bench_run_id?: string
|
||||
swe_bench_instance_id?: string
|
||||
swe_bench_task_id?: string
|
||||
// Swarm/team agent identification
|
||||
agent_id?: string
|
||||
parent_session_id?: string
|
||||
agent_type?: 'teammate' | 'subagent' | 'standalone'
|
||||
team_name?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Complete event logging metadata format for 1P events.
|
||||
*/
|
||||
export type FirstPartyEventLoggingMetadata = {
|
||||
env: EnvironmentMetadata
|
||||
process?: string
|
||||
// auth is a top-level field on ClaudeCodeInternalEvent (proto PublicApiAuth).
|
||||
// account_id is intentionally omitted — only UUID fields are populated client-side.
|
||||
auth?: PublicApiAuth
|
||||
// core fields correspond to the top level of ClaudeCodeInternalEvent.
|
||||
// They get directly exported to their individual columns in the BigQuery tables
|
||||
core: FirstPartyEventLoggingCoreMetadata
|
||||
// additional fields are populated in the additional_metadata field of the
|
||||
// ClaudeCodeInternalEvent proto. Includes but is not limited to information
|
||||
// that differs by event type.
|
||||
additional: Record<string, unknown>
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert metadata to 1P event logging format (snake_case fields).
|
||||
*
|
||||
* The /api/event_logging/batch endpoint expects snake_case field names
|
||||
* for environment and core metadata.
|
||||
*
|
||||
* @param metadata - Core event metadata
|
||||
* @param additionalMetadata - Additional metadata to include
|
||||
* @returns Metadata formatted for 1P event logging
|
||||
*/
|
||||
export function to1PEventFormat(
|
||||
metadata: EventMetadata,
|
||||
userMetadata: CoreUserData,
|
||||
additionalMetadata: Record<string, unknown> = {},
|
||||
): FirstPartyEventLoggingMetadata {
|
||||
const {
|
||||
envContext,
|
||||
processMetrics,
|
||||
rh,
|
||||
kairosActive,
|
||||
skillMode,
|
||||
observerMode,
|
||||
...coreFields
|
||||
} = metadata
|
||||
|
||||
// Convert envContext to snake_case.
|
||||
// IMPORTANT: env is typed as the proto-generated EnvironmentMetadata so that
|
||||
// adding a field here that the proto doesn't define is a compile error. The
|
||||
// generated toJSON() serializer silently drops unknown keys — a hand-written
|
||||
// parallel type previously let #11318, #13924, #19448, and coworker_type all
|
||||
// ship fields that never reached BQ.
|
||||
// Adding a field? Update the monorepo proto first (go/cc-logging):
|
||||
// event_schemas/.../claude_code/v1/claude_code_internal_event.proto
|
||||
// then run `bun run generate:proto` here.
|
||||
const env: EnvironmentMetadata = {
|
||||
platform: envContext.platform,
|
||||
platform_raw: envContext.platformRaw,
|
||||
arch: envContext.arch,
|
||||
node_version: envContext.nodeVersion,
|
||||
terminal: envContext.terminal || 'unknown',
|
||||
package_managers: envContext.packageManagers,
|
||||
runtimes: envContext.runtimes,
|
||||
is_running_with_bun: envContext.isRunningWithBun,
|
||||
is_ci: envContext.isCi,
|
||||
is_claubbit: envContext.isClaubbit,
|
||||
is_claude_code_remote: envContext.isClaudeCodeRemote,
|
||||
is_local_agent_mode: envContext.isLocalAgentMode,
|
||||
is_conductor: envContext.isConductor,
|
||||
is_github_action: envContext.isGithubAction,
|
||||
is_claude_code_action: envContext.isClaudeCodeAction,
|
||||
is_claude_ai_auth: envContext.isClaudeAiAuth,
|
||||
version: envContext.version,
|
||||
build_time: envContext.buildTime,
|
||||
deployment_environment: envContext.deploymentEnvironment,
|
||||
}
|
||||
|
||||
// Add optional env fields
|
||||
if (envContext.remoteEnvironmentType) {
|
||||
env.remote_environment_type = envContext.remoteEnvironmentType
|
||||
}
|
||||
if (feature('COWORKER_TYPE_TELEMETRY') && envContext.coworkerType) {
|
||||
env.coworker_type = envContext.coworkerType
|
||||
}
|
||||
if (envContext.claudeCodeContainerId) {
|
||||
env.claude_code_container_id = envContext.claudeCodeContainerId
|
||||
}
|
||||
if (envContext.claudeCodeRemoteSessionId) {
|
||||
env.claude_code_remote_session_id = envContext.claudeCodeRemoteSessionId
|
||||
}
|
||||
if (envContext.tags) {
|
||||
env.tags = envContext.tags
|
||||
.split(',')
|
||||
.map(t => t.trim())
|
||||
.filter(Boolean)
|
||||
}
|
||||
if (envContext.githubEventName) {
|
||||
env.github_event_name = envContext.githubEventName
|
||||
}
|
||||
if (envContext.githubActionsRunnerEnvironment) {
|
||||
env.github_actions_runner_environment =
|
||||
envContext.githubActionsRunnerEnvironment
|
||||
}
|
||||
if (envContext.githubActionsRunnerOs) {
|
||||
env.github_actions_runner_os = envContext.githubActionsRunnerOs
|
||||
}
|
||||
if (envContext.githubActionRef) {
|
||||
env.github_action_ref = envContext.githubActionRef
|
||||
}
|
||||
if (envContext.wslVersion) {
|
||||
env.wsl_version = envContext.wslVersion
|
||||
}
|
||||
if (envContext.linuxDistroId) {
|
||||
env.linux_distro_id = envContext.linuxDistroId
|
||||
}
|
||||
if (envContext.linuxDistroVersion) {
|
||||
env.linux_distro_version = envContext.linuxDistroVersion
|
||||
}
|
||||
if (envContext.linuxKernel) {
|
||||
env.linux_kernel = envContext.linuxKernel
|
||||
}
|
||||
if (envContext.vcs) {
|
||||
env.vcs = envContext.vcs
|
||||
}
|
||||
if (envContext.versionBase) {
|
||||
env.version_base = envContext.versionBase
|
||||
}
|
||||
|
||||
// Convert core fields to snake_case
|
||||
const core: FirstPartyEventLoggingCoreMetadata = {
|
||||
session_id: coreFields.sessionId,
|
||||
model: coreFields.model,
|
||||
user_type: coreFields.userType,
|
||||
is_interactive: coreFields.isInteractive === 'true',
|
||||
client_type: coreFields.clientType,
|
||||
}
|
||||
|
||||
// Add other core fields
|
||||
if (coreFields.betas) {
|
||||
core.betas = coreFields.betas
|
||||
}
|
||||
if (coreFields.entrypoint) {
|
||||
core.entrypoint = coreFields.entrypoint
|
||||
}
|
||||
if (coreFields.agentSdkVersion) {
|
||||
core.agent_sdk_version = coreFields.agentSdkVersion
|
||||
}
|
||||
if (coreFields.sweBenchRunId) {
|
||||
core.swe_bench_run_id = coreFields.sweBenchRunId
|
||||
}
|
||||
if (coreFields.sweBenchInstanceId) {
|
||||
core.swe_bench_instance_id = coreFields.sweBenchInstanceId
|
||||
}
|
||||
if (coreFields.sweBenchTaskId) {
|
||||
core.swe_bench_task_id = coreFields.sweBenchTaskId
|
||||
}
|
||||
// Swarm/team agent identification
|
||||
if (coreFields.agentId) {
|
||||
core.agent_id = coreFields.agentId
|
||||
}
|
||||
if (coreFields.parentSessionId) {
|
||||
core.parent_session_id = coreFields.parentSessionId
|
||||
}
|
||||
if (coreFields.agentType) {
|
||||
core.agent_type = coreFields.agentType
|
||||
}
|
||||
if (coreFields.teamName) {
|
||||
core.team_name = coreFields.teamName
|
||||
}
|
||||
|
||||
// Map userMetadata to output fields.
|
||||
// Based on src/utils/user.ts getUser(), but with fields present in other
|
||||
// parts of ClaudeCodeInternalEvent deduplicated.
|
||||
// Convert camelCase GitHubActionsMetadata to snake_case for 1P API
|
||||
// Note: github_actions_metadata is placed inside env (EnvironmentMetadata)
|
||||
// rather than at the top level of ClaudeCodeInternalEvent
|
||||
if (userMetadata.githubActionsMetadata) {
|
||||
const ghMeta = userMetadata.githubActionsMetadata
|
||||
env.github_actions_metadata = {
|
||||
actor_id: ghMeta.actorId,
|
||||
repository_id: ghMeta.repositoryId,
|
||||
repository_owner_id: ghMeta.repositoryOwnerId,
|
||||
}
|
||||
}
|
||||
|
||||
let auth: PublicApiAuth | undefined
|
||||
if (userMetadata.accountUuid || userMetadata.organizationUuid) {
|
||||
auth = {
|
||||
account_uuid: userMetadata.accountUuid,
|
||||
organization_uuid: userMetadata.organizationUuid,
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
env,
|
||||
...(processMetrics && {
|
||||
process: Buffer.from(jsonStringify(processMetrics)).toString('base64'),
|
||||
}),
|
||||
...(auth && { auth }),
|
||||
core,
|
||||
additional: {
|
||||
...(rh && { rh }),
|
||||
...(kairosActive && { is_assistant_mode: true }),
|
||||
...(skillMode && { skill_mode: skillMode }),
|
||||
...(observerMode && { observer_mode: observerMode }),
|
||||
...additionalMetadata,
|
||||
},
|
||||
}
|
||||
return result as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS
|
||||
}
|
||||
|
||||
@@ -23,10 +23,6 @@ function logEventAsyncImpl(
|
||||
return Promise.resolve()
|
||||
}
|
||||
|
||||
export function initializeAnalyticsGates(): void {
|
||||
return
|
||||
}
|
||||
|
||||
export function initializeAnalyticsSink(): void {
|
||||
attachAnalyticsSink({
|
||||
logEvent: logEventImpl,
|
||||
|
||||
@@ -1,25 +0,0 @@
|
||||
import { getDynamicConfig_CACHED_MAY_BE_STALE } from './growthbook.js'
|
||||
|
||||
// Mangled name: per-sink analytics killswitch
|
||||
const SINK_KILLSWITCH_CONFIG_NAME = 'tengu_frond_boric'
|
||||
|
||||
export type SinkName = 'datadog' | 'firstParty'
|
||||
|
||||
/**
|
||||
* GrowthBook JSON config that disables individual analytics sinks.
|
||||
* Shape: { datadog?: boolean, firstParty?: boolean }
|
||||
* A value of true for a key stops all dispatch to that sink.
|
||||
* Default {} (nothing killed). Fail-open: missing/malformed config = sink stays on.
|
||||
*
|
||||
* NOTE: Must NOT be called from inside is1PEventLoggingEnabled() -
|
||||
* growthbook.ts:isGrowthBookEnabled() calls that, so a lookup here would recurse.
|
||||
* Call at per-event dispatch sites instead.
|
||||
*/
|
||||
export function isSinkKilled(sink: SinkName): boolean {
|
||||
const config = getDynamicConfig_CACHED_MAY_BE_STALE<
|
||||
Partial<Record<SinkName, boolean>>
|
||||
>(SINK_KILLSWITCH_CONFIG_NAME, {})
|
||||
// getFeatureValue_CACHED_MAY_BE_STALE guards on `!== undefined`, so a
|
||||
// cached JSON null leaks through instead of falling back to {}.
|
||||
return config?.[sink] === true
|
||||
}
|
||||
@@ -1,159 +0,0 @@
|
||||
import axios from 'axios'
|
||||
import { hasProfileScope, isClaudeAISubscriber } from '../../utils/auth.js'
|
||||
import { getGlobalConfig, saveGlobalConfig } from '../../utils/config.js'
|
||||
import { logForDebugging } from '../../utils/debug.js'
|
||||
import { errorMessage } from '../../utils/errors.js'
|
||||
import { getAuthHeaders, withOAuth401Retry } from '../../utils/http.js'
|
||||
import { logError } from '../../utils/log.js'
|
||||
import { memoizeWithTTLAsync } from '../../utils/memoize.js'
|
||||
import { isEssentialTrafficOnly } from '../../utils/privacyLevel.js'
|
||||
import { getClaudeCodeUserAgent } from '../../utils/userAgent.js'
|
||||
|
||||
type MetricsEnabledResponse = {
|
||||
metrics_logging_enabled: boolean
|
||||
}
|
||||
|
||||
type MetricsStatus = {
|
||||
enabled: boolean
|
||||
hasError: boolean
|
||||
}
|
||||
|
||||
// In-memory TTL — dedupes calls within a single process
|
||||
const CACHE_TTL_MS = 60 * 60 * 1000
|
||||
|
||||
// Disk TTL — org settings rarely change. When disk cache is fresher than this,
|
||||
// we skip the network entirely (no background refresh). This is what collapses
|
||||
// N `claude -p` invocations into ~1 API call/day.
|
||||
const DISK_CACHE_TTL_MS = 24 * 60 * 60 * 1000
|
||||
|
||||
/**
|
||||
* Internal function to call the API and check if metrics are enabled
|
||||
* This is wrapped by memoizeWithTTLAsync to add caching behavior
|
||||
*/
|
||||
async function _fetchMetricsEnabled(): Promise<MetricsEnabledResponse> {
|
||||
const authResult = getAuthHeaders()
|
||||
if (authResult.error) {
|
||||
throw new Error(`Auth error: ${authResult.error}`)
|
||||
}
|
||||
|
||||
const headers = {
|
||||
'Content-Type': 'application/json',
|
||||
'User-Agent': getClaudeCodeUserAgent(),
|
||||
...authResult.headers,
|
||||
}
|
||||
|
||||
const endpoint = `https://api.anthropic.com/api/claude_code/organizations/metrics_enabled`
|
||||
const response = await axios.get<MetricsEnabledResponse>(endpoint, {
|
||||
headers,
|
||||
timeout: 5000,
|
||||
})
|
||||
return response.data
|
||||
}
|
||||
|
||||
async function _checkMetricsEnabledAPI(): Promise<MetricsStatus> {
|
||||
// Incident kill switch: skip the network call when nonessential traffic is disabled.
|
||||
// Returning enabled:false sheds load at the consumer (bigqueryExporter skips
|
||||
// export). Matches the non-subscriber early-return shape below.
|
||||
if (isEssentialTrafficOnly()) {
|
||||
return { enabled: false, hasError: false }
|
||||
}
|
||||
|
||||
try {
|
||||
const data = await withOAuth401Retry(_fetchMetricsEnabled, {
|
||||
also403Revoked: true,
|
||||
})
|
||||
|
||||
logForDebugging(
|
||||
`Metrics opt-out API response: enabled=${data.metrics_logging_enabled}`,
|
||||
)
|
||||
|
||||
return {
|
||||
enabled: data.metrics_logging_enabled,
|
||||
hasError: false,
|
||||
}
|
||||
} catch (error) {
|
||||
logForDebugging(
|
||||
`Failed to check metrics opt-out status: ${errorMessage(error)}`,
|
||||
)
|
||||
logError(error)
|
||||
return { enabled: false, hasError: true }
|
||||
}
|
||||
}
|
||||
|
||||
// Create memoized version with custom error handling
|
||||
const memoizedCheckMetrics = memoizeWithTTLAsync(
|
||||
_checkMetricsEnabledAPI,
|
||||
CACHE_TTL_MS,
|
||||
)
|
||||
|
||||
/**
|
||||
* Fetch (in-memory memoized) and persist to disk on change.
|
||||
* Errors are not persisted — a transient failure should not overwrite a
|
||||
* known-good disk value.
|
||||
*/
|
||||
async function refreshMetricsStatus(): Promise<MetricsStatus> {
|
||||
const result = await memoizedCheckMetrics()
|
||||
if (result.hasError) {
|
||||
return result
|
||||
}
|
||||
|
||||
const cached = getGlobalConfig().metricsStatusCache
|
||||
const unchanged = cached !== undefined && cached.enabled === result.enabled
|
||||
// Skip write when unchanged AND timestamp still fresh — avoids config churn
|
||||
// when concurrent callers race past a stale disk entry and all try to write.
|
||||
if (unchanged && Date.now() - cached.timestamp < DISK_CACHE_TTL_MS) {
|
||||
return result
|
||||
}
|
||||
|
||||
saveGlobalConfig(current => ({
|
||||
...current,
|
||||
metricsStatusCache: {
|
||||
enabled: result.enabled,
|
||||
timestamp: Date.now(),
|
||||
},
|
||||
}))
|
||||
return result
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if metrics are enabled for the current organization.
|
||||
*
|
||||
* Two-tier cache:
|
||||
* - Disk (24h TTL): survives process restarts. Fresh disk cache → zero network.
|
||||
* - In-memory (1h TTL): dedupes the background refresh within a process.
|
||||
*
|
||||
* The caller (bigqueryExporter) tolerates stale reads — a missed export or
|
||||
* an extra one during the 24h window is acceptable.
|
||||
*/
|
||||
export async function checkMetricsEnabled(): Promise<MetricsStatus> {
|
||||
// Service key OAuth sessions lack user:profile scope → would 403.
|
||||
// API key users (non-subscribers) fall through and use x-api-key auth.
|
||||
// This check runs before the disk read so we never persist auth-state-derived
|
||||
// answers — only real API responses go to disk. Otherwise a service-key
|
||||
// session would poison the cache for a later full-OAuth session.
|
||||
if (isClaudeAISubscriber() && !hasProfileScope()) {
|
||||
return { enabled: false, hasError: false }
|
||||
}
|
||||
|
||||
const cached = getGlobalConfig().metricsStatusCache
|
||||
if (cached) {
|
||||
if (Date.now() - cached.timestamp > DISK_CACHE_TTL_MS) {
|
||||
// saveGlobalConfig's fallback path (config.ts:731) can throw if both
|
||||
// locked and fallback writes fail — catch here so fire-and-forget
|
||||
// doesn't become an unhandled rejection.
|
||||
void refreshMetricsStatus().catch(logError)
|
||||
}
|
||||
return {
|
||||
enabled: cached.enabled,
|
||||
hasError: false,
|
||||
}
|
||||
}
|
||||
|
||||
// First-ever run on this machine: block on the network to populate disk.
|
||||
return refreshMetricsStatus()
|
||||
}
|
||||
|
||||
// Export for testing purposes only
|
||||
export const _clearMetricsEnabledCacheForTesting = (): void => {
|
||||
memoizedCheckMetrics.cache.clear()
|
||||
}
|
||||
@@ -1,13 +0,0 @@
|
||||
export function bootstrapTelemetry(): void {}
|
||||
|
||||
export function isTelemetryEnabled(): boolean {
|
||||
return false
|
||||
}
|
||||
|
||||
export async function initializeTelemetry(): Promise<null> {
|
||||
return null
|
||||
}
|
||||
|
||||
export async function flushTelemetry(): Promise<void> {
|
||||
return
|
||||
}
|
||||
@@ -12,17 +12,10 @@
|
||||
*/
|
||||
|
||||
import { createHash } from 'crypto'
|
||||
import { sep } from 'path'
|
||||
import {
|
||||
type AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
|
||||
type AnalyticsMetadata_I_VERIFIED_THIS_IS_PII_TAGGED,
|
||||
logEvent,
|
||||
} from '../../services/analytics/index.js'
|
||||
import type {
|
||||
LoadedPlugin,
|
||||
PluginError,
|
||||
PluginManifest,
|
||||
} from '../../types/plugin.js'
|
||||
import type { PluginManifest } from '../../types/plugin.js'
|
||||
import {
|
||||
isOfficialMarketplaceName,
|
||||
parsePluginIdentifier,
|
||||
@@ -80,17 +73,6 @@ export function getTelemetryPluginScope(
|
||||
return 'user-local'
|
||||
}
|
||||
|
||||
/**
|
||||
* How a plugin arrived in the session. Splits self-selected from org-pushed
|
||||
* — plugin_scope alone doesn't (an official plugin can be user-installed OR
|
||||
* org-pushed; both are scope='official').
|
||||
*/
|
||||
export type EnabledVia =
|
||||
| 'user-install'
|
||||
| 'org-policy'
|
||||
| 'default-enable'
|
||||
| 'seed-mount'
|
||||
|
||||
/** How a skill/command invocation was triggered. */
|
||||
export type InvocationTrigger =
|
||||
| 'user-slash'
|
||||
@@ -107,24 +89,6 @@ export type InstallSource =
|
||||
| 'ui-suggestion'
|
||||
| 'deep-link'
|
||||
|
||||
export function getEnabledVia(
|
||||
plugin: LoadedPlugin,
|
||||
managedNames: Set<string> | null,
|
||||
seedDirs: string[],
|
||||
): EnabledVia {
|
||||
if (plugin.isBuiltin) return 'default-enable'
|
||||
if (managedNames?.has(plugin.name)) return 'org-policy'
|
||||
// Trailing sep: /opt/plugins must not match /opt/plugins-extra
|
||||
if (
|
||||
seedDirs.some(dir =>
|
||||
plugin.path.startsWith(dir.endsWith(sep) ? dir : dir + sep),
|
||||
)
|
||||
) {
|
||||
return 'seed-mount'
|
||||
}
|
||||
return 'user-install'
|
||||
}
|
||||
|
||||
/**
|
||||
* Common plugin telemetry fields keyed off name@marketplace. Returns the
|
||||
* hash, scope enum, and the redacted-twin columns. Callers add the raw
|
||||
@@ -165,10 +129,7 @@ export function buildPluginTelemetryFields(
|
||||
|
||||
/**
|
||||
* Per-invocation callers (SkillTool, processSlashCommand) pass
|
||||
* managedNames=null — the session-level tengu_plugin_enabled_for_session
|
||||
* event carries the authoritative plugin_scope, and per-invocation rows can
|
||||
* join on plugin_id_hash to recover it. This keeps hot-path call sites free
|
||||
* of the extra settings read.
|
||||
* managedNames=null to keep hot-path call sites free of the extra settings read.
|
||||
*/
|
||||
export function buildPluginCommandTelemetryFields(
|
||||
pluginInfo: { pluginManifest: PluginManifest; repository: string },
|
||||
@@ -182,47 +143,6 @@ export function buildPluginCommandTelemetryFields(
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Emit tengu_plugin_enabled_for_session once per enabled plugin at session
|
||||
* start. Supplements tengu_skill_loaded (which still fires per-skill) — use
|
||||
* this for plugin-level aggregates instead of DISTINCT-on-prefix hacks.
|
||||
* A plugin with 5 skills emits 5 skill_loaded rows but 1 of these.
|
||||
*/
|
||||
export function logPluginsEnabledForSession(
|
||||
plugins: LoadedPlugin[],
|
||||
managedNames: Set<string> | null,
|
||||
seedDirs: string[],
|
||||
): void {
|
||||
for (const plugin of plugins) {
|
||||
const { marketplace } = parsePluginIdentifier(plugin.repository)
|
||||
|
||||
logEvent('tengu_plugin_enabled_for_session', {
|
||||
_PROTO_plugin_name:
|
||||
plugin.name as AnalyticsMetadata_I_VERIFIED_THIS_IS_PII_TAGGED,
|
||||
...(marketplace && {
|
||||
_PROTO_marketplace_name:
|
||||
marketplace as AnalyticsMetadata_I_VERIFIED_THIS_IS_PII_TAGGED,
|
||||
}),
|
||||
...buildPluginTelemetryFields(plugin.name, marketplace, managedNames),
|
||||
enabled_via: getEnabledVia(
|
||||
plugin,
|
||||
managedNames,
|
||||
seedDirs,
|
||||
) as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
|
||||
skill_path_count:
|
||||
(plugin.skillsPath ? 1 : 0) + (plugin.skillsPaths?.length ?? 0),
|
||||
command_path_count:
|
||||
(plugin.commandsPath ? 1 : 0) + (plugin.commandsPaths?.length ?? 0),
|
||||
has_mcp: plugin.manifest.mcpServers !== undefined,
|
||||
has_hooks: plugin.hooksConfig !== undefined,
|
||||
...(plugin.manifest.version && {
|
||||
version: plugin.manifest
|
||||
.version as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
|
||||
}),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Bounded-cardinality error bucket for CLI plugin operation failures.
|
||||
* Maps free-form error messages to 5 stable categories so dashboard
|
||||
@@ -257,33 +177,3 @@ export function classifyPluginCommandError(
|
||||
}
|
||||
return 'unknown'
|
||||
}
|
||||
|
||||
/**
|
||||
* Emit tengu_plugin_load_failed once per error surfaced by session-start
|
||||
* plugin loading. Pairs with tengu_plugin_enabled_for_session so dashboards
|
||||
* can compute a load-success rate. PluginError.type is already a bounded
|
||||
* enum — use it directly as error_category.
|
||||
*/
|
||||
export function logPluginLoadErrors(
|
||||
errors: PluginError[],
|
||||
managedNames: Set<string> | null,
|
||||
): void {
|
||||
for (const err of errors) {
|
||||
const { name, marketplace } = parsePluginIdentifier(err.source)
|
||||
// Not all PluginError variants carry a plugin name (some have pluginId,
|
||||
// some are marketplace-level). Use the 'plugin' property if present,
|
||||
// fall back to the name parsed from err.source.
|
||||
const pluginName = 'plugin' in err && err.plugin ? err.plugin : name
|
||||
logEvent('tengu_plugin_load_failed', {
|
||||
error_category:
|
||||
err.type as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
|
||||
_PROTO_plugin_name:
|
||||
pluginName as AnalyticsMetadata_I_VERIFIED_THIS_IS_PII_TAGGED,
|
||||
...(marketplace && {
|
||||
_PROTO_marketplace_name:
|
||||
marketplace as AnalyticsMetadata_I_VERIFIED_THIS_IS_PII_TAGGED,
|
||||
}),
|
||||
...buildPluginTelemetryFields(pluginName, marketplace, managedNames),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,39 +0,0 @@
|
||||
import { getSkillToolCommands } from '../../commands.js'
|
||||
import {
|
||||
type AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
|
||||
type AnalyticsMetadata_I_VERIFIED_THIS_IS_PII_TAGGED,
|
||||
logEvent,
|
||||
} from '../../services/analytics/index.js'
|
||||
import { getCharBudget } from '../../tools/SkillTool/prompt.js'
|
||||
|
||||
/**
|
||||
* Logs a tengu_skill_loaded event for each skill available at session startup.
|
||||
* This enables analytics on which skills are available across sessions.
|
||||
*/
|
||||
export async function logSkillsLoaded(
|
||||
cwd: string,
|
||||
contextWindowTokens: number,
|
||||
): Promise<void> {
|
||||
const skills = await getSkillToolCommands(cwd)
|
||||
const skillBudget = getCharBudget(contextWindowTokens)
|
||||
|
||||
for (const skill of skills) {
|
||||
if (skill.type !== 'prompt') continue
|
||||
|
||||
logEvent('tengu_skill_loaded', {
|
||||
// _PROTO_skill_name routes to the privileged skill_name BQ column.
|
||||
// Unredacted names don't go in additional_metadata.
|
||||
_PROTO_skill_name:
|
||||
skill.name as AnalyticsMetadata_I_VERIFIED_THIS_IS_PII_TAGGED,
|
||||
skill_source:
|
||||
skill.source as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
|
||||
skill_loaded_from:
|
||||
skill.loadedFrom as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
|
||||
skill_budget: skillBudget,
|
||||
...(skill.kind && {
|
||||
skill_kind:
|
||||
skill.kind as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
|
||||
}),
|
||||
})
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user