Files
llm-wiki/raw/articles/claude-code-telemetry-audit-2026.md
2026-07-03 00:38:05 +09:00

38 KiB

source_url, ingested, sha256, discovered_from
source_url ingested sha256 discovered_from
https://gist.github.com/AdnaneKhan/7a2040bcdebdc923ef73a19f8831132d 2026-07-01 18c3e9f1df9f7496951e816227eaf08155f98c00ae39744b586eff7f314a1026
platform channel_id channel_name message_id author_id posted_at message_excerpt
discord 1028287639918497822 chat 1521880360374374594 890908900520505354 2026-07-01T14:09:13.083000000Z Direct #chat link from toymaker to a Claude Code 2.1.196 telemetry, analytics, and error-reporting audit.

Claude Code 2.1.196 — Telemetry / Analytics / Error Reporting Audit

Source: /tmp/claude-2.1.196.bundle.js (18,057,941 bytes, 33,502 lines, minified CJS). Build: VERSION=2.1.196, BUILD_TIME=2026-06-29T00:53:27Z, GIT_SHA=a4ca500badcac68511fb5f04303e32e4360f3dfb.

TL;DR

  • There is no Statsig SDK and no Sentry SDK in this bundle. The "Statsig" hit at line 3541 and the "Sentry" hit at line 2846 are both documentation/prose strings (a permission-policy doc and an MCP upsell tip). Anthropic's public docs say "Statsig metrics + Sentry errors"; the 2.1.196 implementation has moved on. Feature flags are served by an internal service called ATIS (cached as cachedGrowthBookFeatures), and error reporting is Datadog RUM/error-tracking, not Sentry.
  • Telemetry splits into four outbound pipelines (one opt-in), all rooted in a single in-process sink (attachAnalyticsSink):
    1. 1P OTLP log events → https://api.anthropic.com/api/event_logging/v2/batch (the primary "Statsig-equivalent" metrics stream). 2. Datadog logs (events) → https://http-intake.logs.us5.datadoghq.com/api/v2/logs (hard-coded public DD key pubea5604404508cdd34afb69e6f42a05bc). 3. Datadog error tracking (RUM-style) → https://browser-intake-us5-datadoghq.com/api/v2/logs (same key, form-encoded, includes stack frames). 4. 3P OTLP (bring-your-own OTEL backend) — opt-in only, fires only if the user sets OTEL_EXPORTER_OTLP_* / BETA_TRACING_ENDPOINT.
  • 1,479 distinct tengu_* event names are instrumented (full product analytics: tool calls, modes, auto-mode decisions, advisor, adopt, chrome-bridge, api errors, etc.).
  • Opt-out matrix has one real hole. DISABLE_TELEMETRY=1 cleanly kills pipelines (1) and (3) but does not guard pipeline (2) (Datadog events) — that path is gated only by "is this a firstParty customer" + two server-side toggles. If Anthropic has turned on the tengu_log_datadog_events gate for an account, DISABLE_TELEMETRY will not stop it.
  • No prompt content, no file contents, no command history, and no shell snapshots are transmitted by any telemetry path. Identifiers are a persistent random user_id / machine_id, sessionId, account/org UUID, and — notably — a 16-char SHA-256 of the git remote URL (rh) attached to every 1P event.

1. The analytics core (sink plumbing)

The whole telemetry system is a small in-process event bus. Minified names below are shown with their de-obfuscated export aliases where available.

// createAnalyticsState / attachAnalyticsSink / logEvent  (bundle byte ~65500, line 12)
function lis() { return { eventQueue: [], sink: null }; }                 // createAnalyticsState
function _Er(e) {                                                        // attachAnalyticsSink (one sink only)
  let t = san;
  if (t.sink !== null) return;
  t.sink = e;
  if (t.eventQueue.length > 0) {
    let n = t.eventQueue; t.eventQueue = [];
    queueMicrotask(() => {
      for (let r of n)
        r.async ? e.logEventAsync(r.eventName, r.metadata)
                : e.logEvent(r.eventName, r.metadata);
    });
  }
}
function G(e, t)  { /* logEvent      */ let n = san; if (n.sink === null) { n.eventQueue.push({eventName:e, metadata:t, async:false}); return; } n.sink.logEvent(e, t); }
async function f_(e, t) { /* logEventAsync */ ... n.sink.logEventAsync(e, t); }

The concrete sink is attached by _We() (export: initializeAnalyticsSink):

