601 lines
38 KiB
Markdown
601 lines
38 KiB
Markdown
---
|
|
source_url: "https://gist.github.com/AdnaneKhan/7a2040bcdebdc923ef73a19f8831132d"
|
|
ingested: 2026-07-01
|
|
sha256: 18c3e9f1df9f7496951e816227eaf08155f98c00ae39744b586eff7f314a1026
|
|
discovered_from:
|
|
platform: discord
|
|
channel_id: '1028287639918497822'
|
|
channel_name: 'chat'
|
|
message_id: '1521880360374374594'
|
|
author_id: '890908900520505354'
|
|
posted_at: '2026-07-01T14:09:13.083000000Z'
|
|
message_excerpt: '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"}\`;
|
|
``` |