mirror of
https://github.com/Gitlawb/openclaude.git
synced 2026-08-24 02:34:15 -05:00
chore(build): clean up external dependency validation warnings (#1124)
* chore(build): clean up external dependency validation warnings Remove 2 unused externals (@opentelemetry/sdk-trace-node, ink) and add 12 missing packages to package.json that are dynamically imported at runtime but weren't declared as dependencies. Also remove the unused @opentelemetry/sdk-trace-node dependency. This eliminates all 13 build validation warnings: - 8 missing OTel exporter deps (http, proto, grpc variants + prometheus) - 4 missing AWS SDK deps (bedrock, bedrock-runtime, sts, credential-providers) - 1 missing Azure dep (@azure/identity) - ink external pointed to local reimplementation, not npm package - sdk-trace-node was declared external but never imported Build validation now passes cleanly with 0 warnings. Co-Authored-By: OpenClaude <openclaude@gitlawb.com> * chore(build): eliminate external validation warnings Remove unused @opentelemetry/sdk-trace-node from externals and package.json (it's not imported anywhere in src/). Remove ink from SDK_ONLY_EXTERNALS (the project reimplements ink locally at src/ink/). Add OPTIONAL_RUNTIME_EXTERNALS list for packages that are dynamically imported but intentionally not direct deps — OTel protocol exporters and cloud provider SDKs are resolved from transitive deps or installed by users who need them. Validation now passes with 0 warnings instead of 13. Co-Authored-By: OpenClaude <openclaude@gitlawb.com> * feat(telemetry): full OpenTelemetry purge — remove all tracking dependencies Replace all @opentelemetry/* runtime dependencies with no-op stubs, delete OTel-only source modules, and remove 10 @opentelemetry packages from package.json plus @growthbook/growthbook. Key changes: - Delete 5 OTel-only modules (instrumentation, betaSessionTracing, bigqueryExporter, logger, firstPartyEventLoggingExporter) - Replace 9 modules with no-op stubs (sessionTracing, events, telemetryAttributes, firstPartyEventLogger, growthbook, index, sink, datadog, sinkKillswitch, perfettoTracing) - Remove all @opentelemetry/* imports from bootstrap/state.ts, entrypoints/init.ts, and ~20 caller files - Remove all OTel counter types, meter/provider state from state.ts - Clean externals.ts: remove 27 @opentelemetry/* entries - Clean build.ts: remove OTel native-stub namespace exports - Simplify no-telemetry-plugin.ts: remove redundant source-level stubs - Remove 10 @opentelemetry/* + @growthbook/growthbook from package.json - GrowthBook stub reads local ~/.claude/feature-flags.json for overrides Co-Authored-By: OpenClaude (zai-org-glm-5-1) <openclaude@gitlawb.com> * fix format * chore: regenerate lockfile after OTel dependency removal Co-Authored-By: OpenClaude (zai-org-glm-5-1) <openclaude@gitlawb.com> * fix(growthbook): route gate helpers through local flag overrides checkStatsigFeatureGate_CACHED_MAY_BE_STALE() and checkGate_CACHED_OR_BLOCKING() now resolve from ~/.claude/feature-flags.json like getFeatureValue_* does, so gates like tengu_thinkback, tengu_ccr_bridge, and VS Code upsells can be flipped on locally. Security gates (checkSecurityRestrictionGate) remain hard-false. Also adds 5 tests covering gate helper override behavior and unifies JSDoc wording for _getFlagValue-routed functions. Co-Authored-By: OpenClaude (zai-org-glm-5-1) <openclaude@gitlawb.com> --------- Co-authored-by: OpenClaude <openclaude@gitlawb.com>
This commit is contained in:
co-authored by
OpenClaude
parent
a4cbb78585
commit
2c71e09394
@@ -12,22 +12,10 @@
|
||||
"@anthropic-ai/sdk": "0.81.0",
|
||||
"@anthropic-ai/vertex-sdk": "0.14.4",
|
||||
"@commander-js/extra-typings": "12.1.0",
|
||||
"@growthbook/growthbook": "1.6.5",
|
||||
"@grpc/grpc-js": "^1.14.3",
|
||||
"@grpc/proto-loader": "^0.8.0",
|
||||
"@mendable/firecrawl-js": "4.18.1",
|
||||
"@modelcontextprotocol/sdk": "1.29.0",
|
||||
"@opentelemetry/api": "1.9.1",
|
||||
"@opentelemetry/api-logs": "0.214.0",
|
||||
"@opentelemetry/core": "2.6.1",
|
||||
"@opentelemetry/exporter-logs-otlp-http": "0.214.0",
|
||||
"@opentelemetry/exporter-trace-otlp-grpc": "0.57.2",
|
||||
"@opentelemetry/resources": "2.6.1",
|
||||
"@opentelemetry/sdk-logs": "0.214.0",
|
||||
"@opentelemetry/sdk-metrics": "2.6.1",
|
||||
"@opentelemetry/sdk-trace-base": "2.6.1",
|
||||
"@opentelemetry/sdk-trace-node": "2.6.1",
|
||||
"@opentelemetry/semantic-conventions": "1.40.0",
|
||||
"@orama/orama": "^3.1.18",
|
||||
"@orama/plugin-data-persistence": "^3.1.18",
|
||||
"@vscode/ripgrep": "^1.17.1",
|
||||
@@ -242,8 +230,6 @@
|
||||
|
||||
"@esbuild/win32-x64": ["@esbuild/win32-x64@0.27.7", "", { "os": "win32", "cpu": "x64" }, "sha512-56hiAJPhwQ1R4i+21FVF7V8kSD5zZTdHcVuRFMW0hn753vVfQN8xlx4uOPT4xoGH0Z/oVATuR82AiqSTDIpaHg=="],
|
||||
|
||||
"@growthbook/growthbook": ["@growthbook/growthbook@1.6.5", "", { "dependencies": { "dom-mutator": "^0.6.0" } }, "sha512-mUaMsgeUTpRIUOTn33EUXHRK6j7pxBjwqH4WpQyq+pukjd1AIzWlEa6w7i6bInJUcweGgP2beXZmaP6b6UPn7A=="],
|
||||
|
||||
"@grpc/grpc-js": ["@grpc/grpc-js@1.14.3", "", { "dependencies": { "@grpc/proto-loader": "^0.8.0", "@js-sdsl/ordered-map": "^4.4.2" } }, "sha512-Iq8QQQ/7X3Sac15oB6p0FmUg/klxQvXLeileoqrTRGJYLV+/9tubbr9ipz0GKHjmXVsgFPo/+W+2cA8eNcR+XA=="],
|
||||
|
||||
"@grpc/proto-loader": ["@grpc/proto-loader@0.8.0", "", { "dependencies": { "lodash.camelcase": "^4.3.0", "long": "^5.0.0", "protobufjs": "^7.5.3", "yargs": "^17.7.2" }, "bin": { "proto-loader-gen-types": "build/bin/proto-loader-gen-types.js" } }, "sha512-rc1hOQtjIWGxcxpb9aHAfLpIctjEnsDehj0DAiVfBlmT84uvR0uUtN2hEi/ecvWVjXUGf5qPF4qEgiLOx1YIMQ=="],
|
||||
@@ -310,36 +296,6 @@
|
||||
|
||||
"@msgpack/msgpack": ["@msgpack/msgpack@3.1.3", "", {}, "sha512-47XIizs9XZXvuJgoaJUIE2lFoID8ugvc0jzSHP+Ptfk8nTbnR8g788wv48N03Kx0UkAv559HWRQ3yzOgzlRNUA=="],
|
||||
|
||||
"@opentelemetry/api": ["@opentelemetry/api@1.9.1", "", {}, "sha512-gLyJlPHPZYdAk1JENA9LeHejZe1Ti77/pTeFm/nMXmQH/HFZlcS/O2XJB+L8fkbrNSqhdtlvjBVjxwUYanNH5Q=="],
|
||||
|
||||
"@opentelemetry/api-logs": ["@opentelemetry/api-logs@0.214.0", "", { "dependencies": { "@opentelemetry/api": "^1.3.0" } }, "sha512-40lSJeqYO8Uz2Yj7u94/SJWE/wONa7rmMKjI1ZcIjgf3MHNHv1OZUCrCETGuaRF62d5pQD1wKIW+L4lmSMTzZA=="],
|
||||
|
||||
"@opentelemetry/context-async-hooks": ["@opentelemetry/context-async-hooks@2.6.1", "", { "peerDependencies": { "@opentelemetry/api": ">=1.0.0 <1.10.0" } }, "sha512-XHzhwRNkBpeP8Fs/qjGrAf9r9PRv67wkJQ/7ZPaBQQ68DYlTBBx5MF9LvPx7mhuXcDessKK2b+DcxqwpgkcivQ=="],
|
||||
|
||||
"@opentelemetry/core": ["@opentelemetry/core@2.6.1", "", { "dependencies": { "@opentelemetry/semantic-conventions": "^1.29.0" }, "peerDependencies": { "@opentelemetry/api": ">=1.0.0 <1.10.0" } }, "sha512-8xHSGWpJP9wBxgBpnqGL0R3PbdWQndL1Qp50qrg71+B28zK5OQmUgcDKLJgzyAAV38t4tOyLMGDD60LneR5W8g=="],
|
||||
|
||||
"@opentelemetry/exporter-logs-otlp-http": ["@opentelemetry/exporter-logs-otlp-http@0.214.0", "", { "dependencies": { "@opentelemetry/api-logs": "0.214.0", "@opentelemetry/core": "2.6.1", "@opentelemetry/otlp-exporter-base": "0.214.0", "@opentelemetry/otlp-transformer": "0.214.0", "@opentelemetry/sdk-logs": "0.214.0" }, "peerDependencies": { "@opentelemetry/api": "^1.3.0" } }, "sha512-9qv2Tl/Hq6qc5pJCbzFJnzA0uvlb9DgM70yGJPYf3bA5LlLkRCpcn81i4JbcIH4grlQIWY6A+W7YG0LLvS1BAw=="],
|
||||
|
||||
"@opentelemetry/exporter-trace-otlp-grpc": ["@opentelemetry/exporter-trace-otlp-grpc@0.57.2", "", { "dependencies": { "@grpc/grpc-js": "^1.7.1", "@opentelemetry/core": "1.30.1", "@opentelemetry/otlp-exporter-base": "0.57.2", "@opentelemetry/otlp-grpc-exporter-base": "0.57.2", "@opentelemetry/otlp-transformer": "0.57.2", "@opentelemetry/resources": "1.30.1", "@opentelemetry/sdk-trace-base": "1.30.1" }, "peerDependencies": { "@opentelemetry/api": "^1.3.0" } }, "sha512-gHU1vA3JnHbNxEXg5iysqCWxN9j83d7/epTYBZflqQnTyCC4N7yZXn/dMM+bEmyhQPGjhCkNZLx4vZuChH1PYw=="],
|
||||
|
||||
"@opentelemetry/otlp-exporter-base": ["@opentelemetry/otlp-exporter-base@0.214.0", "", { "dependencies": { "@opentelemetry/core": "2.6.1", "@opentelemetry/otlp-transformer": "0.214.0" }, "peerDependencies": { "@opentelemetry/api": "^1.3.0" } }, "sha512-u1Gdv0/E9wP+apqWf7Wv2npXmgJtxsW2XL0TEv9FZloTZRuMBKmu8cYVXwS4Hm3q/f/3FuCnPTgiwYvIqRSpRg=="],
|
||||
|
||||
"@opentelemetry/otlp-grpc-exporter-base": ["@opentelemetry/otlp-grpc-exporter-base@0.57.2", "", { "dependencies": { "@grpc/grpc-js": "^1.7.1", "@opentelemetry/core": "1.30.1", "@opentelemetry/otlp-exporter-base": "0.57.2", "@opentelemetry/otlp-transformer": "0.57.2" }, "peerDependencies": { "@opentelemetry/api": "^1.3.0" } }, "sha512-USn173KTWy0saqqRB5yU9xUZ2xdgb1Rdu5IosJnm9aV4hMTuFFRTUsQxbgc24QxpCHeoKzzCSnS/JzdV0oM2iQ=="],
|
||||
|
||||
"@opentelemetry/otlp-transformer": ["@opentelemetry/otlp-transformer@0.214.0", "", { "dependencies": { "@opentelemetry/api-logs": "0.214.0", "@opentelemetry/core": "2.6.1", "@opentelemetry/resources": "2.6.1", "@opentelemetry/sdk-logs": "0.214.0", "@opentelemetry/sdk-metrics": "2.6.1", "@opentelemetry/sdk-trace-base": "2.6.1", "protobufjs": "^7.0.0" }, "peerDependencies": { "@opentelemetry/api": "^1.3.0" } }, "sha512-DSaYcuBRh6uozfsWN3R8HsN0yDhCuWP7tOFdkUOVaWD1KVJg8m4qiLUsg/tNhTLS9HUYUcwNpwL2eroLtsZZ/w=="],
|
||||
|
||||
"@opentelemetry/resources": ["@opentelemetry/resources@2.6.1", "", { "dependencies": { "@opentelemetry/core": "2.6.1", "@opentelemetry/semantic-conventions": "^1.29.0" }, "peerDependencies": { "@opentelemetry/api": ">=1.3.0 <1.10.0" } }, "sha512-lID/vxSuKWXM55XhAKNoYXu9Cutoq5hFdkbTdI/zDKQktXzcWBVhNsOkiZFTMU9UtEWuGRNe0HUgmsFldIdxVA=="],
|
||||
|
||||
"@opentelemetry/sdk-logs": ["@opentelemetry/sdk-logs@0.214.0", "", { "dependencies": { "@opentelemetry/api-logs": "0.214.0", "@opentelemetry/core": "2.6.1", "@opentelemetry/resources": "2.6.1", "@opentelemetry/semantic-conventions": "^1.29.0" }, "peerDependencies": { "@opentelemetry/api": ">=1.4.0 <1.10.0" } }, "sha512-zf6acnScjhsaBUU22zXZ/sLWim1dfhUAbGXdMmHmNG3LfBnQ3DKsOCITb2IZwoUsNNMTogqFKBnlIPPftUgGwA=="],
|
||||
|
||||
"@opentelemetry/sdk-metrics": ["@opentelemetry/sdk-metrics@2.6.1", "", { "dependencies": { "@opentelemetry/core": "2.6.1", "@opentelemetry/resources": "2.6.1" }, "peerDependencies": { "@opentelemetry/api": ">=1.9.0 <1.10.0" } }, "sha512-9t9hJHX15meBy2NmTJxL+NJfXmnausR2xUDvE19XQce0Qi/GBtDGamU8nS1RMbdgDmhgpm3VaOu2+fiS/SfTpQ=="],
|
||||
|
||||
"@opentelemetry/sdk-trace-base": ["@opentelemetry/sdk-trace-base@2.6.1", "", { "dependencies": { "@opentelemetry/core": "2.6.1", "@opentelemetry/resources": "2.6.1", "@opentelemetry/semantic-conventions": "^1.29.0" }, "peerDependencies": { "@opentelemetry/api": ">=1.3.0 <1.10.0" } }, "sha512-r86ut4T1e8vNwB35CqCcKd45yzqH6/6Wzvpk2/cZB8PsPLlZFTvrh8yfOS3CYZYcUmAx4hHTZJ8AO8Dj8nrdhw=="],
|
||||
|
||||
"@opentelemetry/sdk-trace-node": ["@opentelemetry/sdk-trace-node@2.6.1", "", { "dependencies": { "@opentelemetry/context-async-hooks": "2.6.1", "@opentelemetry/core": "2.6.1", "@opentelemetry/sdk-trace-base": "2.6.1" }, "peerDependencies": { "@opentelemetry/api": ">=1.0.0 <1.10.0" } }, "sha512-Hh2i4FwHWRFhnO2Q/p6svMxy8MPsNCG0uuzUY3glqm0rwM0nQvbTO1dXSp9OqQoTKXcQzaz9q1f65fsurmOhNw=="],
|
||||
|
||||
"@opentelemetry/semantic-conventions": ["@opentelemetry/semantic-conventions@1.40.0", "", {}, "sha512-cifvXDhcqMwwTlTK04GBNeIe7yyo28Mfby85QXFe1Yk8nmi36Ab/5UQwptOx84SsoGNRg+EVSjwzfSZMy6pmlw=="],
|
||||
|
||||
"@orama/orama": ["@orama/orama@3.1.18", "", {}, "sha512-a61ljmRVVyG5MC/698C8/FfFDw5a8LOIvyOLW5fztgUXqUpc1jOfQzOitSCbge657OgXXThmY3Tk8fpiDb4UcA=="],
|
||||
|
||||
"@orama/plugin-data-persistence": ["@orama/plugin-data-persistence@3.1.18", "", { "dependencies": { "@msgpack/msgpack": "^3.1.2", "@orama/orama": "3.1.18", "dpack": "^0.6.22", "seqproto": "^0.2.3" } }, "sha512-pfBbpK96VRW/7IkdMHn2HaW3/+4k2C9Uwyup0IONNuz5bG3L1orCNFZPBmu+zcokOU2YH+IAVuQz6MlvqOe3iw=="],
|
||||
@@ -568,8 +524,6 @@
|
||||
|
||||
"dijkstrajs": ["dijkstrajs@1.0.3", "", {}, "sha512-qiSlmBq9+BCdCA/L46dw8Uy93mloxsPSbwnm5yrKn2vMPiy8KyAskTF6zuV/j5BMsmOGZDPs7KjU+mjb670kfA=="],
|
||||
|
||||
"dom-mutator": ["dom-mutator@0.6.0", "", {}, "sha512-iCt9o0aYfXMUkz/43ZOAUFQYotjGB+GNbYJiJdz4TgXkyToXbbRy5S6FbTp72lRBtfpUMwEc1KmpFEU4CZeoNg=="],
|
||||
|
||||
"dpack": ["dpack@0.6.22", "", {}, "sha512-WGPNlW2OAE7Bj0eODMpAHUcEqxrlg01e9OFZDxQodminIgC194/cRHT7K04Z1j7AUEWTeeplYGrIv/xRdwU9Hg=="],
|
||||
|
||||
"duck-duck-scrape": ["duck-duck-scrape@2.2.7", "", { "dependencies": { "html-entities": "^2.3.3", "needle": "^3.2.0" } }, "sha512-BEcglwnfx5puJl90KQfX+Q2q5vCguqyMpZcSRPBWk8OY55qWwV93+E+7DbIkrGDW4qkqPfUvtOUdi0lXz6lEMQ=="],
|
||||
@@ -1176,22 +1130,6 @@
|
||||
|
||||
"@mendable/firecrawl-js/axios": ["axios@1.14.0", "", { "dependencies": { "follow-redirects": "^1.15.11", "form-data": "^4.0.5", "proxy-from-env": "^2.1.0" } }, "sha512-3Y8yrqLSwjuzpXuZ0oIYZ/XGgLwUIBU3uLvbcpb0pidD9ctpShJd43KSlEEkVQg6DS0G9NKyzOvBfUtDKEyHvQ=="],
|
||||
|
||||
"@opentelemetry/exporter-trace-otlp-grpc/@opentelemetry/core": ["@opentelemetry/core@1.30.1", "", { "dependencies": { "@opentelemetry/semantic-conventions": "1.28.0" }, "peerDependencies": { "@opentelemetry/api": ">=1.0.0 <1.10.0" } }, "sha512-OOCM2C/QIURhJMuKaekP3TRBxBKxG/TWWA0TL2J6nXUtDnuCtccy49LUJF8xPFXMX+0LMcxFpCo8M9cGY1W6rQ=="],
|
||||
|
||||
"@opentelemetry/exporter-trace-otlp-grpc/@opentelemetry/otlp-exporter-base": ["@opentelemetry/otlp-exporter-base@0.57.2", "", { "dependencies": { "@opentelemetry/core": "1.30.1", "@opentelemetry/otlp-transformer": "0.57.2" }, "peerDependencies": { "@opentelemetry/api": "^1.3.0" } }, "sha512-XdxEzL23Urhidyebg5E6jZoaiW5ygP/mRjxLHixogbqwDy2Faduzb5N0o/Oi+XTIJu+iyxXdVORjXax+Qgfxag=="],
|
||||
|
||||
"@opentelemetry/exporter-trace-otlp-grpc/@opentelemetry/otlp-transformer": ["@opentelemetry/otlp-transformer@0.57.2", "", { "dependencies": { "@opentelemetry/api-logs": "0.57.2", "@opentelemetry/core": "1.30.1", "@opentelemetry/resources": "1.30.1", "@opentelemetry/sdk-logs": "0.57.2", "@opentelemetry/sdk-metrics": "1.30.1", "@opentelemetry/sdk-trace-base": "1.30.1", "protobufjs": "^7.3.0" }, "peerDependencies": { "@opentelemetry/api": "^1.3.0" } }, "sha512-48IIRj49gbQVK52jYsw70+Jv+JbahT8BqT2Th7C4H7RCM9d0gZ5sgNPoMpWldmfjvIsSgiGJtjfk9MeZvjhoig=="],
|
||||
|
||||
"@opentelemetry/exporter-trace-otlp-grpc/@opentelemetry/resources": ["@opentelemetry/resources@1.30.1", "", { "dependencies": { "@opentelemetry/core": "1.30.1", "@opentelemetry/semantic-conventions": "1.28.0" }, "peerDependencies": { "@opentelemetry/api": ">=1.0.0 <1.10.0" } }, "sha512-5UxZqiAgLYGFjS4s9qm5mBVo433u+dSPUFWVWXmLAD4wB65oMCoXaJP1KJa9DIYYMeHu3z4BZcStG3LC593cWA=="],
|
||||
|
||||
"@opentelemetry/exporter-trace-otlp-grpc/@opentelemetry/sdk-trace-base": ["@opentelemetry/sdk-trace-base@1.30.1", "", { "dependencies": { "@opentelemetry/core": "1.30.1", "@opentelemetry/resources": "1.30.1", "@opentelemetry/semantic-conventions": "1.28.0" }, "peerDependencies": { "@opentelemetry/api": ">=1.0.0 <1.10.0" } }, "sha512-jVPgBbH1gCy2Lb7X0AVQ8XAfgg0pJ4nvl8/IiQA6nxOsPvS+0zMJaFSs2ltXe0J6C8dqjcnpyqINDJmU30+uOg=="],
|
||||
|
||||
"@opentelemetry/otlp-grpc-exporter-base/@opentelemetry/core": ["@opentelemetry/core@1.30.1", "", { "dependencies": { "@opentelemetry/semantic-conventions": "1.28.0" }, "peerDependencies": { "@opentelemetry/api": ">=1.0.0 <1.10.0" } }, "sha512-OOCM2C/QIURhJMuKaekP3TRBxBKxG/TWWA0TL2J6nXUtDnuCtccy49LUJF8xPFXMX+0LMcxFpCo8M9cGY1W6rQ=="],
|
||||
|
||||
"@opentelemetry/otlp-grpc-exporter-base/@opentelemetry/otlp-exporter-base": ["@opentelemetry/otlp-exporter-base@0.57.2", "", { "dependencies": { "@opentelemetry/core": "1.30.1", "@opentelemetry/otlp-transformer": "0.57.2" }, "peerDependencies": { "@opentelemetry/api": "^1.3.0" } }, "sha512-XdxEzL23Urhidyebg5E6jZoaiW5ygP/mRjxLHixogbqwDy2Faduzb5N0o/Oi+XTIJu+iyxXdVORjXax+Qgfxag=="],
|
||||
|
||||
"@opentelemetry/otlp-grpc-exporter-base/@opentelemetry/otlp-transformer": ["@opentelemetry/otlp-transformer@0.57.2", "", { "dependencies": { "@opentelemetry/api-logs": "0.57.2", "@opentelemetry/core": "1.30.1", "@opentelemetry/resources": "1.30.1", "@opentelemetry/sdk-logs": "0.57.2", "@opentelemetry/sdk-metrics": "1.30.1", "@opentelemetry/sdk-trace-base": "1.30.1", "protobufjs": "^7.3.0" }, "peerDependencies": { "@opentelemetry/api": "^1.3.0" } }, "sha512-48IIRj49gbQVK52jYsw70+Jv+JbahT8BqT2Th7C4H7RCM9d0gZ5sgNPoMpWldmfjvIsSgiGJtjfk9MeZvjhoig=="],
|
||||
|
||||
"@smithy/abort-controller/tslib": ["tslib@2.8.1", "", {}, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="],
|
||||
|
||||
"@smithy/config-resolver/@smithy/types": ["@smithy/types@4.13.1", "", { "dependencies": { "tslib": "^2.6.2" } }, "sha512-787F3yzE2UiJIQ+wYW1CVg2odHjmaWLGksnKQHUrK/lYZSEcy1msuLVvxaR/sI2/aDe9U+TBuLsXnr3vod1g0g=="],
|
||||
@@ -1456,30 +1394,6 @@
|
||||
|
||||
"@mendable/firecrawl-js/axios/proxy-from-env": ["proxy-from-env@2.1.0", "", {}, "sha512-cJ+oHTW1VAEa8cJslgmUZrc+sjRKgAKl3Zyse6+PV38hZe/V6Z14TbCuXcan9F9ghlz4QrFr2c92TNF82UkYHA=="],
|
||||
|
||||
"@opentelemetry/exporter-trace-otlp-grpc/@opentelemetry/core/@opentelemetry/semantic-conventions": ["@opentelemetry/semantic-conventions@1.28.0", "", {}, "sha512-lp4qAiMTD4sNWW4DbKLBkfiMZ4jbAboJIGOQr5DvciMRI494OapieI9qiODpOt0XBr1LjIDy1xAGAnVs5supTA=="],
|
||||
|
||||
"@opentelemetry/exporter-trace-otlp-grpc/@opentelemetry/otlp-transformer/@opentelemetry/api-logs": ["@opentelemetry/api-logs@0.57.2", "", { "dependencies": { "@opentelemetry/api": "^1.3.0" } }, "sha512-uIX52NnTM0iBh84MShlpouI7UKqkZ7MrUszTmaypHBu4r7NofznSnQRfJ+uUeDtQDj6w8eFGg5KBLDAwAPz1+A=="],
|
||||
|
||||
"@opentelemetry/exporter-trace-otlp-grpc/@opentelemetry/otlp-transformer/@opentelemetry/sdk-logs": ["@opentelemetry/sdk-logs@0.57.2", "", { "dependencies": { "@opentelemetry/api-logs": "0.57.2", "@opentelemetry/core": "1.30.1", "@opentelemetry/resources": "1.30.1" }, "peerDependencies": { "@opentelemetry/api": ">=1.4.0 <1.10.0" } }, "sha512-TXFHJ5c+BKggWbdEQ/inpgIzEmS2BGQowLE9UhsMd7YYlUfBQJ4uax0VF/B5NYigdM/75OoJGhAV3upEhK+3gg=="],
|
||||
|
||||
"@opentelemetry/exporter-trace-otlp-grpc/@opentelemetry/otlp-transformer/@opentelemetry/sdk-metrics": ["@opentelemetry/sdk-metrics@1.30.1", "", { "dependencies": { "@opentelemetry/core": "1.30.1", "@opentelemetry/resources": "1.30.1" }, "peerDependencies": { "@opentelemetry/api": ">=1.3.0 <1.10.0" } }, "sha512-q9zcZ0Okl8jRgmy7eNW3Ku1XSgg3sDLa5evHZpCwjspw7E8Is4K/haRPDJrBcX3YSn/Y7gUvFnByNYEKQNbNog=="],
|
||||
|
||||
"@opentelemetry/exporter-trace-otlp-grpc/@opentelemetry/resources/@opentelemetry/semantic-conventions": ["@opentelemetry/semantic-conventions@1.28.0", "", {}, "sha512-lp4qAiMTD4sNWW4DbKLBkfiMZ4jbAboJIGOQr5DvciMRI494OapieI9qiODpOt0XBr1LjIDy1xAGAnVs5supTA=="],
|
||||
|
||||
"@opentelemetry/exporter-trace-otlp-grpc/@opentelemetry/sdk-trace-base/@opentelemetry/semantic-conventions": ["@opentelemetry/semantic-conventions@1.28.0", "", {}, "sha512-lp4qAiMTD4sNWW4DbKLBkfiMZ4jbAboJIGOQr5DvciMRI494OapieI9qiODpOt0XBr1LjIDy1xAGAnVs5supTA=="],
|
||||
|
||||
"@opentelemetry/otlp-grpc-exporter-base/@opentelemetry/core/@opentelemetry/semantic-conventions": ["@opentelemetry/semantic-conventions@1.28.0", "", {}, "sha512-lp4qAiMTD4sNWW4DbKLBkfiMZ4jbAboJIGOQr5DvciMRI494OapieI9qiODpOt0XBr1LjIDy1xAGAnVs5supTA=="],
|
||||
|
||||
"@opentelemetry/otlp-grpc-exporter-base/@opentelemetry/otlp-transformer/@opentelemetry/api-logs": ["@opentelemetry/api-logs@0.57.2", "", { "dependencies": { "@opentelemetry/api": "^1.3.0" } }, "sha512-uIX52NnTM0iBh84MShlpouI7UKqkZ7MrUszTmaypHBu4r7NofznSnQRfJ+uUeDtQDj6w8eFGg5KBLDAwAPz1+A=="],
|
||||
|
||||
"@opentelemetry/otlp-grpc-exporter-base/@opentelemetry/otlp-transformer/@opentelemetry/resources": ["@opentelemetry/resources@1.30.1", "", { "dependencies": { "@opentelemetry/core": "1.30.1", "@opentelemetry/semantic-conventions": "1.28.0" }, "peerDependencies": { "@opentelemetry/api": ">=1.0.0 <1.10.0" } }, "sha512-5UxZqiAgLYGFjS4s9qm5mBVo433u+dSPUFWVWXmLAD4wB65oMCoXaJP1KJa9DIYYMeHu3z4BZcStG3LC593cWA=="],
|
||||
|
||||
"@opentelemetry/otlp-grpc-exporter-base/@opentelemetry/otlp-transformer/@opentelemetry/sdk-logs": ["@opentelemetry/sdk-logs@0.57.2", "", { "dependencies": { "@opentelemetry/api-logs": "0.57.2", "@opentelemetry/core": "1.30.1", "@opentelemetry/resources": "1.30.1" }, "peerDependencies": { "@opentelemetry/api": ">=1.4.0 <1.10.0" } }, "sha512-TXFHJ5c+BKggWbdEQ/inpgIzEmS2BGQowLE9UhsMd7YYlUfBQJ4uax0VF/B5NYigdM/75OoJGhAV3upEhK+3gg=="],
|
||||
|
||||
"@opentelemetry/otlp-grpc-exporter-base/@opentelemetry/otlp-transformer/@opentelemetry/sdk-metrics": ["@opentelemetry/sdk-metrics@1.30.1", "", { "dependencies": { "@opentelemetry/core": "1.30.1", "@opentelemetry/resources": "1.30.1" }, "peerDependencies": { "@opentelemetry/api": ">=1.3.0 <1.10.0" } }, "sha512-q9zcZ0Okl8jRgmy7eNW3Ku1XSgg3sDLa5evHZpCwjspw7E8Is4K/haRPDJrBcX3YSn/Y7gUvFnByNYEKQNbNog=="],
|
||||
|
||||
"@opentelemetry/otlp-grpc-exporter-base/@opentelemetry/otlp-transformer/@opentelemetry/sdk-trace-base": ["@opentelemetry/sdk-trace-base@1.30.1", "", { "dependencies": { "@opentelemetry/core": "1.30.1", "@opentelemetry/resources": "1.30.1", "@opentelemetry/semantic-conventions": "1.28.0" }, "peerDependencies": { "@opentelemetry/api": ">=1.0.0 <1.10.0" } }, "sha512-jVPgBbH1gCy2Lb7X0AVQ8XAfgg0pJ4nvl8/IiQA6nxOsPvS+0zMJaFSs2ltXe0J6C8dqjcnpyqINDJmU30+uOg=="],
|
||||
|
||||
"@smithy/core/@smithy/util-base64/@smithy/util-buffer-from": ["@smithy/util-buffer-from@4.2.2", "", { "dependencies": { "@smithy/is-array-buffer": "^4.2.2", "tslib": "^2.6.2" } }, "sha512-FDXD7cvUoFWwN6vtQfEta540Y/YBe5JneK3SoZg9bThSoOAC/eGeYEua6RkBgKjGa/sz6Y+DuBZj3+YEY21y4Q=="],
|
||||
|
||||
"@smithy/eventstream-serde-universal/@smithy/eventstream-codec/@aws-crypto/crc32": ["@aws-crypto/crc32@3.0.0", "", { "dependencies": { "@aws-crypto/util": "^3.0.0", "@aws-sdk/types": "^3.222.0", "tslib": "^1.11.1" } }, "sha512-IzSgsrxUcsrejQbPVilIKy16kAT52EwB6zSaI+M3xxIhKh5+aldEyvI+z6erM7TCLB2BJsFrtHjp6/4/sr+3dA=="],
|
||||
@@ -1570,10 +1484,6 @@
|
||||
|
||||
"@aws-sdk/nested-clients/@smithy/util-base64/@smithy/util-buffer-from/@smithy/is-array-buffer": ["@smithy/is-array-buffer@4.2.2", "", { "dependencies": { "tslib": "^2.6.2" } }, "sha512-n6rQ4N8Jj4YTQO3YFrlgZuwKodf4zUFs7EJIWH86pSCWBaAtAGBFfCM7Wx6D2bBJ2xqFNxGBSrUWswT3M0VJow=="],
|
||||
|
||||
"@opentelemetry/otlp-grpc-exporter-base/@opentelemetry/otlp-transformer/@opentelemetry/resources/@opentelemetry/semantic-conventions": ["@opentelemetry/semantic-conventions@1.28.0", "", {}, "sha512-lp4qAiMTD4sNWW4DbKLBkfiMZ4jbAboJIGOQr5DvciMRI494OapieI9qiODpOt0XBr1LjIDy1xAGAnVs5supTA=="],
|
||||
|
||||
"@opentelemetry/otlp-grpc-exporter-base/@opentelemetry/otlp-transformer/@opentelemetry/sdk-trace-base/@opentelemetry/semantic-conventions": ["@opentelemetry/semantic-conventions@1.28.0", "", {}, "sha512-lp4qAiMTD4sNWW4DbKLBkfiMZ4jbAboJIGOQr5DvciMRI494OapieI9qiODpOt0XBr1LjIDy1xAGAnVs5supTA=="],
|
||||
|
||||
"@smithy/core/@smithy/util-base64/@smithy/util-buffer-from/@smithy/is-array-buffer": ["@smithy/is-array-buffer@4.2.2", "", { "dependencies": { "tslib": "^2.6.2" } }, "sha512-n6rQ4N8Jj4YTQO3YFrlgZuwKodf4zUFs7EJIWH86pSCWBaAtAGBFfCM7Wx6D2bBJ2xqFNxGBSrUWswT3M0VJow=="],
|
||||
|
||||
"@smithy/eventstream-serde-universal/@smithy/eventstream-codec/@aws-crypto/crc32/@aws-crypto/util": ["@aws-crypto/util@3.0.0", "", { "dependencies": { "@aws-sdk/types": "^3.222.0", "@aws-sdk/util-utf8-browser": "^3.0.0", "tslib": "^1.11.1" } }, "sha512-2OJlpeJpCR48CC8r+uKVChzs9Iungj9wkZrl8Z041DWEWvyIHILYKCPNzJghKsivj+S3mLo6BVc7mBNzdxA46w=="],
|
||||
|
||||
@@ -75,22 +75,10 @@
|
||||
"@anthropic-ai/sdk": "0.81.0",
|
||||
"@anthropic-ai/vertex-sdk": "0.14.4",
|
||||
"@commander-js/extra-typings": "12.1.0",
|
||||
"@growthbook/growthbook": "1.6.5",
|
||||
"@grpc/grpc-js": "^1.14.3",
|
||||
"@grpc/proto-loader": "^0.8.0",
|
||||
"@mendable/firecrawl-js": "4.18.1",
|
||||
"@modelcontextprotocol/sdk": "1.29.0",
|
||||
"@opentelemetry/api": "1.9.1",
|
||||
"@opentelemetry/api-logs": "0.214.0",
|
||||
"@opentelemetry/core": "2.6.1",
|
||||
"@opentelemetry/exporter-logs-otlp-http": "0.214.0",
|
||||
"@opentelemetry/exporter-trace-otlp-grpc": "0.57.2",
|
||||
"@opentelemetry/resources": "2.6.1",
|
||||
"@opentelemetry/sdk-logs": "0.214.0",
|
||||
"@opentelemetry/sdk-metrics": "2.6.1",
|
||||
"@opentelemetry/sdk-trace-base": "2.6.1",
|
||||
"@opentelemetry/sdk-trace-node": "2.6.1",
|
||||
"@opentelemetry/semantic-conventions": "1.40.0",
|
||||
"@orama/orama": "^3.1.18",
|
||||
"@orama/plugin-data-persistence": "^3.1.18",
|
||||
"@vscode/ripgrep": "^1.17.1",
|
||||
|
||||
@@ -224,8 +224,6 @@ export async function handleBgFlag() { throw new Error("Background sessions are
|
||||
}),
|
||||
)
|
||||
|
||||
// NOTE: @opentelemetry/* kept as external deps (too many named exports to stub)
|
||||
|
||||
// Resolve native addon and missing snapshot imports to stubs
|
||||
for (const mod of [
|
||||
'audio-capture-napi',
|
||||
@@ -260,8 +258,6 @@ const handler = {
|
||||
get(_, prop) {
|
||||
if (prop === '__esModule') return true;
|
||||
if (prop === 'default') return new Proxy({}, handler);
|
||||
if (prop === 'ExportResultCode') return { SUCCESS: 0, FAILED: 1 };
|
||||
if (prop === 'resourceFromAttributes') return () => ({});
|
||||
if (prop === 'SandboxRuntimeConfigSchema') return { parse: () => ({}) };
|
||||
return noop;
|
||||
}
|
||||
@@ -280,35 +276,6 @@ export const ColorFile = null;
|
||||
export const getSyntaxTheme = noop;
|
||||
export const plot = noop;
|
||||
export const createClaudeForChromeMcpServer = noop;
|
||||
// OpenTelemetry exports
|
||||
export const ExportResultCode = { SUCCESS: 0, FAILED: 1 };
|
||||
export const resourceFromAttributes = noop;
|
||||
export const Resource = noopClass;
|
||||
export const SimpleSpanProcessor = noopClass;
|
||||
export const BatchSpanProcessor = noopClass;
|
||||
export const NodeTracerProvider = noopClass;
|
||||
export const BasicTracerProvider = noopClass;
|
||||
export const OTLPTraceExporter = noopClass;
|
||||
export const OTLPLogExporter = noopClass;
|
||||
export const OTLPMetricExporter = noopClass;
|
||||
export const PrometheusExporter = noopClass;
|
||||
export const LoggerProvider = noopClass;
|
||||
export const SimpleLogRecordProcessor = noopClass;
|
||||
export const BatchLogRecordProcessor = noopClass;
|
||||
export const MeterProvider = noopClass;
|
||||
export const PeriodicExportingMetricReader = noopClass;
|
||||
export const trace = { getTracer: () => ({ startSpan: () => ({ end: noop, setAttribute: noop, setStatus: noop, recordException: noop }) }) };
|
||||
export const context = { active: noop, with: (_, fn) => fn() };
|
||||
export const SpanStatusCode = { OK: 0, ERROR: 1, UNSET: 2 };
|
||||
export const ATTR_SERVICE_NAME = 'service.name';
|
||||
export const ATTR_SERVICE_VERSION = 'service.version';
|
||||
export const SEMRESATTRS_SERVICE_NAME = 'service.name';
|
||||
export const SEMRESATTRS_SERVICE_VERSION = 'service.version';
|
||||
export const AggregationTemporality = { CUMULATIVE: 0, DELTA: 1 };
|
||||
export const DataPointType = { HISTOGRAM: 0, SUM: 1, GAUGE: 2 };
|
||||
export const InstrumentType = { COUNTER: 0, HISTOGRAM: 1, UP_DOWN_COUNTER: 2 };
|
||||
export const PushMetricExporter = noopClass;
|
||||
export const SeverityNumber = {};
|
||||
`,
|
||||
loader: 'js',
|
||||
}),
|
||||
|
||||
+13
-24
@@ -8,26 +8,6 @@
|
||||
|
||||
// Packages that should be kept external in ALL bundles (CLI + SDK)
|
||||
export const COMMON_EXTERNALS: string[] = [
|
||||
// OpenTelemetry — too many named exports to stub, kept external
|
||||
'@opentelemetry/api',
|
||||
'@opentelemetry/api-logs',
|
||||
'@opentelemetry/core',
|
||||
'@opentelemetry/exporter-trace-otlp-grpc',
|
||||
'@opentelemetry/exporter-trace-otlp-http',
|
||||
'@opentelemetry/exporter-trace-otlp-proto',
|
||||
'@opentelemetry/exporter-logs-otlp-http',
|
||||
'@opentelemetry/exporter-logs-otlp-proto',
|
||||
'@opentelemetry/exporter-logs-otlp-grpc',
|
||||
'@opentelemetry/exporter-metrics-otlp-proto',
|
||||
'@opentelemetry/exporter-metrics-otlp-grpc',
|
||||
'@opentelemetry/exporter-metrics-otlp-http',
|
||||
'@opentelemetry/exporter-prometheus',
|
||||
'@opentelemetry/resources',
|
||||
'@opentelemetry/sdk-trace-base',
|
||||
'@opentelemetry/sdk-trace-node',
|
||||
'@opentelemetry/sdk-logs',
|
||||
'@opentelemetry/sdk-metrics',
|
||||
'@opentelemetry/semantic-conventions',
|
||||
// Native image processing
|
||||
'sharp',
|
||||
// Cloud provider SDKs
|
||||
@@ -51,11 +31,22 @@ export const COMMON_EXTERNALS: string[] = [
|
||||
export const SDK_ONLY_EXTERNALS: string[] = [
|
||||
'react',
|
||||
'react-reconciler',
|
||||
'ink',
|
||||
'@anthropic-ai/sdk',
|
||||
'@modelcontextprotocol/sdk',
|
||||
]
|
||||
|
||||
// Packages kept external but NOT listed in package.json dependencies.
|
||||
// These are dynamically imported at runtime — they're optional and resolved
|
||||
// from transitive deps or installed by users who need that provider/protocol.
|
||||
export const OPTIONAL_RUNTIME_EXTERNALS: string[] = [
|
||||
// Cloud provider SDKs (dynamically imported per-provider)
|
||||
'@aws-sdk/client-bedrock',
|
||||
'@aws-sdk/client-bedrock-runtime',
|
||||
'@aws-sdk/client-sts',
|
||||
'@aws-sdk/credential-providers',
|
||||
'@azure/identity',
|
||||
]
|
||||
|
||||
// Computed full lists
|
||||
export const CLI_EXTERNALS: string[] = COMMON_EXTERNALS
|
||||
export const SDK_EXTERNALS: string[] = [...COMMON_EXTERNALS, ...SDK_ONLY_EXTERNALS]
|
||||
@@ -129,9 +120,7 @@ export const INTENTIONALLY_BUNDLED: string[] = [
|
||||
'@modelcontextprotocol/sdk',
|
||||
// Schema validation
|
||||
'zod',
|
||||
// Feature flags / analytics
|
||||
'@growthbook/growthbook',
|
||||
// gRPC (bundled into CLI, not external)
|
||||
// gRPC (bundled into CLI, not external)
|
||||
'@grpc/grpc-js',
|
||||
'@grpc/proto-loader',
|
||||
// Web scraping
|
||||
|
||||
@@ -1,29 +1,22 @@
|
||||
import { afterAll, beforeEach, describe, expect, test } from 'bun:test'
|
||||
import { mkdirSync, readFileSync, rmSync, unlinkSync, writeFileSync } from 'node:fs'
|
||||
import { mkdirSync, rmSync, unlinkSync, writeFileSync } from 'node:fs'
|
||||
import { join } from 'node:path'
|
||||
import { tmpdir } from 'node:os'
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Setup: extract the growthbook stub from no-telemetry-plugin.ts, write it to
|
||||
// a temp .mjs file, and dynamically import it so we can test the real code
|
||||
// that gets bundled.
|
||||
// Setup: dynamically import the source-level growthbook no-op stub.
|
||||
// The stub reads ~/.claude/feature-flags.json for local flag overrides.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const pluginSource = readFileSync(join(__dirname, 'no-telemetry-plugin.ts'), 'utf-8')
|
||||
const stubMatch = pluginSource.match(/'services\/analytics\/growthbook': `([\s\S]*?)`/)
|
||||
if (!stubMatch) throw new Error('Could not extract growthbook stub from no-telemetry-plugin.ts')
|
||||
|
||||
const testDir = join(tmpdir(), `growthbook-stub-test-${process.pid}`)
|
||||
const stubFile = join(testDir, 'growthbook-stub.mjs')
|
||||
const flagsFile = join(testDir, 'test-flags.json')
|
||||
|
||||
mkdirSync(testDir, { recursive: true })
|
||||
writeFileSync(stubFile, stubMatch[1])
|
||||
|
||||
// Point the stub at our test flags file (checked by _loadFlags on first access)
|
||||
// Point the stub at our test flags file before import
|
||||
process.env.CLAUDE_FEATURE_FLAGS_FILE = flagsFile
|
||||
|
||||
const stub = await import(stubFile)
|
||||
const stub = await import('../src/services/analytics/growthbook.js')
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Tests
|
||||
@@ -50,23 +43,6 @@ describe('growthbook stub — local feature flag overrides', () => {
|
||||
expect(stub.getAllGrowthBookFeatures()).toEqual({})
|
||||
})
|
||||
|
||||
// ── Open-build defaults (_openBuildDefaults) ────────────────────
|
||||
|
||||
test('returns open-build default when flags file is absent', () => {
|
||||
// tengu_passport_quail is in _openBuildDefaults as true; without a
|
||||
// flags file the stub should return the open-build override, not
|
||||
// the call-site defaultValue.
|
||||
expect(stub.getFeatureValue_CACHED_MAY_BE_STALE('tengu_passport_quail', false)).toBe(true)
|
||||
expect(stub.getFeatureValue_CACHED_MAY_BE_STALE('tengu_coral_fern', false)).toBe(true)
|
||||
})
|
||||
|
||||
test('flags file overrides open-build defaults', () => {
|
||||
// User-provided feature-flags.json takes priority over _openBuildDefaults.
|
||||
writeFileSync(flagsFile, JSON.stringify({ tengu_passport_quail: false }))
|
||||
|
||||
expect(stub.getFeatureValue_CACHED_MAY_BE_STALE('tengu_passport_quail', true)).toBe(false)
|
||||
})
|
||||
|
||||
// ── Valid JSON object ────────────────────────────────────────────
|
||||
|
||||
test('loads and returns values from a valid JSON file', () => {
|
||||
@@ -124,33 +100,6 @@ describe('growthbook stub — local feature flag overrides', () => {
|
||||
expect(stub.getFeatureValue_CACHED_MAY_BE_STALE('tengu_foo', 'x')).toBe('second')
|
||||
})
|
||||
|
||||
test('refreshGrowthBookFeatures clears cache', async () => {
|
||||
writeFileSync(flagsFile, JSON.stringify({ tengu_foo: 'v1' }))
|
||||
expect(stub.getFeatureValue_CACHED_MAY_BE_STALE('tengu_foo', 'x')).toBe('v1')
|
||||
|
||||
writeFileSync(flagsFile, JSON.stringify({ tengu_foo: 'v2' }))
|
||||
await stub.refreshGrowthBookFeatures()
|
||||
expect(stub.getFeatureValue_CACHED_MAY_BE_STALE('tengu_foo', 'x')).toBe('v2')
|
||||
})
|
||||
|
||||
// ── Multiple getter variants ─────────────────────────────────────
|
||||
|
||||
test('all getter functions read from local flags', async () => {
|
||||
writeFileSync(flagsFile, JSON.stringify({ tengu_gate: true, tengu_config: { a: 1 } }))
|
||||
|
||||
expect(await stub.getFeatureValue_DEPRECATED('tengu_gate', false)).toBe(true)
|
||||
stub.resetGrowthBook()
|
||||
expect(stub.getFeatureValue_CACHED_WITH_REFRESH('tengu_gate', false)).toBe(true)
|
||||
stub.resetGrowthBook()
|
||||
expect(stub.checkStatsigFeatureGate_CACHED_MAY_BE_STALE('tengu_gate')).toBe(true)
|
||||
stub.resetGrowthBook()
|
||||
expect(await stub.checkGate_CACHED_OR_BLOCKING('tengu_gate')).toBe(true)
|
||||
stub.resetGrowthBook()
|
||||
expect(await stub.getDynamicConfig_BLOCKS_ON_INIT('tengu_config', {})).toEqual({ a: 1 })
|
||||
stub.resetGrowthBook()
|
||||
expect(stub.getDynamicConfig_CACHED_MAY_BE_STALE('tengu_config', {})).toEqual({ a: 1 })
|
||||
})
|
||||
|
||||
// ── Security gate ────────────────────────────────────────────────
|
||||
|
||||
test('checkSecurityRestrictionGate always returns false regardless of flags', async () => {
|
||||
@@ -158,6 +107,42 @@ describe('growthbook stub — local feature flag overrides', () => {
|
||||
tengu_disable_bypass_permissions_mode: true,
|
||||
}))
|
||||
|
||||
expect(await stub.checkSecurityRestrictionGate()).toBe(false)
|
||||
expect(await stub.checkSecurityRestrictionGate('tengu_disable_bypass_permissions_mode')).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
// ── All getter variants return default ───────────────────────────
|
||||
|
||||
test('all getter functions return default values when no flags file', async () => {
|
||||
expect(stub.getFeatureValue_DEPRECATED('tengu_gate', false)).toBe(false)
|
||||
expect(stub.getFeatureValue_CACHED_WITH_REFRESH('tengu_gate', false)).toBe(false)
|
||||
expect(stub.checkStatsigFeatureGate_CACHED_MAY_BE_STALE('tengu_gate')).toBe(false)
|
||||
expect(await stub.checkGate_CACHED_OR_BLOCKING('tengu_gate')).toBe(false)
|
||||
expect(await stub.getDynamicConfig_BLOCKS_ON_INIT('tengu_config', {})).toEqual({})
|
||||
expect(stub.getDynamicConfig_CACHED_MAY_BE_STALE('tengu_config', {})).toEqual({})
|
||||
})
|
||||
|
||||
// ── Gate helpers route through _getFlagValue ──────────────────────────
|
||||
|
||||
test('checkStatsigFeatureGate_CACHED_MAY_BE_STALE returns false when file is absent', () => {
|
||||
expect(stub.checkStatsigFeatureGate_CACHED_MAY_BE_STALE('tengu_gate')).toBe(false)
|
||||
})
|
||||
|
||||
test('checkStatsigFeatureGate_CACHED_MAY_BE_STALE returns true from flags file', () => {
|
||||
writeFileSync(flagsFile, JSON.stringify({ tengu_gate: true }))
|
||||
expect(stub.checkStatsigFeatureGate_CACHED_MAY_BE_STALE('tengu_gate')).toBe(true)
|
||||
})
|
||||
|
||||
test('checkGate_CACHED_OR_BLOCKING returns false when file is absent', async () => {
|
||||
expect(await stub.checkGate_CACHED_OR_BLOCKING('tengu_bridge')).toBe(false)
|
||||
})
|
||||
|
||||
test('checkGate_CACHED_OR_BLOCKING returns true from flags file', async () => {
|
||||
writeFileSync(flagsFile, JSON.stringify({ tengu_bridge: true }))
|
||||
expect(await stub.checkGate_CACHED_OR_BLOCKING('tengu_bridge')).toBe(true)
|
||||
})
|
||||
|
||||
test('checkSecurityRestrictionGate always returns false regardless of flags', async () => {
|
||||
writeFileSync(flagsFile, JSON.stringify({ tengu_disable_bypass_permissions_mode: true }))
|
||||
expect(await stub.checkSecurityRestrictionGate('tengu_disable_bypass_permissions_mode')).toBe(false)
|
||||
})
|
||||
})
|
||||
+12
-331
@@ -1,21 +1,20 @@
|
||||
/**
|
||||
* No-Telemetry Build Plugin for OpenClaude
|
||||
*
|
||||
* Replaces all analytics, telemetry, and phone-home modules with no-op stubs
|
||||
* at compile time. Zero runtime cost, zero network calls to Anthropic.
|
||||
* Replaces phone-home, internal-only, and deleted-Anthropics-internal modules
|
||||
* with no-op stubs at compile time. Zero runtime cost, zero network calls.
|
||||
*
|
||||
* Analytics and telemetry modules have been replaced at the source level and
|
||||
* no longer need build-time stubs. This plugin now only covers:
|
||||
*
|
||||
* - Auto-updater (phones home to GCS + npm)
|
||||
* - Plugin fetch telemetry
|
||||
* - Transcript / feedback sharing
|
||||
* - Internal employee logging
|
||||
* - Deleted Anthropic-internal modules (dump prompts, undercover, protobuf stubs)
|
||||
*
|
||||
* This file is NOT tracked upstream — merge conflicts are impossible.
|
||||
* Only build.ts needs a one-line import + one-line array entry.
|
||||
*
|
||||
* Kills:
|
||||
* - GrowthBook remote feature flags (api.anthropic.com)
|
||||
* - Datadog event intake
|
||||
* - 1P event logging (api.anthropic.com/api/event_logging/batch)
|
||||
* - BigQuery metrics exporter (api.anthropic.com/api/claude_code/metrics)
|
||||
* - Perfetto / OpenTelemetry session tracing
|
||||
* - Auto-updater (storage.googleapis.com, npm registry)
|
||||
* - Plugin fetch telemetry
|
||||
* - Transcript / feedback sharing
|
||||
*/
|
||||
|
||||
import type { BunPlugin } from 'bun'
|
||||
@@ -23,324 +22,6 @@ import type { BunPlugin } from 'bun'
|
||||
// Module path (relative to src/, without extension) → stub source
|
||||
const stubs: Record<string, string> = {
|
||||
|
||||
// ─── Analytics core ─────────────────────────────────────────────
|
||||
|
||||
'services/analytics/index': `
|
||||
export function stripProtoFields(metadata) { return metadata; }
|
||||
export function attachAnalyticsSink() {}
|
||||
export function logEvent() {}
|
||||
export async function logEventAsync() {}
|
||||
export function _resetForTesting() {}
|
||||
`,
|
||||
|
||||
'services/analytics/growthbook': `
|
||||
import _fs from 'node:fs';
|
||||
import _path from 'node:path';
|
||||
import _os from 'node:os';
|
||||
|
||||
let _flags = undefined;
|
||||
|
||||
// ── Open-build GrowthBook overrides ───────────────────────────────────
|
||||
// Override upstream defaultValue for runtime gates tied to build-time
|
||||
// features. Only keys that DIFFER from upstream belong here — the
|
||||
// catalog below is pure documentation and does NOT affect resolution.
|
||||
//
|
||||
// Priority: ~/.claude/feature-flags.json > _openBuildDefaults > defaultValue
|
||||
//
|
||||
// To override at runtime, create ~/.claude/feature-flags.json:
|
||||
// { "tengu_some_flag": true }
|
||||
const _openBuildDefaults = {
|
||||
'tengu_sedge_lantern': true, // AWAY_SUMMARY — "while you were away" recap (upstream: false)
|
||||
'tengu_hive_evidence': true, // VERIFICATION_AGENT — read-only test/verification agent (upstream: false)
|
||||
'tengu_passport_quail': true, // EXTRACT_MEMORIES — enable memory extraction (upstream: false)
|
||||
'tengu_coral_fern': true, // EXTRACT_MEMORIES — enable memory search in past context (upstream: false)
|
||||
};
|
||||
|
||||
/* ── Known runtime feature keys (reference only) ───────────────────────
|
||||
* This catalog does NOT participate in flag resolution. It documents
|
||||
* the known GrowthBook keys and their upstream default values, scraped
|
||||
* from src/ call sites. It is NOT exhaustive — new keys may be added
|
||||
* upstream between catalog updates.
|
||||
*
|
||||
* Some keys have different defaults at different call sites — this is
|
||||
* intentional upstream (the server unifies the value at runtime).
|
||||
*
|
||||
* To activate any of these, add them to ~/.claude/feature-flags.json
|
||||
* or to _openBuildDefaults above.
|
||||
*
|
||||
* ── Reasoning & thinking ──────────────────────────────────────────────
|
||||
* tengu_turtle_carbon = true ULTRATHINK deep thinking runtime gate
|
||||
* tengu_thinkback = gate /thinkback replay command
|
||||
*
|
||||
* ── Agents & orchestration ────────────────────────────────────────────
|
||||
* tengu_amber_flint = true Agent swarms coordination
|
||||
* tengu_amber_stoat = true Built-in agent availability (Explore, Plan, etc.)
|
||||
* tengu_agent_list_attach = true Attach file context to agent list
|
||||
* tengu_auto_background_agents = false Auto-spawn background agents
|
||||
* tengu_slim_subagent_claudemd = true Lighter ClaudeMD for subagents
|
||||
* tengu_hive_evidence = false Verification agent / evidence tracking (4 call sites)
|
||||
* tengu_ultraplan_model = model cfg ULTRAPLAN model selection (dynamic config)
|
||||
*
|
||||
* ── Memory & context ──────────────────────────────────────────────────
|
||||
* tengu_passport_quail = false EXTRACT_MEMORIES main gate (isExtractModeActive)
|
||||
* tengu_coral_fern = false EXTRACT_MEMORIES search in past context
|
||||
* tengu_slate_thimble = false Memory dir paths (non-interactive sessions)
|
||||
* tengu_herring_clock = true/false Team memory paths (varies by call site)
|
||||
* tengu_bramble_lintel = null Extract memories throttle (null → every turn)
|
||||
* tengu_sedge_lantern = false AWAY_SUMMARY "while you were away" recap
|
||||
* tengu_session_memory = false Session memory service
|
||||
* tengu_sm_config = {} Session memory config (dynamic)
|
||||
* tengu_sm_compact_config = {} Session memory compaction config (dynamic)
|
||||
* tengu_cobalt_raccoon = false Reactive compaction (suppress auto-compact)
|
||||
* tengu_pebble_leaf_prune = false Session storage pruning
|
||||
*
|
||||
* ── Kairos & cron ─────────────────────────────────────────────────────
|
||||
* tengu_kairos_brief = false Brief layout mode (KAIROS)
|
||||
* tengu_kairos_brief_config = {} Brief config (dynamic)
|
||||
* tengu_kairos_cron = true Cron scheduler enable
|
||||
* tengu_kairos_cron_durable = true Durable (disk-persistent) cron tasks
|
||||
* tengu_kairos_cron_config = {} Cron jitter config (dynamic)
|
||||
*
|
||||
* ── Bridge & remote (require Anthropic infra) ─────────────────────────
|
||||
* tengu_ccr_bridge = false CCR bridge connection
|
||||
* tengu_ccr_bridge_multi_session = gate Multi-session spawn mode
|
||||
* tengu_ccr_mirror = false CCR session mirroring
|
||||
* tengu_ccr_bundle_seed_enabled = gate Git bundle seeding for CCR
|
||||
* tengu_ccr_bundle_max_bytes = null Bundle size limit (null → default)
|
||||
* tengu_bridge_repl_v2 = false Environment-less REPL bridge v2
|
||||
* tengu_bridge_repl_v2_cse_shim_enabled = true CSE→Session tag retag shim
|
||||
* tengu_bridge_min_version = {min:'0'} Min CLI version for bridge (dynamic)
|
||||
* tengu_bridge_initial_history_cap = 200 Initial history cap for bridge
|
||||
* tengu_bridge_system_init = false Bridge system initialization
|
||||
* tengu_cobalt_harbor = false Auto-connect CCR at startup
|
||||
* tengu_cobalt_lantern = false Remote setup preconditions
|
||||
* tengu_remote_backend = false Remote TUI backend
|
||||
* tengu_surreal_dali = false Remote agent tasks / triggers
|
||||
*
|
||||
* ── Prompt & API ──────────────────────────────────────────────────────
|
||||
* tengu_attribution_header = true Attribution header in API requests
|
||||
* tengu_basalt_3kr = true MCP instructions delta
|
||||
* tengu_slate_prism = true/false Message formatting (varies by call site)
|
||||
* tengu_amber_prism = false Message content formatting
|
||||
* tengu_amber_json_tools = false JSON format for tool schemas
|
||||
* tengu_fgts = false API feature gates
|
||||
* tengu_otk_slot_v1 = false One-time key slots for API auth
|
||||
* tengu_cicada_nap_ms = 0 Background GrowthBook refresh throttle (ms)
|
||||
* tengu_miraculo_the_bard = false Service initialization gate
|
||||
* tengu_immediate_model_command = false Immediate /model command execution
|
||||
* tengu_chomp_inflection = false Prompt suggestions after responses
|
||||
* tengu_tool_pear = gate API betas for tool use
|
||||
* tengu-off-switch = {act:false} Service kill switch (dynamic; uses dash)
|
||||
*
|
||||
* ── Permissions & security ────────────────────────────────────────────
|
||||
* tengu_birch_trellis = true Bash auto-mode permissions config
|
||||
* tengu_auto_mode_config = {} Auto-mode configuration (dynamic, many call sites)
|
||||
* tengu_iron_gate_closed = true Permission iron gate (with refresh)
|
||||
* tengu_destructive_command_warning = false Warning for destructive bash commands
|
||||
* tengu_disable_bypass_permissions_mode = security Security killswitch (always false in open build)
|
||||
*
|
||||
* ── UI & UX ───────────────────────────────────────────────────────────
|
||||
* tengu_willow_mode = 'off' REPL rendering mode
|
||||
* tengu_terminal_panel = false Terminal panel keybinding
|
||||
* tengu_terminal_sidebar = false Terminal sidebar in REPL/config
|
||||
* tengu_marble_sandcastle = false Fast mode gate
|
||||
* tengu_jade_anvil_4 = false Rate limit options UI ordering
|
||||
* tengu_collage_kaleidoscope = true Native clipboard image paste (macOS)
|
||||
* tengu_lapis_finch = false Plugin/hint recommendation
|
||||
* tengu_lodestone_enabled = false Deep links claude-cli:// protocol
|
||||
* tengu_copper_panda = false Skill improvement suggestions
|
||||
* tengu_desktop_upsell = {} Desktop app upsell config (dynamic)
|
||||
* tengu-top-of-feed-tip = {} Emergency tip of feed (dynamic; uses dash)
|
||||
*
|
||||
* ── File operations ───────────────────────────────────────────────────
|
||||
* tengu_quartz_lantern = false File read/write dedup optimization
|
||||
* tengu_moth_copse = false Attachments handling (variant A)
|
||||
* tengu_marble_fox = false Attachments handling (variant B)
|
||||
* tengu_scratch = gate Scratchpad filesystem access / coordinator
|
||||
*
|
||||
* ── MCP & plugins ─────────────────────────────────────────────────────
|
||||
* tengu_harbor = false MCP channel allowlist verification
|
||||
* tengu_harbor_permissions = false MCP channel permissions enforcement
|
||||
* tengu_copper_bridge = false Chrome MCP bridge
|
||||
* tengu_chrome_auto_enable = false Auto-enable Chrome MCP on startup
|
||||
* tengu_glacier_2xr = false Enhanced tool search / ToolSearchTool
|
||||
* tengu_malort_pedway = {} Computer-use (Chicago) config (dynamic)
|
||||
*
|
||||
* ── VSCode / IDE ──────────────────────────────────────────────────────
|
||||
* tengu_quiet_fern = false VSCode browser support
|
||||
* tengu_vscode_cc_auth = false VSCode in-band OAuth via claude_authenticate
|
||||
* tengu_vscode_review_upsell = gate VSCode review upsell
|
||||
* tengu_vscode_onboarding = gate VSCode onboarding experience
|
||||
*
|
||||
* ── Voice ─────────────────────────────────────────────────────────────
|
||||
* tengu_amber_quartz_disabled = false VOICE_MODE kill-switch (false = voice allowed)
|
||||
*
|
||||
* ── Auto-updater (stubbed in open build) ──────────────────────────────
|
||||
* tengu_version_config = {min:'0'} Min version enforcement (dynamic)
|
||||
* tengu_max_version_config = {} Max version / deprecation config (dynamic)
|
||||
*
|
||||
* ── Telemetry & tracing ───────────────────────────────────────────────
|
||||
* tengu_trace_lantern = false Beta session tracing
|
||||
* tengu_chair_sermon = gate Analytics / message formatting gate
|
||||
* tengu_strap_foyer = false Settings sync to cloud
|
||||
*/
|
||||
|
||||
function _loadFlags() {
|
||||
if (_flags !== undefined) return;
|
||||
try {
|
||||
const flagsPath = process.env.CLAUDE_FEATURE_FLAGS_FILE
|
||||
|| _path.join(_os.homedir(), '.claude', 'feature-flags.json');
|
||||
const parsed = JSON.parse(_fs.readFileSync(flagsPath, 'utf-8'));
|
||||
_flags = (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) ? parsed : null;
|
||||
} catch {
|
||||
_flags = null;
|
||||
}
|
||||
}
|
||||
|
||||
function _getFlagValue(key, defaultValue) {
|
||||
_loadFlags();
|
||||
if (_flags != null && Object.hasOwn(_flags, key)) return _flags[key];
|
||||
if (Object.hasOwn(_openBuildDefaults, key)) return _openBuildDefaults[key];
|
||||
return defaultValue;
|
||||
}
|
||||
|
||||
const noop = () => {};
|
||||
export function onGrowthBookRefresh() { return noop; }
|
||||
export function hasGrowthBookEnvOverride() { return false; }
|
||||
export function getAllGrowthBookFeatures() { _loadFlags(); return _flags || {}; }
|
||||
export function getGrowthBookConfigOverrides() { return {}; }
|
||||
export function setGrowthBookConfigOverride() {}
|
||||
export function clearGrowthBookConfigOverrides() {}
|
||||
export function getApiBaseUrlHost() { return undefined; }
|
||||
export const initializeGrowthBook = async () => null;
|
||||
export async function getFeatureValue_DEPRECATED(feature, defaultValue) { return _getFlagValue(feature, defaultValue); }
|
||||
export function getFeatureValue_CACHED_MAY_BE_STALE(feature, defaultValue) { return _getFlagValue(feature, defaultValue); }
|
||||
export function getFeatureValue_CACHED_WITH_REFRESH(feature, defaultValue) { return _getFlagValue(feature, defaultValue); }
|
||||
export function checkStatsigFeatureGate_CACHED_MAY_BE_STALE(gate) { return Boolean(_getFlagValue(gate, false)); }
|
||||
// Security killswitch — always false in the open build. Anthropic uses this
|
||||
// gate to remotely disable bypassPermissions mode; exposing it via local flags
|
||||
// would let users accidentally lock themselves out of --dangerously-skip-permissions.
|
||||
export async function checkSecurityRestrictionGate(gate) { return false; }
|
||||
export async function checkGate_CACHED_OR_BLOCKING(gate) { return Boolean(_getFlagValue(gate, false)); }
|
||||
export function refreshGrowthBookAfterAuthChange() {}
|
||||
export function resetGrowthBook() { _flags = undefined; }
|
||||
export async function refreshGrowthBookFeatures() { _flags = undefined; }
|
||||
export function setupPeriodicGrowthBookRefresh() {}
|
||||
export function stopPeriodicGrowthBookRefresh() {}
|
||||
export async function getDynamicConfig_BLOCKS_ON_INIT(configName, defaultValue) { return _getFlagValue(configName, defaultValue); }
|
||||
export function getDynamicConfig_CACHED_MAY_BE_STALE(configName, defaultValue) { return _getFlagValue(configName, defaultValue); }
|
||||
`,
|
||||
|
||||
'services/analytics/sink': `
|
||||
export function initializeAnalyticsGates() {}
|
||||
export function initializeAnalyticsSink() {}
|
||||
`,
|
||||
|
||||
'services/analytics/config': `
|
||||
export function isAnalyticsDisabled() { return true; }
|
||||
export function isFeedbackSurveyDisabled() { return true; }
|
||||
`,
|
||||
|
||||
'services/analytics/datadog': `
|
||||
export const initializeDatadog = async () => false;
|
||||
export async function shutdownDatadog() {}
|
||||
export async function trackDatadogEvent() {}
|
||||
`,
|
||||
|
||||
'services/analytics/firstPartyEventLogger': `
|
||||
export function getEventSamplingConfig() { return {}; }
|
||||
export function shouldSampleEvent() { return null; }
|
||||
export async function shutdown1PEventLogging() {}
|
||||
export function is1PEventLoggingEnabled() { return false; }
|
||||
export function logEventTo1P() {}
|
||||
export function logGrowthBookExperimentTo1P() {}
|
||||
export function initialize1PEventLogging() {}
|
||||
export async function reinitialize1PEventLoggingIfConfigChanged() {}
|
||||
`,
|
||||
|
||||
'services/analytics/firstPartyEventLoggingExporter': `
|
||||
export class FirstPartyEventLoggingExporter {
|
||||
constructor() {}
|
||||
async export(logs, resultCallback) { resultCallback({ code: 0 }); }
|
||||
async getQueuedEventCount() { return 0; }
|
||||
async shutdown() {}
|
||||
async forceFlush() {}
|
||||
}
|
||||
`,
|
||||
|
||||
'services/analytics/metadata': `
|
||||
export function sanitizeToolNameForAnalytics(toolName) { return toolName; }
|
||||
export function isToolDetailsLoggingEnabled() { return false; }
|
||||
export function isAnalyticsToolDetailsLoggingEnabled() { return false; }
|
||||
export function mcpToolDetailsForAnalytics() { return {}; }
|
||||
export function extractMcpToolDetails() { return undefined; }
|
||||
export function extractSkillName() { return undefined; }
|
||||
export function extractToolInputForTelemetry() { return undefined; }
|
||||
export function getFileExtensionForAnalytics() { return undefined; }
|
||||
export function getFileExtensionsFromBashCommand() { return undefined; }
|
||||
export async function getEventMetadata() { return {}; }
|
||||
export function to1PEventFormat() { return {}; }
|
||||
`,
|
||||
|
||||
// ─── Telemetry subsystems ───────────────────────────────────────
|
||||
|
||||
'utils/telemetry/bigqueryExporter': `
|
||||
export class BigQueryMetricsExporter {
|
||||
constructor() {}
|
||||
async export(metrics, resultCallback) { resultCallback({ code: 0 }); }
|
||||
async shutdown() {}
|
||||
async forceFlush() {}
|
||||
selectAggregationTemporality() { return 0; }
|
||||
}
|
||||
`,
|
||||
|
||||
'utils/telemetry/perfettoTracing': `
|
||||
export function initializePerfettoTracing() {}
|
||||
export function isPerfettoTracingEnabled() { return false; }
|
||||
export function registerAgent() {}
|
||||
export function unregisterAgent() {}
|
||||
export function startLLMRequestPerfettoSpan() { return ''; }
|
||||
export function endLLMRequestPerfettoSpan() {}
|
||||
export function startToolPerfettoSpan() { return ''; }
|
||||
export function endToolPerfettoSpan() {}
|
||||
export function startUserInputPerfettoSpan() { return ''; }
|
||||
export function endUserInputPerfettoSpan() {}
|
||||
export function emitPerfettoInstant() {}
|
||||
export function emitPerfettoCounter() {}
|
||||
export function startInteractionPerfettoSpan() { return ''; }
|
||||
export function endInteractionPerfettoSpan() {}
|
||||
export function getPerfettoEvents() { return []; }
|
||||
export function resetPerfettoTracer() {}
|
||||
export async function triggerPeriodicWriteForTesting() {}
|
||||
export function evictStaleSpansForTesting() {}
|
||||
export const MAX_EVENTS_FOR_TESTING = 0;
|
||||
export function evictOldestEventsForTesting() {}
|
||||
`,
|
||||
|
||||
'utils/telemetry/sessionTracing': `
|
||||
const noopSpan = {
|
||||
end() {}, setAttribute() {}, setStatus() {},
|
||||
recordException() {}, addEvent() {}, isRecording() { return false; },
|
||||
};
|
||||
export function isBetaTracingEnabled() { return false; }
|
||||
export function isEnhancedTelemetryEnabled() { return false; }
|
||||
export function startInteractionSpan() { return noopSpan; }
|
||||
export function endInteractionSpan() {}
|
||||
export function startLLMRequestSpan() { return noopSpan; }
|
||||
export function endLLMRequestSpan() {}
|
||||
export function startToolSpan() { return noopSpan; }
|
||||
export function startToolBlockedOnUserSpan() { return noopSpan; }
|
||||
export function endToolBlockedOnUserSpan() {}
|
||||
export function startToolExecutionSpan() { return noopSpan; }
|
||||
export function endToolExecutionSpan() {}
|
||||
export function endToolSpan() {}
|
||||
export function addToolContentEvent() {}
|
||||
export function getCurrentSpan() { return null; }
|
||||
export async function executeInSpan(spanName, fn) { return fn(noopSpan); }
|
||||
export function startHookSpan() { return noopSpan; }
|
||||
export function endHookSpan() {}
|
||||
`,
|
||||
|
||||
// ─── Auto-updater (phones home to GCS + npm) ──────────────────
|
||||
|
||||
'utils/autoUpdater': `
|
||||
@@ -456,4 +137,4 @@ export const noTelemetryPlugin: BunPlugin = {
|
||||
|
||||
console.log(` 🔇 no-telemetry: stubbed ${Object.keys(stubs).length} modules`)
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -5,7 +5,7 @@
|
||||
* Run as part of the build to catch missing externals early.
|
||||
*/
|
||||
import { readFileSync } from 'fs'
|
||||
import { CLI_EXTERNALS, SDK_EXTERNALS, INTENTIONALLY_BUNDLED } from './externals.js'
|
||||
import { CLI_EXTERNALS, SDK_EXTERNALS, INTENTIONALLY_BUNDLED, OPTIONAL_RUNTIME_EXTERNALS } from './externals.js'
|
||||
|
||||
const pkg = JSON.parse(readFileSync('package.json', 'utf8'))
|
||||
const allDeps = new Set([
|
||||
@@ -32,7 +32,8 @@ function validate(bundleName: string, externals: string[]): boolean {
|
||||
return false
|
||||
}
|
||||
|
||||
const extra = [...externalSet].filter(d => !allDeps.has(d))
|
||||
const optionalSet = new Set(OPTIONAL_RUNTIME_EXTERNALS)
|
||||
const extra = [...externalSet].filter(d => !allDeps.has(d) && !optionalSet.has(d))
|
||||
if (extra.length > 0) {
|
||||
console.warn(`⚠️ ${bundleName}: External entries not in package.json (may be ok):`)
|
||||
for (const dep of extra) {
|
||||
|
||||
@@ -1,9 +1,4 @@
|
||||
import type { BetaMessageStreamParams } from '@anthropic-ai/sdk/resources/beta/messages/messages.mjs'
|
||||
import type { Attributes, Meter, MetricOptions } from '@opentelemetry/api'
|
||||
import type { logs } from '@opentelemetry/api-logs'
|
||||
import type { LoggerProvider } from '@opentelemetry/sdk-logs'
|
||||
import type { MeterProvider } from '@opentelemetry/sdk-metrics'
|
||||
import type { BasicTracerProvider } from '@opentelemetry/sdk-trace-base'
|
||||
import { realpathSync } from 'fs'
|
||||
import sumBy from 'lodash-es/sumBy.js'
|
||||
import { cwd } from 'process'
|
||||
@@ -38,10 +33,6 @@ export type ChannelEntry =
|
||||
| { kind: 'plugin'; name: string; marketplace: string; dev?: boolean }
|
||||
| { kind: 'server'; name: string; dev?: boolean }
|
||||
|
||||
export type AttributedCounter = {
|
||||
add(value: number, additionalAttributes?: Attributes): void
|
||||
}
|
||||
|
||||
type State = {
|
||||
originalCwd: string
|
||||
// Stable project root - set once at startup (including by --worktree flag),
|
||||
@@ -86,27 +77,10 @@ type State = {
|
||||
sessionIngressToken: string | null | undefined
|
||||
oauthTokenFromFd: string | null | undefined
|
||||
apiKeyFromFd: string | null | undefined
|
||||
// Telemetry state
|
||||
meter: Meter | null
|
||||
sessionCounter: AttributedCounter | null
|
||||
locCounter: AttributedCounter | null
|
||||
prCounter: AttributedCounter | null
|
||||
commitCounter: AttributedCounter | null
|
||||
costCounter: AttributedCounter | null
|
||||
tokenCounter: AttributedCounter | null
|
||||
codeEditToolDecisionCounter: AttributedCounter | null
|
||||
activeTimeCounter: AttributedCounter | null
|
||||
statsStore: { observe(name: string, value: number): void } | null
|
||||
sessionId: SessionId
|
||||
// Parent session ID for tracking session lineage (e.g., plan mode -> implementation)
|
||||
parentSessionId: SessionId | undefined
|
||||
// Logger state
|
||||
loggerProvider: LoggerProvider | null
|
||||
eventLogger: ReturnType<typeof logs.getLogger> | null
|
||||
// Meter provider state
|
||||
meterProvider: MeterProvider | null
|
||||
// Tracer provider state
|
||||
tracerProvider: BasicTracerProvider | null
|
||||
// Agent color state
|
||||
agentColorMap: Map<string, AgentColorName>
|
||||
agentColorIndex: number
|
||||
@@ -317,25 +291,9 @@ function getInitialState(): State {
|
||||
'flagSettings',
|
||||
'policySettings',
|
||||
],
|
||||
// Telemetry state
|
||||
meter: null,
|
||||
sessionCounter: null,
|
||||
locCounter: null,
|
||||
prCounter: null,
|
||||
commitCounter: null,
|
||||
costCounter: null,
|
||||
tokenCounter: null,
|
||||
codeEditToolDecisionCounter: null,
|
||||
activeTimeCounter: null,
|
||||
statsStore: null,
|
||||
sessionId: randomUUID() as SessionId,
|
||||
parentSessionId: undefined,
|
||||
// Logger state
|
||||
loggerProvider: null,
|
||||
eventLogger: null,
|
||||
// Meter provider state
|
||||
meterProvider: null,
|
||||
tracerProvider: null,
|
||||
// Agent color state
|
||||
agentColorMap: new Map(),
|
||||
agentColorIndex: 0,
|
||||
@@ -1019,115 +977,6 @@ export function resetModelStringsForTestingOnly() {
|
||||
STATE.modelStrings = null
|
||||
}
|
||||
|
||||
export function setMeter(
|
||||
meter: Meter,
|
||||
createCounter: (name: string, options: MetricOptions) => AttributedCounter,
|
||||
): void {
|
||||
STATE.meter = meter
|
||||
|
||||
// Initialize all counters using the provided factory
|
||||
STATE.sessionCounter = createCounter('claude_code.session.count', {
|
||||
description: 'Count of CLI sessions started',
|
||||
})
|
||||
STATE.locCounter = createCounter('claude_code.lines_of_code.count', {
|
||||
description:
|
||||
"Count of lines of code modified, with the 'type' attribute indicating whether lines were added or removed",
|
||||
})
|
||||
STATE.prCounter = createCounter('claude_code.pull_request.count', {
|
||||
description: 'Number of pull requests created',
|
||||
})
|
||||
STATE.commitCounter = createCounter('claude_code.commit.count', {
|
||||
description: 'Number of git commits created',
|
||||
})
|
||||
STATE.costCounter = createCounter('claude_code.cost.usage', {
|
||||
description: 'Cost of the Claude Code session',
|
||||
unit: 'USD',
|
||||
})
|
||||
STATE.tokenCounter = createCounter('claude_code.token.usage', {
|
||||
description: 'Number of tokens used',
|
||||
unit: 'tokens',
|
||||
})
|
||||
STATE.codeEditToolDecisionCounter = createCounter(
|
||||
'claude_code.code_edit_tool.decision',
|
||||
{
|
||||
description:
|
||||
'Count of code editing tool permission decisions (accept/reject) for Edit, Write, and NotebookEdit tools',
|
||||
},
|
||||
)
|
||||
STATE.activeTimeCounter = createCounter('claude_code.active_time.total', {
|
||||
description: 'Total active time in seconds',
|
||||
unit: 's',
|
||||
})
|
||||
}
|
||||
|
||||
export function getMeter(): Meter | null {
|
||||
return STATE.meter
|
||||
}
|
||||
|
||||
export function getSessionCounter(): AttributedCounter | null {
|
||||
return STATE.sessionCounter
|
||||
}
|
||||
|
||||
export function getLocCounter(): AttributedCounter | null {
|
||||
return STATE.locCounter
|
||||
}
|
||||
|
||||
export function getPrCounter(): AttributedCounter | null {
|
||||
return STATE.prCounter
|
||||
}
|
||||
|
||||
export function getCommitCounter(): AttributedCounter | null {
|
||||
return STATE.commitCounter
|
||||
}
|
||||
|
||||
export function getCostCounter(): AttributedCounter | null {
|
||||
return STATE.costCounter
|
||||
}
|
||||
|
||||
export function getTokenCounter(): AttributedCounter | null {
|
||||
return STATE.tokenCounter
|
||||
}
|
||||
|
||||
export function getCodeEditToolDecisionCounter(): AttributedCounter | null {
|
||||
return STATE.codeEditToolDecisionCounter
|
||||
}
|
||||
|
||||
export function getActiveTimeCounter(): AttributedCounter | null {
|
||||
return STATE.activeTimeCounter
|
||||
}
|
||||
|
||||
export function getLoggerProvider(): LoggerProvider | null {
|
||||
return STATE.loggerProvider
|
||||
}
|
||||
|
||||
export function setLoggerProvider(provider: LoggerProvider | null): void {
|
||||
STATE.loggerProvider = provider
|
||||
}
|
||||
|
||||
export function getEventLogger(): ReturnType<typeof logs.getLogger> | null {
|
||||
return STATE.eventLogger
|
||||
}
|
||||
|
||||
export function setEventLogger(
|
||||
logger: ReturnType<typeof logs.getLogger> | null,
|
||||
): void {
|
||||
STATE.eventLogger = logger
|
||||
}
|
||||
|
||||
export function getMeterProvider(): MeterProvider | null {
|
||||
return STATE.meterProvider
|
||||
}
|
||||
|
||||
export function setMeterProvider(provider: MeterProvider | null): void {
|
||||
STATE.meterProvider = provider
|
||||
}
|
||||
export function getTracerProvider(): BasicTracerProvider | null {
|
||||
return STATE.tracerProvider
|
||||
}
|
||||
export function setTracerProvider(provider: BasicTracerProvider | null): void {
|
||||
STATE.tracerProvider = provider
|
||||
}
|
||||
|
||||
export function getIsNonInteractiveSession(): boolean {
|
||||
return !STATE.isInteractive
|
||||
}
|
||||
|
||||
@@ -4,7 +4,6 @@ import { hostname, tmpdir } from 'os'
|
||||
import { basename, join, resolve } from 'path'
|
||||
import { getRemoteSessionUrl } from '../constants/product.js'
|
||||
import { shutdownDatadog } from '../services/analytics/datadog.js'
|
||||
import { shutdown1PEventLogging } from '../services/analytics/firstPartyEventLogger.js'
|
||||
import { checkGate_CACHED_OR_BLOCKING } from '../services/analytics/growthbook.js'
|
||||
import {
|
||||
type AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
|
||||
@@ -2064,7 +2063,7 @@ export async function bridgeMain(args: string[]): Promise<void> {
|
||||
// (sleep() doesn't unref its timer, but process.exit() follows immediately
|
||||
// so the ref'd timer can't delay shutdown.)
|
||||
await Promise.race([
|
||||
Promise.all([shutdown1PEventLogging(), shutdownDatadog()]),
|
||||
shutdownDatadog(),
|
||||
sleep(500, undefined, { unref: true }),
|
||||
]).catch(() => {})
|
||||
// biome-ignore lint/suspicious/noConsole: intentional error output
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -3,7 +3,6 @@ import { readFile, stat } from 'fs/promises';
|
||||
import * as React from 'react';
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
import { getLastAPIRequest } from 'src/bootstrap/state.js';
|
||||
import { logEventTo1P } from 'src/services/analytics/firstPartyEventLogger.js';
|
||||
import { type AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, logEvent } from 'src/services/analytics/index.js';
|
||||
import { getLastAssistantMessage, normalizeMessagesForAPI } from 'src/utils/messages.js';
|
||||
import type { CommandResultDisplay } from '../commands.js';
|
||||
@@ -236,12 +235,7 @@ export function Feedback({
|
||||
feedback_id: result.feedbackId as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
|
||||
last_assistant_message_id: lastAssistantMessageId as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS
|
||||
});
|
||||
// 1P-only: freeform text approved for BQ. Join on feedback_id.
|
||||
logEventTo1P('tengu_bug_report_description', {
|
||||
feedback_id: result.feedbackId as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
|
||||
description: redactSensitiveInfo(description) as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS
|
||||
});
|
||||
}
|
||||
}
|
||||
setStep('done');
|
||||
} else {
|
||||
if (result.isZdrOrg) {
|
||||
|
||||
@@ -9,7 +9,6 @@ import { isEnvTruthy } from '../../utils/envUtils.js';
|
||||
import { getLastAssistantMessage } from '../../utils/messages.js';
|
||||
import { getMainLoopModel } from '../../utils/model/model.js';
|
||||
import { getInitialSettings } from '../../utils/settings/settings.js';
|
||||
import { logOTelEvent } from '../../utils/telemetry/events.js';
|
||||
import { submitTranscriptShare, type TranscriptShareTrigger } from './submitTranscriptShare.js';
|
||||
import type { TranscriptShareResponse } from './TranscriptSharePrompt.js';
|
||||
import { useSurveyState } from './useSurveyState.js';
|
||||
@@ -99,12 +98,7 @@ export function useFeedbackSurvey(messages: Message[], isLoading: boolean, submi
|
||||
last_assistant_message_id: lastAssistantMessageIdRef.current as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
|
||||
survey_type: surveyType as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS
|
||||
});
|
||||
void logOTelEvent('feedback_survey', {
|
||||
event_type: 'appeared',
|
||||
appearance_id: appearanceId,
|
||||
survey_type: surveyType
|
||||
});
|
||||
}, [updateLastShownTime, surveyType]);
|
||||
}, [updateLastShownTime, surveyType]);
|
||||
const onSelect = useCallback((appearanceId_0: string, selected: FeedbackSurveyResponse) => {
|
||||
updateLastShownTime(Date.now(), submitCountRef.current);
|
||||
logEvent('tengu_feedback_survey_event', {
|
||||
@@ -114,13 +108,7 @@ export function useFeedbackSurvey(messages: Message[], isLoading: boolean, submi
|
||||
last_assistant_message_id: lastAssistantMessageIdRef.current as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
|
||||
survey_type: surveyType as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS
|
||||
});
|
||||
void logOTelEvent('feedback_survey', {
|
||||
event_type: 'responded',
|
||||
appearance_id: appearanceId_0,
|
||||
response: selected,
|
||||
survey_type: surveyType
|
||||
});
|
||||
}, [updateLastShownTime, surveyType]);
|
||||
}, [updateLastShownTime, surveyType]);
|
||||
const shouldShowTranscriptPrompt = useCallback((selected_0: FeedbackSurveyResponse) => {
|
||||
// Only bad and good ratings trigger the transcript ask
|
||||
if (selected_0 !== 'bad' && selected_0 !== 'good') {
|
||||
@@ -150,12 +138,7 @@ export function useFeedbackSurvey(messages: Message[], isLoading: boolean, submi
|
||||
survey_type: surveyType as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
|
||||
trigger: trigger as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS
|
||||
});
|
||||
void logOTelEvent('feedback_survey', {
|
||||
event_type: 'transcript_prompt_appeared',
|
||||
appearance_id: appearanceId_1,
|
||||
survey_type: surveyType
|
||||
});
|
||||
}, [surveyType]);
|
||||
}, [surveyType]);
|
||||
const onTranscriptSelect = useCallback(async (appearanceId_2: string, selected_1: TranscriptShareResponse, surveyResponse_0: FeedbackSurveyResponse | null): Promise<boolean> => {
|
||||
const trigger_0: TranscriptShareTrigger = surveyResponse_0 === 'good' ? 'good_feedback_survey' : 'bad_feedback_survey';
|
||||
logEvent('tengu_feedback_survey_event', {
|
||||
|
||||
@@ -10,7 +10,6 @@ import { getGlobalConfig, saveGlobalConfig } from '../../utils/config.js';
|
||||
import { isEnvTruthy } from '../../utils/envUtils.js';
|
||||
import { isAutoManagedMemoryFile } from '../../utils/memoryFileDetection.js';
|
||||
import { extractTextContent, getLastAssistantMessage } from '../../utils/messages.js';
|
||||
import { logOTelEvent } from '../../utils/telemetry/events.js';
|
||||
import { submitTranscriptShare } from './submitTranscriptShare.js';
|
||||
import type { TranscriptShareResponse } from './TranscriptSharePrompt.js';
|
||||
import { useSurveyState } from './useSurveyState.js';
|
||||
@@ -67,25 +66,14 @@ export function useMemorySurvey(messages: Message[], isLoading: boolean, hasActi
|
||||
event_type: 'appeared' as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
|
||||
appearance_id: appearanceId as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS
|
||||
});
|
||||
void logOTelEvent('feedback_survey', {
|
||||
event_type: 'appeared',
|
||||
appearance_id: appearanceId,
|
||||
survey_type: 'memory'
|
||||
});
|
||||
}, []);
|
||||
}, []);
|
||||
const onSelect = useCallback((appearanceId_0: string, selected: FeedbackSurveyResponse) => {
|
||||
logEvent(MEMORY_SURVEY_EVENT, {
|
||||
event_type: 'responded' as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
|
||||
appearance_id: appearanceId_0 as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
|
||||
response: selected as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS
|
||||
});
|
||||
void logOTelEvent('feedback_survey', {
|
||||
event_type: 'responded',
|
||||
appearance_id: appearanceId_0,
|
||||
response: selected,
|
||||
survey_type: 'memory'
|
||||
});
|
||||
}, []);
|
||||
}, []);
|
||||
const shouldShowTranscriptPrompt = useCallback((selected_0: FeedbackSurveyResponse) => {
|
||||
if ("external" !== 'ant') {
|
||||
return false;
|
||||
@@ -107,12 +95,7 @@ export function useMemorySurvey(messages: Message[], isLoading: boolean, hasActi
|
||||
appearance_id: appearanceId_1 as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
|
||||
trigger: TRANSCRIPT_SHARE_TRIGGER as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS
|
||||
});
|
||||
void logOTelEvent('feedback_survey', {
|
||||
event_type: 'transcript_prompt_appeared',
|
||||
appearance_id: appearanceId_1,
|
||||
survey_type: 'memory'
|
||||
});
|
||||
}, []);
|
||||
}, []);
|
||||
const onTranscriptSelect = useCallback(async (appearanceId_2: string, selected_1: TranscriptShareResponse): Promise<boolean> => {
|
||||
logEvent(MEMORY_SURVEY_EVENT, {
|
||||
event_type: `transcript_share_${selected_1}` as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
|
||||
|
||||
@@ -7,7 +7,6 @@ import { shouldUseSessionMemoryCompaction } from '../../services/compact/session
|
||||
import type { Message } from '../../types/message.js';
|
||||
import { isEnvTruthy } from '../../utils/envUtils.js';
|
||||
import { isCompactBoundaryMessage } from '../../utils/messages.js';
|
||||
import { logOTelEvent } from '../../utils/telemetry/events.js';
|
||||
import { useSurveyState } from './useSurveyState.js';
|
||||
import type { FeedbackSurveyResponse } from './utils.js';
|
||||
const HIDE_THANKS_AFTER_MS = 3000;
|
||||
@@ -183,13 +182,7 @@ function _temp2(appearanceId_0, selected) {
|
||||
response: selected as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
|
||||
session_memory_compaction_enabled: smCompactionEnabled_0 as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS
|
||||
});
|
||||
logOTelEvent("feedback_survey", {
|
||||
event_type: "responded",
|
||||
appearance_id: appearanceId_0,
|
||||
response: selected,
|
||||
survey_type: "post_compact"
|
||||
});
|
||||
}
|
||||
}
|
||||
function _temp(appearanceId) {
|
||||
const smCompactionEnabled = shouldUseSessionMemoryCompaction();
|
||||
logEvent("tengu_post_compact_survey_event", {
|
||||
@@ -197,9 +190,4 @@ function _temp(appearanceId) {
|
||||
appearance_id: appearanceId as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
|
||||
session_memory_compaction_enabled: smCompactionEnabled as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS
|
||||
});
|
||||
logOTelEvent("feedback_survey", {
|
||||
event_type: "appeared",
|
||||
appearance_id: appearanceId,
|
||||
survey_type: "post_compact"
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -12,11 +12,9 @@ import { getAPIProvider, isGithubNativeAnthropicMode } from './utils/model/provi
|
||||
import {
|
||||
addToTotalCostState,
|
||||
addToTotalLinesChanged,
|
||||
getCostCounter,
|
||||
getModelUsage,
|
||||
getSdkBetas,
|
||||
getSessionId,
|
||||
getTokenCounter,
|
||||
getTotalAPIDuration,
|
||||
getTotalAPIDurationWithoutRetries,
|
||||
getTotalCacheCreationInputTokens,
|
||||
@@ -358,18 +356,6 @@ export function addToTotalSessionCost(
|
||||
? { model, speed: 'fast' }
|
||||
: { model }
|
||||
|
||||
getCostCounter()?.add(cost, attrs)
|
||||
getTokenCounter()?.add(usage.input_tokens, { ...attrs, type: 'input' })
|
||||
getTokenCounter()?.add(usage.output_tokens, { ...attrs, type: 'output' })
|
||||
getTokenCounter()?.add(usage.cache_read_input_tokens ?? 0, {
|
||||
...attrs,
|
||||
type: 'cacheRead',
|
||||
})
|
||||
getTokenCounter()?.add(usage.cache_creation_input_tokens ?? 0, {
|
||||
...attrs,
|
||||
type: 'cacheCreation',
|
||||
})
|
||||
|
||||
let totalCost = cost
|
||||
for (const advisorUsage of getAdvisorUsage(usage)) {
|
||||
const advisorCost = calculateUSDCost(advisorUsage.model, advisorUsage)
|
||||
|
||||
+4
-125
@@ -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,18 +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()
|
||||
@@ -87,22 +75,7 @@ export const init = memoize(async (): Promise<void> => {
|
||||
setupGracefulShutdown()
|
||||
profileCheckpoint('init_after_graceful_shutdown')
|
||||
|
||||
// Initialize 1P event logging (no security concerns, but deferred to avoid
|
||||
// loading OpenTelemetry sdk-logs at startup). growthbook.js is already in
|
||||
// the module cache by this point (firstPartyEventLogger imports it), so the
|
||||
// second dynamic import adds no load cost.
|
||||
void Promise.all([
|
||||
import('../services/analytics/firstPartyEventLogger.js'),
|
||||
import('../services/analytics/growthbook.js'),
|
||||
]).then(([fp, gb]) => {
|
||||
fp.initialize1PEventLogging()
|
||||
// Rebuild the logger provider if tengu_1p_event_batch_config changes
|
||||
// mid-session. Change detection (isEqual) is inside the handler so
|
||||
// unchanged refreshes are no-ops.
|
||||
gb.onGrowthBookRefresh(() => {
|
||||
void fp.reinitialize1PEventLoggingIfConfigChanged()
|
||||
})
|
||||
})
|
||||
// 1P event logging and GrowthBook initialization removed (no-op stubs)
|
||||
profileCheckpoint('init_after_1p_event_logging')
|
||||
|
||||
// Populate OAuth account info if it is not already cached in config. This is needed since the
|
||||
@@ -238,103 +211,9 @@ 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.
|
||||
* No-op — telemetry initialization has been removed.
|
||||
* Kept as an empty function for API compatibility with callers.
|
||||
*/
|
||||
export function initializeTelemetryAfterTrust(): void {
|
||||
if (isEligibleForRemoteManagedSettings()) {
|
||||
// For SDK/headless mode with beta tracing, initialize eagerly first
|
||||
// to ensure the tracer is ready before the first query runs.
|
||||
// The async path below will still run but doInitializeTelemetry() guards against double init.
|
||||
if (getIsNonInteractiveSession() && isBetaTracingEnabled()) {
|
||||
void doInitializeTelemetry().catch(error => {
|
||||
logForDebugging(
|
||||
`[3P telemetry] Eager telemetry init failed (beta tracing): ${errorMessage(error)}`,
|
||||
{ level: 'error' },
|
||||
)
|
||||
})
|
||||
}
|
||||
logForDebugging(
|
||||
'[3P telemetry] Waiting for remote managed settings before telemetry init',
|
||||
)
|
||||
void waitForRemoteManagedSettingsToLoad()
|
||||
.then(async () => {
|
||||
logForDebugging(
|
||||
'[3P telemetry] Remote managed settings loaded, initializing telemetry',
|
||||
)
|
||||
// Re-apply env vars to pick up remote settings before initializing telemetry.
|
||||
applyConfigEnvironmentVariables()
|
||||
await doInitializeTelemetry()
|
||||
})
|
||||
.catch(error => {
|
||||
logForDebugging(
|
||||
`[3P telemetry] Telemetry init failed (remote settings path): ${errorMessage(error)}`,
|
||||
{ level: 'error' },
|
||||
)
|
||||
})
|
||||
} else {
|
||||
void doInitializeTelemetry().catch(error => {
|
||||
logForDebugging(
|
||||
`[3P telemetry] Telemetry init failed: ${errorMessage(error)}`,
|
||||
{ level: 'error' },
|
||||
)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
async function doInitializeTelemetry(): Promise<void> {
|
||||
if (telemetryInitialized) {
|
||||
// Already initialized, nothing to do
|
||||
return
|
||||
}
|
||||
|
||||
// Set flag before init to prevent double initialization
|
||||
telemetryInitialized = true
|
||||
try {
|
||||
await setMeterState()
|
||||
} catch (error) {
|
||||
// Reset flag on failure so subsequent calls can retry
|
||||
telemetryInitialized = false
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
async function setMeterState(): Promise<void> {
|
||||
// Lazy-load instrumentation to defer ~400KB of OpenTelemetry + protobuf
|
||||
const { initializeTelemetry } = await import(
|
||||
'../utils/telemetry/instrumentation.js'
|
||||
)
|
||||
// Initialize customer OTLP telemetry (metrics, logs, traces)
|
||||
const meter = await initializeTelemetry()
|
||||
if (meter) {
|
||||
// Create factory function for attributed counters
|
||||
const createAttributedCounter = (
|
||||
name: string,
|
||||
options: MetricOptions,
|
||||
): AttributedCounter => {
|
||||
const counter = meter?.createCounter(name, options)
|
||||
|
||||
return {
|
||||
add(value: number, additionalAttributes: Attributes = {}) {
|
||||
// Always fetch fresh telemetry attributes to ensure they're up to date
|
||||
const currentAttributes = getTelemetryAttributes()
|
||||
const mergedAttributes = {
|
||||
...currentAttributes,
|
||||
...additionalAttributes,
|
||||
}
|
||||
counter?.add(value, mergedAttributes)
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
setMeter(meter, createAttributedCounter)
|
||||
|
||||
// Increment session counter here because the startup telemetry path
|
||||
// runs before this async initialization completes, so the counter
|
||||
// would be null there.
|
||||
getSessionCounter()?.add(1)
|
||||
}
|
||||
// Telemetry is no longer initialized; this is a no-op.
|
||||
}
|
||||
|
||||
@@ -7,11 +7,9 @@ import {
|
||||
logEvent,
|
||||
} from 'src/services/analytics/index.js'
|
||||
import { sanitizeToolNameForAnalytics } from 'src/services/analytics/metadata.js'
|
||||
import { getCodeEditToolDecisionCounter } from '../../bootstrap/state.js'
|
||||
import type { Tool as ToolType, ToolUseContext } from '../../Tool.js'
|
||||
import { getLanguageName } from '../../utils/cliHighlight.js'
|
||||
import { SandboxManager } from '../../utils/sandbox/sandbox-adapter.js'
|
||||
import { logOTelEvent } from '../../utils/telemetry/events.js'
|
||||
import type {
|
||||
PermissionApprovalSource,
|
||||
PermissionRejectionSource,
|
||||
@@ -210,13 +208,6 @@ function logPermissionDecision(
|
||||
|
||||
const sourceString = source === 'config' ? 'config' : sourceToString(source)
|
||||
|
||||
// Track code editing tool metrics
|
||||
if (isCodeEditingTool(tool.name)) {
|
||||
void buildCodeEditToolAttributes(tool, input, decision, sourceString).then(
|
||||
attributes => getCodeEditToolDecisionCounter()?.add(1, attributes),
|
||||
)
|
||||
}
|
||||
|
||||
// Persist decision on the context so downstream code can inspect what happened
|
||||
if (!toolUseContext.toolDecisions) {
|
||||
toolUseContext.toolDecisions = new Map()
|
||||
@@ -226,12 +217,6 @@ function logPermissionDecision(
|
||||
decision,
|
||||
timestamp: Date.now(),
|
||||
})
|
||||
|
||||
void logOTelEvent('tool_decision', {
|
||||
decision,
|
||||
source: sourceString,
|
||||
tool_name: sanitizeToolNameForAnalytics(tool.name),
|
||||
})
|
||||
}
|
||||
|
||||
export { isCodeEditingTool, buildCodeEditToolAttributes, logPermissionDecision }
|
||||
|
||||
@@ -45,7 +45,6 @@ import { WorkerPendingPermission } from '../components/permissions/WorkerPending
|
||||
import { injectUserMessageToTeammate, getAllInProcessTeammateTasks } from '../tasks/InProcessTeammateTask/InProcessTeammateTask.js';
|
||||
import { isLocalAgentTask, queuePendingMessage, appendMessageToLocalAgent, type LocalAgentTaskState } from '../tasks/LocalAgentTask/LocalAgentTask.js';
|
||||
import { registerLeaderToolUseConfirmQueue, unregisterLeaderToolUseConfirmQueue, registerLeaderSetToolPermissionContext, unregisterLeaderSetToolPermissionContext } from '../utils/swarm/leaderPermissionBridge.js';
|
||||
import { endInteractionSpan } from '../utils/telemetry/sessionTracing.js';
|
||||
import { useLogMessages } from '../hooks/useLogMessages.js';
|
||||
import { useReplBridge } from '../hooks/useReplBridge.js';
|
||||
import { type Command, type CommandResultDisplay, type ResumeEntrypoint, getCommandName, isCommandEnabled } from '../commands.js';
|
||||
@@ -1613,8 +1612,7 @@ export function REPL({
|
||||
setSpinnerColor(null);
|
||||
setSpinnerShimmerColor(null);
|
||||
pickNewSpinnerTip();
|
||||
endInteractionSpan();
|
||||
// Speculative bash classifier checks are only valid for the current
|
||||
// Speculative bash classifier checks are only valid for the current
|
||||
// turn's commands — clear after each turn to avoid accumulating
|
||||
// Promise chains for unconsumed checks (denied/aborted paths).
|
||||
clearSpeculativeChecks();
|
||||
|
||||
@@ -1,307 +1,8 @@
|
||||
import axios from 'axios'
|
||||
import { createHash } from 'crypto'
|
||||
import memoize from 'lodash-es/memoize.js'
|
||||
import { getOrCreateUserID } from '../../utils/config.js'
|
||||
import { logError } from '../../utils/log.js'
|
||||
import { getCanonicalName } from '../../utils/model/model.js'
|
||||
import { getAPIProvider } from '../../utils/model/providers.js'
|
||||
import { MODEL_COSTS } from '../../utils/modelCost.js'
|
||||
import { isAnalyticsDisabled } from './config.js'
|
||||
import { getEventMetadata } from './metadata.js'
|
||||
|
||||
const DATADOG_LOGS_ENDPOINT =
|
||||
'https://http-intake.logs.us5.datadoghq.com/api/v2/logs'
|
||||
const DATADOG_CLIENT_TOKEN = process.env.DATADOG_CLIENT_TOKEN ?? ''
|
||||
const DEFAULT_FLUSH_INTERVAL_MS = 15000
|
||||
const MAX_BATCH_SIZE = 100
|
||||
const NETWORK_TIMEOUT_MS = 5000
|
||||
|
||||
const DATADOG_ALLOWED_EVENTS = new Set([
|
||||
'chrome_bridge_connection_succeeded',
|
||||
'chrome_bridge_connection_failed',
|
||||
'chrome_bridge_disconnected',
|
||||
'chrome_bridge_tool_call_completed',
|
||||
'chrome_bridge_tool_call_error',
|
||||
'chrome_bridge_tool_call_started',
|
||||
'chrome_bridge_tool_call_timeout',
|
||||
'tengu_api_error',
|
||||
'tengu_api_success',
|
||||
'tengu_brief_mode_enabled',
|
||||
'tengu_brief_mode_toggled',
|
||||
'tengu_brief_send',
|
||||
'tengu_cancel',
|
||||
'tengu_compact_failed',
|
||||
'tengu_exit',
|
||||
'tengu_flicker',
|
||||
'tengu_init',
|
||||
'tengu_model_fallback_triggered',
|
||||
'tengu_oauth_error',
|
||||
'tengu_oauth_success',
|
||||
'tengu_oauth_token_refresh_failure',
|
||||
'tengu_oauth_token_refresh_success',
|
||||
'tengu_oauth_token_refresh_lock_acquiring',
|
||||
'tengu_oauth_token_refresh_lock_acquired',
|
||||
'tengu_oauth_token_refresh_starting',
|
||||
'tengu_oauth_token_refresh_completed',
|
||||
'tengu_oauth_token_refresh_lock_releasing',
|
||||
'tengu_oauth_token_refresh_lock_released',
|
||||
'tengu_query_error',
|
||||
'tengu_session_file_read',
|
||||
'tengu_started',
|
||||
'tengu_tool_use_error',
|
||||
'tengu_tool_use_granted_in_prompt_permanent',
|
||||
'tengu_tool_use_granted_in_prompt_temporary',
|
||||
'tengu_tool_use_rejected_in_prompt',
|
||||
'tengu_tool_use_success',
|
||||
'tengu_uncaught_exception',
|
||||
'tengu_unhandled_rejection',
|
||||
'tengu_voice_recording_started',
|
||||
'tengu_voice_toggled',
|
||||
'tengu_team_mem_sync_pull',
|
||||
'tengu_team_mem_sync_push',
|
||||
'tengu_team_mem_sync_started',
|
||||
'tengu_team_mem_entries_capped',
|
||||
])
|
||||
|
||||
const TAG_FIELDS = [
|
||||
'arch',
|
||||
'clientType',
|
||||
'errorType',
|
||||
'http_status_range',
|
||||
'http_status',
|
||||
'kairosActive',
|
||||
'model',
|
||||
'platform',
|
||||
'provider',
|
||||
'skillMode',
|
||||
'subscriptionType',
|
||||
'toolName',
|
||||
'userBucket',
|
||||
'userType',
|
||||
'version',
|
||||
'versionBase',
|
||||
]
|
||||
|
||||
function camelToSnakeCase(str: string): string {
|
||||
return str.replace(/[A-Z]/g, letter => `_${letter.toLowerCase()}`)
|
||||
}
|
||||
|
||||
type DatadogLog = {
|
||||
ddsource: string
|
||||
ddtags: string
|
||||
message: string
|
||||
service: string
|
||||
hostname: string
|
||||
[key: string]: unknown
|
||||
}
|
||||
|
||||
let logBatch: DatadogLog[] = []
|
||||
let flushTimer: NodeJS.Timeout | null = null
|
||||
let datadogInitialized: boolean | null = null
|
||||
|
||||
async function flushLogs(): Promise<void> {
|
||||
if (logBatch.length === 0) return
|
||||
|
||||
const logsToSend = logBatch
|
||||
logBatch = []
|
||||
|
||||
try {
|
||||
await axios.post(DATADOG_LOGS_ENDPOINT, logsToSend, {
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'DD-API-KEY': DATADOG_CLIENT_TOKEN,
|
||||
},
|
||||
timeout: NETWORK_TIMEOUT_MS,
|
||||
})
|
||||
} catch (error) {
|
||||
logError(error)
|
||||
}
|
||||
}
|
||||
|
||||
function scheduleFlush(): void {
|
||||
if (flushTimer) return
|
||||
|
||||
flushTimer = setTimeout(() => {
|
||||
flushTimer = null
|
||||
void flushLogs()
|
||||
}, getFlushIntervalMs()).unref()
|
||||
}
|
||||
|
||||
export const initializeDatadog = memoize(async (): Promise<boolean> => {
|
||||
if (isAnalyticsDisabled()) {
|
||||
datadogInitialized = false
|
||||
return false
|
||||
}
|
||||
|
||||
try {
|
||||
datadogInitialized = true
|
||||
return true
|
||||
} catch (error) {
|
||||
logError(error)
|
||||
datadogInitialized = false
|
||||
return false
|
||||
}
|
||||
})
|
||||
|
||||
/**
|
||||
* Flush remaining Datadog logs and shut down.
|
||||
* Called from gracefulShutdown() before process.exit() since
|
||||
* forceExit() prevents the beforeExit handler from firing.
|
||||
*/
|
||||
export async function shutdownDatadog(): Promise<void> {
|
||||
if (flushTimer) {
|
||||
clearTimeout(flushTimer)
|
||||
flushTimer = null
|
||||
}
|
||||
await flushLogs()
|
||||
}
|
||||
|
||||
// NOTE: use via src/services/analytics/index.ts > logEvent
|
||||
export async function trackDatadogEvent(
|
||||
eventName: string,
|
||||
properties: { [key: string]: boolean | number | undefined },
|
||||
): Promise<void> {
|
||||
if (process.env.NODE_ENV !== 'production') {
|
||||
return
|
||||
}
|
||||
|
||||
// Don't send events for 3P providers (Bedrock, Vertex, Foundry)
|
||||
if (getAPIProvider() !== 'firstParty') {
|
||||
return
|
||||
}
|
||||
|
||||
// Fast path: use cached result if available to avoid await overhead
|
||||
let initialized = datadogInitialized
|
||||
if (initialized === null) {
|
||||
initialized = await initializeDatadog()
|
||||
}
|
||||
if (!initialized || !DATADOG_ALLOWED_EVENTS.has(eventName)) {
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
const metadata = await getEventMetadata({
|
||||
model: properties.model,
|
||||
betas: properties.betas,
|
||||
})
|
||||
// Destructure to avoid duplicate envContext (once nested, once flattened)
|
||||
const { envContext, ...restMetadata } = metadata
|
||||
const allData: Record<string, unknown> = {
|
||||
...restMetadata,
|
||||
...envContext,
|
||||
...properties,
|
||||
userBucket: getUserBucket(),
|
||||
}
|
||||
|
||||
// Normalize MCP tool names to "mcp" for cardinality reduction
|
||||
if (
|
||||
typeof allData.toolName === 'string' &&
|
||||
allData.toolName.startsWith('mcp__')
|
||||
) {
|
||||
allData.toolName = 'mcp'
|
||||
}
|
||||
|
||||
// Normalize model names for cardinality reduction (external users only)
|
||||
if (process.env.USER_TYPE !== 'ant' && typeof allData.model === 'string') {
|
||||
const shortName = getCanonicalName(allData.model.replace(/\[1m]$/i, ''))
|
||||
allData.model = shortName in MODEL_COSTS ? shortName : 'other'
|
||||
}
|
||||
|
||||
// Truncate dev version to base + date (remove timestamp and sha for cardinality reduction)
|
||||
// e.g. "2.0.53-dev.20251124.t173302.sha526cc6a" -> "2.0.53-dev.20251124"
|
||||
if (typeof allData.version === 'string') {
|
||||
allData.version = allData.version.replace(
|
||||
/^(\d+\.\d+\.\d+-dev\.\d{8})\.t\d+\.sha[a-f0-9]+$/,
|
||||
'$1',
|
||||
)
|
||||
}
|
||||
|
||||
// Transform status to http_status and http_status_range to avoid Datadog reserved field
|
||||
if (allData.status !== undefined && allData.status !== null) {
|
||||
const statusCode = String(allData.status)
|
||||
allData.http_status = statusCode
|
||||
|
||||
// Determine status range (1xx, 2xx, 3xx, 4xx, 5xx)
|
||||
const firstDigit = statusCode.charAt(0)
|
||||
if (firstDigit >= '1' && firstDigit <= '5') {
|
||||
allData.http_status_range = `${firstDigit}xx`
|
||||
}
|
||||
|
||||
// Remove original status field to avoid conflict with Datadog's reserved field
|
||||
delete allData.status
|
||||
}
|
||||
|
||||
// Build ddtags with high-cardinality fields for filtering.
|
||||
// event:<name> is prepended so the event name is searchable via the
|
||||
// log search API — the `message` field (where eventName also lives)
|
||||
// is a DD reserved field and is NOT queryable from dashboard widget
|
||||
// queries or the aggregation API. See scripts/release/MONITORING.md.
|
||||
const allDataRecord = allData
|
||||
const tags = [
|
||||
`event:${eventName}`,
|
||||
...TAG_FIELDS.filter(
|
||||
field =>
|
||||
allDataRecord[field] !== undefined && allDataRecord[field] !== null,
|
||||
).map(field => `${camelToSnakeCase(field)}:${allDataRecord[field]}`),
|
||||
]
|
||||
|
||||
const log: DatadogLog = {
|
||||
ddsource: 'nodejs',
|
||||
ddtags: tags.join(','),
|
||||
message: eventName,
|
||||
service: 'claude-code',
|
||||
hostname: 'claude-code',
|
||||
env: process.env.USER_TYPE,
|
||||
}
|
||||
|
||||
// Add all fields as searchable attributes (not duplicated in tags)
|
||||
for (const [key, value] of Object.entries(allData)) {
|
||||
if (value !== undefined && value !== null) {
|
||||
log[camelToSnakeCase(key)] = value
|
||||
}
|
||||
}
|
||||
|
||||
logBatch.push(log)
|
||||
|
||||
// Flush immediately if batch is full, otherwise schedule
|
||||
if (logBatch.length >= MAX_BATCH_SIZE) {
|
||||
if (flushTimer) {
|
||||
clearTimeout(flushTimer)
|
||||
flushTimer = null
|
||||
}
|
||||
void flushLogs()
|
||||
} else {
|
||||
scheduleFlush()
|
||||
}
|
||||
} catch (error) {
|
||||
logError(error)
|
||||
}
|
||||
}
|
||||
|
||||
const NUM_USER_BUCKETS = 30
|
||||
|
||||
/**
|
||||
* Gets a 'bucket' that the user ID falls into.
|
||||
* No-op Datadog client — all tracking is disabled.
|
||||
*
|
||||
* For alerting purposes, we want to alert on the number of users impacted
|
||||
* by an issue, rather than the number of events- often a small number of users
|
||||
* can generate a large number of events (e.g. due to retries). To approximate
|
||||
* this without ruining cardinality by counting user IDs directly, we hash the user ID
|
||||
* and assign it to one of a fixed number of buckets.
|
||||
*
|
||||
* This allows us to estimate the number of unique users by counting unique buckets,
|
||||
* while preserving user privacy and reducing cardinality.
|
||||
* Events are silently discarded. No data is sent to Datadog.
|
||||
*/
|
||||
const getUserBucket = memoize((): number => {
|
||||
const userId = getOrCreateUserID()
|
||||
const hash = createHash('sha256').update(userId).digest('hex')
|
||||
return parseInt(hash.slice(0, 8), 16) % NUM_USER_BUCKETS
|
||||
})
|
||||
|
||||
function getFlushIntervalMs(): number {
|
||||
// Allow tests to override to not block on the default flush interval.
|
||||
return (
|
||||
parseInt(process.env.CLAUDE_CODE_DATADOG_FLUSH_INTERVAL_MS || '', 10) ||
|
||||
DEFAULT_FLUSH_INTERVAL_MS
|
||||
)
|
||||
}
|
||||
/** No-op — nothing to shut down. */
|
||||
export function shutdownDatadog(): void {}
|
||||
@@ -1,449 +1,41 @@
|
||||
import type { AnyValueMap, Logger, logs } from '@opentelemetry/api-logs'
|
||||
import { resourceFromAttributes } from '@opentelemetry/resources'
|
||||
import {
|
||||
BatchLogRecordProcessor,
|
||||
LoggerProvider,
|
||||
} from '@opentelemetry/sdk-logs'
|
||||
import {
|
||||
ATTR_SERVICE_NAME,
|
||||
ATTR_SERVICE_VERSION,
|
||||
} from '@opentelemetry/semantic-conventions'
|
||||
import { randomUUID } from 'crypto'
|
||||
import { isEqual } from 'lodash-es'
|
||||
import { getOrCreateUserID } from '../../utils/config.js'
|
||||
import { logForDebugging } from '../../utils/debug.js'
|
||||
import { logError } from '../../utils/log.js'
|
||||
import { getPlatform, getWslVersion } from '../../utils/platform.js'
|
||||
import { jsonStringify } from '../../utils/slowOperations.js'
|
||||
import { profileCheckpoint } from '../../utils/startupProfiler.js'
|
||||
import { getCoreUserData } from '../../utils/user.js'
|
||||
import { isAnalyticsDisabled } from './config.js'
|
||||
import { FirstPartyEventLoggingExporter } from './firstPartyEventLoggingExporter.js'
|
||||
import type { GrowthBookUserAttributes } from './growthbook.js'
|
||||
import { getDynamicConfig_CACHED_MAY_BE_STALE } from './growthbook.js'
|
||||
import { getEventMetadata } from './metadata.js'
|
||||
import { isSinkKilled } from './sinkKillswitch.js'
|
||||
|
||||
/**
|
||||
* Configuration for sampling individual event types.
|
||||
* Each event name maps to an object containing sample_rate (0-1).
|
||||
* Events not in the config are logged at 100% rate.
|
||||
* No-op stub for 1P event logging.
|
||||
* All functions are no-ops; boolean checks return false;
|
||||
* getEventSamplingConfig returns {}; shouldSampleEvent returns null.
|
||||
* No @opentelemetry imports.
|
||||
*/
|
||||
|
||||
export type EventSamplingConfig = {
|
||||
[eventName: string]: {
|
||||
sample_rate: number
|
||||
}
|
||||
[eventName: string]: {
|
||||
sample_rate: number
|
||||
}
|
||||
}
|
||||
|
||||
const EVENT_SAMPLING_CONFIG_NAME = 'tengu_event_sampling_config'
|
||||
/**
|
||||
* Get the event sampling configuration from GrowthBook.
|
||||
* Uses cached value if available, updates cache in background.
|
||||
*/
|
||||
export function getEventSamplingConfig(): EventSamplingConfig {
|
||||
return getDynamicConfig_CACHED_MAY_BE_STALE<EventSamplingConfig>(
|
||||
EVENT_SAMPLING_CONFIG_NAME,
|
||||
{},
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Determine if an event should be sampled based on its sample rate.
|
||||
* Returns the sample rate if sampled, null if not sampled.
|
||||
*
|
||||
* @param eventName - Name of the event to check
|
||||
* @returns The sample_rate if event should be logged, null if it should be dropped
|
||||
*/
|
||||
export function shouldSampleEvent(eventName: string): number | null {
|
||||
const config = getEventSamplingConfig()
|
||||
const eventConfig = config[eventName]
|
||||
|
||||
// If no config for this event, log at 100% rate (no sampling)
|
||||
if (!eventConfig) {
|
||||
return null
|
||||
}
|
||||
|
||||
const sampleRate = eventConfig.sample_rate
|
||||
|
||||
// Validate sample rate is in valid range
|
||||
if (typeof sampleRate !== 'number' || sampleRate < 0 || sampleRate > 1) {
|
||||
return null
|
||||
}
|
||||
|
||||
// Sample rate of 1 means log everything (no need to add metadata)
|
||||
if (sampleRate >= 1) {
|
||||
return null
|
||||
}
|
||||
|
||||
// Sample rate of 0 means drop everything
|
||||
if (sampleRate <= 0) {
|
||||
return 0
|
||||
}
|
||||
|
||||
// Randomly decide whether to sample this event
|
||||
return Math.random() < sampleRate ? sampleRate : 0
|
||||
}
|
||||
|
||||
const BATCH_CONFIG_NAME = 'tengu_1p_event_batch_config'
|
||||
type BatchConfig = {
|
||||
scheduledDelayMillis?: number
|
||||
maxExportBatchSize?: number
|
||||
maxQueueSize?: number
|
||||
skipAuth?: boolean
|
||||
maxAttempts?: number
|
||||
path?: string
|
||||
baseUrl?: string
|
||||
}
|
||||
function getBatchConfig(): BatchConfig {
|
||||
return getDynamicConfig_CACHED_MAY_BE_STALE<BatchConfig>(
|
||||
BATCH_CONFIG_NAME,
|
||||
{},
|
||||
)
|
||||
}
|
||||
|
||||
// Module-local state for event logging (not exposed globally)
|
||||
let firstPartyEventLogger: ReturnType<typeof logs.getLogger> | null = null
|
||||
let firstPartyEventLoggerProvider: LoggerProvider | null = null
|
||||
// Last batch config used to construct the provider — used by
|
||||
// reinitialize1PEventLoggingIfConfigChanged to decide whether a rebuild is
|
||||
// needed when GrowthBook refreshes.
|
||||
let lastBatchConfig: BatchConfig | null = null
|
||||
/**
|
||||
* Flush and shutdown the 1P event logger.
|
||||
* This should be called as the final step before process exit to ensure
|
||||
* all events (including late ones from API responses) are exported.
|
||||
*/
|
||||
export async function shutdown1PEventLogging(): Promise<void> {
|
||||
if (!firstPartyEventLoggerProvider) {
|
||||
return
|
||||
}
|
||||
try {
|
||||
await firstPartyEventLoggerProvider.shutdown()
|
||||
if (process.env.USER_TYPE === 'ant') {
|
||||
logForDebugging('1P event logging: final shutdown complete')
|
||||
}
|
||||
} catch {
|
||||
// Ignore shutdown errors
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if 1P event logging is enabled.
|
||||
* Respects the same opt-outs as other analytics sinks:
|
||||
* - Test environment
|
||||
* - Third-party cloud providers (Bedrock/Vertex)
|
||||
* - Global telemetry opt-outs
|
||||
* - Non-essential traffic disabled
|
||||
*
|
||||
* Note: Unlike BigQuery metrics, event logging does NOT check organization-level
|
||||
* metrics opt-out via API. It follows the same pattern as Statsig event logging.
|
||||
*/
|
||||
export function is1PEventLoggingEnabled(): boolean {
|
||||
// Respect standard analytics opt-outs
|
||||
return !isAnalyticsDisabled()
|
||||
}
|
||||
|
||||
/**
|
||||
* Log a 1st-party event for internal analytics (async version).
|
||||
* Events are batched and exported to /api/event_logging/batch
|
||||
*
|
||||
* This enriches the event with core metadata (model, session, env context, etc.)
|
||||
* at log time, similar to logEventToStatsig.
|
||||
*
|
||||
* @param eventName - Name of the event (e.g., 'tengu_api_query')
|
||||
* @param metadata - Additional metadata for the event (intentionally no strings, to avoid accidentally logging code/filepaths)
|
||||
*/
|
||||
async function logEventTo1PAsync(
|
||||
firstPartyEventLogger: Logger,
|
||||
eventName: string,
|
||||
metadata: Record<string, number | boolean | undefined> = {},
|
||||
): Promise<void> {
|
||||
try {
|
||||
// Enrich with core metadata at log time (similar to Statsig pattern)
|
||||
const coreMetadata = await getEventMetadata({
|
||||
model: metadata.model,
|
||||
betas: metadata.betas,
|
||||
})
|
||||
|
||||
// Build attributes - OTel supports nested objects natively via AnyValueMap
|
||||
// Cast through unknown since our nested objects are structurally compatible
|
||||
// with AnyValue but TS doesn't recognize it due to missing index signatures
|
||||
const attributes = {
|
||||
event_name: eventName,
|
||||
event_id: randomUUID(),
|
||||
// Pass objects directly - no JSON serialization needed
|
||||
core_metadata: coreMetadata,
|
||||
user_metadata: getCoreUserData(true),
|
||||
event_metadata: metadata,
|
||||
} as unknown as AnyValueMap
|
||||
|
||||
// Add user_id if available
|
||||
const userId = getOrCreateUserID()
|
||||
if (userId) {
|
||||
attributes.user_id = userId
|
||||
}
|
||||
|
||||
// Debug logging when debug mode is enabled
|
||||
if (process.env.USER_TYPE === 'ant') {
|
||||
logForDebugging(
|
||||
`[internal-only] 1P event: ${eventName} ${jsonStringify(metadata, null, 0)}`,
|
||||
)
|
||||
}
|
||||
|
||||
// Emit log record
|
||||
firstPartyEventLogger.emit({
|
||||
body: eventName,
|
||||
attributes,
|
||||
})
|
||||
} catch (e) {
|
||||
if (process.env.NODE_ENV === 'development') {
|
||||
throw e
|
||||
}
|
||||
if (process.env.USER_TYPE === 'ant') {
|
||||
logError(e as Error)
|
||||
}
|
||||
// swallow
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Log a 1st-party event for internal analytics.
|
||||
* Events are batched and exported to /api/event_logging/batch
|
||||
*
|
||||
* @param eventName - Name of the event (e.g., 'tengu_api_query')
|
||||
* @param metadata - Additional metadata for the event (intentionally no strings, to avoid accidentally logging code/filepaths)
|
||||
*/
|
||||
export function logEventTo1P(
|
||||
eventName: string,
|
||||
metadata: Record<string, number | boolean | undefined> = {},
|
||||
): void {
|
||||
if (!is1PEventLoggingEnabled()) {
|
||||
return
|
||||
}
|
||||
|
||||
if (!firstPartyEventLogger || isSinkKilled('firstParty')) {
|
||||
return
|
||||
}
|
||||
|
||||
// Fire and forget - don't block on metadata enrichment
|
||||
void logEventTo1PAsync(firstPartyEventLogger, eventName, metadata)
|
||||
}
|
||||
|
||||
/**
|
||||
* GrowthBook experiment event data for logging
|
||||
*/
|
||||
export type GrowthBookExperimentData = {
|
||||
experimentId: string
|
||||
variationId: number
|
||||
userAttributes?: GrowthBookUserAttributes
|
||||
experimentMetadata?: Record<string, unknown>
|
||||
experimentId: string
|
||||
variationId: number
|
||||
userAttributes?: Record<string, unknown>
|
||||
experimentMetadata?: Record<string, unknown>
|
||||
}
|
||||
|
||||
// api.anthropic.com only serves the "production" GrowthBook environment
|
||||
// (see starling/starling/cli/cli.py DEFAULT_ENVIRONMENTS). Staging and
|
||||
// development environments are not exported to the prod API.
|
||||
function getEnvironmentForGrowthBook(): string {
|
||||
return 'production'
|
||||
export function initialize1PEventLogging() {}
|
||||
|
||||
export function logEventTo1P() {}
|
||||
|
||||
export function logGrowthBookExperimentTo1P() {}
|
||||
|
||||
export function is1PEventLoggingEnabled(): boolean {
|
||||
return false
|
||||
}
|
||||
|
||||
/**
|
||||
* Log a GrowthBook experiment assignment event to 1P.
|
||||
* Events are batched and exported to /api/event_logging/batch
|
||||
*
|
||||
* @param data - GrowthBook experiment assignment data
|
||||
*/
|
||||
export function logGrowthBookExperimentTo1P(
|
||||
data: GrowthBookExperimentData,
|
||||
): void {
|
||||
if (!is1PEventLoggingEnabled()) {
|
||||
return
|
||||
}
|
||||
export async function shutdown1PEventLogging(): Promise<void> {}
|
||||
|
||||
if (!firstPartyEventLogger || isSinkKilled('firstParty')) {
|
||||
return
|
||||
}
|
||||
export async function reinitialize1PEventLoggingIfConfigChanged(): Promise<void> {}
|
||||
|
||||
const userId = getOrCreateUserID()
|
||||
const { accountUuid, organizationUuid } = getCoreUserData(true)
|
||||
|
||||
// Build attributes for GrowthbookExperimentEvent
|
||||
const attributes = {
|
||||
event_type: 'GrowthbookExperimentEvent',
|
||||
event_id: randomUUID(),
|
||||
experiment_id: data.experimentId,
|
||||
variation_id: data.variationId,
|
||||
...(userId && { device_id: userId }),
|
||||
...(accountUuid && { account_uuid: accountUuid }),
|
||||
...(organizationUuid && { organization_uuid: organizationUuid }),
|
||||
...(data.userAttributes && {
|
||||
session_id: data.userAttributes.sessionId,
|
||||
user_attributes: jsonStringify(data.userAttributes),
|
||||
}),
|
||||
...(data.experimentMetadata && {
|
||||
experiment_metadata: jsonStringify(data.experimentMetadata),
|
||||
}),
|
||||
environment: getEnvironmentForGrowthBook(),
|
||||
}
|
||||
|
||||
if (process.env.USER_TYPE === 'ant') {
|
||||
logForDebugging(
|
||||
`[internal-only] 1P GrowthBook experiment: ${data.experimentId} variation=${data.variationId}`,
|
||||
)
|
||||
}
|
||||
|
||||
firstPartyEventLogger.emit({
|
||||
body: 'growthbook_experiment',
|
||||
attributes,
|
||||
})
|
||||
export function getEventSamplingConfig(): EventSamplingConfig {
|
||||
return {}
|
||||
}
|
||||
|
||||
const DEFAULT_LOGS_EXPORT_INTERVAL_MS = 10000
|
||||
const DEFAULT_MAX_EXPORT_BATCH_SIZE = 200
|
||||
const DEFAULT_MAX_QUEUE_SIZE = 8192
|
||||
|
||||
/**
|
||||
* Initialize 1P event logging infrastructure.
|
||||
* This creates a separate LoggerProvider for internal event logging,
|
||||
* independent of customer OTLP telemetry.
|
||||
*
|
||||
* This uses its own minimal resource configuration with just the attributes
|
||||
* we need for internal analytics (service name, version, platform info).
|
||||
*/
|
||||
export function initialize1PEventLogging(): void {
|
||||
profileCheckpoint('1p_event_logging_start')
|
||||
const enabled = is1PEventLoggingEnabled()
|
||||
|
||||
if (!enabled) {
|
||||
if (process.env.USER_TYPE === 'ant') {
|
||||
logForDebugging('1P event logging not enabled')
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// Fetch batch processor configuration from GrowthBook dynamic config
|
||||
// Uses cached value if available, refreshes in background
|
||||
const batchConfig = getBatchConfig()
|
||||
lastBatchConfig = batchConfig
|
||||
profileCheckpoint('1p_event_after_growthbook_config')
|
||||
|
||||
const scheduledDelayMillis =
|
||||
batchConfig.scheduledDelayMillis ||
|
||||
parseInt(
|
||||
process.env.OTEL_LOGS_EXPORT_INTERVAL ||
|
||||
DEFAULT_LOGS_EXPORT_INTERVAL_MS.toString(),
|
||||
)
|
||||
|
||||
const maxExportBatchSize =
|
||||
batchConfig.maxExportBatchSize || DEFAULT_MAX_EXPORT_BATCH_SIZE
|
||||
|
||||
const maxQueueSize = batchConfig.maxQueueSize || DEFAULT_MAX_QUEUE_SIZE
|
||||
|
||||
// Build our own resource for 1P event logging with minimal attributes
|
||||
const platform = getPlatform()
|
||||
const attributes: Record<string, string> = {
|
||||
[ATTR_SERVICE_NAME]: 'claude-code',
|
||||
[ATTR_SERVICE_VERSION]: MACRO.VERSION,
|
||||
}
|
||||
|
||||
// Add WSL-specific attributes if running on WSL
|
||||
if (platform === 'wsl') {
|
||||
const wslVersion = getWslVersion()
|
||||
if (wslVersion) {
|
||||
attributes['wsl.version'] = wslVersion
|
||||
}
|
||||
}
|
||||
|
||||
const resource = resourceFromAttributes(attributes)
|
||||
|
||||
// Create a new LoggerProvider with the EventLoggingExporter
|
||||
// NOTE: This is kept separate from customer telemetry logs to ensure
|
||||
// internal events don't leak to customer endpoints and vice versa.
|
||||
// We don't register this globally - it's only used for internal event logging.
|
||||
const eventLoggingExporter = new FirstPartyEventLoggingExporter({
|
||||
maxBatchSize: maxExportBatchSize,
|
||||
skipAuth: batchConfig.skipAuth,
|
||||
maxAttempts: batchConfig.maxAttempts,
|
||||
path: batchConfig.path,
|
||||
baseUrl: batchConfig.baseUrl,
|
||||
isKilled: () => isSinkKilled('firstParty'),
|
||||
})
|
||||
firstPartyEventLoggerProvider = new LoggerProvider({
|
||||
resource,
|
||||
processors: [
|
||||
new BatchLogRecordProcessor(eventLoggingExporter, {
|
||||
scheduledDelayMillis,
|
||||
maxExportBatchSize,
|
||||
maxQueueSize,
|
||||
}),
|
||||
],
|
||||
})
|
||||
|
||||
// Initialize event logger from our internal provider (NOT from global API)
|
||||
// IMPORTANT: We must get the logger from our local provider, not logs.getLogger()
|
||||
// because logs.getLogger() returns a logger from the global provider, which is
|
||||
// separate and used for customer telemetry.
|
||||
firstPartyEventLogger = firstPartyEventLoggerProvider.getLogger(
|
||||
'com.anthropic.claude_code.events',
|
||||
MACRO.VERSION,
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Rebuild the 1P event logging pipeline if the batch config changed.
|
||||
* Register this with onGrowthBookRefresh so long-running sessions pick up
|
||||
* changes to batch size, delay, endpoint, etc.
|
||||
*
|
||||
* Event-loss safety:
|
||||
* 1. Null the logger first — concurrent logEventTo1P() calls hit the
|
||||
* !firstPartyEventLogger guard and bail during the swap window. This drops
|
||||
* a handful of events but prevents emitting to a draining provider.
|
||||
* 2. forceFlush() drains the old BatchLogRecordProcessor buffer to the
|
||||
* exporter. Export failures go to disk at getCurrentBatchFilePath() which
|
||||
* is keyed by module-level BATCH_UUID + sessionId — unchanged across
|
||||
* reinit — so the NEW exporter's disk-backed retry picks them up.
|
||||
* 3. Swap to new provider/logger; old provider shutdown runs in background
|
||||
* (buffer already drained, just cleanup).
|
||||
*/
|
||||
export async function reinitialize1PEventLoggingIfConfigChanged(): Promise<void> {
|
||||
if (!is1PEventLoggingEnabled() || !firstPartyEventLoggerProvider) {
|
||||
return
|
||||
}
|
||||
|
||||
const newConfig = getBatchConfig()
|
||||
|
||||
if (isEqual(newConfig, lastBatchConfig)) {
|
||||
return
|
||||
}
|
||||
|
||||
if (process.env.USER_TYPE === 'ant') {
|
||||
logForDebugging(
|
||||
`1P event logging: ${BATCH_CONFIG_NAME} changed, reinitializing`,
|
||||
)
|
||||
}
|
||||
|
||||
const oldProvider = firstPartyEventLoggerProvider
|
||||
const oldLogger = firstPartyEventLogger
|
||||
firstPartyEventLogger = null
|
||||
|
||||
try {
|
||||
await oldProvider.forceFlush()
|
||||
} catch {
|
||||
// Export failures are already on disk; new exporter will retry them.
|
||||
}
|
||||
|
||||
firstPartyEventLoggerProvider = null
|
||||
try {
|
||||
initialize1PEventLogging()
|
||||
} catch (e) {
|
||||
// Restore so the next GrowthBook refresh can retry. oldProvider was
|
||||
// only forceFlush()'d, not shut down — it's still functional. Without
|
||||
// this, both stay null and the !firstPartyEventLoggerProvider gate at
|
||||
// the top makes recovery impossible.
|
||||
firstPartyEventLoggerProvider = oldProvider
|
||||
firstPartyEventLogger = oldLogger
|
||||
logError(e)
|
||||
return
|
||||
}
|
||||
|
||||
void oldProvider.shutdown().catch(() => {})
|
||||
}
|
||||
export function shouldSampleEvent(): number | null {
|
||||
return null
|
||||
}
|
||||
@@ -1,806 +0,0 @@
|
||||
import type { HrTime } from '@opentelemetry/api'
|
||||
import { type ExportResult, ExportResultCode } from '@opentelemetry/core'
|
||||
import type {
|
||||
LogRecordExporter,
|
||||
ReadableLogRecord,
|
||||
} from '@opentelemetry/sdk-logs'
|
||||
import axios from 'axios'
|
||||
import { randomUUID } from 'crypto'
|
||||
import { appendFile, mkdir, readdir, unlink, writeFile } from 'fs/promises'
|
||||
import * as path from 'path'
|
||||
import type { CoreUserData } from 'src/utils/user.js'
|
||||
import {
|
||||
getIsNonInteractiveSession,
|
||||
getSessionId,
|
||||
} from '../../bootstrap/state.js'
|
||||
import { ClaudeCodeInternalEvent } from '../../types/generated/events_mono/claude_code/v1/claude_code_internal_event.js'
|
||||
import { GrowthbookExperimentEvent } from '../../types/generated/events_mono/growthbook/v1/growthbook_experiment_event.js'
|
||||
import {
|
||||
getClaudeAIOAuthTokens,
|
||||
hasProfileScope,
|
||||
isClaudeAISubscriber,
|
||||
} from '../../utils/auth.js'
|
||||
import { checkHasTrustDialogAccepted } from '../../utils/config.js'
|
||||
import { logForDebugging } from '../../utils/debug.js'
|
||||
import { getClaudeConfigHomeDir } from '../../utils/envUtils.js'
|
||||
import { errorMessage, isFsInaccessible, toError } from '../../utils/errors.js'
|
||||
import { getAuthHeaders } from '../../utils/http.js'
|
||||
import { readJSONLFile } from '../../utils/json.js'
|
||||
import { logError } from '../../utils/log.js'
|
||||
import { sleep } from '../../utils/sleep.js'
|
||||
import { jsonStringify } from '../../utils/slowOperations.js'
|
||||
import { getClaudeCodeUserAgent } from '../../utils/userAgent.js'
|
||||
import { isOAuthTokenExpired } from '../oauth/client.js'
|
||||
import { stripProtoFields } from './index.js'
|
||||
import { type EventMetadata, to1PEventFormat } from './metadata.js'
|
||||
|
||||
// Unique ID for this process run - used to isolate failed event files between runs
|
||||
const BATCH_UUID = randomUUID()
|
||||
|
||||
// File prefix for failed event storage
|
||||
const FILE_PREFIX = '1p_failed_events.'
|
||||
|
||||
// Storage directory for failed events - evaluated at runtime to respect CLAUDE_CONFIG_DIR in tests
|
||||
function getStorageDir(): string {
|
||||
return path.join(getClaudeConfigHomeDir(), 'telemetry')
|
||||
}
|
||||
|
||||
// API envelope - event_data is the JSON output from proto toJSON()
|
||||
type FirstPartyEventLoggingEvent = {
|
||||
event_type: 'ClaudeCodeInternalEvent' | 'GrowthbookExperimentEvent'
|
||||
event_data: unknown
|
||||
}
|
||||
|
||||
type FirstPartyEventLoggingPayload = {
|
||||
events: FirstPartyEventLoggingEvent[]
|
||||
}
|
||||
|
||||
/**
|
||||
* Exporter for 1st-party event logging to /api/event_logging/batch.
|
||||
*
|
||||
* Export cycles are controlled by OpenTelemetry's BatchLogRecordProcessor, which
|
||||
* triggers export() when either:
|
||||
* - Time interval elapses (default: 5 seconds via scheduledDelayMillis)
|
||||
* - Batch size is reached (default: 200 events via maxExportBatchSize)
|
||||
*
|
||||
* This exporter adds resilience on top:
|
||||
* - Append-only log for failed events (concurrency-safe)
|
||||
* - Quadratic backoff retry for failed events, dropped after maxAttempts
|
||||
* - Immediate retry of queued events when any export succeeds (endpoint is healthy)
|
||||
* - Chunking large event sets into smaller batches
|
||||
* - Auth fallback: retries without auth on 401 errors
|
||||
*/
|
||||
export class FirstPartyEventLoggingExporter implements LogRecordExporter {
|
||||
private readonly endpoint: string
|
||||
private readonly timeout: number
|
||||
private readonly maxBatchSize: number
|
||||
private readonly skipAuth: boolean
|
||||
private readonly batchDelayMs: number
|
||||
private readonly baseBackoffDelayMs: number
|
||||
private readonly maxBackoffDelayMs: number
|
||||
private readonly maxAttempts: number
|
||||
private readonly isKilled: () => boolean
|
||||
private pendingExports: Promise<void>[] = []
|
||||
private isShutdown = false
|
||||
private readonly schedule: (
|
||||
fn: () => Promise<void>,
|
||||
delayMs: number,
|
||||
) => () => void
|
||||
private cancelBackoff: (() => void) | null = null
|
||||
private attempts = 0
|
||||
private isRetrying = false
|
||||
private lastExportErrorContext: string | undefined
|
||||
|
||||
constructor(
|
||||
options: {
|
||||
timeout?: number
|
||||
maxBatchSize?: number
|
||||
skipAuth?: boolean
|
||||
batchDelayMs?: number
|
||||
baseBackoffDelayMs?: number
|
||||
maxBackoffDelayMs?: number
|
||||
maxAttempts?: number
|
||||
path?: string
|
||||
baseUrl?: string
|
||||
// Injected killswitch probe. Checked per-POST so that disabling the
|
||||
// firstParty sink also stops backoff retries (not just new emits).
|
||||
// Passed in rather than imported to avoid a cycle with firstPartyEventLogger.ts.
|
||||
isKilled?: () => boolean
|
||||
schedule?: (fn: () => Promise<void>, delayMs: number) => () => void
|
||||
} = {},
|
||||
) {
|
||||
// Default: prod, except when ANTHROPIC_BASE_URL is explicitly staging.
|
||||
// Overridable via tengu_1p_event_batch_config.baseUrl.
|
||||
const baseUrl =
|
||||
options.baseUrl ||
|
||||
(process.env.ANTHROPIC_BASE_URL === 'https://api-staging.anthropic.com'
|
||||
? 'https://api-staging.anthropic.com'
|
||||
: 'https://api.anthropic.com')
|
||||
|
||||
this.endpoint = `${baseUrl}${options.path || '/api/event_logging/batch'}`
|
||||
|
||||
this.timeout = options.timeout || 10000
|
||||
this.maxBatchSize = options.maxBatchSize || 200
|
||||
this.skipAuth = options.skipAuth ?? false
|
||||
this.batchDelayMs = options.batchDelayMs || 100
|
||||
this.baseBackoffDelayMs = options.baseBackoffDelayMs || 500
|
||||
this.maxBackoffDelayMs = options.maxBackoffDelayMs || 30000
|
||||
this.maxAttempts = options.maxAttempts ?? 8
|
||||
this.isKilled = options.isKilled ?? (() => false)
|
||||
this.schedule =
|
||||
options.schedule ??
|
||||
((fn, ms) => {
|
||||
const t = setTimeout(fn, ms)
|
||||
return () => clearTimeout(t)
|
||||
})
|
||||
|
||||
// Retry any failed events from previous runs of this session (in background)
|
||||
void this.retryPreviousBatches()
|
||||
}
|
||||
|
||||
// Expose for testing
|
||||
async getQueuedEventCount(): Promise<number> {
|
||||
return (await this.loadEventsFromCurrentBatch()).length
|
||||
}
|
||||
|
||||
// --- Storage helpers ---
|
||||
|
||||
private getCurrentBatchFilePath(): string {
|
||||
return path.join(
|
||||
getStorageDir(),
|
||||
`${FILE_PREFIX}${getSessionId()}.${BATCH_UUID}.json`,
|
||||
)
|
||||
}
|
||||
|
||||
private async loadEventsFromFile(
|
||||
filePath: string,
|
||||
): Promise<FirstPartyEventLoggingEvent[]> {
|
||||
try {
|
||||
return await readJSONLFile<FirstPartyEventLoggingEvent>(filePath)
|
||||
} catch {
|
||||
return []
|
||||
}
|
||||
}
|
||||
|
||||
private async loadEventsFromCurrentBatch(): Promise<
|
||||
FirstPartyEventLoggingEvent[]
|
||||
> {
|
||||
return this.loadEventsFromFile(this.getCurrentBatchFilePath())
|
||||
}
|
||||
|
||||
private async saveEventsToFile(
|
||||
filePath: string,
|
||||
events: FirstPartyEventLoggingEvent[],
|
||||
): Promise<void> {
|
||||
try {
|
||||
if (events.length === 0) {
|
||||
try {
|
||||
await unlink(filePath)
|
||||
} catch {
|
||||
// File doesn't exist, nothing to delete
|
||||
}
|
||||
} else {
|
||||
// Ensure storage directory exists
|
||||
await mkdir(getStorageDir(), { recursive: true })
|
||||
// Write as JSON lines (one event per line)
|
||||
const content = events.map(e => jsonStringify(e)).join('\n') + '\n'
|
||||
await writeFile(filePath, content, 'utf8')
|
||||
}
|
||||
} catch (error) {
|
||||
logError(error)
|
||||
}
|
||||
}
|
||||
|
||||
private async appendEventsToFile(
|
||||
filePath: string,
|
||||
events: FirstPartyEventLoggingEvent[],
|
||||
): Promise<void> {
|
||||
if (events.length === 0) return
|
||||
try {
|
||||
// Ensure storage directory exists
|
||||
await mkdir(getStorageDir(), { recursive: true })
|
||||
// Append as JSON lines (one event per line) - atomic on most filesystems
|
||||
const content = events.map(e => jsonStringify(e)).join('\n') + '\n'
|
||||
await appendFile(filePath, content, 'utf8')
|
||||
} catch (error) {
|
||||
logError(error)
|
||||
}
|
||||
}
|
||||
|
||||
private async deleteFile(filePath: string): Promise<void> {
|
||||
try {
|
||||
await unlink(filePath)
|
||||
} catch {
|
||||
// File doesn't exist or can't be deleted, ignore
|
||||
}
|
||||
}
|
||||
|
||||
// --- Previous batch retry (startup) ---
|
||||
|
||||
private async retryPreviousBatches(): Promise<void> {
|
||||
try {
|
||||
const prefix = `${FILE_PREFIX}${getSessionId()}.`
|
||||
let files: string[]
|
||||
try {
|
||||
files = (await readdir(getStorageDir()))
|
||||
.filter((f: string) => f.startsWith(prefix) && f.endsWith('.json'))
|
||||
.filter((f: string) => !f.includes(BATCH_UUID)) // Exclude current batch
|
||||
} catch (e) {
|
||||
if (isFsInaccessible(e)) return
|
||||
throw e
|
||||
}
|
||||
|
||||
for (const file of files) {
|
||||
const filePath = path.join(getStorageDir(), file)
|
||||
void this.retryFileInBackground(filePath)
|
||||
}
|
||||
} catch (error) {
|
||||
logError(error)
|
||||
}
|
||||
}
|
||||
|
||||
private async retryFileInBackground(filePath: string): Promise<void> {
|
||||
if (this.attempts >= this.maxAttempts) {
|
||||
await this.deleteFile(filePath)
|
||||
return
|
||||
}
|
||||
|
||||
const events = await this.loadEventsFromFile(filePath)
|
||||
if (events.length === 0) {
|
||||
await this.deleteFile(filePath)
|
||||
return
|
||||
}
|
||||
|
||||
if (process.env.USER_TYPE === 'ant') {
|
||||
logForDebugging(
|
||||
`1P event logging: retrying ${events.length} events from previous batch`,
|
||||
)
|
||||
}
|
||||
|
||||
const failedEvents = await this.sendEventsInBatches(events)
|
||||
if (failedEvents.length === 0) {
|
||||
await this.deleteFile(filePath)
|
||||
if (process.env.USER_TYPE === 'ant') {
|
||||
logForDebugging('1P event logging: previous batch retry succeeded')
|
||||
}
|
||||
} else {
|
||||
// Save only the failed events back (not all original events)
|
||||
await this.saveEventsToFile(filePath, failedEvents)
|
||||
if (process.env.USER_TYPE === 'ant') {
|
||||
logForDebugging(
|
||||
`1P event logging: previous batch retry failed, ${failedEvents.length} events remain`,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async export(
|
||||
logs: ReadableLogRecord[],
|
||||
resultCallback: (result: ExportResult) => void,
|
||||
): Promise<void> {
|
||||
if (this.isShutdown) {
|
||||
if (process.env.USER_TYPE === 'ant') {
|
||||
logForDebugging(
|
||||
'1P event logging export failed: Exporter has been shutdown',
|
||||
)
|
||||
}
|
||||
resultCallback({
|
||||
code: ExportResultCode.FAILED,
|
||||
error: new Error('Exporter has been shutdown'),
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
const exportPromise = this.doExport(logs, resultCallback)
|
||||
this.pendingExports.push(exportPromise)
|
||||
|
||||
// Clean up completed exports
|
||||
void exportPromise.finally(() => {
|
||||
const index = this.pendingExports.indexOf(exportPromise)
|
||||
if (index > -1) {
|
||||
void this.pendingExports.splice(index, 1)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
private async doExport(
|
||||
logs: ReadableLogRecord[],
|
||||
resultCallback: (result: ExportResult) => void,
|
||||
): Promise<void> {
|
||||
try {
|
||||
// Filter for event logs only (by scope name)
|
||||
const eventLogs = logs.filter(
|
||||
log =>
|
||||
log.instrumentationScope?.name === 'com.anthropic.claude_code.events',
|
||||
)
|
||||
|
||||
if (eventLogs.length === 0) {
|
||||
resultCallback({ code: ExportResultCode.SUCCESS })
|
||||
return
|
||||
}
|
||||
|
||||
// Transform new logs (failed events are retried independently via backoff)
|
||||
const events = this.transformLogsToEvents(eventLogs).events
|
||||
|
||||
if (events.length === 0) {
|
||||
resultCallback({ code: ExportResultCode.SUCCESS })
|
||||
return
|
||||
}
|
||||
|
||||
if (this.attempts >= this.maxAttempts) {
|
||||
resultCallback({
|
||||
code: ExportResultCode.FAILED,
|
||||
error: new Error(
|
||||
`Dropped ${events.length} events: max attempts (${this.maxAttempts}) reached`,
|
||||
),
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
// Send events
|
||||
const failedEvents = await this.sendEventsInBatches(events)
|
||||
this.attempts++
|
||||
|
||||
if (failedEvents.length > 0) {
|
||||
await this.queueFailedEvents(failedEvents)
|
||||
this.scheduleBackoffRetry()
|
||||
const context = this.lastExportErrorContext
|
||||
? ` (${this.lastExportErrorContext})`
|
||||
: ''
|
||||
resultCallback({
|
||||
code: ExportResultCode.FAILED,
|
||||
error: new Error(
|
||||
`Failed to export ${failedEvents.length} events${context}`,
|
||||
),
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
// Success - reset backoff and immediately retry any queued events
|
||||
this.resetBackoff()
|
||||
if ((await this.getQueuedEventCount()) > 0 && !this.isRetrying) {
|
||||
void this.retryFailedEvents()
|
||||
}
|
||||
resultCallback({ code: ExportResultCode.SUCCESS })
|
||||
} catch (error) {
|
||||
if (process.env.USER_TYPE === 'ant') {
|
||||
logForDebugging(
|
||||
`1P event logging export failed: ${errorMessage(error)}`,
|
||||
)
|
||||
}
|
||||
logError(error)
|
||||
resultCallback({
|
||||
code: ExportResultCode.FAILED,
|
||||
error: toError(error),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
private async sendEventsInBatches(
|
||||
events: FirstPartyEventLoggingEvent[],
|
||||
): Promise<FirstPartyEventLoggingEvent[]> {
|
||||
// Chunk events into batches
|
||||
const batches: FirstPartyEventLoggingEvent[][] = []
|
||||
for (let i = 0; i < events.length; i += this.maxBatchSize) {
|
||||
batches.push(events.slice(i, i + this.maxBatchSize))
|
||||
}
|
||||
|
||||
if (process.env.USER_TYPE === 'ant') {
|
||||
logForDebugging(
|
||||
`1P event logging: exporting ${events.length} events in ${batches.length} batch(es)`,
|
||||
)
|
||||
}
|
||||
|
||||
// Send each batch with delay between them. On first failure, assume the
|
||||
// endpoint is down and short-circuit: queue the failed batch plus all
|
||||
// remaining unsent batches without POSTing them. The backoff retry will
|
||||
// probe again with a single batch next tick.
|
||||
const failedBatchEvents: FirstPartyEventLoggingEvent[] = []
|
||||
let lastErrorContext: string | undefined
|
||||
for (let i = 0; i < batches.length; i++) {
|
||||
const batch = batches[i]!
|
||||
try {
|
||||
await this.sendBatchWithRetry({ events: batch })
|
||||
} catch (error) {
|
||||
lastErrorContext = getAxiosErrorContext(error)
|
||||
for (let j = i; j < batches.length; j++) {
|
||||
failedBatchEvents.push(...batches[j]!)
|
||||
}
|
||||
if (process.env.USER_TYPE === 'ant') {
|
||||
const skipped = batches.length - 1 - i
|
||||
logForDebugging(
|
||||
`1P event logging: batch ${i + 1}/${batches.length} failed (${lastErrorContext}); short-circuiting ${skipped} remaining batch(es)`,
|
||||
)
|
||||
}
|
||||
break
|
||||
}
|
||||
|
||||
if (i < batches.length - 1 && this.batchDelayMs > 0) {
|
||||
await sleep(this.batchDelayMs)
|
||||
}
|
||||
}
|
||||
|
||||
if (failedBatchEvents.length > 0 && lastErrorContext) {
|
||||
this.lastExportErrorContext = lastErrorContext
|
||||
}
|
||||
|
||||
return failedBatchEvents
|
||||
}
|
||||
|
||||
private async queueFailedEvents(
|
||||
events: FirstPartyEventLoggingEvent[],
|
||||
): Promise<void> {
|
||||
const filePath = this.getCurrentBatchFilePath()
|
||||
|
||||
// Append-only: just add new events to file (atomic on most filesystems)
|
||||
await this.appendEventsToFile(filePath, events)
|
||||
|
||||
const context = this.lastExportErrorContext
|
||||
? ` (${this.lastExportErrorContext})`
|
||||
: ''
|
||||
const message = `1P event logging: ${events.length} events failed to export${context}`
|
||||
logError(new Error(message))
|
||||
}
|
||||
|
||||
private scheduleBackoffRetry(): void {
|
||||
// Don't schedule if already retrying or shutdown
|
||||
if (this.cancelBackoff || this.isRetrying || this.isShutdown) {
|
||||
return
|
||||
}
|
||||
|
||||
// Quadratic backoff (matching Statsig SDK): base * attempts²
|
||||
const delay = Math.min(
|
||||
this.baseBackoffDelayMs * this.attempts * this.attempts,
|
||||
this.maxBackoffDelayMs,
|
||||
)
|
||||
|
||||
if (process.env.USER_TYPE === 'ant') {
|
||||
logForDebugging(
|
||||
`1P event logging: scheduling backoff retry in ${delay}ms (attempt ${this.attempts})`,
|
||||
)
|
||||
}
|
||||
|
||||
this.cancelBackoff = this.schedule(async () => {
|
||||
this.cancelBackoff = null
|
||||
await this.retryFailedEvents()
|
||||
}, delay)
|
||||
}
|
||||
|
||||
private async retryFailedEvents(): Promise<void> {
|
||||
const filePath = this.getCurrentBatchFilePath()
|
||||
|
||||
// Keep retrying while there are events and endpoint is healthy
|
||||
while (!this.isShutdown) {
|
||||
const events = await this.loadEventsFromFile(filePath)
|
||||
if (events.length === 0) break
|
||||
|
||||
if (this.attempts >= this.maxAttempts) {
|
||||
if (process.env.USER_TYPE === 'ant') {
|
||||
logForDebugging(
|
||||
`1P event logging: max attempts (${this.maxAttempts}) reached, dropping ${events.length} events`,
|
||||
)
|
||||
}
|
||||
await this.deleteFile(filePath)
|
||||
this.resetBackoff()
|
||||
return
|
||||
}
|
||||
|
||||
this.isRetrying = true
|
||||
|
||||
// Clear file before retry (we have events in memory now)
|
||||
await this.deleteFile(filePath)
|
||||
|
||||
if (process.env.USER_TYPE === 'ant') {
|
||||
logForDebugging(
|
||||
`1P event logging: retrying ${events.length} failed events (attempt ${this.attempts + 1})`,
|
||||
)
|
||||
}
|
||||
|
||||
const failedEvents = await this.sendEventsInBatches(events)
|
||||
this.attempts++
|
||||
|
||||
this.isRetrying = false
|
||||
|
||||
if (failedEvents.length > 0) {
|
||||
// Write failures back to disk
|
||||
await this.saveEventsToFile(filePath, failedEvents)
|
||||
this.scheduleBackoffRetry()
|
||||
return // Failed - wait for backoff
|
||||
}
|
||||
|
||||
// Success - reset backoff and continue loop to drain any newly queued events
|
||||
this.resetBackoff()
|
||||
if (process.env.USER_TYPE === 'ant') {
|
||||
logForDebugging('1P event logging: backoff retry succeeded')
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private resetBackoff(): void {
|
||||
this.attempts = 0
|
||||
if (this.cancelBackoff) {
|
||||
this.cancelBackoff()
|
||||
this.cancelBackoff = null
|
||||
}
|
||||
}
|
||||
|
||||
private async sendBatchWithRetry(
|
||||
payload: FirstPartyEventLoggingPayload,
|
||||
): Promise<void> {
|
||||
if (this.isKilled()) {
|
||||
// Throw so the caller short-circuits remaining batches and queues
|
||||
// everything to disk. Zero network traffic while killed; the backoff
|
||||
// timer keeps ticking and will resume POSTs as soon as the GrowthBook
|
||||
// cache picks up the cleared flag.
|
||||
throw new Error('firstParty sink killswitch active')
|
||||
}
|
||||
|
||||
const baseHeaders: Record<string, string> = {
|
||||
'Content-Type': 'application/json',
|
||||
'User-Agent': getClaudeCodeUserAgent(),
|
||||
'x-service-name': 'claude-code',
|
||||
}
|
||||
|
||||
// Skip auth if trust hasn't been established yet
|
||||
// This prevents executing apiKeyHelper commands before the trust dialog
|
||||
// Non-interactive sessions implicitly have workspace trust
|
||||
const hasTrust =
|
||||
checkHasTrustDialogAccepted() || getIsNonInteractiveSession()
|
||||
if (process.env.USER_TYPE === 'ant' && !hasTrust) {
|
||||
logForDebugging('1P event logging: Trust not accepted')
|
||||
}
|
||||
|
||||
// Skip auth when the OAuth token is expired or lacks user:profile
|
||||
// scope (service key sessions). Falls through to unauthenticated send.
|
||||
let shouldSkipAuth = this.skipAuth || !hasTrust
|
||||
if (!shouldSkipAuth && isClaudeAISubscriber()) {
|
||||
const tokens = getClaudeAIOAuthTokens()
|
||||
if (!hasProfileScope()) {
|
||||
shouldSkipAuth = true
|
||||
} else if (tokens && isOAuthTokenExpired(tokens.expiresAt)) {
|
||||
shouldSkipAuth = true
|
||||
if (process.env.USER_TYPE === 'ant') {
|
||||
logForDebugging(
|
||||
'1P event logging: OAuth token expired, skipping auth to avoid 401',
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Try with auth headers first (unless trust not established or token is known to be expired)
|
||||
const authResult = shouldSkipAuth
|
||||
? { headers: {}, error: 'trust not established or Oauth token expired' }
|
||||
: getAuthHeaders()
|
||||
const useAuth = !authResult.error
|
||||
|
||||
if (!useAuth && process.env.USER_TYPE === 'ant') {
|
||||
logForDebugging(
|
||||
`1P event logging: auth not available, sending without auth`,
|
||||
)
|
||||
}
|
||||
|
||||
const headers = useAuth
|
||||
? { ...baseHeaders, ...authResult.headers }
|
||||
: baseHeaders
|
||||
|
||||
try {
|
||||
const response = await axios.post(this.endpoint, payload, {
|
||||
timeout: this.timeout,
|
||||
headers,
|
||||
})
|
||||
this.logSuccess(payload.events.length, useAuth, response.data)
|
||||
return
|
||||
} catch (error) {
|
||||
// Handle 401 by retrying without auth
|
||||
if (
|
||||
useAuth &&
|
||||
axios.isAxiosError(error) &&
|
||||
error.response?.status === 401
|
||||
) {
|
||||
if (process.env.USER_TYPE === 'ant') {
|
||||
logForDebugging(
|
||||
'1P event logging: 401 auth error, retrying without auth',
|
||||
)
|
||||
}
|
||||
const response = await axios.post(this.endpoint, payload, {
|
||||
timeout: this.timeout,
|
||||
headers: baseHeaders,
|
||||
})
|
||||
this.logSuccess(payload.events.length, false, response.data)
|
||||
return
|
||||
}
|
||||
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
private logSuccess(
|
||||
eventCount: number,
|
||||
withAuth: boolean,
|
||||
responseData: unknown,
|
||||
): void {
|
||||
if (process.env.USER_TYPE === 'ant') {
|
||||
logForDebugging(
|
||||
`1P event logging: ${eventCount} events exported successfully${withAuth ? ' (with auth)' : ' (without auth)'}`,
|
||||
)
|
||||
logForDebugging(`API Response: ${jsonStringify(responseData, null, 2)}`)
|
||||
}
|
||||
}
|
||||
|
||||
private hrTimeToDate(hrTime: HrTime): Date {
|
||||
const [seconds, nanoseconds] = hrTime
|
||||
return new Date(seconds * 1000 + nanoseconds / 1000000)
|
||||
}
|
||||
|
||||
private transformLogsToEvents(
|
||||
logs: ReadableLogRecord[],
|
||||
): FirstPartyEventLoggingPayload {
|
||||
const events: FirstPartyEventLoggingEvent[] = []
|
||||
|
||||
for (const log of logs) {
|
||||
const attributes = log.attributes || {}
|
||||
|
||||
// Check if this is a GrowthBook experiment event
|
||||
if (attributes.event_type === 'GrowthbookExperimentEvent') {
|
||||
const timestamp = this.hrTimeToDate(log.hrTime)
|
||||
const account_uuid = attributes.account_uuid as string | undefined
|
||||
const organization_uuid = attributes.organization_uuid as
|
||||
| string
|
||||
| undefined
|
||||
events.push({
|
||||
event_type: 'GrowthbookExperimentEvent',
|
||||
event_data: GrowthbookExperimentEvent.toJSON({
|
||||
event_id: attributes.event_id as string,
|
||||
timestamp,
|
||||
experiment_id: attributes.experiment_id as string,
|
||||
variation_id: attributes.variation_id as number,
|
||||
environment: attributes.environment as string,
|
||||
user_attributes: attributes.user_attributes as string,
|
||||
experiment_metadata: attributes.experiment_metadata as string,
|
||||
device_id: attributes.device_id as string,
|
||||
session_id: attributes.session_id as string,
|
||||
auth:
|
||||
account_uuid || organization_uuid
|
||||
? { account_uuid, organization_uuid }
|
||||
: undefined,
|
||||
}),
|
||||
})
|
||||
continue
|
||||
}
|
||||
|
||||
// Extract event name
|
||||
const eventName =
|
||||
(attributes.event_name as string) || (log.body as string) || 'unknown'
|
||||
|
||||
// Extract metadata objects directly (no JSON parsing needed)
|
||||
const coreMetadata = attributes.core_metadata as EventMetadata | undefined
|
||||
const userMetadata = attributes.user_metadata as CoreUserData
|
||||
const eventMetadata = (attributes.event_metadata || {}) as Record<
|
||||
string,
|
||||
unknown
|
||||
>
|
||||
|
||||
if (!coreMetadata) {
|
||||
// Emit partial event if core metadata is missing
|
||||
if (process.env.USER_TYPE === 'ant') {
|
||||
logForDebugging(
|
||||
`1P event logging: core_metadata missing for event ${eventName}`,
|
||||
)
|
||||
}
|
||||
events.push({
|
||||
event_type: 'ClaudeCodeInternalEvent',
|
||||
event_data: ClaudeCodeInternalEvent.toJSON({
|
||||
event_id: attributes.event_id as string | undefined,
|
||||
event_name: eventName,
|
||||
client_timestamp: this.hrTimeToDate(log.hrTime),
|
||||
session_id: getSessionId(),
|
||||
additional_metadata: Buffer.from(
|
||||
jsonStringify({
|
||||
transform_error: 'core_metadata attribute is missing',
|
||||
}),
|
||||
).toString('base64'),
|
||||
}),
|
||||
})
|
||||
continue
|
||||
}
|
||||
|
||||
// Transform to 1P format
|
||||
const formatted = to1PEventFormat(
|
||||
coreMetadata,
|
||||
userMetadata,
|
||||
eventMetadata,
|
||||
)
|
||||
|
||||
// _PROTO_* keys are PII-tagged values meinternal only for privileged BQ
|
||||
// columns. Hoist known keys to proto fields, then defensively strip any
|
||||
// remaining _PROTO_* so an unrecognized future key can't silently land
|
||||
// in the general-access additional_metadata blob. sink.ts applies the
|
||||
// same strip before Datadog; this closes the 1P side.
|
||||
const {
|
||||
_PROTO_skill_name,
|
||||
_PROTO_plugin_name,
|
||||
_PROTO_marketplace_name,
|
||||
...rest
|
||||
} = formatted.additional
|
||||
const additionalMetadata = stripProtoFields(rest)
|
||||
|
||||
events.push({
|
||||
event_type: 'ClaudeCodeInternalEvent',
|
||||
event_data: ClaudeCodeInternalEvent.toJSON({
|
||||
event_id: attributes.event_id as string | undefined,
|
||||
event_name: eventName,
|
||||
client_timestamp: this.hrTimeToDate(log.hrTime),
|
||||
device_id: attributes.user_id as string | undefined,
|
||||
email: userMetadata?.email,
|
||||
auth: formatted.auth,
|
||||
...formatted.core,
|
||||
env: formatted.env,
|
||||
process: formatted.process,
|
||||
skill_name:
|
||||
typeof _PROTO_skill_name === 'string'
|
||||
? _PROTO_skill_name
|
||||
: undefined,
|
||||
plugin_name:
|
||||
typeof _PROTO_plugin_name === 'string'
|
||||
? _PROTO_plugin_name
|
||||
: undefined,
|
||||
marketplace_name:
|
||||
typeof _PROTO_marketplace_name === 'string'
|
||||
? _PROTO_marketplace_name
|
||||
: undefined,
|
||||
additional_metadata:
|
||||
Object.keys(additionalMetadata).length > 0
|
||||
? Buffer.from(jsonStringify(additionalMetadata)).toString(
|
||||
'base64',
|
||||
)
|
||||
: undefined,
|
||||
}),
|
||||
})
|
||||
}
|
||||
|
||||
return { events }
|
||||
}
|
||||
|
||||
async shutdown(): Promise<void> {
|
||||
this.isShutdown = true
|
||||
this.resetBackoff()
|
||||
await this.forceFlush()
|
||||
if (process.env.USER_TYPE === 'ant') {
|
||||
logForDebugging('1P event logging exporter shutdown complete')
|
||||
}
|
||||
}
|
||||
|
||||
async forceFlush(): Promise<void> {
|
||||
await Promise.all(this.pendingExports)
|
||||
if (process.env.USER_TYPE === 'ant') {
|
||||
logForDebugging('1P event logging exporter flush complete')
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function getAxiosErrorContext(error: unknown): string {
|
||||
if (!axios.isAxiosError(error)) {
|
||||
return errorMessage(error)
|
||||
}
|
||||
|
||||
const parts: string[] = []
|
||||
|
||||
const requestId = error.response?.headers?.['request-id']
|
||||
if (requestId) {
|
||||
parts.push(`request-id=${requestId}`)
|
||||
}
|
||||
|
||||
if (error.response?.status) {
|
||||
parts.push(`status=${error.response.status}`)
|
||||
}
|
||||
|
||||
if (error.code) {
|
||||
parts.push(`code=${error.code}`)
|
||||
}
|
||||
|
||||
if (error.message) {
|
||||
parts.push(error.message)
|
||||
}
|
||||
|
||||
return parts.join(', ')
|
||||
}
|
||||
+127
-1096
File diff suppressed because it is too large
Load Diff
+34
-133
@@ -1,173 +1,74 @@
|
||||
/**
|
||||
* Analytics service - public API for event logging
|
||||
* No-op analytics service — events are silently discarded.
|
||||
*
|
||||
* This module serves as the main entry point for analytics events in Claude CLI.
|
||||
*
|
||||
* DESIGN: This module has NO dependencies to avoid import cycles.
|
||||
* Events are queued until attachAnalyticsSink() is called during app initialization.
|
||||
* The sink handles routing to Datadog and 1P event logging.
|
||||
* This module provides the public API for analytics event logging,
|
||||
* satisfying all import sites without sending any data.
|
||||
*/
|
||||
|
||||
/**
|
||||
* 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.
|
||||
* Marker type for verifying analytics metadata doesn't contain sensitive data.
|
||||
*
|
||||
* Usage: `myString as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS`
|
||||
*/
|
||||
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 routed to PII-tagged proto columns.
|
||||
*
|
||||
* 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.
|
||||
* Strip `_PROTO_*` keys from a payload.
|
||||
* No-op in this stub — returns the input unchanged when no _PROTO_ keys present.
|
||||
*/
|
||||
export function stripProtoFields<V>(
|
||||
metadata: Record<string, 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
|
||||
type LogEventMetadata = { [key: string]: boolean | number | undefined }
|
||||
|
||||
type QueuedEvent = {
|
||||
eventName: string
|
||||
metadata: LogEventMetadata
|
||||
async: boolean
|
||||
// Return a copy with _PROTO_ keys removed (no-op if none present)
|
||||
const hasProtoKey = Object.keys(metadata).some((k) => k.startsWith('_PROTO_'))
|
||||
if (!hasProtoKey) return metadata
|
||||
const result: Record<string, V> = { ...metadata }
|
||||
for (const key of Object.keys(result)) {
|
||||
if (key.startsWith('_PROTO_')) {
|
||||
delete result[key]
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
/**
|
||||
* Sink interface for the analytics backend
|
||||
* Analytics sink interface — no consumers in the no-op build,
|
||||
* but kept for type compatibility.
|
||||
*/
|
||||
export type AnalyticsSink = {
|
||||
logEvent: (eventName: string, metadata: LogEventMetadata) => void
|
||||
logEventAsync: (
|
||||
eventName: string,
|
||||
metadata: LogEventMetadata,
|
||||
) => Promise<void>
|
||||
logEvent: (eventName: string, metadata: { [key: string]: boolean | number | undefined }) => void
|
||||
logEventAsync: (eventName: string, metadata: { [key: string]: boolean | number | undefined }) => Promise<void>
|
||||
}
|
||||
|
||||
// Event queue for events logged before sink is attached
|
||||
const eventQueue: QueuedEvent[] = []
|
||||
|
||||
// Sink - initialized during app startup
|
||||
let sink: AnalyticsSink | null = null
|
||||
/** No-op — there is no sink to attach. */
|
||||
export function attachAnalyticsSink(_sink: AnalyticsSink): void {}
|
||||
|
||||
/**
|
||||
* Attach the analytics sink that will receive all events.
|
||||
* Queued events are drained asynchronously via queueMicrotask to avoid
|
||||
* adding latency to the startup path.
|
||||
*
|
||||
* Idempotent: if a sink is already attached, this is a no-op. This allows
|
||||
* calling from both the preAction hook (for subcommands) and setup() (for
|
||||
* the default command) without coordination.
|
||||
*/
|
||||
export function attachAnalyticsSink(newSink: AnalyticsSink): void {
|
||||
if (sink !== null) {
|
||||
return
|
||||
}
|
||||
sink = newSink
|
||||
|
||||
// Drain the queue asynchronously to avoid blocking startup
|
||||
if (eventQueue.length > 0) {
|
||||
const queuedEvents = [...eventQueue]
|
||||
eventQueue.length = 0
|
||||
|
||||
// Log queue size for ants to help debug analytics initialization timing
|
||||
if (process.env.USER_TYPE === 'ant') {
|
||||
sink.logEvent('analytics_sink_attached', {
|
||||
queued_event_count: queuedEvents.length,
|
||||
})
|
||||
}
|
||||
|
||||
queueMicrotask(() => {
|
||||
for (const event of queuedEvents) {
|
||||
if (event.async) {
|
||||
void sink!.logEventAsync(event.eventName, event.metadata)
|
||||
} else {
|
||||
sink!.logEvent(event.eventName, event.metadata)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Log an event to analytics backends (synchronous)
|
||||
*
|
||||
* Events may be sampled based on the 'tengu_event_sampling_config' dynamic config.
|
||||
* When sampled, the sample_rate is added to the event metadata.
|
||||
*
|
||||
* If no sink is attached, events are queued and drained when the sink attaches.
|
||||
* Log an event — silently discarded.
|
||||
*/
|
||||
export function logEvent(
|
||||
eventName: string,
|
||||
// intentionally no strings unless AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
|
||||
// to avoid accidentally logging code/filepaths
|
||||
metadata: LogEventMetadata,
|
||||
_eventName: string,
|
||||
_metadata?: { [key: string]: boolean | number | undefined },
|
||||
): void {
|
||||
if (sink === null) {
|
||||
eventQueue.push({ eventName, metadata, async: false })
|
||||
return
|
||||
}
|
||||
sink.logEvent(eventName, metadata)
|
||||
// No-op: events are discarded
|
||||
}
|
||||
|
||||
/**
|
||||
* Log an event to analytics backends (asynchronous)
|
||||
*
|
||||
* Events may be sampled based on the 'tengu_event_sampling_config' dynamic config.
|
||||
* When sampled, the sample_rate is added to the event metadata.
|
||||
*
|
||||
* If no sink is attached, events are queued and drained when the sink attaches.
|
||||
* Log an event asynchronously — silently discarded.
|
||||
*/
|
||||
export async function logEventAsync(
|
||||
eventName: string,
|
||||
// intentionally no strings, to avoid accidentally logging code/filepaths
|
||||
metadata: LogEventMetadata,
|
||||
_eventName: string,
|
||||
_metadata?: { [key: string]: boolean | number | undefined },
|
||||
): Promise<void> {
|
||||
if (sink === null) {
|
||||
eventQueue.push({ eventName, metadata, async: true })
|
||||
return
|
||||
}
|
||||
await sink.logEventAsync(eventName, metadata)
|
||||
// No-op: events are discarded
|
||||
}
|
||||
|
||||
/**
|
||||
* Reset analytics state for testing purposes only.
|
||||
* @internal
|
||||
*/
|
||||
export function _resetForTesting(): void {
|
||||
sink = null
|
||||
eventQueue.length = 0
|
||||
}
|
||||
/** Reset for testing — no-op. */
|
||||
export function _resetForTesting(): void {}
|
||||
@@ -1,114 +1,12 @@
|
||||
/**
|
||||
* Analytics sink implementation
|
||||
* No-op analytics sink — all event routing is disabled.
|
||||
*
|
||||
* This module contains the actual analytics routing logic and should be
|
||||
* initialized during app startup. It routes events to Datadog and 1P event
|
||||
* logging.
|
||||
*
|
||||
* Usage: Call initializeAnalyticsSink() during app startup to attach the sink.
|
||||
* With Datadog and 1P event logging stubbed out, the sink
|
||||
* has nothing to route to.
|
||||
*/
|
||||
|
||||
import { trackDatadogEvent } from './datadog.js'
|
||||
import { logEventTo1P, shouldSampleEvent } from './firstPartyEventLogger.js'
|
||||
import { checkStatsigFeatureGate_CACHED_MAY_BE_STALE } from './growthbook.js'
|
||||
import { attachAnalyticsSink, stripProtoFields } from './index.js'
|
||||
import { isSinkKilled } from './sinkKillswitch.js'
|
||||
/** No-op — there are no gates to initialize. */
|
||||
export function initializeAnalyticsGates(): void {}
|
||||
|
||||
// Local type matching the logEvent metadata signature
|
||||
type LogEventMetadata = { [key: string]: boolean | number | undefined }
|
||||
|
||||
const DATADOG_GATE_NAME = 'tengu_log_datadog_events'
|
||||
|
||||
// Module-level gate state - starts undefined, initialized during startup
|
||||
let isDatadogGateEnabled: boolean | undefined = undefined
|
||||
|
||||
/**
|
||||
* Check if Datadog tracking is enabled.
|
||||
* Falls back to cached value from previous session if not yet initialized.
|
||||
*/
|
||||
function shouldTrackDatadog(): boolean {
|
||||
if (isSinkKilled('datadog')) {
|
||||
return false
|
||||
}
|
||||
if (isDatadogGateEnabled !== undefined) {
|
||||
return isDatadogGateEnabled
|
||||
}
|
||||
|
||||
// Fallback to cached value from previous session
|
||||
try {
|
||||
return checkStatsigFeatureGate_CACHED_MAY_BE_STALE(DATADOG_GATE_NAME)
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Log an event (synchronous implementation)
|
||||
*/
|
||||
function logEventImpl(eventName: string, metadata: LogEventMetadata): void {
|
||||
// Check if this event should be sampled
|
||||
const sampleResult = shouldSampleEvent(eventName)
|
||||
|
||||
// If sample result is 0, the event was not selected for logging
|
||||
if (sampleResult === 0) {
|
||||
return
|
||||
}
|
||||
|
||||
// If sample result is a positive number, add it to metadata
|
||||
const metadataWithSampleRate =
|
||||
sampleResult !== null
|
||||
? { ...metadata, sample_rate: sampleResult }
|
||||
: metadata
|
||||
|
||||
if (shouldTrackDatadog()) {
|
||||
// Datadog is a general-access backend — strip _PROTO_* keys
|
||||
// (unredacted PII-tagged values meinternal only for the 1P privileged column).
|
||||
void trackDatadogEvent(eventName, stripProtoFields(metadataWithSampleRate))
|
||||
}
|
||||
|
||||
// 1P receives the full payload including _PROTO_* — the exporter
|
||||
// destructures and routes those keys to proto fields itself.
|
||||
logEventTo1P(eventName, metadataWithSampleRate)
|
||||
}
|
||||
|
||||
/**
|
||||
* Log an event (asynchronous implementation)
|
||||
*
|
||||
* With Segment removed the two remaining sinks are fire-and-forget, so this
|
||||
* just wraps the sync impl — kept to preserve the sink interface contract.
|
||||
*/
|
||||
function logEventAsyncImpl(
|
||||
eventName: string,
|
||||
metadata: LogEventMetadata,
|
||||
): Promise<void> {
|
||||
logEventImpl(eventName, metadata)
|
||||
return Promise.resolve()
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize analytics gates during startup.
|
||||
*
|
||||
* Updates gate values from server. Early events use cached values from previous
|
||||
* session to avoid data loss during initialization.
|
||||
*
|
||||
* Called from main.tsx during setupBackend().
|
||||
*/
|
||||
export function initializeAnalyticsGates(): void {
|
||||
isDatadogGateEnabled =
|
||||
checkStatsigFeatureGate_CACHED_MAY_BE_STALE(DATADOG_GATE_NAME)
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize the analytics sink.
|
||||
*
|
||||
* Call this during app startup to attach the analytics backend.
|
||||
* Any events logged before this is called will be queued and drained.
|
||||
*
|
||||
* Idempotent: safe to call multiple times (subsequent calls are no-ops).
|
||||
*/
|
||||
export function initializeAnalyticsSink(): void {
|
||||
attachAnalyticsSink({
|
||||
logEvent: logEventImpl,
|
||||
logEventAsync: logEventAsyncImpl,
|
||||
})
|
||||
}
|
||||
/** No-op — there is no sink to initialize. */
|
||||
export function initializeAnalyticsSink(): void {}
|
||||
@@ -1,25 +1,11 @@
|
||||
import { getDynamicConfig_CACHED_MAY_BE_STALE } from './growthbook.js'
|
||||
|
||||
// Mangled name: per-sink analytics killswitch
|
||||
const SINK_KILLSWITCH_CONFIG_NAME = 'tengu_frond_boric'
|
||||
/**
|
||||
* No-op sink killswitch — all sinks are effectively "not killed" since
|
||||
* the analytics subsystem is fully disabled.
|
||||
*/
|
||||
|
||||
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
|
||||
}
|
||||
/** Returns false — no sink is killed because analytics is disabled entirely. */
|
||||
export function isSinkKilled(_sink: SinkName): boolean {
|
||||
return false
|
||||
}
|
||||
@@ -210,11 +210,6 @@ import {
|
||||
stopSessionActivity,
|
||||
} from '../../utils/sessionActivity.js'
|
||||
import { jsonStringify } from '../../utils/slowOperations.js'
|
||||
import {
|
||||
isBetaTracingEnabled,
|
||||
type LLMRequestNewContext,
|
||||
startLLMRequestSpan,
|
||||
} from '../../utils/telemetry/sessionTracing.js'
|
||||
/* eslint-enable @typescript-eslint/no-require-imports */
|
||||
import {
|
||||
type AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
|
||||
@@ -1413,9 +1408,6 @@ async function* queryModel(
|
||||
})
|
||||
const useBetas = betas.length > 0
|
||||
|
||||
// Build minimal context for detailed tracing (when beta tracing is enabled)
|
||||
// Note: The actual new_context message extraction is done in sessionTracing.ts using
|
||||
// hash-based tracking per querySource (agent) from the messagesForAPI array
|
||||
const extraToolSchemas = [...(options.extraToolSchemas ?? [])]
|
||||
if (advisorModel) {
|
||||
// Server tools must be in the tools array by API contract. Appended after
|
||||
@@ -1523,23 +1515,6 @@ async function* queryModel(
|
||||
})
|
||||
}
|
||||
|
||||
const newContext: LLMRequestNewContext | undefined = isBetaTracingEnabled()
|
||||
? {
|
||||
systemPrompt: systemPrompt.join('\n\n'),
|
||||
querySource: options.querySource,
|
||||
tools: jsonStringify(allTools),
|
||||
}
|
||||
: undefined
|
||||
|
||||
// Capture the span so we can pass it to endLLMRequestSpan later
|
||||
// This ensures responses are matched to the correct request when multiple requests run in parallel
|
||||
const llmSpan = startLLMRequestSpan(
|
||||
options.model,
|
||||
newContext,
|
||||
messagesForAPI,
|
||||
isFastMode,
|
||||
)
|
||||
|
||||
const startIncludingRetries = Date.now()
|
||||
let start = Date.now()
|
||||
let attemptNumber = 0
|
||||
@@ -2774,7 +2749,6 @@ async function* queryModel(
|
||||
didFallBackToNonStreaming,
|
||||
queryTracking: options.queryTracking,
|
||||
querySource: options.querySource,
|
||||
llmSpan,
|
||||
fastMode: isFastModeRequest,
|
||||
previousRequestId,
|
||||
})
|
||||
@@ -2830,7 +2804,6 @@ async function* queryModel(
|
||||
didFallBackToNonStreaming,
|
||||
queryTracking: options.queryTracking,
|
||||
querySource: options.querySource,
|
||||
llmSpan,
|
||||
fastMode: isFastModeRequest,
|
||||
previousRequestId,
|
||||
})
|
||||
@@ -2918,10 +2891,8 @@ async function* queryModel(
|
||||
costUSD,
|
||||
queryTracking: options.queryTracking,
|
||||
permissionMode: permissionContext.mode,
|
||||
// Pass newMessages for beta tracing - extraction happens in logging.ts
|
||||
// only when beta tracing is enabled
|
||||
// Pass newMessages for content-length extraction in logging.ts
|
||||
newMessages,
|
||||
llmSpan,
|
||||
globalCacheStrategy,
|
||||
requestSetupMs: start - startIncludingRetries,
|
||||
attemptStartTimes,
|
||||
|
||||
@@ -22,12 +22,6 @@ import { logError } from 'src/utils/log.js'
|
||||
import { getAPIProviderForStatsig } from 'src/utils/model/providers.js'
|
||||
import type { PermissionMode } from 'src/utils/permissions/PermissionMode.js'
|
||||
import { jsonStringify } from 'src/utils/slowOperations.js'
|
||||
import { logOTelEvent } from 'src/utils/telemetry/events.js'
|
||||
import {
|
||||
endLLMRequestSpan,
|
||||
isBetaTracingEnabled,
|
||||
type Span,
|
||||
} from 'src/utils/telemetry/sessionTracing.js'
|
||||
import type { NonNullableUsage } from '../../entrypoints/sdk/sdkUtilityTypes.js'
|
||||
import { consumeInvokingRequestId } from '../../utils/agentContext.js'
|
||||
import {
|
||||
@@ -247,7 +241,6 @@ export function logAPIError({
|
||||
headers,
|
||||
queryTracking,
|
||||
querySource,
|
||||
llmSpan,
|
||||
fastMode,
|
||||
previousRequestId,
|
||||
}: {
|
||||
@@ -266,8 +259,6 @@ export function logAPIError({
|
||||
headers?: globalThis.Headers
|
||||
queryTracking?: QueryChainTracking
|
||||
querySource?: string
|
||||
/** The span from startLLMRequestSpan - pass this to correctly match responses to requests */
|
||||
llmSpan?: Span
|
||||
fastMode?: boolean
|
||||
previousRequestId?: string | null
|
||||
}): void {
|
||||
@@ -364,24 +355,7 @@ export function logAPIError({
|
||||
...getAnthropicEnvMetadata(),
|
||||
})
|
||||
|
||||
// Log API error event for OTLP
|
||||
void logOTelEvent('api_error', {
|
||||
model: model,
|
||||
error: errStr,
|
||||
status_code: String(status),
|
||||
duration_ms: String(durationMs),
|
||||
attempt: String(attempt),
|
||||
speed: fastMode ? 'fast' : 'normal',
|
||||
})
|
||||
|
||||
// Pass the span to correctly match responses to requests when beta tracing is enabled
|
||||
endLLMRequestSpan(llmSpan, {
|
||||
success: false,
|
||||
statusCode: status ? parseInt(status) : undefined,
|
||||
error: errStr,
|
||||
attempt,
|
||||
})
|
||||
|
||||
|
||||
// Log first error for teleported sessions (reliability tracking)
|
||||
const teleportInfo = getTeleportedSessionInfo()
|
||||
if (teleportInfo?.isTeleported && !teleportInfo.hasLoggedFirstMessage) {
|
||||
@@ -597,7 +571,6 @@ export function logAPISuccessAndDuration({
|
||||
queryTracking,
|
||||
permissionMode,
|
||||
newMessages,
|
||||
llmSpan,
|
||||
globalCacheStrategy,
|
||||
requestSetupMs,
|
||||
attemptStartTimes,
|
||||
@@ -622,11 +595,8 @@ export function logAPISuccessAndDuration({
|
||||
costUSD: number
|
||||
queryTracking?: QueryChainTracking
|
||||
permissionMode?: PermissionMode
|
||||
/** Assistant messages from the response - used to extract model_output and thinking_output
|
||||
* when beta tracing is enabled */
|
||||
/** Assistant messages from the response - used to calculate content length */
|
||||
newMessages?: AssistantMessage[]
|
||||
/** The span from startLLMRequestSpan - pass this to correctly match responses to requests */
|
||||
llmSpan?: Span
|
||||
/** Strategy used for global prompt caching: 'tool_based', 'system_prompt', or 'none' */
|
||||
globalCacheStrategy?: GlobalCacheStrategy
|
||||
/** Time spent in pre-request setup before the successful attempt */
|
||||
@@ -714,67 +684,6 @@ export function logAPISuccessAndDuration({
|
||||
previousRequestId,
|
||||
betas,
|
||||
})
|
||||
// Log API request event for OTLP
|
||||
void logOTelEvent('api_request', {
|
||||
model,
|
||||
input_tokens: String(usage.input_tokens),
|
||||
output_tokens: String(usage.output_tokens),
|
||||
cache_read_tokens: String(usage.cache_read_input_tokens),
|
||||
cache_creation_tokens: String(usage.cache_creation_input_tokens),
|
||||
cost_usd: String(costUSD),
|
||||
duration_ms: String(durationMs),
|
||||
speed: fastMode ? 'fast' : 'normal',
|
||||
})
|
||||
|
||||
// Extract model output, thinking output, and tool call flag when beta tracing is enabled
|
||||
let modelOutput: string | undefined
|
||||
let thinkingOutput: string | undefined
|
||||
let hasToolCall: boolean | undefined
|
||||
|
||||
if (isBetaTracingEnabled() && newMessages) {
|
||||
// Model output - visible to all users
|
||||
modelOutput =
|
||||
newMessages
|
||||
.flatMap(m =>
|
||||
m.message.content
|
||||
.filter(c => c.type === 'text')
|
||||
.map(c => (c as { type: 'text'; text: string }).text),
|
||||
)
|
||||
.join('\n') || undefined
|
||||
|
||||
// Thinking output - Ant-only (build-time gated)
|
||||
if (process.env.USER_TYPE === 'ant') {
|
||||
thinkingOutput =
|
||||
newMessages
|
||||
.flatMap(m =>
|
||||
m.message.content
|
||||
.filter(c => c.type === 'thinking')
|
||||
.map(c => (c as { type: 'thinking'; thinking: string }).thinking),
|
||||
)
|
||||
.join('\n') || undefined
|
||||
}
|
||||
|
||||
// Check if any tool_use blocks were in the output
|
||||
hasToolCall = newMessages.some(m =>
|
||||
m.message.content.some(c => c.type === 'tool_use'),
|
||||
)
|
||||
}
|
||||
|
||||
// Pass the span to correctly match responses to requests when beta tracing is enabled
|
||||
endLLMRequestSpan(llmSpan, {
|
||||
success: true,
|
||||
inputTokens: usage.input_tokens,
|
||||
outputTokens: usage.output_tokens,
|
||||
cacheReadTokens: usage.cache_read_input_tokens,
|
||||
cacheCreationTokens: usage.cache_creation_input_tokens,
|
||||
attempt,
|
||||
modelOutput,
|
||||
thinkingOutput,
|
||||
hasToolCall,
|
||||
ttftMs: ttftMs ?? undefined,
|
||||
requestSetupMs,
|
||||
attemptStartTimes,
|
||||
})
|
||||
|
||||
// Log first successful message for teleported sessions (reliability tracking)
|
||||
const teleportInfo = getTeleportedSessionInfo()
|
||||
|
||||
@@ -6,7 +6,6 @@ import { clearSpeculativeChecks } from '../../tools/BashTool/bashPermissions.js'
|
||||
import { clearClassifierApprovals } from '../../utils/classifierApprovals.js'
|
||||
import { resetGetMemoryFilesCache } from '../../utils/claudemd.js'
|
||||
import { clearSessionMessagesCache } from '../../utils/sessionStorage.js'
|
||||
import { clearBetaTracingState } from '../../utils/telemetry/betaSessionTracing.js'
|
||||
import { resetMicrocompactState } from './microCompact.js'
|
||||
|
||||
/**
|
||||
@@ -67,8 +66,7 @@ export function runPostCompactCleanup(querySource?: QuerySource): void {
|
||||
// model still has SkillTool in schema, invoked_skills preserves used
|
||||
// skills, and dynamic additions are handled by skillChangeDetector /
|
||||
// cacheUtils resets. See compactConversation() for full rationale.
|
||||
clearBetaTracingState()
|
||||
if (feature('COMMIT_ATTRIBUTION')) {
|
||||
if (feature('COMMIT_ATTRIBUTION')) {
|
||||
void import('../../utils/attributionHooks.js').then(m =>
|
||||
m.sweepFileContentCache(),
|
||||
)
|
||||
|
||||
@@ -20,7 +20,6 @@ import {
|
||||
} from 'src/services/analytics/metadata.js'
|
||||
import {
|
||||
addToToolDuration,
|
||||
getCodeEditToolDecisionCounter,
|
||||
getStatsStore,
|
||||
} from '../../bootstrap/state.js'
|
||||
import {
|
||||
@@ -88,17 +87,6 @@ import {
|
||||
} from '../../utils/sessionActivity.js'
|
||||
import { jsonStringify } from '../../utils/slowOperations.js'
|
||||
import { Stream } from '../../utils/stream.js'
|
||||
import { logOTelEvent } from '../../utils/telemetry/events.js'
|
||||
import {
|
||||
addToolContentEvent,
|
||||
endToolBlockedOnUserSpan,
|
||||
endToolExecutionSpan,
|
||||
endToolSpan,
|
||||
isBetaTracingEnabled,
|
||||
startToolBlockedOnUserSpan,
|
||||
startToolExecutionSpan,
|
||||
startToolSpan,
|
||||
} from '../../utils/telemetry/sessionTracing.js'
|
||||
import {
|
||||
formatError,
|
||||
formatZodValidationError,
|
||||
@@ -938,13 +926,6 @@ async function checkPermissionsAndCallTool(
|
||||
}
|
||||
}
|
||||
|
||||
startToolSpan(
|
||||
tool.name,
|
||||
toolAttributes,
|
||||
isBetaTracingEnabled() ? jsonStringify(processedInput) : undefined,
|
||||
)
|
||||
startToolBlockedOnUserSpan()
|
||||
|
||||
// Check whether we have permission to use the tool,
|
||||
// and ask the user for permission if we don't
|
||||
const permissionMode = toolUseContext.getAppState().toolPermissionContext.mode
|
||||
@@ -991,21 +972,6 @@ async function checkPermissionsAndCallTool(
|
||||
permissionDecision.decisionReason,
|
||||
permissionDecision.behavior,
|
||||
)
|
||||
void logOTelEvent('tool_decision', {
|
||||
decision,
|
||||
source,
|
||||
tool_name: sanitizeToolNameForAnalytics(tool.name),
|
||||
})
|
||||
|
||||
// Increment code-edit tool decision counter for headless mode
|
||||
if (isCodeEditingTool(tool.name)) {
|
||||
void buildCodeEditToolAttributes(
|
||||
tool,
|
||||
processedInput,
|
||||
decision,
|
||||
source,
|
||||
).then(attributes => getCodeEditToolDecisionCounter()?.add(1, attributes))
|
||||
}
|
||||
}
|
||||
|
||||
// Add message if permission was granted/denied by PermissionRequest hook
|
||||
@@ -1027,8 +993,6 @@ async function checkPermissionsAndCallTool(
|
||||
if (permissionDecision.behavior !== 'allow') {
|
||||
logForDebugging(`${tool.name} tool permission denied`)
|
||||
const decisionInfo = toolUseContext.toolDecisions?.get(toolUseID)
|
||||
endToolBlockedOnUserSpan('reject', decisionInfo?.source || 'unknown')
|
||||
endToolSpan()
|
||||
|
||||
logEvent('tengu_tool_use_can_use_tool_rejected', {
|
||||
messageID:
|
||||
@@ -1201,11 +1165,6 @@ async function checkPermissionsAndCallTool(
|
||||
}
|
||||
|
||||
const decisionInfo = toolUseContext.toolDecisions?.get(toolUseID)
|
||||
endToolBlockedOnUserSpan(
|
||||
decisionInfo?.decision || 'unknown',
|
||||
decisionInfo?.source || 'unknown',
|
||||
)
|
||||
startToolExecutionSpan()
|
||||
|
||||
const startTime = Date.now()
|
||||
|
||||
@@ -1256,51 +1215,6 @@ async function checkPermissionsAndCallTool(
|
||||
const durationMs = Date.now() - startTime
|
||||
addToToolDuration(durationMs)
|
||||
|
||||
// Log tool content/output as span event if enabled
|
||||
if (result.data && typeof result.data === 'object') {
|
||||
const contentAttributes: Record<string, string | number | boolean> = {}
|
||||
|
||||
// Read tool: capture file_path and content
|
||||
if (tool.name === FILE_READ_TOOL_NAME && 'content' in result.data) {
|
||||
if ('file_path' in processedInput) {
|
||||
contentAttributes.file_path = String(processedInput.file_path)
|
||||
}
|
||||
contentAttributes.content = String(result.data.content)
|
||||
}
|
||||
|
||||
// Edit/Write tools: capture file_path and diff
|
||||
if (
|
||||
(tool.name === FILE_EDIT_TOOL_NAME ||
|
||||
tool.name === FILE_WRITE_TOOL_NAME) &&
|
||||
'file_path' in processedInput
|
||||
) {
|
||||
contentAttributes.file_path = String(processedInput.file_path)
|
||||
|
||||
// For Edit, capture the actual changes made
|
||||
if (tool.name === FILE_EDIT_TOOL_NAME && 'diff' in result.data) {
|
||||
contentAttributes.diff = String(result.data.diff)
|
||||
}
|
||||
// For Write, capture the written content
|
||||
if (tool.name === FILE_WRITE_TOOL_NAME && 'content' in processedInput) {
|
||||
contentAttributes.content = String(processedInput.content)
|
||||
}
|
||||
}
|
||||
|
||||
// Bash tool: capture command
|
||||
if (tool.name === BASH_TOOL_NAME && 'command' in processedInput) {
|
||||
const bashInput = processedInput as BashToolInput
|
||||
contentAttributes.bash_command = bashInput.command
|
||||
// Also capture output if available
|
||||
if ('output' in result.data) {
|
||||
contentAttributes.output = String(result.data.output)
|
||||
}
|
||||
}
|
||||
|
||||
if (Object.keys(contentAttributes).length > 0) {
|
||||
addToolContentEvent('tool.output', contentAttributes)
|
||||
}
|
||||
}
|
||||
|
||||
// Capture structured output from tool result if present
|
||||
if (typeof result === 'object' && 'structured_output' in result) {
|
||||
// Store the structured output in an attachment message
|
||||
@@ -1312,14 +1226,6 @@ async function checkPermissionsAndCallTool(
|
||||
})
|
||||
}
|
||||
|
||||
endToolExecutionSpan({ success: true })
|
||||
// Pass tool result for new_context logging
|
||||
const toolResultStr =
|
||||
result.data && typeof result.data === 'object'
|
||||
? jsonStringify(result.data)
|
||||
: String(result.data ?? '')
|
||||
endToolSpan(toolResultStr)
|
||||
|
||||
// Map the tool result to API format once and cache it. This block is reused
|
||||
// by addToolResult (skipping the remap) and measured here for analytics.
|
||||
const mappedToolResultBlock = tool.mapToolResultToToolResultBlockParam(
|
||||
@@ -1411,22 +1317,7 @@ async function checkPermissionsAndCallTool(
|
||||
? getMcpServerScopeFromToolName(tool.name)
|
||||
: null
|
||||
|
||||
void logOTelEvent('tool_result', {
|
||||
tool_name: sanitizeToolNameForAnalytics(tool.name),
|
||||
success: 'true',
|
||||
duration_ms: String(durationMs),
|
||||
...(Object.keys(toolParameters).length > 0 && {
|
||||
tool_parameters: jsonStringify(toolParameters),
|
||||
}),
|
||||
...(telemetryToolInput && { tool_input: telemetryToolInput }),
|
||||
tool_result_size_bytes: String(toolResultSizeBytes),
|
||||
...(decisionInfo && {
|
||||
decision_source: decisionInfo.source,
|
||||
decision_type: decisionInfo.decision,
|
||||
}),
|
||||
...(mcpServerScope && { mcp_server_scope: mcpServerScope }),
|
||||
})
|
||||
|
||||
|
||||
// Run PostToolUse hooks
|
||||
let toolOutput = result.data
|
||||
const hookResults = []
|
||||
@@ -1623,12 +1514,6 @@ async function checkPermissionsAndCallTool(
|
||||
const durationMs = Date.now() - startTime
|
||||
addToToolDuration(durationMs)
|
||||
|
||||
endToolExecutionSpan({
|
||||
success: false,
|
||||
error: errorMessage(error),
|
||||
})
|
||||
endToolSpan()
|
||||
|
||||
// Handle MCP auth errors by updating the client status to 'needs-auth'
|
||||
// This updates the /mcp display to show the server needs re-authorization
|
||||
if (error instanceof McpAuthError) {
|
||||
@@ -1704,23 +1589,7 @@ async function checkPermissionsAndCallTool(
|
||||
? getMcpServerScopeFromToolName(tool.name)
|
||||
: null
|
||||
|
||||
void logOTelEvent('tool_result', {
|
||||
tool_name: sanitizeToolNameForAnalytics(tool.name),
|
||||
use_id: toolUseID,
|
||||
success: 'false',
|
||||
duration_ms: String(durationMs),
|
||||
error: errorMessage(error),
|
||||
...(Object.keys(toolParameters).length > 0 && {
|
||||
tool_parameters: jsonStringify(toolParameters),
|
||||
}),
|
||||
...(telemetryToolInput && { tool_input: telemetryToolInput }),
|
||||
...(decisionInfo && {
|
||||
decision_source: decisionInfo.source,
|
||||
decision_type: decisionInfo.decision,
|
||||
}),
|
||||
...(mcpServerScope && { mcp_server_scope: mcpServerScope }),
|
||||
})
|
||||
}
|
||||
}
|
||||
const content = formatError(error)
|
||||
|
||||
// Determine if this was a user interrupt
|
||||
|
||||
@@ -74,11 +74,6 @@ import {
|
||||
asSystemPrompt,
|
||||
type SystemPrompt,
|
||||
} from '../../utils/systemPromptType.js'
|
||||
import {
|
||||
isPerfettoTracingEnabled,
|
||||
registerAgent as registerPerfettoAgent,
|
||||
unregisterAgent as unregisterPerfettoAgent,
|
||||
} from '../../utils/telemetry/perfettoTracing.js'
|
||||
import type { ContentReplacementState } from '../../utils/toolResultStorage.js'
|
||||
import { createAgentId } from '../../utils/uuid.js'
|
||||
import { resolveAgentTools } from './agentToolUtils.js'
|
||||
@@ -365,12 +360,7 @@ export async function* runAgent({
|
||||
setAgentTranscriptSubdir(agentId, transcriptSubdir)
|
||||
}
|
||||
|
||||
// Register agent in Perfetto trace for hierarchy visualization
|
||||
if (isPerfettoTracingEnabled()) {
|
||||
const parentId = toolUseContext.agentId ?? getSessionId()
|
||||
registerPerfettoAgent(agentId, agentDefinition.agentType, parentId)
|
||||
}
|
||||
|
||||
|
||||
// Log API calls path for subagents (internal-only)
|
||||
if (process.env.USER_TYPE === 'ant') {
|
||||
logForDebugging(
|
||||
@@ -842,8 +832,6 @@ export async function* runAgent({
|
||||
agentToolUseContext.readFileState.clear()
|
||||
// Release the cloned fork context messages
|
||||
initialMessages.length = 0
|
||||
// Release perfetto agent registry entry
|
||||
unregisterPerfettoAgent(agentId)
|
||||
// Release transcript subdir mapping
|
||||
clearAgentTranscriptSubdir(agentId)
|
||||
// Release this agent's todos entry. Without this, every subagent that
|
||||
|
||||
@@ -8,7 +8,6 @@
|
||||
* external binaries with the same argv syntax).
|
||||
*/
|
||||
|
||||
import { getCommitCounter, getPrCounter } from '../../bootstrap/state.js'
|
||||
import {
|
||||
type AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
|
||||
logEvent,
|
||||
@@ -207,7 +206,6 @@ export function trackGitOperations(
|
||||
'commit_amend' as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
|
||||
})
|
||||
}
|
||||
getCommitCounter()?.add(1)
|
||||
}
|
||||
if (GIT_PUSH_RE.test(command)) {
|
||||
logEvent('tengu_git_operation', {
|
||||
@@ -223,7 +221,6 @@ export function trackGitOperations(
|
||||
})
|
||||
}
|
||||
if (prHit?.action === 'created') {
|
||||
getPrCounter()?.add(1)
|
||||
// Auto-link session to PR if we can extract PR URL from stdout
|
||||
if (stdout) {
|
||||
const prInfo = findPrInStdout(stdout)
|
||||
@@ -252,7 +249,6 @@ export function trackGitOperations(
|
||||
operation:
|
||||
'pr_create' as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
|
||||
})
|
||||
getPrCounter()?.add(1)
|
||||
}
|
||||
// Detect PR creation via curl to REST APIs (Bitbucket, GitHub API, GitLab API)
|
||||
// Check for POST method and PR endpoint separately to handle any argument order
|
||||
@@ -272,6 +268,5 @@ export function trackGitOperations(
|
||||
operation:
|
||||
'pr_create' as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
|
||||
})
|
||||
getPrCounter()?.add(1)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,8 +1,5 @@
|
||||
import { getActiveTimeCounter as getActiveTimeCounterImpl } from '../bootstrap/state.js'
|
||||
|
||||
type ActivityManagerOptions = {
|
||||
getNow?: () => number
|
||||
getActiveTimeCounter?: typeof getActiveTimeCounterImpl
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -21,14 +18,11 @@ export class ActivityManager {
|
||||
private readonly USER_ACTIVITY_TIMEOUT_MS = 5000 // 5 seconds
|
||||
|
||||
private readonly getNow: () => number
|
||||
private readonly getActiveTimeCounter: typeof getActiveTimeCounterImpl
|
||||
|
||||
private static instance: ActivityManager | null = null
|
||||
|
||||
constructor(options?: ActivityManagerOptions) {
|
||||
this.getNow = options?.getNow ?? (() => Date.now())
|
||||
this.getActiveTimeCounter =
|
||||
options?.getActiveTimeCounter ?? getActiveTimeCounterImpl
|
||||
this.lastCLIRecordedTime = this.getNow()
|
||||
}
|
||||
|
||||
@@ -58,24 +52,6 @@ export class ActivityManager {
|
||||
* Called when user interacts with the CLI (typing, commands, etc.)
|
||||
*/
|
||||
recordUserActivity(): void {
|
||||
// Don't record user time if CLI is active (CLI takes precedence)
|
||||
if (!this.isCLIActive && this.lastUserActivityTime !== 0) {
|
||||
const now = this.getNow()
|
||||
const timeSinceLastActivity = (now - this.lastUserActivityTime) / 1000
|
||||
|
||||
if (timeSinceLastActivity > 0) {
|
||||
const activeTimeCounter = this.getActiveTimeCounter()
|
||||
if (activeTimeCounter) {
|
||||
const timeoutSeconds = this.USER_ACTIVITY_TIMEOUT_MS / 1000
|
||||
|
||||
// Only record time if within the timeout window
|
||||
if (timeSinceLastActivity < timeoutSeconds) {
|
||||
activeTimeCounter.add(timeSinceLastActivity, { type: 'user' })
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Update the last user activity timestamp
|
||||
this.lastUserActivityTime = this.getNow()
|
||||
}
|
||||
@@ -108,18 +84,7 @@ export class ActivityManager {
|
||||
|
||||
if (this.activeOperations.size === 0) {
|
||||
// Last operation ended - CLI becoming inactive
|
||||
// Record the CLI time before switching to inactive
|
||||
const now = this.getNow()
|
||||
const timeSinceLastRecord = (now - this.lastCLIRecordedTime) / 1000
|
||||
|
||||
if (timeSinceLastRecord > 0) {
|
||||
const activeTimeCounter = this.getActiveTimeCounter()
|
||||
if (activeTimeCounter) {
|
||||
activeTimeCounter.add(timeSinceLastRecord, { type: 'cli' })
|
||||
}
|
||||
}
|
||||
|
||||
this.lastCLIRecordedTime = now
|
||||
this.lastCLIRecordedTime = this.getNow()
|
||||
this.isCLIActive = false
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,7 +7,6 @@ import {
|
||||
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js'
|
||||
import { format } from 'util'
|
||||
import { shutdownDatadog } from '../../services/analytics/datadog.js'
|
||||
import { shutdown1PEventLogging } from '../../services/analytics/firstPartyEventLogger.js'
|
||||
import { getFeatureValue_CACHED_MAY_BE_STALE } from '../../services/analytics/growthbook.js'
|
||||
import {
|
||||
type AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
|
||||
@@ -258,7 +257,6 @@ export async function runClaudeInChromeMcpServer(): Promise<void> {
|
||||
return
|
||||
}
|
||||
exiting = true
|
||||
await shutdown1PEventLogging()
|
||||
await shutdownDatadog()
|
||||
// eslint-disable-next-line custom-rules/no-process-exit
|
||||
process.exit(0)
|
||||
|
||||
@@ -7,7 +7,6 @@ import { ListToolsRequestSchema } from '@modelcontextprotocol/sdk/types.js'
|
||||
import { homedir } from 'os'
|
||||
|
||||
import { shutdownDatadog } from '../../services/analytics/datadog.js'
|
||||
import { shutdown1PEventLogging } from '../../services/analytics/firstPartyEventLogger.js'
|
||||
import { initializeAnalyticsSink } from '../../services/analytics/sink.js'
|
||||
import { enableConfigs } from '../config.js'
|
||||
import { logForDebugging } from '../debug.js'
|
||||
@@ -93,7 +92,7 @@ export async function runComputerUseMcpServer(): Promise<void> {
|
||||
const shutdownAndExit = async (): Promise<void> => {
|
||||
if (exiting) return
|
||||
exiting = true
|
||||
await Promise.all([shutdown1PEventLogging(), shutdownDatadog()])
|
||||
await shutdownDatadog()
|
||||
// eslint-disable-next-line custom-rules/no-process-exit
|
||||
process.exit(0)
|
||||
}
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import { type StructuredPatchHunk, structuredPatch } from 'diff'
|
||||
import { logEvent } from 'src/services/analytics/index.js'
|
||||
import { getLocCounter } from '../bootstrap/state.js'
|
||||
import { addToTotalLinesChanged } from '../cost-tracker.js'
|
||||
import type { FileEdit } from '../tools/FileEditTool/types.js'
|
||||
import { count } from './array.js'
|
||||
@@ -69,9 +68,6 @@ export function countLinesChanged(
|
||||
|
||||
addToTotalLinesChanged(numAdditions, numRemovals)
|
||||
|
||||
getLocCounter()?.add(numAdditions, { type: 'added' })
|
||||
getLocCounter()?.add(numRemovals, { type: 'removed' })
|
||||
|
||||
logEvent('tengu_file_changed', {
|
||||
lines_added: numAdditions,
|
||||
lines_removed: numRemovals,
|
||||
|
||||
@@ -30,7 +30,6 @@ import {
|
||||
wrapForMultiplexer,
|
||||
} from '../ink/termio/osc.js'
|
||||
import { shutdownDatadog } from '../services/analytics/datadog.js'
|
||||
import { shutdown1PEventLogging } from '../services/analytics/firstPartyEventLogger.js'
|
||||
import {
|
||||
type AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
|
||||
logEvent,
|
||||
@@ -519,7 +518,7 @@ export async function gracefulShutdown(
|
||||
// Lost analytics on slow networks are acceptable; a hanging exit is not.
|
||||
try {
|
||||
await Promise.race([
|
||||
Promise.all([shutdown1PEventLogging(), shutdownDatadog()]),
|
||||
shutdownDatadog(),
|
||||
sleep(500),
|
||||
])
|
||||
} catch {
|
||||
|
||||
@@ -1,491 +0,0 @@
|
||||
/**
|
||||
* Beta Session Tracing for Claude Code
|
||||
*
|
||||
* This module contains beta tracing features enabled when
|
||||
* ENABLE_BETA_TRACING_DETAILED=1 and BETA_TRACING_ENDPOINT are set.
|
||||
*
|
||||
* For external users, tracing is enabled in SDK/headless mode, or in
|
||||
* interactive mode when the org is allowlisted via the
|
||||
* tengu_trace_lantern GrowthBook gate.
|
||||
* For ant users, tracing is enabled in all modes.
|
||||
*
|
||||
* Visibility Rules:
|
||||
* | Content | External | Ant |
|
||||
* |------------------|----------|------|
|
||||
* | System prompts | ✅ | ✅ |
|
||||
* | Model output | ✅ | ✅ |
|
||||
* | Thinking output | ❌ | ✅ |
|
||||
* | Tools | ✅ | ✅ |
|
||||
* | new_context | ✅ | ✅ |
|
||||
*
|
||||
* Features:
|
||||
* - Per-agent message tracking with hash-based deduplication
|
||||
* - System prompt logging (once per unique hash)
|
||||
* - Hook execution spans
|
||||
* - Detailed new_context attributes for LLM requests
|
||||
*/
|
||||
|
||||
import type { Span } from '@opentelemetry/api'
|
||||
import { createHash } from 'crypto'
|
||||
import { getIsNonInteractiveSession } from '../../bootstrap/state.js'
|
||||
import { getFeatureValue_CACHED_MAY_BE_STALE } from '../../services/analytics/growthbook.js'
|
||||
import { sanitizeToolNameForAnalytics } from '../../services/analytics/metadata.js'
|
||||
import type { AssistantMessage, UserMessage } from '../../types/message.js'
|
||||
import { isEnvTruthy } from '../envUtils.js'
|
||||
import { jsonParse, jsonStringify } from '../slowOperations.js'
|
||||
import { logOTelEvent } from './events.js'
|
||||
|
||||
// Message type for API calls (UserMessage or AssistantMessage)
|
||||
type APIMessage = UserMessage | AssistantMessage
|
||||
|
||||
/**
|
||||
* Track hashes we've already logged this session (system prompts, tools, etc).
|
||||
*
|
||||
* WHY: System prompts and tool schemas are large and rarely change within a session.
|
||||
* Sending full content on every request would be wasteful. Instead, we hash and
|
||||
* only log the full content once per unique hash.
|
||||
*/
|
||||
const seenHashes = new Set<string>()
|
||||
|
||||
/**
|
||||
* Track the last reported message hash per querySource (agent) for incremental context.
|
||||
*
|
||||
* WHY: When debugging traces, we want to see what NEW information was added each turn,
|
||||
* not the entire conversation history (which can be huge). By tracking the last message
|
||||
* we reported per agent, we can compute and send only the delta (new messages since
|
||||
* the last request). This is tracked per-agent (querySource) because different agents
|
||||
* (main thread, subagents, warmup requests) have independent conversation contexts.
|
||||
*/
|
||||
const lastReportedMessageHash = new Map<string, string>()
|
||||
|
||||
/**
|
||||
* Clear tracking state after compaction.
|
||||
* Old hashes are irrelevant once messages have been replaced.
|
||||
*/
|
||||
export function clearBetaTracingState(): void {
|
||||
seenHashes.clear()
|
||||
lastReportedMessageHash.clear()
|
||||
}
|
||||
|
||||
const MAX_CONTENT_SIZE = 60 * 1024 // 60KB (Honeycomb limit is 64KB, staying safe)
|
||||
|
||||
/**
|
||||
* Check if beta detailed tracing is enabled.
|
||||
* - Requires ENABLE_BETA_TRACING_DETAILED=1 and BETA_TRACING_ENDPOINT
|
||||
* - For external users, enabled in SDK/headless mode OR when org is
|
||||
* allowlisted via the tengu_trace_lantern GrowthBook gate
|
||||
*/
|
||||
export function isBetaTracingEnabled(): boolean {
|
||||
const baseEnabled =
|
||||
isEnvTruthy(process.env.ENABLE_BETA_TRACING_DETAILED) &&
|
||||
Boolean(process.env.BETA_TRACING_ENDPOINT)
|
||||
|
||||
if (!baseEnabled) {
|
||||
return false
|
||||
}
|
||||
|
||||
// For external users, enable in SDK/headless mode OR when org is allowlisted.
|
||||
// Gate reads from disk cache, so first run after allowlisting returns false;
|
||||
// works from second run onward (same behavior as enhanced_telemetry_beta).
|
||||
if (process.env.USER_TYPE !== 'ant') {
|
||||
return (
|
||||
getIsNonInteractiveSession() ||
|
||||
getFeatureValue_CACHED_MAY_BE_STALE('tengu_trace_lantern', false)
|
||||
)
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
/**
|
||||
* Truncate content to fit within Honeycomb limits.
|
||||
*/
|
||||
export function truncateContent(
|
||||
content: string,
|
||||
maxSize: number = MAX_CONTENT_SIZE,
|
||||
): { content: string; truncated: boolean } {
|
||||
if (content.length <= maxSize) {
|
||||
return { content, truncated: false }
|
||||
}
|
||||
|
||||
return {
|
||||
content:
|
||||
content.slice(0, maxSize) +
|
||||
'\n\n[TRUNCATED - Content exceeds 60KB limit]',
|
||||
truncated: true,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate a short hash (first 12 hex chars of SHA-256).
|
||||
*/
|
||||
function shortHash(content: string): string {
|
||||
return createHash('sha256').update(content).digest('hex').slice(0, 12)
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate a hash for a system prompt.
|
||||
*/
|
||||
function hashSystemPrompt(systemPrompt: string): string {
|
||||
return `sp_${shortHash(systemPrompt)}`
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate a hash for a message based on its content.
|
||||
*/
|
||||
function hashMessage(message: APIMessage): string {
|
||||
const content = jsonStringify(message.message.content)
|
||||
return `msg_${shortHash(content)}`
|
||||
}
|
||||
|
||||
// Regex to detect content wrapped in <system-reminder> tags
|
||||
const SYSTEM_REMINDER_REGEX =
|
||||
/^<system-reminder>\n?([\s\S]*?)\n?<\/system-reminder>$/
|
||||
|
||||
/**
|
||||
* Check if text is entirely a system reminder (wrapped in <system-reminder> tags).
|
||||
* Returns the inner content if it is, null otherwise.
|
||||
*/
|
||||
function extractSystemReminderContent(text: string): string | null {
|
||||
const match = text.trim().match(SYSTEM_REMINDER_REGEX)
|
||||
return match && match[1] ? match[1].trim() : null
|
||||
}
|
||||
|
||||
/**
|
||||
* Result of formatting messages - separates regular content from system reminders.
|
||||
*/
|
||||
interface FormattedMessages {
|
||||
contextParts: string[]
|
||||
systemReminders: string[]
|
||||
}
|
||||
|
||||
/**
|
||||
* Format user messages for new_context display, separating system reminders.
|
||||
* Only handles user messages (assistant messages are filtered out before this is called).
|
||||
*/
|
||||
function formatMessagesForContext(messages: UserMessage[]): FormattedMessages {
|
||||
const contextParts: string[] = []
|
||||
const systemReminders: string[] = []
|
||||
|
||||
for (const message of messages) {
|
||||
const content = message.message.content
|
||||
if (typeof content === 'string') {
|
||||
const reminderContent = extractSystemReminderContent(content)
|
||||
if (reminderContent) {
|
||||
systemReminders.push(reminderContent)
|
||||
} else {
|
||||
contextParts.push(`[USER]\n${content}`)
|
||||
}
|
||||
} else if (Array.isArray(content)) {
|
||||
for (const block of content) {
|
||||
if (block.type === 'text') {
|
||||
const reminderContent = extractSystemReminderContent(block.text)
|
||||
if (reminderContent) {
|
||||
systemReminders.push(reminderContent)
|
||||
} else {
|
||||
contextParts.push(`[USER]\n${block.text}`)
|
||||
}
|
||||
} else if (block.type === 'tool_result') {
|
||||
const resultContent =
|
||||
typeof block.content === 'string'
|
||||
? block.content
|
||||
: jsonStringify(block.content)
|
||||
// Tool results can also contain system reminders (e.g., malware warning)
|
||||
const reminderContent = extractSystemReminderContent(resultContent)
|
||||
if (reminderContent) {
|
||||
systemReminders.push(reminderContent)
|
||||
} else {
|
||||
contextParts.push(
|
||||
`[TOOL RESULT: ${block.tool_use_id}]\n${resultContent}`,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return { contextParts, systemReminders }
|
||||
}
|
||||
|
||||
export interface LLMRequestNewContext {
|
||||
/** System prompt (typically only on first request or if changed) */
|
||||
systemPrompt?: string
|
||||
/** Query source identifying the agent/purpose (e.g., 'repl_main_thread', 'agent:builtin') */
|
||||
querySource?: string
|
||||
/** Tool schemas sent with the request */
|
||||
tools?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Add beta attributes to an interaction span.
|
||||
* Adds new_context with the user prompt.
|
||||
*/
|
||||
export function addBetaInteractionAttributes(
|
||||
span: Span,
|
||||
userPrompt: string,
|
||||
): void {
|
||||
if (!isBetaTracingEnabled()) {
|
||||
return
|
||||
}
|
||||
|
||||
const { content: truncatedPrompt, truncated } = truncateContent(
|
||||
`[USER PROMPT]\n${userPrompt}`,
|
||||
)
|
||||
span.setAttributes({
|
||||
new_context: truncatedPrompt,
|
||||
...(truncated && {
|
||||
new_context_truncated: true,
|
||||
new_context_original_length: userPrompt.length,
|
||||
}),
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Add beta attributes to an LLM request span.
|
||||
* Handles system prompt logging and new_context computation.
|
||||
*/
|
||||
export function addBetaLLMRequestAttributes(
|
||||
span: Span,
|
||||
newContext?: LLMRequestNewContext,
|
||||
messagesForAPI?: APIMessage[],
|
||||
): void {
|
||||
if (!isBetaTracingEnabled()) {
|
||||
return
|
||||
}
|
||||
|
||||
// Add system prompt info to the span
|
||||
if (newContext?.systemPrompt) {
|
||||
const promptHash = hashSystemPrompt(newContext.systemPrompt)
|
||||
const preview = newContext.systemPrompt.slice(0, 500)
|
||||
|
||||
// Always add hash, preview, and length to the span
|
||||
span.setAttribute('system_prompt_hash', promptHash)
|
||||
span.setAttribute('system_prompt_preview', preview)
|
||||
span.setAttribute('system_prompt_length', newContext.systemPrompt.length)
|
||||
|
||||
// Log the full system prompt only once per unique hash this session
|
||||
if (!seenHashes.has(promptHash)) {
|
||||
seenHashes.add(promptHash)
|
||||
|
||||
// Truncate for the log if needed
|
||||
const { content: truncatedPrompt, truncated } = truncateContent(
|
||||
newContext.systemPrompt,
|
||||
)
|
||||
|
||||
void logOTelEvent('system_prompt', {
|
||||
system_prompt_hash: promptHash,
|
||||
system_prompt: truncatedPrompt,
|
||||
system_prompt_length: String(newContext.systemPrompt.length),
|
||||
...(truncated && { system_prompt_truncated: 'true' }),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Add tools info to the span
|
||||
if (newContext?.tools) {
|
||||
try {
|
||||
const toolsArray = jsonParse(newContext.tools) as Record<
|
||||
string,
|
||||
unknown
|
||||
>[]
|
||||
|
||||
// Build array of {name, hash} for each tool
|
||||
const toolsWithHashes = toolsArray.map(tool => {
|
||||
const toolJson = jsonStringify(tool)
|
||||
const toolHash = shortHash(toolJson)
|
||||
return {
|
||||
name: typeof tool.name === 'string' ? tool.name : 'unknown',
|
||||
hash: toolHash,
|
||||
json: toolJson,
|
||||
}
|
||||
})
|
||||
|
||||
// Set span attribute with array of name/hash pairs
|
||||
span.setAttribute(
|
||||
'tools',
|
||||
jsonStringify(
|
||||
toolsWithHashes.map(({ name, hash }) => ({ name, hash })),
|
||||
),
|
||||
)
|
||||
span.setAttribute('tools_count', toolsWithHashes.length)
|
||||
|
||||
// Log each tool's full description once per unique hash
|
||||
for (const { name, hash, json } of toolsWithHashes) {
|
||||
if (!seenHashes.has(`tool_${hash}`)) {
|
||||
seenHashes.add(`tool_${hash}`)
|
||||
|
||||
const { content: truncatedTool, truncated } = truncateContent(json)
|
||||
|
||||
void logOTelEvent('tool', {
|
||||
tool_name: sanitizeToolNameForAnalytics(name),
|
||||
tool_hash: hash,
|
||||
tool: truncatedTool,
|
||||
...(truncated && { tool_truncated: 'true' }),
|
||||
})
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// If parsing fails, log the raw tools string
|
||||
span.setAttribute('tools_parse_error', true)
|
||||
}
|
||||
}
|
||||
|
||||
// Add new_context using hash-based tracking (visible to all users)
|
||||
if (messagesForAPI && messagesForAPI.length > 0 && newContext?.querySource) {
|
||||
const querySource = newContext.querySource
|
||||
const lastHash = lastReportedMessageHash.get(querySource)
|
||||
|
||||
// Find where the last reported message is in the array
|
||||
let startIndex = 0
|
||||
if (lastHash) {
|
||||
for (let i = 0; i < messagesForAPI.length; i++) {
|
||||
const msg = messagesForAPI[i]
|
||||
if (msg && hashMessage(msg) === lastHash) {
|
||||
startIndex = i + 1 // Start after the last reported message
|
||||
break
|
||||
}
|
||||
}
|
||||
// If lastHash not found, startIndex stays 0 (send everything)
|
||||
}
|
||||
|
||||
// Get new messages (filter out assistant messages - we only want user input/tool results)
|
||||
const newMessages = messagesForAPI
|
||||
.slice(startIndex)
|
||||
.filter((m): m is UserMessage => m.type === 'user')
|
||||
|
||||
if (newMessages.length > 0) {
|
||||
// Format new messages, separating system reminders from regular content
|
||||
const { contextParts, systemReminders } =
|
||||
formatMessagesForContext(newMessages)
|
||||
|
||||
// Set new_context (regular user content and tool results)
|
||||
if (contextParts.length > 0) {
|
||||
const fullContext = contextParts.join('\n\n---\n\n')
|
||||
const { content: truncatedContext, truncated } =
|
||||
truncateContent(fullContext)
|
||||
|
||||
span.setAttributes({
|
||||
new_context: truncatedContext,
|
||||
new_context_message_count: newMessages.length,
|
||||
...(truncated && {
|
||||
new_context_truncated: true,
|
||||
new_context_original_length: fullContext.length,
|
||||
}),
|
||||
})
|
||||
}
|
||||
|
||||
// Set system_reminders as a separate attribute
|
||||
if (systemReminders.length > 0) {
|
||||
const fullReminders = systemReminders.join('\n\n---\n\n')
|
||||
const { content: truncatedReminders, truncated: remindersTruncated } =
|
||||
truncateContent(fullReminders)
|
||||
|
||||
span.setAttributes({
|
||||
system_reminders: truncatedReminders,
|
||||
system_reminders_count: systemReminders.length,
|
||||
...(remindersTruncated && {
|
||||
system_reminders_truncated: true,
|
||||
system_reminders_original_length: fullReminders.length,
|
||||
}),
|
||||
})
|
||||
}
|
||||
|
||||
// Update last reported hash to the last message in the array
|
||||
const lastMessage = messagesForAPI[messagesForAPI.length - 1]
|
||||
if (lastMessage) {
|
||||
lastReportedMessageHash.set(querySource, hashMessage(lastMessage))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Add beta attributes to endLLMRequestSpan.
|
||||
* Handles model_output and thinking_output truncation.
|
||||
*/
|
||||
export function addBetaLLMResponseAttributes(
|
||||
endAttributes: Record<string, string | number | boolean>,
|
||||
metadata?: {
|
||||
modelOutput?: string
|
||||
thinkingOutput?: string
|
||||
},
|
||||
): void {
|
||||
if (!isBetaTracingEnabled() || !metadata) {
|
||||
return
|
||||
}
|
||||
|
||||
// Add model_output (text content) - visible to all users
|
||||
if (metadata.modelOutput !== undefined) {
|
||||
const { content: modelOutput, truncated: outputTruncated } =
|
||||
truncateContent(metadata.modelOutput)
|
||||
endAttributes['response.model_output'] = modelOutput
|
||||
if (outputTruncated) {
|
||||
endAttributes['response.model_output_truncated'] = true
|
||||
endAttributes['response.model_output_original_length'] =
|
||||
metadata.modelOutput.length
|
||||
}
|
||||
}
|
||||
|
||||
// Add thinking_output - internal-only
|
||||
if (
|
||||
process.env.USER_TYPE === 'ant' &&
|
||||
metadata.thinkingOutput !== undefined
|
||||
) {
|
||||
const { content: thinkingOutput, truncated: thinkingTruncated } =
|
||||
truncateContent(metadata.thinkingOutput)
|
||||
endAttributes['response.thinking_output'] = thinkingOutput
|
||||
if (thinkingTruncated) {
|
||||
endAttributes['response.thinking_output_truncated'] = true
|
||||
endAttributes['response.thinking_output_original_length'] =
|
||||
metadata.thinkingOutput.length
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Add beta attributes to startToolSpan.
|
||||
* Adds tool_input with the serialized tool input.
|
||||
*/
|
||||
export function addBetaToolInputAttributes(
|
||||
span: Span,
|
||||
toolName: string,
|
||||
toolInput: string,
|
||||
): void {
|
||||
if (!isBetaTracingEnabled()) {
|
||||
return
|
||||
}
|
||||
|
||||
const { content: truncatedInput, truncated } = truncateContent(
|
||||
`[TOOL INPUT: ${toolName}]\n${toolInput}`,
|
||||
)
|
||||
span.setAttributes({
|
||||
tool_input: truncatedInput,
|
||||
...(truncated && {
|
||||
tool_input_truncated: true,
|
||||
tool_input_original_length: toolInput.length,
|
||||
}),
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Add beta attributes to endToolSpan.
|
||||
* Adds new_context with the tool result.
|
||||
*/
|
||||
export function addBetaToolResultAttributes(
|
||||
endAttributes: Record<string, string | number | boolean>,
|
||||
toolName: string | number | boolean,
|
||||
toolResult: string,
|
||||
): void {
|
||||
if (!isBetaTracingEnabled()) {
|
||||
return
|
||||
}
|
||||
|
||||
const { content: truncatedResult, truncated } = truncateContent(
|
||||
`[TOOL RESULT: ${toolName}]\n${toolResult}`,
|
||||
)
|
||||
endAttributes['new_context'] = truncatedResult
|
||||
if (truncated) {
|
||||
endAttributes['new_context_truncated'] = true
|
||||
endAttributes['new_context_original_length'] = toolResult.length
|
||||
}
|
||||
}
|
||||
@@ -1,252 +0,0 @@
|
||||
import type { Attributes, HrTime } from '@opentelemetry/api'
|
||||
import { type ExportResult, ExportResultCode } from '@opentelemetry/core'
|
||||
import {
|
||||
AggregationTemporality,
|
||||
type MetricData,
|
||||
type DataPoint as OTelDataPoint,
|
||||
type PushMetricExporter,
|
||||
type ResourceMetrics,
|
||||
} from '@opentelemetry/sdk-metrics'
|
||||
import axios from 'axios'
|
||||
import { checkMetricsEnabled } from 'src/services/api/metricsOptOut.js'
|
||||
import { getIsNonInteractiveSession } from '../../bootstrap/state.js'
|
||||
import { getSubscriptionType, isClaudeAISubscriber } from '../auth.js'
|
||||
import { checkHasTrustDialogAccepted } from '../config.js'
|
||||
import { logForDebugging } from '../debug.js'
|
||||
import { errorMessage, toError } from '../errors.js'
|
||||
import { getAuthHeaders } from '../http.js'
|
||||
import { logError } from '../log.js'
|
||||
import { jsonStringify } from '../slowOperations.js'
|
||||
import { getClaudeCodeUserAgent } from '../userAgent.js'
|
||||
|
||||
type DataPoint = {
|
||||
attributes: Record<string, string>
|
||||
value: number
|
||||
timestamp: string
|
||||
}
|
||||
|
||||
type Metric = {
|
||||
name: string
|
||||
description?: string
|
||||
unit?: string
|
||||
data_points: DataPoint[]
|
||||
}
|
||||
|
||||
type InternalMetricsPayload = {
|
||||
resource_attributes: Record<string, string>
|
||||
metrics: Metric[]
|
||||
}
|
||||
|
||||
export class BigQueryMetricsExporter implements PushMetricExporter {
|
||||
private readonly endpoint: string
|
||||
private readonly timeout: number
|
||||
private pendingExports: Promise<void>[] = []
|
||||
private isShutdown = false
|
||||
|
||||
constructor(options: { timeout?: number } = {}) {
|
||||
const defaultEndpoint = 'https://api.anthropic.com/api/claude_code/metrics'
|
||||
|
||||
if (
|
||||
process.env.USER_TYPE === 'ant' &&
|
||||
process.env.ANT_CLAUDE_CODE_METRICS_ENDPOINT
|
||||
) {
|
||||
this.endpoint =
|
||||
process.env.ANT_CLAUDE_CODE_METRICS_ENDPOINT +
|
||||
'/api/claude_code/metrics'
|
||||
} else {
|
||||
this.endpoint = defaultEndpoint
|
||||
}
|
||||
|
||||
this.timeout = options.timeout || 5000
|
||||
}
|
||||
|
||||
async export(
|
||||
metrics: ResourceMetrics,
|
||||
resultCallback: (result: ExportResult) => void,
|
||||
): Promise<void> {
|
||||
if (this.isShutdown) {
|
||||
resultCallback({
|
||||
code: ExportResultCode.FAILED,
|
||||
error: new Error('Exporter has been shutdown'),
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
const exportPromise = this.doExport(metrics, resultCallback)
|
||||
this.pendingExports.push(exportPromise)
|
||||
|
||||
// Clean up completed exports
|
||||
void exportPromise.finally(() => {
|
||||
const index = this.pendingExports.indexOf(exportPromise)
|
||||
if (index > -1) {
|
||||
void this.pendingExports.splice(index, 1)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
private async doExport(
|
||||
metrics: ResourceMetrics,
|
||||
resultCallback: (result: ExportResult) => void,
|
||||
): Promise<void> {
|
||||
try {
|
||||
// Skip if trust not established in interactive mode
|
||||
// This prevents triggering apiKeyHelper before trust dialog
|
||||
const hasTrust =
|
||||
checkHasTrustDialogAccepted() || getIsNonInteractiveSession()
|
||||
if (!hasTrust) {
|
||||
logForDebugging(
|
||||
'BigQuery metrics export: trust not established, skipping',
|
||||
)
|
||||
resultCallback({ code: ExportResultCode.SUCCESS })
|
||||
return
|
||||
}
|
||||
|
||||
// Check organization-level metrics opt-out
|
||||
const metricsStatus = await checkMetricsEnabled()
|
||||
if (!metricsStatus.enabled) {
|
||||
logForDebugging('Metrics export disabled by organization setting')
|
||||
resultCallback({ code: ExportResultCode.SUCCESS })
|
||||
return
|
||||
}
|
||||
|
||||
const payload = this.transformMetricsForInternal(metrics)
|
||||
|
||||
const authResult = getAuthHeaders()
|
||||
if (authResult.error) {
|
||||
logForDebugging(`Metrics export failed: ${authResult.error}`)
|
||||
resultCallback({
|
||||
code: ExportResultCode.FAILED,
|
||||
error: new Error(authResult.error),
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
const headers: Record<string, string> = {
|
||||
'Content-Type': 'application/json',
|
||||
'User-Agent': getClaudeCodeUserAgent(),
|
||||
...authResult.headers,
|
||||
}
|
||||
|
||||
const response = await axios.post(this.endpoint, payload, {
|
||||
timeout: this.timeout,
|
||||
headers,
|
||||
})
|
||||
|
||||
logForDebugging('BigQuery metrics exported successfully')
|
||||
logForDebugging(
|
||||
`BigQuery API Response: ${jsonStringify(response.data, null, 2)}`,
|
||||
)
|
||||
resultCallback({ code: ExportResultCode.SUCCESS })
|
||||
} catch (error) {
|
||||
logForDebugging(`BigQuery metrics export failed: ${errorMessage(error)}`)
|
||||
logError(error)
|
||||
resultCallback({
|
||||
code: ExportResultCode.FAILED,
|
||||
error: toError(error),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
private transformMetricsForInternal(
|
||||
metrics: ResourceMetrics,
|
||||
): InternalMetricsPayload {
|
||||
const attrs = metrics.resource.attributes
|
||||
|
||||
const resourceAttributes: Record<string, string> = {
|
||||
'service.name': (attrs['service.name'] as string) || 'claude-code',
|
||||
'service.version': (attrs['service.version'] as string) || 'unknown',
|
||||
'os.type': (attrs['os.type'] as string) || 'unknown',
|
||||
'os.version': (attrs['os.version'] as string) || 'unknown',
|
||||
'host.arch': (attrs['host.arch'] as string) || 'unknown',
|
||||
'aggregation.temporality':
|
||||
this.selectAggregationTemporality() === AggregationTemporality.DELTA
|
||||
? 'delta'
|
||||
: 'cumulative',
|
||||
}
|
||||
|
||||
// Only add wsl.version if it exists (omit instead of default)
|
||||
if (attrs['wsl.version']) {
|
||||
resourceAttributes['wsl.version'] = attrs['wsl.version'] as string
|
||||
}
|
||||
|
||||
// Add customer type and subscription type
|
||||
if (isClaudeAISubscriber()) {
|
||||
resourceAttributes['user.customer_type'] = 'claude_ai'
|
||||
const subscriptionType = getSubscriptionType()
|
||||
if (subscriptionType) {
|
||||
resourceAttributes['user.subscription_type'] = subscriptionType
|
||||
}
|
||||
} else {
|
||||
resourceAttributes['user.customer_type'] = 'api'
|
||||
}
|
||||
|
||||
const transformed = {
|
||||
resource_attributes: resourceAttributes,
|
||||
metrics: metrics.scopeMetrics.flatMap(scopeMetric =>
|
||||
scopeMetric.metrics.map(metric => ({
|
||||
name: metric.descriptor.name,
|
||||
description: metric.descriptor.description,
|
||||
unit: metric.descriptor.unit,
|
||||
data_points: this.extractDataPoints(metric),
|
||||
})),
|
||||
),
|
||||
}
|
||||
|
||||
return transformed
|
||||
}
|
||||
|
||||
private extractDataPoints(metric: MetricData): DataPoint[] {
|
||||
const dataPoints = metric.dataPoints || []
|
||||
|
||||
return dataPoints
|
||||
.filter(
|
||||
(point): point is OTelDataPoint<number> =>
|
||||
typeof point.value === 'number',
|
||||
)
|
||||
.map(point => ({
|
||||
attributes: this.convertAttributes(point.attributes),
|
||||
value: point.value,
|
||||
timestamp: this.hrTimeToISOString(
|
||||
point.endTime || point.startTime || [Date.now() / 1000, 0],
|
||||
),
|
||||
}))
|
||||
}
|
||||
|
||||
async shutdown(): Promise<void> {
|
||||
this.isShutdown = true
|
||||
await this.forceFlush()
|
||||
logForDebugging('BigQuery metrics exporter shutdown complete')
|
||||
}
|
||||
|
||||
async forceFlush(): Promise<void> {
|
||||
await Promise.all(this.pendingExports)
|
||||
logForDebugging('BigQuery metrics exporter flush complete')
|
||||
}
|
||||
|
||||
private convertAttributes(
|
||||
attributes: Attributes | undefined,
|
||||
): Record<string, string> {
|
||||
const result: Record<string, string> = {}
|
||||
if (attributes) {
|
||||
for (const [key, value] of Object.entries(attributes)) {
|
||||
if (value !== undefined && value !== null) {
|
||||
result[key] = String(value)
|
||||
}
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
private hrTimeToISOString(hrTime: HrTime): string {
|
||||
const [seconds, nanoseconds] = hrTime
|
||||
const date = new Date(seconds * 1000 + nanoseconds / 1000000)
|
||||
return date.toISOString()
|
||||
}
|
||||
|
||||
selectAggregationTemporality(): AggregationTemporality {
|
||||
// DO NOT CHANGE THIS TO CUMULATIVE
|
||||
// It would mess up the aggregation of metrics
|
||||
// for CC Productivity metrics dashboard
|
||||
return AggregationTemporality.DELTA
|
||||
}
|
||||
}
|
||||
@@ -1,75 +1,16 @@
|
||||
import type { Attributes } from '@opentelemetry/api'
|
||||
import { getEventLogger, getPromptId } from 'src/bootstrap/state.js'
|
||||
import { logForDebugging } from '../debug.js'
|
||||
import { isEnvTruthy } from '../envUtils.js'
|
||||
import { getTelemetryAttributes } from '../telemetryAttributes.js'
|
||||
|
||||
// Monotonically increasing counter for ordering events within a session
|
||||
let eventSequence = 0
|
||||
|
||||
// Track whether we've already warned about a null event logger to avoid spamming
|
||||
let hasWarnedNoEventLogger = false
|
||||
|
||||
function isUserPromptLoggingEnabled() {
|
||||
return isEnvTruthy(process.env.OTEL_LOG_USER_PROMPTS)
|
||||
}
|
||||
|
||||
export function redactIfDisabled(content: string): string {
|
||||
return isUserPromptLoggingEnabled() ? content : '<REDACTED>'
|
||||
}
|
||||
/**
|
||||
* No-op stub for telemetry events.
|
||||
* logOTelEvent does nothing; redactIfDisabled always redacts.
|
||||
* No @opentelemetry imports.
|
||||
*/
|
||||
|
||||
export async function logOTelEvent(
|
||||
eventName: string,
|
||||
metadata: { [key: string]: string | undefined } = {},
|
||||
_eventName: string,
|
||||
_metadata: { [key: string]: string | undefined } = {},
|
||||
): Promise<void> {
|
||||
const eventLogger = getEventLogger()
|
||||
if (!eventLogger) {
|
||||
if (!hasWarnedNoEventLogger) {
|
||||
hasWarnedNoEventLogger = true
|
||||
logForDebugging(
|
||||
`[3P telemetry] Event dropped (no event logger initialized): ${eventName}`,
|
||||
{ level: 'warn' },
|
||||
)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// Skip logging in test environment
|
||||
if (process.env.NODE_ENV === 'test') {
|
||||
return
|
||||
}
|
||||
|
||||
const attributes: Attributes = {
|
||||
...getTelemetryAttributes(),
|
||||
'event.name': eventName,
|
||||
'event.timestamp': new Date().toISOString(),
|
||||
'event.sequence': eventSequence++,
|
||||
}
|
||||
|
||||
// Add prompt ID to events (but not metrics, where it would cause unbounded cardinality)
|
||||
const promptId = getPromptId()
|
||||
if (promptId) {
|
||||
attributes['prompt.id'] = promptId
|
||||
}
|
||||
|
||||
// Workspace directory from the desktop app (host path). Events only —
|
||||
// filesystem paths are too high-cardinality for metric dimensions, and
|
||||
// the BQ metrics pipeline must never see them.
|
||||
const workspaceDir = process.env.CLAUDE_CODE_WORKSPACE_HOST_PATHS
|
||||
if (workspaceDir) {
|
||||
attributes['workspace.host_paths'] = workspaceDir.split('|')
|
||||
}
|
||||
|
||||
// Add metadata as attributes - all values are already strings
|
||||
for (const [key, value] of Object.entries(metadata)) {
|
||||
if (value !== undefined) {
|
||||
attributes[key] = value
|
||||
}
|
||||
}
|
||||
|
||||
// Emit log record as an event
|
||||
eventLogger.emit({
|
||||
body: `claude_code.${eventName}`,
|
||||
attributes,
|
||||
})
|
||||
// no-op
|
||||
}
|
||||
|
||||
export function redactIfDisabled(_content: string): string {
|
||||
return '<REDACTED>'
|
||||
}
|
||||
@@ -1,825 +0,0 @@
|
||||
import { DiagLogLevel, diag, trace } from '@opentelemetry/api'
|
||||
import { logs } from '@opentelemetry/api-logs'
|
||||
// OTLP/Prometheus exporters are dynamically imported inside the protocol
|
||||
// switch statements below. A process uses at most one protocol variant per
|
||||
// signal, but static imports would load all 6 (~1.2MB) on every startup.
|
||||
import {
|
||||
envDetector,
|
||||
hostDetector,
|
||||
osDetector,
|
||||
resourceFromAttributes,
|
||||
} from '@opentelemetry/resources'
|
||||
import {
|
||||
BatchLogRecordProcessor,
|
||||
ConsoleLogRecordExporter,
|
||||
LoggerProvider,
|
||||
} from '@opentelemetry/sdk-logs'
|
||||
import {
|
||||
ConsoleMetricExporter,
|
||||
MeterProvider,
|
||||
PeriodicExportingMetricReader,
|
||||
} from '@opentelemetry/sdk-metrics'
|
||||
import {
|
||||
BasicTracerProvider,
|
||||
BatchSpanProcessor,
|
||||
ConsoleSpanExporter,
|
||||
} from '@opentelemetry/sdk-trace-base'
|
||||
import {
|
||||
ATTR_SERVICE_NAME,
|
||||
ATTR_SERVICE_VERSION,
|
||||
SEMRESATTRS_HOST_ARCH,
|
||||
} from '@opentelemetry/semantic-conventions'
|
||||
import { HttpsProxyAgent } from 'https-proxy-agent'
|
||||
import {
|
||||
getLoggerProvider,
|
||||
getMeterProvider,
|
||||
getTracerProvider,
|
||||
setEventLogger,
|
||||
setLoggerProvider,
|
||||
setMeterProvider,
|
||||
setTracerProvider,
|
||||
} from 'src/bootstrap/state.js'
|
||||
import {
|
||||
getOtelHeadersFromHelper,
|
||||
getSubscriptionType,
|
||||
is1PApiCustomer,
|
||||
isClaudeAISubscriber,
|
||||
} from 'src/utils/auth.js'
|
||||
import { getPlatform, getWslVersion } from 'src/utils/platform.js'
|
||||
|
||||
import { getCACertificates } from '../caCerts.js'
|
||||
import { registerCleanup } from '../cleanupRegistry.js'
|
||||
import { getHasFormattedOutput, logForDebugging } from '../debug.js'
|
||||
import { isEnvTruthy } from '../envUtils.js'
|
||||
import { errorMessage } from '../errors.js'
|
||||
import { getMTLSConfig } from '../mtls.js'
|
||||
import { getProxyUrl, shouldBypassProxy } from '../proxy.js'
|
||||
import { getSettings_DEPRECATED } from '../settings/settings.js'
|
||||
import { jsonStringify } from '../slowOperations.js'
|
||||
import { profileCheckpoint } from '../startupProfiler.js'
|
||||
import { isBetaTracingEnabled } from './betaSessionTracing.js'
|
||||
import { BigQueryMetricsExporter } from './bigqueryExporter.js'
|
||||
import { ClaudeCodeDiagLogger } from './logger.js'
|
||||
import { initializePerfettoTracing } from './perfettoTracing.js'
|
||||
import {
|
||||
endInteractionSpan,
|
||||
isEnhancedTelemetryEnabled,
|
||||
} from './sessionTracing.js'
|
||||
|
||||
const DEFAULT_METRICS_EXPORT_INTERVAL_MS = 60000
|
||||
const DEFAULT_LOGS_EXPORT_INTERVAL_MS = 5000
|
||||
const DEFAULT_TRACES_EXPORT_INTERVAL_MS = 5000
|
||||
|
||||
class TelemetryTimeoutError extends Error {}
|
||||
|
||||
function telemetryTimeout(ms: number, message: string): Promise<never> {
|
||||
return new Promise((_, reject) => {
|
||||
setTimeout(
|
||||
(rej: (e: Error) => void, msg: string) =>
|
||||
rej(new TelemetryTimeoutError(msg)),
|
||||
ms,
|
||||
reject,
|
||||
message,
|
||||
).unref()
|
||||
})
|
||||
}
|
||||
|
||||
export function bootstrapTelemetry() {
|
||||
if (process.env.USER_TYPE === 'ant') {
|
||||
// Read from ANT_ prefixed variables that are defined at build time
|
||||
if (process.env.ANT_OTEL_METRICS_EXPORTER) {
|
||||
process.env.OTEL_METRICS_EXPORTER = process.env.ANT_OTEL_METRICS_EXPORTER
|
||||
}
|
||||
if (process.env.ANT_OTEL_LOGS_EXPORTER) {
|
||||
process.env.OTEL_LOGS_EXPORTER = process.env.ANT_OTEL_LOGS_EXPORTER
|
||||
}
|
||||
if (process.env.ANT_OTEL_TRACES_EXPORTER) {
|
||||
process.env.OTEL_TRACES_EXPORTER = process.env.ANT_OTEL_TRACES_EXPORTER
|
||||
}
|
||||
if (process.env.ANT_OTEL_EXPORTER_OTLP_PROTOCOL) {
|
||||
process.env.OTEL_EXPORTER_OTLP_PROTOCOL =
|
||||
process.env.ANT_OTEL_EXPORTER_OTLP_PROTOCOL
|
||||
}
|
||||
if (process.env.ANT_OTEL_EXPORTER_OTLP_ENDPOINT) {
|
||||
process.env.OTEL_EXPORTER_OTLP_ENDPOINT =
|
||||
process.env.ANT_OTEL_EXPORTER_OTLP_ENDPOINT
|
||||
}
|
||||
if (process.env.ANT_OTEL_EXPORTER_OTLP_HEADERS) {
|
||||
process.env.OTEL_EXPORTER_OTLP_HEADERS =
|
||||
process.env.ANT_OTEL_EXPORTER_OTLP_HEADERS
|
||||
}
|
||||
}
|
||||
|
||||
// Set default tempoality to 'delta' because it's the more sane default
|
||||
if (!process.env.OTEL_EXPORTER_OTLP_METRICS_TEMPORALITY_PREFERENCE) {
|
||||
process.env.OTEL_EXPORTER_OTLP_METRICS_TEMPORALITY_PREFERENCE = 'delta'
|
||||
}
|
||||
}
|
||||
|
||||
// Per OTEL spec, "none" means "no automatically configured exporter for this signal".
|
||||
// https://opentelemetry.io/docs/specs/otel/configuration/sdk-environment-variables/#exporter-selection
|
||||
export function parseExporterTypes(value: string | undefined): string[] {
|
||||
return (value || '')
|
||||
.trim()
|
||||
.split(',')
|
||||
.filter(Boolean)
|
||||
.map(t => t.trim())
|
||||
.filter(t => t !== 'none')
|
||||
}
|
||||
|
||||
async function getOtlpReaders() {
|
||||
const exporterTypes = parseExporterTypes(process.env.OTEL_METRICS_EXPORTER)
|
||||
const exportInterval = parseInt(
|
||||
process.env.OTEL_METRIC_EXPORT_INTERVAL ||
|
||||
DEFAULT_METRICS_EXPORT_INTERVAL_MS.toString(),
|
||||
)
|
||||
|
||||
const exporters = []
|
||||
for (const exporterType of exporterTypes) {
|
||||
if (exporterType === 'console') {
|
||||
// Custom console exporter that shows resource attributes
|
||||
const consoleExporter = new ConsoleMetricExporter()
|
||||
const originalExport = consoleExporter.export.bind(consoleExporter)
|
||||
|
||||
consoleExporter.export = (metrics, callback) => {
|
||||
// Log resource attributes once at the start
|
||||
if (metrics.resource && metrics.resource.attributes) {
|
||||
// The console exporter is for debugging, so console output is intentional here
|
||||
|
||||
logForDebugging('\n=== Resource Attributes ===')
|
||||
logForDebugging(jsonStringify(metrics.resource.attributes))
|
||||
logForDebugging('===========================\n')
|
||||
}
|
||||
|
||||
return originalExport(metrics, callback)
|
||||
}
|
||||
|
||||
exporters.push(consoleExporter)
|
||||
} else if (exporterType === 'otlp') {
|
||||
const protocol =
|
||||
process.env.OTEL_EXPORTER_OTLP_METRICS_PROTOCOL?.trim() ||
|
||||
process.env.OTEL_EXPORTER_OTLP_PROTOCOL?.trim()
|
||||
|
||||
const httpConfig = getOTLPExporterConfig()
|
||||
|
||||
switch (protocol) {
|
||||
case 'grpc': {
|
||||
// Lazy-import to keep @grpc/grpc-js (~700KB) out of the telemetry chunk
|
||||
// when the protocol is http/protobuf (ant default) or http/json.
|
||||
const { OTLPMetricExporter } = await import(
|
||||
'@opentelemetry/exporter-metrics-otlp-grpc'
|
||||
)
|
||||
exporters.push(new OTLPMetricExporter())
|
||||
break
|
||||
}
|
||||
case 'http/json': {
|
||||
const { OTLPMetricExporter } = await import(
|
||||
'@opentelemetry/exporter-metrics-otlp-http'
|
||||
)
|
||||
exporters.push(new OTLPMetricExporter(httpConfig))
|
||||
break
|
||||
}
|
||||
case 'http/protobuf': {
|
||||
const { OTLPMetricExporter } = await import(
|
||||
'@opentelemetry/exporter-metrics-otlp-proto'
|
||||
)
|
||||
exporters.push(new OTLPMetricExporter(httpConfig))
|
||||
break
|
||||
}
|
||||
default:
|
||||
throw new Error(
|
||||
`Unknown protocol set in OTEL_EXPORTER_OTLP_METRICS_PROTOCOL or OTEL_EXPORTER_OTLP_PROTOCOL env var: ${protocol}`,
|
||||
)
|
||||
}
|
||||
} else if (exporterType === 'prometheus') {
|
||||
const { PrometheusExporter } = await import(
|
||||
'@opentelemetry/exporter-prometheus'
|
||||
)
|
||||
exporters.push(new PrometheusExporter())
|
||||
} else {
|
||||
throw new Error(
|
||||
`Unknown exporter type set in OTEL_EXPORTER_OTLP_METRICS_PROTOCOL or OTEL_EXPORTER_OTLP_PROTOCOL env var: ${exporterType}`,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
return exporters.map(exporter => {
|
||||
if ('export' in exporter) {
|
||||
return new PeriodicExportingMetricReader({
|
||||
exporter,
|
||||
exportIntervalMillis: exportInterval,
|
||||
})
|
||||
}
|
||||
return exporter
|
||||
})
|
||||
}
|
||||
|
||||
async function getOtlpLogExporters() {
|
||||
const exporterTypes = parseExporterTypes(process.env.OTEL_LOGS_EXPORTER)
|
||||
|
||||
const protocol =
|
||||
process.env.OTEL_EXPORTER_OTLP_LOGS_PROTOCOL?.trim() ||
|
||||
process.env.OTEL_EXPORTER_OTLP_PROTOCOL?.trim()
|
||||
const endpoint = process.env.OTEL_EXPORTER_OTLP_ENDPOINT
|
||||
|
||||
logForDebugging(
|
||||
`[3P telemetry] getOtlpLogExporters: types=${jsonStringify(exporterTypes)}, protocol=${protocol}, endpoint=${endpoint}`,
|
||||
)
|
||||
|
||||
const exporters = []
|
||||
for (const exporterType of exporterTypes) {
|
||||
if (exporterType === 'console') {
|
||||
exporters.push(new ConsoleLogRecordExporter())
|
||||
} else if (exporterType === 'otlp') {
|
||||
const httpConfig = getOTLPExporterConfig()
|
||||
|
||||
switch (protocol) {
|
||||
case 'grpc': {
|
||||
const { OTLPLogExporter } = await import(
|
||||
'@opentelemetry/exporter-logs-otlp-grpc'
|
||||
)
|
||||
exporters.push(new OTLPLogExporter())
|
||||
break
|
||||
}
|
||||
case 'http/json': {
|
||||
const { OTLPLogExporter } = await import(
|
||||
'@opentelemetry/exporter-logs-otlp-http'
|
||||
)
|
||||
exporters.push(new OTLPLogExporter(httpConfig))
|
||||
break
|
||||
}
|
||||
case 'http/protobuf': {
|
||||
const { OTLPLogExporter } = await import(
|
||||
'@opentelemetry/exporter-logs-otlp-proto'
|
||||
)
|
||||
exporters.push(new OTLPLogExporter(httpConfig))
|
||||
break
|
||||
}
|
||||
default:
|
||||
throw new Error(
|
||||
`Unknown protocol set in OTEL_EXPORTER_OTLP_LOGS_PROTOCOL or OTEL_EXPORTER_OTLP_PROTOCOL env var: ${protocol}`,
|
||||
)
|
||||
}
|
||||
} else {
|
||||
throw new Error(
|
||||
`Unknown exporter type set in OTEL_LOGS_EXPORTER env var: ${exporterType}`,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
return exporters
|
||||
}
|
||||
|
||||
async function getOtlpTraceExporters() {
|
||||
const exporterTypes = parseExporterTypes(process.env.OTEL_TRACES_EXPORTER)
|
||||
|
||||
const exporters = []
|
||||
for (const exporterType of exporterTypes) {
|
||||
if (exporterType === 'console') {
|
||||
exporters.push(new ConsoleSpanExporter())
|
||||
} else if (exporterType === 'otlp') {
|
||||
const protocol =
|
||||
process.env.OTEL_EXPORTER_OTLP_TRACES_PROTOCOL?.trim() ||
|
||||
process.env.OTEL_EXPORTER_OTLP_PROTOCOL?.trim()
|
||||
|
||||
const httpConfig = getOTLPExporterConfig()
|
||||
|
||||
switch (protocol) {
|
||||
case 'grpc': {
|
||||
const { OTLPTraceExporter } = await import(
|
||||
'@opentelemetry/exporter-trace-otlp-grpc'
|
||||
)
|
||||
exporters.push(new OTLPTraceExporter())
|
||||
break
|
||||
}
|
||||
case 'http/json': {
|
||||
const { OTLPTraceExporter } = await import(
|
||||
'@opentelemetry/exporter-trace-otlp-http'
|
||||
)
|
||||
exporters.push(new OTLPTraceExporter(httpConfig))
|
||||
break
|
||||
}
|
||||
case 'http/protobuf': {
|
||||
const { OTLPTraceExporter } = await import(
|
||||
'@opentelemetry/exporter-trace-otlp-proto'
|
||||
)
|
||||
exporters.push(new OTLPTraceExporter(httpConfig))
|
||||
break
|
||||
}
|
||||
default:
|
||||
throw new Error(
|
||||
`Unknown protocol set in OTEL_EXPORTER_OTLP_TRACES_PROTOCOL or OTEL_EXPORTER_OTLP_PROTOCOL env var: ${protocol}`,
|
||||
)
|
||||
}
|
||||
} else {
|
||||
throw new Error(
|
||||
`Unknown exporter type set in OTEL_TRACES_EXPORTER env var: ${exporterType}`,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
return exporters
|
||||
}
|
||||
|
||||
export function isTelemetryEnabled() {
|
||||
return isEnvTruthy(process.env.CLAUDE_CODE_ENABLE_TELEMETRY)
|
||||
}
|
||||
|
||||
function getBigQueryExportingReader() {
|
||||
const bigqueryExporter = new BigQueryMetricsExporter()
|
||||
return new PeriodicExportingMetricReader({
|
||||
exporter: bigqueryExporter,
|
||||
exportIntervalMillis: 5 * 60 * 1000, // 5mins for BigQuery metrics exporter to reduce load
|
||||
})
|
||||
}
|
||||
|
||||
function isBigQueryMetricsEnabled() {
|
||||
// BigQuery metrics are enabled for:
|
||||
// 1. API customers (excluding Claude.ai subscribers and Bedrock/Vertex)
|
||||
// 2. Claude for Enterprise (C4E) users
|
||||
// 3. Claude for Teams users
|
||||
const subscriptionType = getSubscriptionType()
|
||||
const isC4EOrTeamUser =
|
||||
isClaudeAISubscriber() &&
|
||||
(subscriptionType === 'enterprise' || subscriptionType === 'team')
|
||||
|
||||
return is1PApiCustomer() || isC4EOrTeamUser
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize beta tracing - a separate code path for detailed debugging.
|
||||
* Uses BETA_TRACING_ENDPOINT instead of OTEL_EXPORTER_OTLP_ENDPOINT.
|
||||
*/
|
||||
async function initializeBetaTracing(
|
||||
resource: ReturnType<typeof resourceFromAttributes>,
|
||||
): Promise<void> {
|
||||
const endpoint = process.env.BETA_TRACING_ENDPOINT
|
||||
if (!endpoint) {
|
||||
return
|
||||
}
|
||||
|
||||
const [{ OTLPTraceExporter }, { OTLPLogExporter }] = await Promise.all([
|
||||
import('@opentelemetry/exporter-trace-otlp-http'),
|
||||
import('@opentelemetry/exporter-logs-otlp-http'),
|
||||
])
|
||||
|
||||
const httpConfig = {
|
||||
url: `${endpoint}/v1/traces`,
|
||||
}
|
||||
|
||||
const logHttpConfig = {
|
||||
url: `${endpoint}/v1/logs`,
|
||||
}
|
||||
|
||||
// Initialize trace exporter
|
||||
const traceExporter = new OTLPTraceExporter(httpConfig)
|
||||
const spanProcessor = new BatchSpanProcessor(traceExporter, {
|
||||
scheduledDelayMillis: DEFAULT_TRACES_EXPORT_INTERVAL_MS,
|
||||
})
|
||||
|
||||
const tracerProvider = new BasicTracerProvider({
|
||||
resource,
|
||||
spanProcessors: [spanProcessor],
|
||||
})
|
||||
|
||||
trace.setGlobalTracerProvider(tracerProvider)
|
||||
setTracerProvider(tracerProvider)
|
||||
|
||||
// Initialize log exporter
|
||||
const logExporter = new OTLPLogExporter(logHttpConfig)
|
||||
const loggerProvider = new LoggerProvider({
|
||||
resource,
|
||||
processors: [
|
||||
new BatchLogRecordProcessor(logExporter, {
|
||||
scheduledDelayMillis: DEFAULT_LOGS_EXPORT_INTERVAL_MS,
|
||||
}),
|
||||
],
|
||||
})
|
||||
|
||||
logs.setGlobalLoggerProvider(loggerProvider)
|
||||
setLoggerProvider(loggerProvider)
|
||||
|
||||
// Initialize event logger
|
||||
const eventLogger = logs.getLogger(
|
||||
'com.anthropic.claude_code.events',
|
||||
MACRO.VERSION,
|
||||
)
|
||||
setEventLogger(eventLogger)
|
||||
|
||||
// Setup flush handlers - flush both logs AND traces
|
||||
process.on('beforeExit', async () => {
|
||||
await loggerProvider?.forceFlush()
|
||||
await tracerProvider?.forceFlush()
|
||||
})
|
||||
|
||||
process.on('exit', () => {
|
||||
void loggerProvider?.forceFlush()
|
||||
void tracerProvider?.forceFlush()
|
||||
})
|
||||
}
|
||||
|
||||
export async function initializeTelemetry() {
|
||||
profileCheckpoint('telemetry_init_start')
|
||||
bootstrapTelemetry()
|
||||
|
||||
// Console exporters call console.dir on a timer (5s logs/traces, 60s
|
||||
// metrics), writing pretty-printed objects to stdout. In stream-json
|
||||
// mode stdout is the SDK message channel; the first line (`{`) breaks
|
||||
// the SDK's line reader. Stripped here (not main.tsx) because init.ts
|
||||
// re-runs applyConfigEnvironmentVariables() inside initializeTelemetry-
|
||||
// AfterTrust for remote-managed-settings users, and bootstrapTelemetry
|
||||
// above copies ANT_OTEL_* for ant users — both would undo an earlier strip.
|
||||
if (getHasFormattedOutput()) {
|
||||
for (const key of [
|
||||
'OTEL_METRICS_EXPORTER',
|
||||
'OTEL_LOGS_EXPORTER',
|
||||
'OTEL_TRACES_EXPORTER',
|
||||
] as const) {
|
||||
const v = process.env[key]
|
||||
if (v?.includes('console')) {
|
||||
process.env[key] = v
|
||||
.split(',')
|
||||
.map(s => s.trim())
|
||||
.filter(s => s !== 'console')
|
||||
.join(',')
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
diag.setLogger(new ClaudeCodeDiagLogger(), DiagLogLevel.ERROR)
|
||||
|
||||
// Initialize Perfetto tracing (independent of OTEL)
|
||||
// Enable via CLAUDE_CODE_PERFETTO_TRACE=1 or CLAUDE_CODE_PERFETTO_TRACE=<path>
|
||||
initializePerfettoTracing()
|
||||
|
||||
const readers = []
|
||||
|
||||
// Add customer exporters (if enabled)
|
||||
const telemetryEnabled = isTelemetryEnabled()
|
||||
logForDebugging(
|
||||
`[3P telemetry] isTelemetryEnabled=${telemetryEnabled} (CLAUDE_CODE_ENABLE_TELEMETRY=${process.env.CLAUDE_CODE_ENABLE_TELEMETRY})`,
|
||||
)
|
||||
if (telemetryEnabled) {
|
||||
readers.push(...(await getOtlpReaders()))
|
||||
}
|
||||
|
||||
// Add BigQuery exporter (for API customers, C4E users, and internal users)
|
||||
if (isBigQueryMetricsEnabled()) {
|
||||
readers.push(getBigQueryExportingReader())
|
||||
}
|
||||
|
||||
// Create base resource with service attributes
|
||||
const platform = getPlatform()
|
||||
const baseAttributes: Record<string, string> = {
|
||||
[ATTR_SERVICE_NAME]: 'claude-code',
|
||||
[ATTR_SERVICE_VERSION]: MACRO.VERSION,
|
||||
}
|
||||
|
||||
// Add WSL-specific attributes if running on WSL
|
||||
if (platform === 'wsl') {
|
||||
const wslVersion = getWslVersion()
|
||||
if (wslVersion) {
|
||||
baseAttributes['wsl.version'] = wslVersion
|
||||
}
|
||||
}
|
||||
|
||||
const baseResource = resourceFromAttributes(baseAttributes)
|
||||
|
||||
// Use OpenTelemetry detectors
|
||||
const osResource = resourceFromAttributes(
|
||||
osDetector.detect().attributes || {},
|
||||
)
|
||||
|
||||
// Extract only host.arch from hostDetector
|
||||
const hostDetected = hostDetector.detect()
|
||||
const hostArchAttributes = hostDetected.attributes?.[SEMRESATTRS_HOST_ARCH]
|
||||
? {
|
||||
[SEMRESATTRS_HOST_ARCH]: hostDetected.attributes[SEMRESATTRS_HOST_ARCH],
|
||||
}
|
||||
: {}
|
||||
const hostArchResource = resourceFromAttributes(hostArchAttributes)
|
||||
|
||||
const envResource = resourceFromAttributes(
|
||||
envDetector.detect().attributes || {},
|
||||
)
|
||||
|
||||
// Merge resources - later resources take precedence
|
||||
const resource = baseResource
|
||||
.merge(osResource)
|
||||
.merge(hostArchResource)
|
||||
.merge(envResource)
|
||||
|
||||
// Check if beta tracing is enabled - this is a separate code path
|
||||
// Available to all users who set ENABLE_BETA_TRACING_DETAILED=1 and BETA_TRACING_ENDPOINT
|
||||
if (isBetaTracingEnabled()) {
|
||||
void initializeBetaTracing(resource).catch(e =>
|
||||
logForDebugging(`Beta tracing init failed: ${e}`, { level: 'error' }),
|
||||
)
|
||||
// Still set up meter provider for metrics (but skip regular logs/traces setup)
|
||||
const meterProvider = new MeterProvider({
|
||||
resource,
|
||||
views: [],
|
||||
readers,
|
||||
})
|
||||
setMeterProvider(meterProvider)
|
||||
|
||||
// Register shutdown for beta tracing
|
||||
const shutdownTelemetry = async () => {
|
||||
const timeoutMs = parseInt(
|
||||
process.env.CLAUDE_CODE_OTEL_SHUTDOWN_TIMEOUT_MS || '2000',
|
||||
)
|
||||
try {
|
||||
endInteractionSpan()
|
||||
|
||||
// Force flush + shutdown together inside the timeout. Previously forceFlush
|
||||
// was awaited unbounded BEFORE the race, blocking exit on slow OTLP endpoints.
|
||||
// Each provider's flush→shutdown is chained independently so a slow logger
|
||||
// flush doesn't delay meterProvider/tracerProvider shutdown (no waterfall).
|
||||
const loggerProvider = getLoggerProvider()
|
||||
const tracerProvider = getTracerProvider()
|
||||
|
||||
const chains: Promise<void>[] = [meterProvider.shutdown()]
|
||||
if (loggerProvider) {
|
||||
chains.push(
|
||||
loggerProvider.forceFlush().then(() => loggerProvider.shutdown()),
|
||||
)
|
||||
}
|
||||
if (tracerProvider) {
|
||||
chains.push(
|
||||
tracerProvider.forceFlush().then(() => tracerProvider.shutdown()),
|
||||
)
|
||||
}
|
||||
|
||||
await Promise.race([
|
||||
Promise.all(chains),
|
||||
telemetryTimeout(timeoutMs, 'OpenTelemetry shutdown timeout'),
|
||||
])
|
||||
} catch {
|
||||
// Ignore shutdown errors
|
||||
}
|
||||
}
|
||||
registerCleanup(shutdownTelemetry)
|
||||
|
||||
return meterProvider.getMeter('com.anthropic.claude_code', MACRO.VERSION)
|
||||
}
|
||||
|
||||
const meterProvider = new MeterProvider({
|
||||
resource,
|
||||
views: [],
|
||||
readers,
|
||||
})
|
||||
|
||||
// Store reference in state for flushing
|
||||
setMeterProvider(meterProvider)
|
||||
|
||||
// Initialize logs if telemetry is enabled
|
||||
if (telemetryEnabled) {
|
||||
const logExporters = await getOtlpLogExporters()
|
||||
logForDebugging(
|
||||
`[3P telemetry] Created ${logExporters.length} log exporter(s)`,
|
||||
)
|
||||
|
||||
if (logExporters.length > 0) {
|
||||
const loggerProvider = new LoggerProvider({
|
||||
resource,
|
||||
// Add batch processors for each exporter
|
||||
processors: logExporters.map(
|
||||
exporter =>
|
||||
new BatchLogRecordProcessor(exporter, {
|
||||
scheduledDelayMillis: parseInt(
|
||||
process.env.OTEL_LOGS_EXPORT_INTERVAL ||
|
||||
DEFAULT_LOGS_EXPORT_INTERVAL_MS.toString(),
|
||||
),
|
||||
}),
|
||||
),
|
||||
})
|
||||
|
||||
// Register the logger provider globally
|
||||
logs.setGlobalLoggerProvider(loggerProvider)
|
||||
setLoggerProvider(loggerProvider)
|
||||
|
||||
// Initialize event logger
|
||||
const eventLogger = logs.getLogger(
|
||||
'com.anthropic.claude_code.events',
|
||||
MACRO.VERSION,
|
||||
)
|
||||
setEventLogger(eventLogger)
|
||||
logForDebugging('[3P telemetry] Event logger set successfully')
|
||||
|
||||
// 'beforeExit' is emitted when Node.js empties its event loop and has no additional work to schedule.
|
||||
// Unlike 'exit', it allows us to perform async operations, so it works well for letting
|
||||
// network requests complete before the process exits naturally.
|
||||
process.on('beforeExit', async () => {
|
||||
await loggerProvider?.forceFlush()
|
||||
// Also flush traces - they use BatchSpanProcessor which needs explicit flush
|
||||
const tracerProvider = getTracerProvider()
|
||||
await tracerProvider?.forceFlush()
|
||||
})
|
||||
|
||||
process.on('exit', () => {
|
||||
// Final attempt to flush logs and traces
|
||||
void loggerProvider?.forceFlush()
|
||||
void getTracerProvider()?.forceFlush()
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Initialize tracing if enhanced telemetry is enabled (BETA)
|
||||
if (telemetryEnabled && isEnhancedTelemetryEnabled()) {
|
||||
const traceExporters = await getOtlpTraceExporters()
|
||||
if (traceExporters.length > 0) {
|
||||
// Create span processors for each exporter
|
||||
const spanProcessors = traceExporters.map(
|
||||
exporter =>
|
||||
new BatchSpanProcessor(exporter, {
|
||||
scheduledDelayMillis: parseInt(
|
||||
process.env.OTEL_TRACES_EXPORT_INTERVAL ||
|
||||
DEFAULT_TRACES_EXPORT_INTERVAL_MS.toString(),
|
||||
),
|
||||
}),
|
||||
)
|
||||
|
||||
const tracerProvider = new BasicTracerProvider({
|
||||
resource,
|
||||
spanProcessors,
|
||||
})
|
||||
|
||||
// Register the tracer provider globally
|
||||
trace.setGlobalTracerProvider(tracerProvider)
|
||||
setTracerProvider(tracerProvider)
|
||||
}
|
||||
}
|
||||
|
||||
// Shutdown metrics and logs on exit (flushes and closes exporters)
|
||||
const shutdownTelemetry = async () => {
|
||||
const timeoutMs = parseInt(
|
||||
process.env.CLAUDE_CODE_OTEL_SHUTDOWN_TIMEOUT_MS || '2000',
|
||||
)
|
||||
|
||||
try {
|
||||
// End any active interaction span before shutdown
|
||||
endInteractionSpan()
|
||||
|
||||
const shutdownPromises = [meterProvider.shutdown()]
|
||||
const loggerProvider = getLoggerProvider()
|
||||
if (loggerProvider) {
|
||||
shutdownPromises.push(loggerProvider.shutdown())
|
||||
}
|
||||
const tracerProvider = getTracerProvider()
|
||||
if (tracerProvider) {
|
||||
shutdownPromises.push(tracerProvider.shutdown())
|
||||
}
|
||||
|
||||
await Promise.race([
|
||||
Promise.all(shutdownPromises),
|
||||
telemetryTimeout(timeoutMs, 'OpenTelemetry shutdown timeout'),
|
||||
])
|
||||
} catch (error) {
|
||||
if (error instanceof Error && error.message.includes('timeout')) {
|
||||
logForDebugging(
|
||||
`
|
||||
OpenTelemetry telemetry flush timed out after ${timeoutMs}ms
|
||||
|
||||
To resolve this issue, you can:
|
||||
1. Increase the timeout by setting CLAUDE_CODE_OTEL_SHUTDOWN_TIMEOUT_MS env var (e.g., 5000 for 5 seconds)
|
||||
2. Check if your OpenTelemetry backend is experiencing scalability issues
|
||||
3. Disable OpenTelemetry by unsetting CLAUDE_CODE_ENABLE_TELEMETRY env var
|
||||
|
||||
Current timeout: ${timeoutMs}ms
|
||||
`,
|
||||
{ level: 'error' },
|
||||
)
|
||||
}
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
// Always register shutdown (internal metrics are always enabled)
|
||||
registerCleanup(shutdownTelemetry)
|
||||
|
||||
return meterProvider.getMeter('com.anthropic.claude_code', MACRO.VERSION)
|
||||
}
|
||||
|
||||
/**
|
||||
* Flush all pending telemetry data immediately.
|
||||
* This should be called before logout or org switching to prevent data leakage.
|
||||
*/
|
||||
export async function flushTelemetry(): Promise<void> {
|
||||
const meterProvider = getMeterProvider()
|
||||
if (!meterProvider) {
|
||||
return
|
||||
}
|
||||
|
||||
const timeoutMs = parseInt(
|
||||
process.env.CLAUDE_CODE_OTEL_FLUSH_TIMEOUT_MS || '5000',
|
||||
)
|
||||
|
||||
try {
|
||||
const flushPromises = [meterProvider.forceFlush()]
|
||||
const loggerProvider = getLoggerProvider()
|
||||
if (loggerProvider) {
|
||||
flushPromises.push(loggerProvider.forceFlush())
|
||||
}
|
||||
const tracerProvider = getTracerProvider()
|
||||
if (tracerProvider) {
|
||||
flushPromises.push(tracerProvider.forceFlush())
|
||||
}
|
||||
|
||||
await Promise.race([
|
||||
Promise.all(flushPromises),
|
||||
telemetryTimeout(timeoutMs, 'OpenTelemetry flush timeout'),
|
||||
])
|
||||
|
||||
logForDebugging('Telemetry flushed successfully')
|
||||
} catch (error) {
|
||||
if (error instanceof TelemetryTimeoutError) {
|
||||
logForDebugging(
|
||||
`Telemetry flush timed out after ${timeoutMs}ms. Some metrics may not be exported.`,
|
||||
{ level: 'warn' },
|
||||
)
|
||||
} else {
|
||||
logForDebugging(`Telemetry flush failed: ${errorMessage(error)}`, {
|
||||
level: 'error',
|
||||
})
|
||||
}
|
||||
// Don't throw - allow logout to continue even if flush fails
|
||||
}
|
||||
}
|
||||
|
||||
function parseOtelHeadersEnvVar(): Record<string, string> {
|
||||
const headers: Record<string, string> = {}
|
||||
const envHeaders = process.env.OTEL_EXPORTER_OTLP_HEADERS
|
||||
if (envHeaders) {
|
||||
for (const pair of envHeaders.split(',')) {
|
||||
const [key, ...valueParts] = pair.split('=')
|
||||
if (key && valueParts.length > 0) {
|
||||
headers[key.trim()] = valueParts.join('=').trim()
|
||||
}
|
||||
}
|
||||
}
|
||||
return headers
|
||||
}
|
||||
|
||||
/**
|
||||
* Get configuration for OTLP exporters including:
|
||||
* - HTTP agent options (proxy, mTLS)
|
||||
* - Dynamic headers via otelHeadersHelper or static headers from env var
|
||||
*/
|
||||
function getOTLPExporterConfig() {
|
||||
const proxyUrl = getProxyUrl()
|
||||
const mtlsConfig = getMTLSConfig()
|
||||
const settings = getSettings_DEPRECATED()
|
||||
|
||||
// Build base config
|
||||
const config: Record<string, unknown> = {}
|
||||
|
||||
// Parse static headers from env var once (doesn't change at runtime)
|
||||
const staticHeaders = parseOtelHeadersEnvVar()
|
||||
|
||||
// If otelHeadersHelper is configured, use async headers function for dynamic refresh
|
||||
// Otherwise just return static headers if any exist
|
||||
if (settings?.otelHeadersHelper) {
|
||||
config.headers = async (): Promise<Record<string, string>> => {
|
||||
const dynamicHeaders = getOtelHeadersFromHelper()
|
||||
return { ...staticHeaders, ...dynamicHeaders }
|
||||
}
|
||||
} else if (Object.keys(staticHeaders).length > 0) {
|
||||
config.headers = async (): Promise<Record<string, string>> => staticHeaders
|
||||
}
|
||||
|
||||
// Check if we should bypass proxy for OTEL endpoint
|
||||
const otelEndpoint = process.env.OTEL_EXPORTER_OTLP_ENDPOINT
|
||||
if (!proxyUrl || (otelEndpoint && shouldBypassProxy(otelEndpoint))) {
|
||||
// No proxy configured or OTEL endpoint should bypass proxy
|
||||
const caCerts = getCACertificates()
|
||||
if (mtlsConfig || caCerts) {
|
||||
config.httpAgentOptions = {
|
||||
...mtlsConfig,
|
||||
...(caCerts && { ca: caCerts }),
|
||||
}
|
||||
}
|
||||
return config
|
||||
}
|
||||
|
||||
// Return an HttpAgentFactory function that creates our proxy agent
|
||||
const caCerts = getCACertificates()
|
||||
const agentFactory = (_protocol: string) => {
|
||||
// Create and return the proxy agent with mTLS and CA cert config
|
||||
const proxyAgent =
|
||||
mtlsConfig || caCerts
|
||||
? new HttpsProxyAgent(proxyUrl, {
|
||||
...(mtlsConfig && {
|
||||
cert: mtlsConfig.cert,
|
||||
key: mtlsConfig.key,
|
||||
passphrase: mtlsConfig.passphrase,
|
||||
}),
|
||||
...(caCerts && { ca: caCerts }),
|
||||
})
|
||||
: new HttpsProxyAgent(proxyUrl)
|
||||
|
||||
return proxyAgent
|
||||
}
|
||||
|
||||
config.httpAgentOptions = agentFactory
|
||||
return config
|
||||
}
|
||||
@@ -1,26 +0,0 @@
|
||||
import type { DiagLogger } from '@opentelemetry/api'
|
||||
import { logForDebugging } from '../debug.js'
|
||||
import { logError } from '../log.js'
|
||||
export class ClaudeCodeDiagLogger implements DiagLogger {
|
||||
error(message: string, ..._: unknown[]) {
|
||||
logError(new Error(message))
|
||||
logForDebugging(`[3P telemetry] OTEL diag error: ${message}`, {
|
||||
level: 'error',
|
||||
})
|
||||
}
|
||||
warn(message: string, ..._: unknown[]) {
|
||||
logError(new Error(message))
|
||||
logForDebugging(`[3P telemetry] OTEL diag warn: ${message}`, {
|
||||
level: 'warn',
|
||||
})
|
||||
}
|
||||
info(_message: string, ..._args: unknown[]) {
|
||||
return
|
||||
}
|
||||
debug(_message: string, ..._args: unknown[]) {
|
||||
return
|
||||
}
|
||||
verbose(_message: string, ..._args: unknown[]) {
|
||||
return
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,927 +1,99 @@
|
||||
/**
|
||||
* Session Tracing for Claude Code using OpenTelemetry (BETA)
|
||||
*
|
||||
* This module provides a high-level API for creating and managing spans
|
||||
* to trace Claude Code workflows. Each user interaction creates a root
|
||||
* interaction span, which contains operation spans (LLM requests, tool calls, etc.).
|
||||
*
|
||||
* Requirements:
|
||||
* - Enhanced telemetry is enabled via feature('ENHANCED_TELEMETRY_BETA')
|
||||
* - Configure OTEL_TRACES_EXPORTER (console, otlp, etc.)
|
||||
* No-op stub for session tracing.
|
||||
* All spans are no-op objects; all boolean checks return false.
|
||||
* No @opentelemetry imports.
|
||||
*/
|
||||
|
||||
import { feature } from 'bun:bundle'
|
||||
import { context as otelContext, type Span, trace } from '@opentelemetry/api'
|
||||
import { AsyncLocalStorage } from 'async_hooks'
|
||||
import { getFeatureValue_CACHED_MAY_BE_STALE } from '../../services/analytics/growthbook.js'
|
||||
import type { AssistantMessage, UserMessage } from '../../types/message.js'
|
||||
import { isEnvDefinedFalsy, isEnvTruthy } from '../envUtils.js'
|
||||
import { getTelemetryAttributes } from '../telemetryAttributes.js'
|
||||
import {
|
||||
addBetaInteractionAttributes,
|
||||
addBetaLLMRequestAttributes,
|
||||
addBetaLLMResponseAttributes,
|
||||
addBetaToolInputAttributes,
|
||||
addBetaToolResultAttributes,
|
||||
isBetaTracingEnabled,
|
||||
type LLMRequestNewContext,
|
||||
truncateContent,
|
||||
} from './betaSessionTracing.js'
|
||||
import {
|
||||
endInteractionPerfettoSpan,
|
||||
endLLMRequestPerfettoSpan,
|
||||
endToolPerfettoSpan,
|
||||
endUserInputPerfettoSpan,
|
||||
isPerfettoTracingEnabled,
|
||||
startInteractionPerfettoSpan,
|
||||
startLLMRequestPerfettoSpan,
|
||||
startToolPerfettoSpan,
|
||||
startUserInputPerfettoSpan,
|
||||
} from './perfettoTracing.js'
|
||||
|
||||
// Re-export for callers
|
||||
export type { Span }
|
||||
export { isBetaTracingEnabled, type LLMRequestNewContext }
|
||||
|
||||
// Message type for API calls (UserMessage or AssistantMessage)
|
||||
type APIMessage = UserMessage | AssistantMessage
|
||||
|
||||
type SpanType =
|
||||
| 'interaction'
|
||||
| 'llm_request'
|
||||
| 'tool'
|
||||
| 'tool.blocked_on_user'
|
||||
| 'tool.execution'
|
||||
| 'hook'
|
||||
|
||||
interface SpanContext {
|
||||
span: Span
|
||||
startTime: number
|
||||
attributes: Record<string, string | number | boolean>
|
||||
ended?: boolean
|
||||
perfettoSpanId?: string
|
||||
export type Span = {
|
||||
spanContext(): { traceId: string; spanId: string; traceFlags: number }
|
||||
setAttribute(key: string, value: string | number | boolean): Span
|
||||
setAttributes(attrs: Record<string, string | number | boolean>): Span
|
||||
setStatus(status: { code: number; message?: string }): Span
|
||||
end(): void
|
||||
recordException(exception: Error | string, attributes?: Record<string, unknown>): void
|
||||
addEvent(name: string, attributes?: Record<string, string | number | boolean>): Span
|
||||
isRecording(): boolean
|
||||
}
|
||||
|
||||
// ALS stores SpanContext directly so it holds a strong reference while a span
|
||||
// is active. With that, activeSpans can use WeakRef — when ALS is cleared
|
||||
// (enterWith(undefined)) and no other code holds the SpanContext, GC can collect
|
||||
// it and the WeakRef goes stale.
|
||||
const interactionContext = new AsyncLocalStorage<SpanContext | undefined>()
|
||||
const toolContext = new AsyncLocalStorage<SpanContext | undefined>()
|
||||
const activeSpans = new Map<string, WeakRef<SpanContext>>()
|
||||
// Spans not stored in ALS (LLM request, blocked-on-user, tool execution, hook)
|
||||
// need a strong reference to prevent GC from collecting the SpanContext before
|
||||
// the corresponding end* function retrieves it.
|
||||
const strongSpans = new Map<string, SpanContext>()
|
||||
let interactionSequence = 0
|
||||
let _cleanupIntervalStarted = false
|
||||
|
||||
const SPAN_TTL_MS = 30 * 60 * 1000 // 30 minutes
|
||||
|
||||
function getSpanId(span: Span): string {
|
||||
return span.spanContext().spanId || ''
|
||||
const noopSpan: Span = {
|
||||
spanContext() {
|
||||
return { traceId: '', spanId: '', traceFlags: 0 }
|
||||
},
|
||||
setAttribute() {
|
||||
return noopSpan
|
||||
},
|
||||
setAttributes() {
|
||||
return noopSpan
|
||||
},
|
||||
setStatus() {
|
||||
return noopSpan
|
||||
},
|
||||
end() {},
|
||||
recordException() {},
|
||||
addEvent() {
|
||||
return noopSpan
|
||||
},
|
||||
isRecording() {
|
||||
return false
|
||||
},
|
||||
}
|
||||
|
||||
/**
|
||||
* Lazily start a background interval that evicts orphaned spans from activeSpans.
|
||||
*
|
||||
* Normal teardown calls endInteractionSpan / endToolSpan, which delete spans
|
||||
* immediately. This interval is a safety net for spans that were never ended
|
||||
* (e.g. aborted streams, uncaught exceptions mid-query) — without it they
|
||||
* accumulate in activeSpans indefinitely, holding references to Span objects
|
||||
* and the OpenTelemetry context chain.
|
||||
*
|
||||
* Initialized on the first startInteractionSpan call (not at module load) to
|
||||
* avoid triggering the no-top-level-side-effects lint rule and to keep the
|
||||
* interval from running in processes that never start a span.
|
||||
* unref() prevents the timer from keeping the process alive after all other
|
||||
* work is done.
|
||||
*/
|
||||
function ensureCleanupInterval(): void {
|
||||
if (_cleanupIntervalStarted) return
|
||||
_cleanupIntervalStarted = true
|
||||
const interval = setInterval(() => {
|
||||
const cutoff = Date.now() - SPAN_TTL_MS
|
||||
for (const [spanId, weakRef] of activeSpans) {
|
||||
const ctx = weakRef.deref()
|
||||
if (ctx === undefined) {
|
||||
activeSpans.delete(spanId)
|
||||
strongSpans.delete(spanId)
|
||||
} else if (ctx.startTime < cutoff) {
|
||||
if (!ctx.ended) ctx.span.end() // flush any recorded attributes to the exporter
|
||||
activeSpans.delete(spanId)
|
||||
strongSpans.delete(spanId)
|
||||
}
|
||||
}
|
||||
}, 60_000)
|
||||
if (typeof interval.unref === 'function') {
|
||||
interval.unref() // Node.js / Bun: don't block process exit
|
||||
}
|
||||
export type LLMRequestNewContext = Record<string, unknown>
|
||||
|
||||
export function isBetaTracingEnabled(): boolean {
|
||||
return false
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if enhanced telemetry is enabled.
|
||||
* Priority: env var override > ant build > GrowthBook gate
|
||||
*/
|
||||
export function isEnhancedTelemetryEnabled(): boolean {
|
||||
if (feature('ENHANCED_TELEMETRY_BETA')) {
|
||||
const env =
|
||||
process.env.CLAUDE_CODE_ENHANCED_TELEMETRY_BETA ??
|
||||
process.env.ENABLE_ENHANCED_TELEMETRY_BETA
|
||||
if (isEnvTruthy(env)) {
|
||||
return true
|
||||
}
|
||||
if (isEnvDefinedFalsy(env)) {
|
||||
return false
|
||||
}
|
||||
return (
|
||||
process.env.USER_TYPE === 'ant' ||
|
||||
getFeatureValue_CACHED_MAY_BE_STALE('enhanced_telemetry_beta', false)
|
||||
)
|
||||
}
|
||||
return false
|
||||
return false
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if any tracing is enabled (either standard enhanced telemetry OR beta tracing)
|
||||
*/
|
||||
function isAnyTracingEnabled(): boolean {
|
||||
return isEnhancedTelemetryEnabled() || isBetaTracingEnabled()
|
||||
export function startInteractionSpan(): Span {
|
||||
return noopSpan
|
||||
}
|
||||
|
||||
function getTracer() {
|
||||
return trace.getTracer('com.anthropic.claude_code.tracing', '1.0.0')
|
||||
export function endInteractionSpan() {}
|
||||
|
||||
export function startLLMRequestSpan(): Span {
|
||||
return noopSpan
|
||||
}
|
||||
|
||||
function createSpanAttributes(
|
||||
spanType: SpanType,
|
||||
customAttributes: Record<string, string | number | boolean> = {},
|
||||
): Record<string, string | number | boolean> {
|
||||
const baseAttributes = getTelemetryAttributes()
|
||||
export function endLLMRequestSpan() {}
|
||||
|
||||
const attributes: Record<string, string | number | boolean> = {
|
||||
...baseAttributes,
|
||||
'span.type': spanType,
|
||||
...customAttributes,
|
||||
}
|
||||
|
||||
return attributes
|
||||
}
|
||||
|
||||
/**
|
||||
* Start an interaction span. This wraps a user request -> Claude response cycle.
|
||||
* This is now a root span that includes all session-level attributes.
|
||||
* Sets the interaction context for all subsequent operations.
|
||||
*/
|
||||
export function startInteractionSpan(userPrompt: string): Span {
|
||||
ensureCleanupInterval()
|
||||
|
||||
// Start Perfetto span regardless of OTel tracing state
|
||||
const perfettoSpanId = isPerfettoTracingEnabled()
|
||||
? startInteractionPerfettoSpan(userPrompt)
|
||||
: undefined
|
||||
|
||||
if (!isAnyTracingEnabled()) {
|
||||
// Still track Perfetto span even if OTel is disabled
|
||||
if (perfettoSpanId) {
|
||||
const dummySpan = trace.getActiveSpan() || getTracer().startSpan('dummy')
|
||||
const spanId = getSpanId(dummySpan)
|
||||
const spanContextObj: SpanContext = {
|
||||
span: dummySpan,
|
||||
startTime: Date.now(),
|
||||
attributes: {},
|
||||
perfettoSpanId,
|
||||
}
|
||||
activeSpans.set(spanId, new WeakRef(spanContextObj))
|
||||
interactionContext.enterWith(spanContextObj)
|
||||
return dummySpan
|
||||
}
|
||||
return trace.getActiveSpan() || getTracer().startSpan('dummy')
|
||||
}
|
||||
|
||||
const tracer = getTracer()
|
||||
const isUserPromptLoggingEnabled = isEnvTruthy(
|
||||
process.env.OTEL_LOG_USER_PROMPTS,
|
||||
)
|
||||
const promptToLog = isUserPromptLoggingEnabled ? userPrompt : '<REDACTED>'
|
||||
|
||||
interactionSequence++
|
||||
|
||||
const attributes = createSpanAttributes('interaction', {
|
||||
user_prompt: promptToLog,
|
||||
user_prompt_length: userPrompt.length,
|
||||
'interaction.sequence': interactionSequence,
|
||||
})
|
||||
|
||||
const span = tracer.startSpan('claude_code.interaction', {
|
||||
attributes,
|
||||
})
|
||||
|
||||
// Add experimental attributes (new_context)
|
||||
addBetaInteractionAttributes(span, userPrompt)
|
||||
|
||||
const spanId = getSpanId(span)
|
||||
const spanContextObj: SpanContext = {
|
||||
span,
|
||||
startTime: Date.now(),
|
||||
attributes,
|
||||
perfettoSpanId,
|
||||
}
|
||||
activeSpans.set(spanId, new WeakRef(spanContextObj))
|
||||
|
||||
interactionContext.enterWith(spanContextObj)
|
||||
|
||||
return span
|
||||
}
|
||||
|
||||
export function endInteractionSpan(): void {
|
||||
const spanContext = interactionContext.getStore()
|
||||
if (!spanContext) {
|
||||
return
|
||||
}
|
||||
|
||||
if (spanContext.ended) {
|
||||
return
|
||||
}
|
||||
|
||||
// End Perfetto span
|
||||
if (spanContext.perfettoSpanId) {
|
||||
endInteractionPerfettoSpan(spanContext.perfettoSpanId)
|
||||
}
|
||||
|
||||
if (!isAnyTracingEnabled()) {
|
||||
spanContext.ended = true
|
||||
activeSpans.delete(getSpanId(spanContext.span))
|
||||
// Clear the store so async continuations created after this point (timers,
|
||||
// promise callbacks, I/O) do not inherit a reference to the ended span.
|
||||
// enterWith(undefined) is intentional: exit(() => {}) is a no-op because it
|
||||
// only suppresses the store inside the callback and returns immediately.
|
||||
interactionContext.enterWith(undefined)
|
||||
return
|
||||
}
|
||||
|
||||
const duration = Date.now() - spanContext.startTime
|
||||
spanContext.span.setAttributes({
|
||||
'interaction.duration_ms': duration,
|
||||
})
|
||||
|
||||
spanContext.span.end()
|
||||
spanContext.ended = true
|
||||
activeSpans.delete(getSpanId(spanContext.span))
|
||||
interactionContext.enterWith(undefined)
|
||||
}
|
||||
|
||||
export function startLLMRequestSpan(
|
||||
model: string,
|
||||
newContext?: LLMRequestNewContext,
|
||||
messagesForAPI?: APIMessage[],
|
||||
fastMode?: boolean,
|
||||
): Span {
|
||||
// Start Perfetto span regardless of OTel tracing state
|
||||
const perfettoSpanId = isPerfettoTracingEnabled()
|
||||
? startLLMRequestPerfettoSpan({
|
||||
model,
|
||||
querySource: newContext?.querySource,
|
||||
messageId: undefined, // Will be set in endLLMRequestSpan
|
||||
})
|
||||
: undefined
|
||||
|
||||
if (!isAnyTracingEnabled()) {
|
||||
// Still track Perfetto span even if OTel is disabled
|
||||
if (perfettoSpanId) {
|
||||
const dummySpan = trace.getActiveSpan() || getTracer().startSpan('dummy')
|
||||
const spanId = getSpanId(dummySpan)
|
||||
const spanContextObj: SpanContext = {
|
||||
span: dummySpan,
|
||||
startTime: Date.now(),
|
||||
attributes: { model },
|
||||
perfettoSpanId,
|
||||
}
|
||||
activeSpans.set(spanId, new WeakRef(spanContextObj))
|
||||
strongSpans.set(spanId, spanContextObj)
|
||||
return dummySpan
|
||||
}
|
||||
return trace.getActiveSpan() || getTracer().startSpan('dummy')
|
||||
}
|
||||
|
||||
const tracer = getTracer()
|
||||
const parentSpanCtx = interactionContext.getStore()
|
||||
|
||||
const attributes = createSpanAttributes('llm_request', {
|
||||
model: model,
|
||||
'llm_request.context': parentSpanCtx ? 'interaction' : 'standalone',
|
||||
speed: fastMode ? 'fast' : 'normal',
|
||||
})
|
||||
|
||||
const ctx = parentSpanCtx
|
||||
? trace.setSpan(otelContext.active(), parentSpanCtx.span)
|
||||
: otelContext.active()
|
||||
const span = tracer.startSpan('claude_code.llm_request', { attributes }, ctx)
|
||||
|
||||
// Add query_source (agent name) if provided
|
||||
if (newContext?.querySource) {
|
||||
span.setAttribute('query_source', newContext.querySource)
|
||||
}
|
||||
|
||||
// Add experimental attributes (system prompt, new_context)
|
||||
addBetaLLMRequestAttributes(span, newContext, messagesForAPI)
|
||||
|
||||
const spanId = getSpanId(span)
|
||||
const spanContextObj: SpanContext = {
|
||||
span,
|
||||
startTime: Date.now(),
|
||||
attributes,
|
||||
perfettoSpanId,
|
||||
}
|
||||
activeSpans.set(spanId, new WeakRef(spanContextObj))
|
||||
strongSpans.set(spanId, spanContextObj)
|
||||
|
||||
return span
|
||||
}
|
||||
|
||||
/**
|
||||
* End an LLM request span and attach response metadata.
|
||||
*
|
||||
* @param span - Optional. The exact span returned by startLLMRequestSpan().
|
||||
* IMPORTANT: When multiple LLM requests run in parallel (e.g., warmup requests,
|
||||
* topic classifier, file path extractor, main thread), you MUST pass the specific span
|
||||
* to ensure responses are attached to the correct request. Without it, responses may be
|
||||
* incorrectly attached to whichever span happens to be "last" in the activeSpans map.
|
||||
*
|
||||
* If not provided, falls back to finding the most recent llm_request span (legacy behavior).
|
||||
*/
|
||||
export function endLLMRequestSpan(
|
||||
span?: Span,
|
||||
metadata?: {
|
||||
inputTokens?: number
|
||||
outputTokens?: number
|
||||
cacheReadTokens?: number
|
||||
cacheCreationTokens?: number
|
||||
success?: boolean
|
||||
statusCode?: number
|
||||
error?: string
|
||||
attempt?: number
|
||||
modelResponse?: string
|
||||
/** Text output from the model (non-thinking content) */
|
||||
modelOutput?: string
|
||||
/** Thinking/reasoning output from the model */
|
||||
thinkingOutput?: string
|
||||
/** Whether the output included tool calls (look at tool spans for details) */
|
||||
hasToolCall?: boolean
|
||||
/** Time to first token in milliseconds */
|
||||
ttftMs?: number
|
||||
/** Time spent in pre-request setup before the successful attempt */
|
||||
requestSetupMs?: number
|
||||
/** Timestamps (Date.now()) of each attempt start — used to emit retry sub-spans */
|
||||
attemptStartTimes?: number[]
|
||||
},
|
||||
): void {
|
||||
let llmSpanContext: SpanContext | undefined
|
||||
|
||||
if (span) {
|
||||
// Use the provided span directly - this is the correct approach for parallel requests
|
||||
const spanId = getSpanId(span)
|
||||
llmSpanContext = activeSpans.get(spanId)?.deref()
|
||||
} else {
|
||||
// Legacy fallback: find the most recent llm_request span
|
||||
// WARNING: This can cause mismatched responses when multiple requests are in flight
|
||||
llmSpanContext = Array.from(activeSpans.values())
|
||||
.findLast(r => {
|
||||
const ctx = r.deref()
|
||||
return (
|
||||
ctx?.attributes['span.type'] === 'llm_request' ||
|
||||
ctx?.attributes['model']
|
||||
)
|
||||
})
|
||||
?.deref()
|
||||
}
|
||||
|
||||
if (!llmSpanContext) {
|
||||
// Span was already ended or never tracked
|
||||
return
|
||||
}
|
||||
|
||||
const duration = Date.now() - llmSpanContext.startTime
|
||||
|
||||
// End Perfetto span with full metadata
|
||||
if (llmSpanContext.perfettoSpanId) {
|
||||
endLLMRequestPerfettoSpan(llmSpanContext.perfettoSpanId, {
|
||||
ttftMs: metadata?.ttftMs,
|
||||
ttltMs: duration, // Time to last token is the total duration
|
||||
promptTokens: metadata?.inputTokens,
|
||||
outputTokens: metadata?.outputTokens,
|
||||
cacheReadTokens: metadata?.cacheReadTokens,
|
||||
cacheCreationTokens: metadata?.cacheCreationTokens,
|
||||
success: metadata?.success,
|
||||
error: metadata?.error,
|
||||
requestSetupMs: metadata?.requestSetupMs,
|
||||
attemptStartTimes: metadata?.attemptStartTimes,
|
||||
})
|
||||
}
|
||||
|
||||
if (!isAnyTracingEnabled()) {
|
||||
const spanId = getSpanId(llmSpanContext.span)
|
||||
activeSpans.delete(spanId)
|
||||
strongSpans.delete(spanId)
|
||||
return
|
||||
}
|
||||
|
||||
const endAttributes: Record<string, string | number | boolean> = {
|
||||
duration_ms: duration,
|
||||
}
|
||||
|
||||
if (metadata) {
|
||||
if (metadata.inputTokens !== undefined)
|
||||
endAttributes['input_tokens'] = metadata.inputTokens
|
||||
if (metadata.outputTokens !== undefined)
|
||||
endAttributes['output_tokens'] = metadata.outputTokens
|
||||
if (metadata.cacheReadTokens !== undefined)
|
||||
endAttributes['cache_read_tokens'] = metadata.cacheReadTokens
|
||||
if (metadata.cacheCreationTokens !== undefined)
|
||||
endAttributes['cache_creation_tokens'] = metadata.cacheCreationTokens
|
||||
if (metadata.success !== undefined)
|
||||
endAttributes['success'] = metadata.success
|
||||
if (metadata.statusCode !== undefined)
|
||||
endAttributes['status_code'] = metadata.statusCode
|
||||
if (metadata.error !== undefined) endAttributes['error'] = metadata.error
|
||||
if (metadata.attempt !== undefined)
|
||||
endAttributes['attempt'] = metadata.attempt
|
||||
if (metadata.hasToolCall !== undefined)
|
||||
endAttributes['response.has_tool_call'] = metadata.hasToolCall
|
||||
if (metadata.ttftMs !== undefined)
|
||||
endAttributes['ttft_ms'] = metadata.ttftMs
|
||||
|
||||
// Add experimental response attributes (model_output, thinking_output)
|
||||
addBetaLLMResponseAttributes(endAttributes, metadata)
|
||||
}
|
||||
|
||||
llmSpanContext.span.setAttributes(endAttributes)
|
||||
llmSpanContext.span.end()
|
||||
|
||||
const spanId = getSpanId(llmSpanContext.span)
|
||||
activeSpans.delete(spanId)
|
||||
strongSpans.delete(spanId)
|
||||
}
|
||||
|
||||
export function startToolSpan(
|
||||
toolName: string,
|
||||
toolAttributes?: Record<string, string | number | boolean>,
|
||||
toolInput?: string,
|
||||
): Span {
|
||||
// Start Perfetto span regardless of OTel tracing state
|
||||
const perfettoSpanId = isPerfettoTracingEnabled()
|
||||
? startToolPerfettoSpan(toolName, toolAttributes)
|
||||
: undefined
|
||||
|
||||
if (!isAnyTracingEnabled()) {
|
||||
// Still track Perfetto span even if OTel is disabled
|
||||
if (perfettoSpanId) {
|
||||
const dummySpan = trace.getActiveSpan() || getTracer().startSpan('dummy')
|
||||
const spanId = getSpanId(dummySpan)
|
||||
const spanContextObj: SpanContext = {
|
||||
span: dummySpan,
|
||||
startTime: Date.now(),
|
||||
attributes: { 'span.type': 'tool', tool_name: toolName },
|
||||
perfettoSpanId,
|
||||
}
|
||||
activeSpans.set(spanId, new WeakRef(spanContextObj))
|
||||
toolContext.enterWith(spanContextObj)
|
||||
return dummySpan
|
||||
}
|
||||
return trace.getActiveSpan() || getTracer().startSpan('dummy')
|
||||
}
|
||||
|
||||
const tracer = getTracer()
|
||||
const parentSpanCtx = interactionContext.getStore()
|
||||
|
||||
const attributes = createSpanAttributes('tool', {
|
||||
tool_name: toolName,
|
||||
...toolAttributes,
|
||||
})
|
||||
|
||||
const ctx = parentSpanCtx
|
||||
? trace.setSpan(otelContext.active(), parentSpanCtx.span)
|
||||
: otelContext.active()
|
||||
const span = tracer.startSpan('claude_code.tool', { attributes }, ctx)
|
||||
|
||||
// Add experimental tool input attributes
|
||||
if (toolInput) {
|
||||
addBetaToolInputAttributes(span, toolName, toolInput)
|
||||
}
|
||||
|
||||
const spanId = getSpanId(span)
|
||||
const spanContextObj: SpanContext = {
|
||||
span,
|
||||
startTime: Date.now(),
|
||||
attributes,
|
||||
perfettoSpanId,
|
||||
}
|
||||
activeSpans.set(spanId, new WeakRef(spanContextObj))
|
||||
|
||||
toolContext.enterWith(spanContextObj)
|
||||
|
||||
return span
|
||||
export function startToolSpan(): Span {
|
||||
return noopSpan
|
||||
}
|
||||
|
||||
export function startToolBlockedOnUserSpan(): Span {
|
||||
// Start Perfetto span regardless of OTel tracing state
|
||||
const perfettoSpanId = isPerfettoTracingEnabled()
|
||||
? startUserInputPerfettoSpan('tool_permission')
|
||||
: undefined
|
||||
|
||||
if (!isAnyTracingEnabled()) {
|
||||
// Still track Perfetto span even if OTel is disabled
|
||||
if (perfettoSpanId) {
|
||||
const dummySpan = trace.getActiveSpan() || getTracer().startSpan('dummy')
|
||||
const spanId = getSpanId(dummySpan)
|
||||
const spanContextObj: SpanContext = {
|
||||
span: dummySpan,
|
||||
startTime: Date.now(),
|
||||
attributes: { 'span.type': 'tool.blocked_on_user' },
|
||||
perfettoSpanId,
|
||||
}
|
||||
activeSpans.set(spanId, new WeakRef(spanContextObj))
|
||||
strongSpans.set(spanId, spanContextObj)
|
||||
return dummySpan
|
||||
}
|
||||
return trace.getActiveSpan() || getTracer().startSpan('dummy')
|
||||
}
|
||||
|
||||
const tracer = getTracer()
|
||||
const parentSpanCtx = toolContext.getStore()
|
||||
|
||||
const attributes = createSpanAttributes('tool.blocked_on_user')
|
||||
|
||||
const ctx = parentSpanCtx
|
||||
? trace.setSpan(otelContext.active(), parentSpanCtx.span)
|
||||
: otelContext.active()
|
||||
const span = tracer.startSpan(
|
||||
'claude_code.tool.blocked_on_user',
|
||||
{ attributes },
|
||||
ctx,
|
||||
)
|
||||
|
||||
const spanId = getSpanId(span)
|
||||
const spanContextObj: SpanContext = {
|
||||
span,
|
||||
startTime: Date.now(),
|
||||
attributes,
|
||||
perfettoSpanId,
|
||||
}
|
||||
activeSpans.set(spanId, new WeakRef(spanContextObj))
|
||||
strongSpans.set(spanId, spanContextObj)
|
||||
|
||||
return span
|
||||
return noopSpan
|
||||
}
|
||||
|
||||
export function endToolBlockedOnUserSpan(
|
||||
decision?: string,
|
||||
source?: string,
|
||||
): void {
|
||||
const blockedSpanContext = Array.from(activeSpans.values())
|
||||
.findLast(
|
||||
r => r.deref()?.attributes['span.type'] === 'tool.blocked_on_user',
|
||||
)
|
||||
?.deref()
|
||||
|
||||
if (!blockedSpanContext) {
|
||||
return
|
||||
}
|
||||
|
||||
// End Perfetto span
|
||||
if (blockedSpanContext.perfettoSpanId) {
|
||||
endUserInputPerfettoSpan(blockedSpanContext.perfettoSpanId, {
|
||||
decision,
|
||||
source,
|
||||
})
|
||||
}
|
||||
|
||||
if (!isAnyTracingEnabled()) {
|
||||
const spanId = getSpanId(blockedSpanContext.span)
|
||||
activeSpans.delete(spanId)
|
||||
strongSpans.delete(spanId)
|
||||
return
|
||||
}
|
||||
|
||||
const duration = Date.now() - blockedSpanContext.startTime
|
||||
const attributes: Record<string, string | number | boolean> = {
|
||||
duration_ms: duration,
|
||||
}
|
||||
|
||||
if (decision) {
|
||||
attributes['decision'] = decision
|
||||
}
|
||||
if (source) {
|
||||
attributes['source'] = source
|
||||
}
|
||||
|
||||
blockedSpanContext.span.setAttributes(attributes)
|
||||
blockedSpanContext.span.end()
|
||||
|
||||
const spanId = getSpanId(blockedSpanContext.span)
|
||||
activeSpans.delete(spanId)
|
||||
strongSpans.delete(spanId)
|
||||
}
|
||||
export function endToolBlockedOnUserSpan() {}
|
||||
|
||||
export function startToolExecutionSpan(): Span {
|
||||
if (!isAnyTracingEnabled()) {
|
||||
return trace.getActiveSpan() || getTracer().startSpan('dummy')
|
||||
}
|
||||
|
||||
const tracer = getTracer()
|
||||
const parentSpanCtx = toolContext.getStore()
|
||||
|
||||
const attributes = createSpanAttributes('tool.execution')
|
||||
|
||||
const ctx = parentSpanCtx
|
||||
? trace.setSpan(otelContext.active(), parentSpanCtx.span)
|
||||
: otelContext.active()
|
||||
const span = tracer.startSpan(
|
||||
'claude_code.tool.execution',
|
||||
{ attributes },
|
||||
ctx,
|
||||
)
|
||||
|
||||
const spanId = getSpanId(span)
|
||||
const spanContextObj: SpanContext = {
|
||||
span,
|
||||
startTime: Date.now(),
|
||||
attributes,
|
||||
}
|
||||
activeSpans.set(spanId, new WeakRef(spanContextObj))
|
||||
strongSpans.set(spanId, spanContextObj)
|
||||
|
||||
return span
|
||||
return noopSpan
|
||||
}
|
||||
|
||||
export function endToolExecutionSpan(metadata?: {
|
||||
success?: boolean
|
||||
error?: string
|
||||
}): void {
|
||||
if (!isAnyTracingEnabled()) {
|
||||
return
|
||||
}
|
||||
export function endToolExecutionSpan() {}
|
||||
|
||||
const executionSpanContext = Array.from(activeSpans.values())
|
||||
.findLast(r => r.deref()?.attributes['span.type'] === 'tool.execution')
|
||||
?.deref()
|
||||
export function endToolSpan() {}
|
||||
|
||||
if (!executionSpanContext) {
|
||||
return
|
||||
}
|
||||
export function addToolContentEvent() {}
|
||||
|
||||
const duration = Date.now() - executionSpanContext.startTime
|
||||
const attributes: Record<string, string | number | boolean> = {
|
||||
duration_ms: duration,
|
||||
}
|
||||
|
||||
if (metadata) {
|
||||
if (metadata.success !== undefined) attributes['success'] = metadata.success
|
||||
if (metadata.error !== undefined) attributes['error'] = metadata.error
|
||||
}
|
||||
|
||||
executionSpanContext.span.setAttributes(attributes)
|
||||
executionSpanContext.span.end()
|
||||
|
||||
const spanId = getSpanId(executionSpanContext.span)
|
||||
activeSpans.delete(spanId)
|
||||
strongSpans.delete(spanId)
|
||||
}
|
||||
|
||||
export function endToolSpan(toolResult?: string, resultTokens?: number): void {
|
||||
const toolSpanContext = toolContext.getStore()
|
||||
|
||||
if (!toolSpanContext) {
|
||||
return
|
||||
}
|
||||
|
||||
// End Perfetto span
|
||||
if (toolSpanContext.perfettoSpanId) {
|
||||
endToolPerfettoSpan(toolSpanContext.perfettoSpanId, {
|
||||
success: true,
|
||||
resultTokens,
|
||||
})
|
||||
}
|
||||
|
||||
if (!isAnyTracingEnabled()) {
|
||||
const spanId = getSpanId(toolSpanContext.span)
|
||||
activeSpans.delete(spanId)
|
||||
// Same reasoning as interactionContext above: clear so subsequent async
|
||||
// work doesn't hold a stale reference to the ended tool span.
|
||||
toolContext.enterWith(undefined)
|
||||
return
|
||||
}
|
||||
|
||||
const duration = Date.now() - toolSpanContext.startTime
|
||||
const endAttributes: Record<string, string | number | boolean> = {
|
||||
duration_ms: duration,
|
||||
}
|
||||
|
||||
// Add experimental tool result attributes (new_context)
|
||||
if (toolResult) {
|
||||
const toolName = toolSpanContext.attributes['tool_name'] || 'unknown'
|
||||
addBetaToolResultAttributes(endAttributes, toolName, toolResult)
|
||||
}
|
||||
|
||||
if (resultTokens !== undefined) {
|
||||
endAttributes['result_tokens'] = resultTokens
|
||||
}
|
||||
|
||||
toolSpanContext.span.setAttributes(endAttributes)
|
||||
toolSpanContext.span.end()
|
||||
|
||||
const spanId = getSpanId(toolSpanContext.span)
|
||||
activeSpans.delete(spanId)
|
||||
toolContext.enterWith(undefined)
|
||||
}
|
||||
|
||||
function isToolContentLoggingEnabled(): boolean {
|
||||
return isEnvTruthy(process.env.OTEL_LOG_TOOL_CONTENT)
|
||||
}
|
||||
|
||||
/**
|
||||
* Add a span event with tool content/output data.
|
||||
* Only logs if OTEL_LOG_TOOL_CONTENT=1 is set.
|
||||
* Truncates content if it exceeds MAX_CONTENT_SIZE.
|
||||
*/
|
||||
export function addToolContentEvent(
|
||||
eventName: string,
|
||||
attributes: Record<string, string | number | boolean>,
|
||||
): void {
|
||||
if (!isAnyTracingEnabled() || !isToolContentLoggingEnabled()) {
|
||||
return
|
||||
}
|
||||
|
||||
const currentSpanCtx = toolContext.getStore()
|
||||
if (!currentSpanCtx) {
|
||||
return
|
||||
}
|
||||
|
||||
// Truncate string attributes that might be large
|
||||
const processedAttributes: Record<string, string | number | boolean> = {}
|
||||
for (const [key, value] of Object.entries(attributes)) {
|
||||
if (typeof value === 'string') {
|
||||
const { content, truncated } = truncateContent(value)
|
||||
processedAttributes[key] = content
|
||||
if (truncated) {
|
||||
processedAttributes[`${key}_truncated`] = true
|
||||
processedAttributes[`${key}_original_length`] = value.length
|
||||
}
|
||||
} else {
|
||||
processedAttributes[key] = value
|
||||
}
|
||||
}
|
||||
|
||||
currentSpanCtx.span.addEvent(eventName, processedAttributes)
|
||||
}
|
||||
|
||||
export function getCurrentSpan(): Span | null {
|
||||
if (!isAnyTracingEnabled()) {
|
||||
return null
|
||||
}
|
||||
|
||||
return (
|
||||
toolContext.getStore()?.span ?? interactionContext.getStore()?.span ?? null
|
||||
)
|
||||
export function getCurrentSpan(): null {
|
||||
return null
|
||||
}
|
||||
|
||||
export async function executeInSpan<T>(
|
||||
spanName: string,
|
||||
fn: (span: Span) => Promise<T>,
|
||||
attributes?: Record<string, string | number | boolean>,
|
||||
_spanName: string,
|
||||
fn: (span: Span) => Promise<T>,
|
||||
_attributes?: Record<string, string | number | boolean>,
|
||||
): Promise<T> {
|
||||
if (!isAnyTracingEnabled()) {
|
||||
return fn(trace.getActiveSpan() || getTracer().startSpan('dummy'))
|
||||
}
|
||||
|
||||
const tracer = getTracer()
|
||||
const parentSpanCtx = toolContext.getStore() ?? interactionContext.getStore()
|
||||
|
||||
const finalAttributes = createSpanAttributes('tool', {
|
||||
...attributes,
|
||||
})
|
||||
|
||||
const ctx = parentSpanCtx
|
||||
? trace.setSpan(otelContext.active(), parentSpanCtx.span)
|
||||
: otelContext.active()
|
||||
const span = tracer.startSpan(spanName, { attributes: finalAttributes }, ctx)
|
||||
|
||||
const spanId = getSpanId(span)
|
||||
const spanContextObj: SpanContext = {
|
||||
span,
|
||||
startTime: Date.now(),
|
||||
attributes: finalAttributes,
|
||||
}
|
||||
activeSpans.set(spanId, new WeakRef(spanContextObj))
|
||||
strongSpans.set(spanId, spanContextObj)
|
||||
|
||||
try {
|
||||
const result = await fn(span)
|
||||
span.end()
|
||||
activeSpans.delete(spanId)
|
||||
strongSpans.delete(spanId)
|
||||
return result
|
||||
} catch (error) {
|
||||
if (error instanceof Error) {
|
||||
span.recordException(error)
|
||||
}
|
||||
span.end()
|
||||
activeSpans.delete(spanId)
|
||||
strongSpans.delete(spanId)
|
||||
throw error
|
||||
}
|
||||
return fn(noopSpan)
|
||||
}
|
||||
|
||||
/**
|
||||
* Start a hook execution span.
|
||||
* Only creates a span when beta tracing is enabled.
|
||||
* @param hookEvent The hook event type (e.g., 'PreToolUse', 'PostToolUse')
|
||||
* @param hookName The full hook name (e.g., 'PreToolUse:Write')
|
||||
* @param numHooks The number of hooks being executed
|
||||
* @param hookDefinitions JSON string of hook definitions for tracing
|
||||
* @returns The span (or a dummy span if tracing is disabled)
|
||||
*/
|
||||
export function startHookSpan(
|
||||
hookEvent: string,
|
||||
hookName: string,
|
||||
numHooks: number,
|
||||
hookDefinitions: string,
|
||||
): Span {
|
||||
if (!isBetaTracingEnabled()) {
|
||||
return trace.getActiveSpan() || getTracer().startSpan('dummy')
|
||||
}
|
||||
|
||||
const tracer = getTracer()
|
||||
const parentSpanCtx = toolContext.getStore() ?? interactionContext.getStore()
|
||||
|
||||
const attributes = createSpanAttributes('hook', {
|
||||
hook_event: hookEvent,
|
||||
hook_name: hookName,
|
||||
num_hooks: numHooks,
|
||||
hook_definitions: hookDefinitions,
|
||||
})
|
||||
|
||||
const ctx = parentSpanCtx
|
||||
? trace.setSpan(otelContext.active(), parentSpanCtx.span)
|
||||
: otelContext.active()
|
||||
const span = tracer.startSpan('claude_code.hook', { attributes }, ctx)
|
||||
|
||||
const spanId = getSpanId(span)
|
||||
const spanContextObj: SpanContext = {
|
||||
span,
|
||||
startTime: Date.now(),
|
||||
attributes,
|
||||
}
|
||||
activeSpans.set(spanId, new WeakRef(spanContextObj))
|
||||
strongSpans.set(spanId, spanContextObj)
|
||||
|
||||
return span
|
||||
export function startHookSpan(): Span {
|
||||
return noopSpan
|
||||
}
|
||||
|
||||
/**
|
||||
* End a hook execution span with outcome metadata.
|
||||
* Only does work when beta tracing is enabled.
|
||||
* @param span The span to end (returned from startHookSpan)
|
||||
* @param metadata The outcome metadata for the hook execution
|
||||
*/
|
||||
export function endHookSpan(
|
||||
span: Span,
|
||||
metadata?: {
|
||||
numSuccess?: number
|
||||
numBlocking?: number
|
||||
numNonBlockingError?: number
|
||||
numCancelled?: number
|
||||
},
|
||||
): void {
|
||||
if (!isBetaTracingEnabled()) {
|
||||
return
|
||||
}
|
||||
|
||||
const spanId = getSpanId(span)
|
||||
const spanContext = activeSpans.get(spanId)?.deref()
|
||||
|
||||
if (!spanContext) {
|
||||
return
|
||||
}
|
||||
|
||||
const duration = Date.now() - spanContext.startTime
|
||||
const endAttributes: Record<string, string | number | boolean> = {
|
||||
duration_ms: duration,
|
||||
}
|
||||
|
||||
if (metadata) {
|
||||
if (metadata.numSuccess !== undefined)
|
||||
endAttributes['num_success'] = metadata.numSuccess
|
||||
if (metadata.numBlocking !== undefined)
|
||||
endAttributes['num_blocking'] = metadata.numBlocking
|
||||
if (metadata.numNonBlockingError !== undefined)
|
||||
endAttributes['num_non_blocking_error'] = metadata.numNonBlockingError
|
||||
if (metadata.numCancelled !== undefined)
|
||||
endAttributes['num_cancelled'] = metadata.numCancelled
|
||||
}
|
||||
|
||||
spanContext.span.setAttributes(endAttributes)
|
||||
spanContext.span.end()
|
||||
activeSpans.delete(spanId)
|
||||
strongSpans.delete(spanId)
|
||||
}
|
||||
export function endHookSpan() {}
|
||||
@@ -1,71 +1,9 @@
|
||||
import type { Attributes } from '@opentelemetry/api'
|
||||
import { getSessionId } from 'src/bootstrap/state.js'
|
||||
import { getOauthAccountInfo } from './auth.js'
|
||||
import { getOrCreateUserID } from './config.js'
|
||||
import { envDynamic } from './envDynamic.js'
|
||||
import { isEnvTruthy } from './envUtils.js'
|
||||
import { toTaggedId } from './taggedId.js'
|
||||
/**
|
||||
* No-op stub for telemetry attributes.
|
||||
* getTelemetryAttributes returns an empty object; no ATTR_ constants.
|
||||
* No @opentelemetry imports.
|
||||
*/
|
||||
|
||||
// Default configuration for metrics cardinality
|
||||
const METRICS_CARDINALITY_DEFAULTS = {
|
||||
OTEL_METRICS_INCLUDE_SESSION_ID: true,
|
||||
OTEL_METRICS_INCLUDE_VERSION: false,
|
||||
OTEL_METRICS_INCLUDE_ACCOUNT_UUID: true,
|
||||
}
|
||||
|
||||
function shouldIncludeAttribute(
|
||||
envVar: keyof typeof METRICS_CARDINALITY_DEFAULTS,
|
||||
): boolean {
|
||||
const defaultValue = METRICS_CARDINALITY_DEFAULTS[envVar]
|
||||
const envValue = process.env[envVar]
|
||||
|
||||
if (envValue === undefined) {
|
||||
return defaultValue
|
||||
}
|
||||
|
||||
return isEnvTruthy(envValue)
|
||||
}
|
||||
|
||||
export function getTelemetryAttributes(): Attributes {
|
||||
const userId = getOrCreateUserID()
|
||||
const sessionId = getSessionId()
|
||||
|
||||
const attributes: Attributes = {
|
||||
'user.id': userId,
|
||||
}
|
||||
|
||||
if (shouldIncludeAttribute('OTEL_METRICS_INCLUDE_SESSION_ID')) {
|
||||
attributes['session.id'] = sessionId
|
||||
}
|
||||
if (shouldIncludeAttribute('OTEL_METRICS_INCLUDE_VERSION')) {
|
||||
attributes['app.version'] = MACRO.VERSION
|
||||
}
|
||||
|
||||
// Only include OAuth account data when actively using OAuth authentication
|
||||
const oauthAccount = getOauthAccountInfo()
|
||||
if (oauthAccount) {
|
||||
const orgId = oauthAccount.organizationUuid
|
||||
const email = oauthAccount.emailAddress
|
||||
const accountUuid = oauthAccount.accountUuid
|
||||
|
||||
if (orgId) attributes['organization.id'] = orgId
|
||||
if (email) attributes['user.email'] = email
|
||||
|
||||
if (
|
||||
accountUuid &&
|
||||
shouldIncludeAttribute('OTEL_METRICS_INCLUDE_ACCOUNT_UUID')
|
||||
) {
|
||||
attributes['user.account_uuid'] = accountUuid
|
||||
attributes['user.account_id'] =
|
||||
process.env.CLAUDE_CODE_ACCOUNT_TAGGED_ID ||
|
||||
toTaggedId('user', accountUuid)
|
||||
}
|
||||
}
|
||||
|
||||
// Add terminal type if available
|
||||
if (envDynamic.terminal) {
|
||||
attributes['terminal.type'] = envDynamic.terminal
|
||||
}
|
||||
|
||||
return attributes
|
||||
}
|
||||
export function getTelemetryAttributes(): Record<string, unknown> {
|
||||
return {}
|
||||
}
|
||||
Reference in New Issue
Block a user