// line 2025, byte ~7040000
function APp(e, t) {                                   // sink.logEvent
  if (Lho) { C(\`logEvent reentered ... dropped ${e}\`, {level:"error"}); return; }  // reentry guard
  Lho = true;
  try {
    let n = rIn(e);                                    // per-event sample rate (server-configured)
    if (n === 0) return;
    let r = n !== null ? { ...t, sample_rate:n } : t;
    if (Mho()) mmt(e, SQe(r));                         // --> Datadog events pipeline (2)
    Lit(e, r);                                         // --> 1P OTLP pipeline   (1)
  } finally { Lho = false; }
}
async function CPp(e, t) {                              // sink.logEventAsync
  let n = rIn(e); if (n === 0) return;
  let r = n !== null ? { ...t, sample_rate:n } : t;
  let o = [];
  if (Mho()) o.push(mmt(e, SQe(r)));                   // --> Datadog (2)
  o.push(BU(e, r));                                    // --> 1P OTLP (1), async variant
  await Promise.all(o);
}
function _We() { _Er({ logEvent: APp, logEventAsync: CPp }); }

_We() is called unconditionally from three sites: the computer-use MCP bootstrap (OPp), the chrome-bridge MCP bootstrap (Zdf), and the global initSinks() (Bjo, which also wires the error sink rjo). There is no DISABLE_TELEMETRY guard at attach time — gating happens inside each pipeline.

SQe (stripProtoFields) strips fields whose names start with _PROTO_ before any sink sees them — an internal "do not emit" marker.


2. Pipeline (1): 1P OTLP event logging (the "Statsig-equivalent")

Endpoint

// Bzr — OTLP log batch exporter, line 466, byte ~3365300
class Bzr {
  constructor(e = {}) {
    let t = e.baseUrl
        || (process.env.ANTHROPIC_BASE_URL === "https://api-staging.anthropic.com"
            ? "https://api-staging.anthropic.com" : "https://api.anthropic.com");
    this.endpoint = \`${t}${e.path || "/api/event_logging/v2/batch"}\`;
    this.timeout      = e.timeout      || 10000;
    this.maxBatchSize = e.maxBatchSize || 200;
    this.maxAttempts  = e.maxAttempts  ?? 8;
    this.skipAuth     = e.skipAuth     ?? false;
    this.isKilled     = e.isKilled     ?? (() => false);   // = () => uqe("firstParty")
    ...
  }
  getCurrentBatchFilePath() { return join(bBt(), \`${ZBi}.${It()}.${QBi}.json\`); }  // <cfg>/telemetry/...
}
function bBt() { return join(Zn(), "telemetry"); }   // persistence dir for failed batches
  • Endpoint: https://api.anthropic.com/api/event_logging/v2/batch (or staging). Path/baseUrl are overridable via the ATIS dynamic config tengu_1p_event_logging_config (oUi() reads it; keys path, baseUrl, skipAuth, maxAttempts, scheduledDelayMillis, maxExportBatchSize, maxQueueSize).
  • Sent as OTLP-shaped log records via a LoggerProvider + BatchLogRecordProcessor. Logger name: com.anthropic.claude_code.events. Resource attrs: service.name=claude-code, service.version=<VERSION>, optional wsl.version.
  • Retry/persistence: on export failure the batch is appended to <configDir>/telemetry/<hash>.<sessionId>.<uuid>.json on disk and retried (up to 8 attempts with backoff). These files are local artifacts but contain the same fields as the wire payload.
  • Server-side kill switch: isKilled = () => uqe("firstParty"), where uqe reads the dynamic config tengu_frond_boric (a category-keyed boolean map: firstParty, datadog, …).

Emit wrapper

// jzr — builds and emits one OTLP log record, line 470
async function jzr(e, t, n = {}) {
  try {
    let r = await eIn({ model:n.model, betas:n.betas });          // core_metadata  (see §6)
    let o = { event_name: t,
              event_id: $zr.randomUUID(),
              core_metadata: r,
              user_metadata: eit(true),                            // user_metadata (see §6)
              event_metadata: n };
    let s = x6(); if (s) o.user_id = s;                           // = deviceId
    let i = new Date;
    e.emit({ timestamp:i, observedTimestamp:i, body:t, attributes:o });
  } catch (r) {}
}
function Lit(e, t = {}) { if (!O6()) return;        // <--- master gate
                       if (!kne) { if (ZY !== null && ZY.length < sUi) ZY.push({eventName:e, metadata:t}); return; }
                       if (uqe("firstParty")) return;              // <--- server-side category kill
                       jzr(kne, e, t); }
async function BU(e, t = {}) { /* same, async */ }

Gates

  • O6() = is1PEventLoggingEnabled() = !V9().
  • V9() = cUd() || If() !== null || zge().
    • cUd() = if (CLAUDE_CODE_PROVIDER_MANAGED_BY_HOST) return false; return !kc(); — i.e. disabled unless firstParty (or host-managed).
      • If() = gateway URL (using a --gateway / ANTHROPIC_GATEWAY_URL setup) → disables 1P.
      • zge() = UAs() !== "default" (see §5).
  • uqe("firstParty") — server-side per-category kill from tengu_frond_boric.

So pipeline (1) is cleanly killed by: DISABLE_TELEMETRY, CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC, DO_NOT_TRACK=1, using any 3rd-party LLM provider (Bedrock/Vertex/Foundry/Mantle/AnthropicAWS), or going through a gateway. Confirmed: oIn() (the logger-provider initializer) early-returns if (!O6()) { ZY = null; return; } — when disabled, the provider is never even constructed.

Growthbook experiment exposure

A sibling emitter Wzr (logGrowthBookExperimentTo1P) fires an OTLP record with body: "growthbook_experiment" whenever a user is bucketed into an experiment:

attributes = {
  event_type: "GrowthbookExperimentEvent",
  event_id, experiment_id, variation_id,
  device_id: x6(),                         // deviceId
  account_uuid, organization_uuid,
  session_id, user_attributes: { appVersion },
  experiment_metadata, environment: "production"
}

Same gates as pipeline (1) (O6() + uqe("firstParty")). Disable via DISABLE_GROWTHBOOK env var as well (the rxu flag).


3. Pipeline (2): Datadog logs (feature events) — the gated-by-server-only one

// mmt — line 11004, byte ~14171286
async function mmt(e, t) {
  if (_r() !== "firstParty") return;                 // firstParty-only (no env-var check!)
  let n = stn; if (n === null) n = await gVo();      // fetch DD config (endpoint/key/flush)
  if (!n || !qdf.has(e)) return;                     // event must be in the allow-list
  try {
    let r = await eIn({ model:t.model, betas:t.betas }),
        { envContext:o, head_sha:s, ...i } = r;      // NOTE: strips envContext + head_sha
    let a = { ...i, ...o, ...t, userBucket: Vdf() };
    if (typeof a.toolName === "string" && a.toolName.startsWith("mcp__")) a.toolName = "mcp";
    if (typeof a.model === "string") {
      if (!a.model.toLowerCase().includes("claude")) return;
      let p = io(Ba(a.model)); a.model = p in F9e ? p : "other";
    }
    if (typeof a.version === "string") a.version = a.version.replace(/^(\d+\.\d+\.\d+-dev\.\d{8})\.t\d+\.sha[a-f0-9]+$/, "$1");
    if (a.status !== undefined && a.status !== null) {
      let p = String(a.status); a.http_status = p;
      let m = p.charAt(0); if (m >= "1" && m <= "5") a.http_status_range = \`${m}xx\`;
      delete a.status;
    }
    let c = a,
        d = { ddsource:"nodejs",
               ddtags:[\`event:${e}\`, ...jdf.filter(p => c[p] !== undefined && c[p] !== null)
                                       .map(p => \`${Tfc(p)}:${c[p]}\`)].join(","),
               message:e, service:"claude-code", hostname:"claude-code", env:"external" };
    for (let [p,m] of Object.entries(a)) if (m !== undefined && m !== null) d[Tfc(p)] = m;
    if (otn.push(d), otn.length >= Udf) { if (hRe) clearTimeout(hRe); hRe = null; hVo(); }   // flush at 100
    else Wdf();                                                                              // schedule 15s flush
  } catch (r) { Ie(r); }
}

var bfc = "https://http-intake.logs.us5.datadoghq.com/api/v2/logs";
var z4n = "pubea5604404508cdd34afb69e6f42a05bc";   // hard-coded DD *public* API key
var Bdf = 15000, Udf = 100, $df = 5000;            // flush interval / batch size / ...

Gates (the important part)

  • _r() === "firstParty" inside mmt.
  • Mho() at the call site in APp / CPp:
    var EPp = "tengu_log_datadog_events";
    function Mho() { if (uqe("datadog")) return false; try { return it(EPp, false); } catch { return false; } }
    
    • uqe("datadog") — server-side kill via tengu_frond_boric.datadog.
      • it("tengu_log_datadog_events", false) — GrowthBook gate, default off.
  • The allow-list qdf (~30 events): tengu_feature_ok/bad/sad, tengu_api_error/success/fallback_last_resort, tengu_auto_mode_*, chrome_bridge_*. Only these names go to Datadog; everything else is 1P-only.

There is no check of DISABLE_TELEMETRY, CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC, DO_NOT_TRACK, or O6() / zge() anywhere on this path. In practice the gate defaults off, so this is dormant unless Anthropic enables tengu_log_datadog_events for an account/cohort. But if they do, DISABLE_TELEMETRY=1 does not stop it — only the server-side kill switch (uqe("datadog")) or not being firstParty will. This is the single most noteworthy opt-out discrepancy in the bundle.

Notable field handling: this pipeline strips envContext and head_sha from core_metadata before sending (unlike pipeline 1), normalizes model names to a small enum (opus-4-8, sonnet-4-6, …, else "other"), collapses any mcp__* tool name to "mcp", and buckets the user via userBucket: Vdf() (a stable hash).


4. Pipeline (3): Datadog error tracking (RUM-style) — the "Sentry replacement"

There is no Sentry SDK in the bundle. Verified absent: @sentry/*, captureException, captureMessage, addBreadcrumb, beforeSend, sentry.io, any DSN URL. The single prose "Sentry" mention (line 2846) is in an MCP-upsell tip ("MCP connects Claude to … Sentry …").

Error capture flows through Ie(err) → Ste.logError(err) (singleton set by qAs):

// Ie — the public reportError, line 139
function Ie(e) {
  let t = er(e);
  try {
    if (ct(process.env.CLAUDE_CODE_USE_BEDROCK) || ct(process.env.CLAUDE_CODE_USE_VERTEX)
        || ct(process.env.CLAUDE_CODE_USE_FOUNDRY) || ct(process.env.CLAUDE_CODE_USE_ANTHROPIC_AWS)
        || ct(process.env.CLAUDE_CODE_USE_MANTLE) || process.env.DISABLE_ERROR_REPORTING || zi())
      return;
    let r = { error: t.stack || t.message, timestamp: new Date().toISOString() };
    if (_Nu(r), Ste === null) { zet.push({type:"error", error:t}); return; }
    Ste.logError(t);
  } catch {}
}

// Sink wired in initSinks (Bjo -> rjo), line 9053
function AZm(e) {                                   // logError
  pXi(e);                                           // -> Jc("internal_error", {error_name, error_code})  [pipeline 4 if configured]
  Wjt(e);                                           // -> Datadog error-tracking (this pipeline)
  let t = e.stack || e.message, n = "";
  if (mo.isAxiosError(e) && e.config?.url) {        // *** axios failures include url+status+body ***
    let r = [\`url=${e.config.url}\`];
    if (e.response?.status !== undefined) r.push(\`status=${e.response.status}\`);
    let o = EZm(e.response?.data); if (o) r.push(\`body=${o}\`);
    n = \`[${r.join(", ")}] \`;
  }
  C(\`${e.name}: ${n}${t}\`, {level:"error"});
  bZm(tjo(), { error: \`${n}${t}\` });                // bZm is a NO-OP (\`function bZm(e,t){return}\`) in this build
}

Wjt builds a Datadog error-tracking payload and batches it:

// BUa — gate for error tracking, line 2684
function BUa() {
  if (process.env.DISABLE_ERROR_REPORTING) return false;
  if (zge()) return false;                          // ANY non-default traffic mode disables this
  if (_r() !== "firstParty" || !bu()) return false; // firstParty + real anthropic base URL only
  if (!Y4n.gte(VERSION, <min-version>)) return false;
  ...
  return true;
}
function Wjt(e, t = "logError") {
  if (!BUa()) return;
  try {
    let n = er(e);
    if (t === "logError" && sBp(n)) return;                 // noisy-error blocklist
    if ((t === "unhandled_rejection" || t === "uncaught_exception") && oBp(n)) return;
    if (Eyo()) return;                                       // rate-limit / dedupe
    let r = eBp(n, t);                                       // build payload
    Ayo(r);                                                  // batch -> DD
  } catch {}
}

var NUa = "https://browser-intake-us5-datadoghq.com/api/v2/logs";
var wFp = 30000, xFp = 25, bft = 100;              // flush 30s / batch 25 / per-process cap 100
// IFp POSTs as URLSearchParams: ddsource=browser, dd-api-key=z4n, dd-evp-origin=browser,
//   dd-evp-origin-version=<VERSION>

What an error payload contains (eBp)

{
  ddtags: \`service:claude-code-error-tracking,team:claude-code,version:<v>,env:external,
           origin:<logError|unhandled_rejection|uncaught_exception>,platform:<wsl|darwin|...>,
           os_release:<x.y>,user_bucket:<hash>,entrypoint:<cli|sdk-cli|...>,
           node_version:<v>,bun_version:1.4.0,is_native_runtime:<bool>[,model:<m>][,error_code:<c>]
           [,session_kind:..][,has_attacher:..][,renderer_mode:..]\`,
  service: "claude-code-error-tracking",
  hostname: "claude-code",
  status: "error",
  message: "<ErrorName>: <redacted message>".slice(0, 4000),     // *** message is redacted (see below)
  timestamp,
  error: {
    kind: <ErrorName>,
    message: <redacted>.slice(0, 4000),
    stack: a.formatted.slice(0, 16000),                          // *** up to 16KB of stack trace
    fingerprint: u,                                              // dedupe hash
    handling: "handled" | "unhandled"
  },
  version, sourcemap_group: "darwin", env: "external",
  user_bucket, origin, host_platform, host_os_release,
  host_name_redacted: Gjt().slice(0, 12),                        // first 12 hex of machineID
  entrypoint, node_version, bun_version,
  ..., model ...,
  error_frames: a.frames.slice(0, 20),                           // top 20 frames {file, function, ...}
  feature_flags: QFp()                                           // current gate/experiment state
}

Redaction applied to messages and stacks

  • N3(msg) (line 1560, byte ~5421022): truncates to 4000 chars; rewrites ://user:pass@ → ://<userinfo>@; replaces git URLs containing credentials → <url>; then runs a chain of regex scrubbers (ahp, thp, shp, php, Xfp, Yfp, chp, lhp, dhp) that strip emails, IPs (v4/v6), phone numbers, and similar PII patterns.
  • fma(err, msg) (line 1560): if the error object has .path / .dest strings (FS tool errors), those literal paths are replaced with the token <path> in the message before redaction.
  • A quirky marker: error-name normalization strips a literal suffix _I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS (t2e(n.replace(/_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS$/, ""))) — an internal convention for dev-asserted "clean" error names.
  • host_name_redacted is the first 12 hex chars of the machineID — not the hostname.

Stack frames are sent as-is (top 20, capped at 16KB total). File paths in frames are not globally redacted (only err.path / err.dest -style values fed through fma). So a stack frame like at foo (/Users/<you>/secret-repo/file.js:12:3) will reach Datadog if it appears in the stack string. This is the main residual content-leak risk in the error path.

Gates (clean, unlike pipeline 2)

BUa() returns false if any of: DISABLE_ERROR_REPORTING, zge() (i.e. DISABLE_TELEMETRY / CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC / DO_NOT_TRACK), non-firstParty provider, non-anthropic base URL, or version below the floor. So error tracking is properly killed by both DISABLE_ERROR_REPORTING and the telemetry/non-essential env vars.


5. Pipeline (4): 3P OTLP (opt-in, user-configured)

Jc(eventName, attrs) is the 3P event logger. It emits OTLP-shaped records with body claude_code.<eventName> to whatever OTLP LoggerProvider the user configured via standard OTEL_EXPORTER_OTLP_* environment. If no 3P exporter is wired, events are dropped with a warn-level log:

async function Jc(e, t = {}) {
  let n = { ...j6e(), "event.name": e, "event.timestamp": new Date().toISOString(),
            "event.sequence": ZQd++ };
  let r = $Ue(); if (r) n["prompt.id"] = r;                                  // *** prompt correlation id ***
  if (process.env.CLAUDE_CODE_WORKSPACE_HOST_PATHS) n["workspace.host_paths"] = ...;
  for (let [l,c] of Object.entries(t)) if (c !== undefined) n[l] = c;
  let i = { timestamp:s, observedTimestamp:s, body:\`claude_code.${e}\`, attributes:n };
  let a = XSr();                                       // = Bt.eventLogger (set by Oin())
  if (a) { a.emit(i); return; }
  if (!QSr(i) && !uXi) uXi = true, C(\`[3P telemetry] Event dropped (no event logger initialized): ${e}\`, {level:"warn"});
}

function j6e() {                                       // common 3P attributes
  let e = x6(), t = It(), n = IMn(), r = Object.keys(n).length > 0, o = {};
  if (C$t("OTEL_METRICS_INCLUDE_RESOURCE_ATTRIBUTES"))
    for (let [i,a] of Object.entries(XQd(process.env.OTEL_RESOURCE_ATTRIBUTES))) {
      if (r && (i.startsWith("user.") || i.startsWith("identity."))) continue;   // respect identity attrs
      o[i] = a;
    }
  if (o["user.id"] = e, C$t("OTEL_METRICS_INCLUDE_SESSION_ID")) {
    if (o["session.id"] = t, process.env.CLAUDE_CODE_REMOTE_SESSION_ID) o["ccr.session.id"] = ...;
  }
  if (C$t("OTEL_METRICS_INCLUDE_VERSION")) o["app.version"] = VERSION;
  ...
}

Activation is explicit: the exporter is only constructed when the user sets OTEL_EXPORTER_OTLP_LOGS_PROTOCOL / OTEL_EXPORTER_OTLP_ENDPOINT (or BETA_TRACING_ENDPOINT for traces). Without those env vars, XSr() stays null and Jc drops events. pXi(err) (invoked from the error sink AZm) calls Jc("internal_error", {error_name, error_code}) — so internal-error summaries also flow here when configured.


6. Data fields sent (per pipeline)

core_metadata (eIn, line 466, attached to every 1P and Datadog event)

model, sessionId, userType:"external",
betas (comma-joined),
envContext: {                                  // = a2d() → flattened by XBi:
    platform, platform_raw, arch, node_version, terminal, shell,
    package_managers, runtimes, is_running_with_bun, is_ci, is_claubbit,
    is_claude_code_remote, is_local_agent_mode, is_conductor, is_github_action,
    is_claude_code_action, is_claude_ai_auth, version, build_time,
    deployment_environment, remote_environment_type, claude_code_container_id,
    claude_code_remote_session_id
},
entrypoint (CLAUDE_CODE_ENTRYPOINT),
sessionKind, hasAttacher,
agentSdkVersion (CLAUDE_AGENT_SDK_VERSION),
isInteractive, clientType,
processMetrics (cpu/memory),
sweBenchRunId/sweBenchInstanceId/sweBenchTaskId (env vars, usually empty),
subscriptionType, rateLimitTier,
rh,                                            // *** 16-char SHA-256 of normalized git remote URL (qfn) ***
head_sha  (ONLY if CLAUDE_CODE_ENABLE_FEEDBACK_SURVEY_FOR_OTEL is set),
rendererMode

rh derivation:

function qfn() { let e = await Vz(); if (!e) return null;       // Vz() = git remote.origin.url
                 let t = n_e(e); if (!t) return null;            // n_e: strip git@/https://user@, .git, lowercase
                 return createHash("sha256").update(t).digest("hex").substring(0, 16); }

i.e. rh = sha256("github.com/owner/repo").slice(0,16). A stable, per-repo identifier attached to every event. Not the literal URL, but correlatable across sessions and accounts.

user_metadata (eit, line 444)

deviceId (x6 — 32-byte hex, persisted across runs in the config file),
sessionId,
email: JLd() === undefined always,              // email collection is stubbed out in this build
appVersion, platform,
organizationUuid, accountUuid (from Nc() — claude.ai account),
userType:"external",
subscriptionType, rateLimitTier, firstTokenTime,
githubActionsMetadata { actor, actorId, repository, repositoryId,
                        repositoryOwner, repositoryOwnerId }   // *** only when GITHUB_ACTIONS=true ***

The GitHub-Actions block is worth calling out: when CC runs inside GitHub Actions, every event carries the actor handle, actor ID, full owner/repo string, repo numeric ID, and owner numeric ID. This is far more identifying than the other fields and is gated only by the same O6() master switch (so DISABLE_TELEMETRY kills it; nothing short of that does).

Identifiers in brief

  • user_id / deviceId = x6() = 32 random bytes hex, persisted in the local settings file (Ot().userID); lazily generated on first event.
  • machineID = Gjt() = 32 random bytes hex, persisted.
  • sessionId = It().
  • accountUuid / organizationUuid from the claude.ai account session (only when authenticated against api.anthropic.com).
  • No email is collected (JLd returns undefined).
  • No prompt content, file content, command history, or cwd path is sent by any pipeline. (cwd appears only in the local MCP-error/mcp-debug JSONL writers CZm / RZm, which write to disk under mcp-logs-<server>/, not over the network.)

7. Opt-out matrix (the deliverable)

Pipelines:

  • 1P = OTLP events to api.anthropic.com/api/event_logging/v2/batch (incl. Growthbook experiment exposures)
  • DD-EVT = Datadog logs to http-intake.logs.us5.datadoghq.com (events)
  • DD-ERR = Datadog error tracking to browser-intake-us5-datadoghq.com (errors + stacks)
  • 3P = user-configured OTLP backend (opt-in)
Env var / condition 1P (Lit/BU) DD-EVT (mmt) DD-ERR (Wjt) 3P (Jc) Notes
no env vars set (default firstParty) ON gated by tengu_log_datadog_events (default OFF) ON OFF (no exporter) normal operation
DISABLE_TELEMETRY=1 OFF still ON if DD-EVT gate is server-on OFF (via zge) unaffected hole
DISABLE_ERROR_REPORTING=1 unaffected unaffected OFF unaffected clean
CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC=1 OFF still ON if DD-EVT gate is server-on OFF unaffected hole (same as above)
DO_NOT_TRACK=1 OFF still ON if DD-EVT gate is server-on OFF unaffected hole
DISABLE_GROWTHBOOK=1 OFF (experiments only) unaffected unaffected unaffected
CLAUDE_CODE_USE_BEDROCK/VERTEX/FOUNDRY/MANTLE/ANTHROPIC_AWS=1 OFF (_r()!=firstParty) OFF (_r()!=firstParty) OFF unaffected all 1P/DD telemetry dies for 3rd-party LLM users
ANTHROPIC_BASE_URL to non-api.anthropic.com host OFF (!bu()) unaffected (still firstParty by _r) OFF (!bu()) unaffected
Going through --gateway (If()) OFF unaffected unaffected unaffected
Server-side tengu_frond_boric.firstParty=true OFF (extra kill) unaffected unaffected unaffected Anthropic-side
Server-side tengu_frond_boric.datadog=true unaffected OFF unaffected unaffected Anthropic-side DD-EVT kill
OTEL_EXPORTER_OTLP_ENDPOINT unset — — — OFF (opt-in) 3P stays dormant

The hole, precisely: DISABLE_TELEMETRY, CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC, and DO_NOT_TRACK all route through UAs() / zge(), which guards pipelines 1P and DD-ERR but not DD-EVT. DD-EVT is gated only by _r()==="firstParty" + Mho() (server toggle). So a firstParty user for whom Anthropic has enabled tengu_log_datadog_events will still emit Datadog feature events even with all three user-facing opt-out env vars set.

Traffic that is never gated by these env vars (by design — "essential")

  • POST https://api.anthropic.com/v1/messages (and /v1/messages?beta=...) — the LLM API itself.
  • https://api.anthropic.com/v1/sessions*, /v1/agents*, /v1/files*, /v1/environments*, /v1/deployments*, /v1/design/mcp — Claude platform API (sessions, agents, files, environments).
  • https://api.anthropic.com/api/oauth/claude_cli/* — CLI auth/OAuth (create_api_key, roles).
  • https://api.anthropic.com/api/web/domain_info — domain info lookup.
  • https://api.anthropic.com/api/claude_code/discovery/team_usage — team skills/MCP discovery (additionally gated by allow_team_discovery permission + tengu_team_discovery gate + Eo() logged-in).
  • https://claude.ai/oauth/claude-code-client-metadata, https://claude.ai/install.sh, https://downloads.claude.ai/claude-code-releases* — installer / OAuth metadata.
  • https://storage.googleapis.com/claude-code-dist-.../plugin-stats/plugin-details.json — plugin marketplace details.
  • Auto-updater hits to downloads.claude.ai/claude-code-releases (unless DISABLE_UPDATES / DISABLE_AUTOUPDATER).
  • Cloud-provider OAuth (oauth2.googleapis.com/token / tokeninfo / revoke, cloudresourcemanager.googleapis.com, aiplatform*.googleapis.com) — only when using Vertex.
  • No prompt-embedded steganography or watermarking was found. tengu_canary sounds suspicious but is just the native-installer update channel (a version string served from a GrowthBook config). watermark occurrences are all UI/screen-recording overlays. There is no code that injects tracking tokens into prompts or API request bodies.

8. Outbound network destinations referenced in the bundle (filtered to telemetry/analytics/auth/host infra)

Host / URL Purpose Gated by opt-out?
https://api.anthropic.com/api/event_logging/v2/batch 1P OTLP telemetry (pipeline 1) yes (DISABLE_TELEMETRY etc.)
https://http-intake.logs.us5.datadoghq.com/api/v2/logs Datadog feature events (pipeline 2) partial — server gate only, not user env vars
https://browser-intake-us5-datadoghq.com/api/v2/logs Datadog error tracking (pipeline 3) yes (DISABLE_ERROR_REPORTING and telemetry env vars)
user-configured OTLP endpoint (OTEL_EXPORTER_OTLP_ENDPOINT, BETA_TRACING_ENDPOINT) 3P telemetry (pipeline 4) n/a (opt-in)
https://api.anthropic.com/v1/messages, /v1/sessions, /v1/agents, /v1/files, /v1/environments, /v1/deployments, /v1/design/mcp, /api/web/domain_info, /api/oauth/claude_cli/*, /api/claude_code/discovery/team_usage Claude platform API / auth no (essential)
https://api-staging.anthropic.com staging variant of all the above no (essential when BASE_URL=staging)
https://mcp-proxy.anthropic.com MCP proxy no (essential when configured)
https://claude.ai, https://claude.ai/oauth/claude-code-client-metadata, https://downloads.claude.ai/claude-code-releases*, https://claude.ai/install.sh install / OAuth / onboarding no (essential)
https://storage.googleapis.com/claude-code-dist-86c565f3-f756-42ad-8dfa-d59b1c096819/plugin-stats/plugin-details.json plugin marketplace metadata fetch no
https://api.datadoghq.com/mcp, https://api.githubcopilot.com/mcp, https://mcp.sentry.dev/mcp, https://api.notion.com/v1/oauth/token, https://slack.com/api/oauth.v2.access, https://api.github.com, https://api.github.com/graphql, https://api.example.com/mcp, https://app.corridor.dev/api/mcp MCP server URLs — only hit if the user configures them as MCP servers; not automatic n/a
https://aiplatform.googleapis.com, https://oauth2.googleapis.com/*, https://cloudresourcemanager.googleapis.com/*, https://www.googleapis.com/oauth2/*, https://admin.googleapis.com/admin/directory/v1/groups Vertex AI / GCP OAuth only when CLAUDE_CODE_USE_VERTEX
https://status.anthropic.com, https://support.anthropic.com, https://www.anthropic.com/legal/*, https://docs.anthropic.com/..., https://platform.claude.com/docs/..., https://code.claude.com/docs/..., https://github.com/anthropics/* doc / status / legal links — opened in browser on demand, not hit by the runtime n/a

No hits for: statsigapi.net, featuregates.org, api.statsig.com, ingest.sentry.io, o*.ingest.sentry.io, amplitude.com, api.segment.io, api.mixpanel.com, track.posthog.com, api.rudderstack.com, insights.collector.newrelic.com. The only third-party analytics hosts are the two Datadog ones above.


9. "Undisclosed collection" assessment (vs Anthropic's public data-usage docs)

Anthropic's published docs say telemetry consists of "Statsig metrics" and "Sentry errors", explicitly excluding code contents and file paths. Compared to that, the 2.1.196 bundle shows:

  1. No Statsig and no Sentry are bundled. The actual implementation is (a) a 1P OTLP log stream to api.anthropic.com, (b) Datadog for both feature events and error tracking, and (c) optionally Growthbook for experiment exposures. The spirit of the docs (metrics + errors) is preserved, but the named vendors are wrong/incomplete. Medium disclosure gap.
  2. Hard-coded Datadog public key pubea5604404508cdd34afb69e6f42a05bc ships in the bundle, sending to us5.datadoghq.com. Datadog as a recipient of Claude Code usage data is not prominently disclosed. Medium gap.
  3. rh — a 16-char SHA-256 of the git remote URL is attached to every 1P event. This is a stable repo identifier. It is not "code or paths" in the literal sense (no filename, no content), but it does let Anthropic see, per event, which repository the user is working in. Close to the line of what the docs say is excluded; worth disclosure. Low-medium gap.
  4. GitHub-Actions context block (actor, actorId, repository, repositoryId, repositoryOwner, repositoryOwnerId) attached to every event when GITHUB_ACTIONS=true. The literal owner/repo string is sent here (not hashed, unlike rh). Same caveat as (3); more identifying. Low-medium gap.
  5. Stack traces (up to 16KB, top 20 frames) are sent to Datadog on errors. N3 / fma redacts credentials, emails, IPs, and err.path / err.dest values, but file paths that appear inside stack frames as (/path/to/file.js:line:col) are not scrubbed. The docs say no file paths are sent; stack frames can carry them. This is the most concrete content-leak risk in the whole telemetry surface. Medium gap.
  6. prompt.id is attached to 3P telemetry events (Jc), correlating metrics to specific prompts. Prompt content is not sent. Borderline; arguably fine.
  7. DISABLE_TELEMETRY does not cover the DD-EVT pipeline (§7 hole). A user who sets DISABLE_TELEMETRY=1 reasonably believes all usage metrics stop. For the (currently dormant, gate-default-off) Datadog feature-events pipeline, they don't. High disclosure gap if Anthropic ever turns tengu_log_datadog_events on broadly; today it is inert.
  8. Server-side dynamic config can re-target telemetry at runtime: tengu_frond_boric (category kill switches), tengu_1p_event_logging_config (can override endpoint, skipAuth, batch params, retry count), tengu_event_*_sampling (per-event sample rates), tengu_log_datadog_events (DD-EVT on/off). The endpoint-overridability of the 1P exporter means Anthropic could in principle repoint event collection without a client update. Operational, not necessarily a disclosure issue, but worth noting for threat modeling.

Confirmed not collected / not sent

  • No prompt or completion text.
  • No file contents, no diffs, no command history, no shell snapshots.
  • No cwd path over the network (it appears only in local on-disk MCP debug logs).
  • No email (JLd is a no-op returning undefined).
  • No prompt-embedded tracking tokens / steganography / canary watermarks.

10. Code excerpts (verbatim, de-obfuscated where aliases are known)

Analytics sink attach (unconditional)

// Bjo — initSinks, line 9104
function Bjo() { rjo(); _We(); }   // rjo = error sink, _We = analytics sink; NO traffic-mode check

Master gates

function UAs() {                                                 // traffic mode resolver, line 139
  if (process.env.CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC) return "essential-traffic";
  if (process.env.DISABLE_TELEMETRY) return "no-telemetry";
  if (ct(process.env.DO_NOT_TRACK)) return "no-telemetry";
  return "default";
}
function zi()  { return UAs() === "essential-traffic"; }
function zge() { return UAs() !== "default"; }                   // disables 1P + DD-ERR
function cUd() {                                                 // firstParty check
  if (ct(process.env.CLAUDE_CODE_PROVIDER_MANAGED_BY_HOST)) return false;
  return !kc();                                                  // kc = (_r()==="firstParty")
}
function V9()  { return cUd() || If() !== null || zge(); }       // 1P disabled if true
function O6()  { return !V9(); }                                  // is1PEventLoggingEnabled
function _r() {                                                  // provider tier, line 260
  if (If()) return "gateway";
  if (ct(process.env.CLAUDE_CODE_USE_BEDROCK))  return "bedrock";
  if (ct(process.env.CLAUDE_CODE_USE_FOUNDRY))  return "foundry";
  if (ct(process.env.CLAUDE_CODE_USE_ANTHROPIC_AWS)) return "anthropicAws";
  if (ct(process.env.CLAUDE_CODE_USE_MANTLE))   return "mantle";
  if (ct(process.env.CLAUDE_CODE_USE_VERTEX))   return "vertex";
  return "firstParty";
}
function uqe(e) { return i0("tengu_frond_boric", {})?.[e] === true; }   // server-side category kill
function Mho() {                                                  // shouldTrackDatadog
  if (uqe("datadog")) return false;
  try { return it("tengu_log_datadog_events", false); } catch { return false; }
}
function BUa() {                                                 // DD-ERR gate, line 2684
  if (process.env.DISABLE_ERROR_REPORTING) return false;
  if (zge()) return false;
  if (_r() !== "firstParty" || !bu()) return false;
  if (!Y4n.gte(VERSION, <min>)) return false;
  /* ... */ return true;
}

Identifiers

function x6() {                                                  // deviceId, line 11004
  let e = Ot(); if (e.userID) return e.userID;
  if (nVo) return nVo;
  let t = randomBytes(32).toString("hex"); nVo = t;
  try { _n(n => ({ ...n, userID: t })); } catch { /* ... */ }
  return t;
}
// Gjt() is identical for machineID.

Datadog key + endpoints

var bfc = "https://http-intake.logs.us5.datadoghq.com/api/v2/logs";     // pipeline 2 (events)
var NUa = "https://browser-intake-us5-datadoghq.com/api/v2/logs";       // pipeline 3 (errors)
var z4n = "pubea5604404508cdd34afb69e6f42a05bc";                        // hard-coded public DD key

1P OTLP endpoint

// Bzr constructor
this.endpoint = \`${e.baseUrl || "https://api.anthropic.com"}${e.path || "/api/event_logging/v2/batch"}\`;