Explorar o código

revert(session-telemetry-otel): leave telemetry on its own transport

Telemetry was the only call site this PR could not cover without changing
the SDK transport underneath it, and both ways of doing that cost more
than the channel is worth.

Routing an `http.Agent` needs Node's `proxyEnv`, added in 22.21 and 24.5
— inside the engines range, so three supported runtimes stayed direct
anyway, and the proxy package had to keep a `createNodeHttpAgent` export
for a path that only sometimes worked. Replacing the transport with the
SDK's `fetch` delegate covered every runtime but has no compression,
while the shipped `base` bundle enables gzip and a realistic OTLP batch
is 6.4x smaller with it; keeping both meant gzipping at the serializer,
which put transport code inside a telemetry plugin.

Telemetry is the one outbound channel whose loss costs the user nothing:
no tool, model request, or session depends on it, and an export that
cannot connect is already dropped silently. A user behind a mandatory
proxy is left where they were rather than regressed.

`src/index.ts`, `otel.spec.ts`, and `tsconfig.json` return to their state
on master; the package keeps only a dev dependency on the proxy library.
`egress.spec.ts` inverts: it installs a policy and asserts the fake proxy
saw nothing, so an SDK upgrade that moved the exporter onto `fetch` would
surface as a failing test rather than silently routing telemetry.
Yichen Jiang hai 1 semana
pai
achega
13daefe073

+ 2 - 2
.agents/notes/implemented/architecture/2026-08-27-outbound-proxy-policy.i18n.yaml

@@ -2,5 +2,5 @@
 # side as of the last confirmed-consistent state. Both languages carry equal authority;
 # after editing either side, bring the other along and re-record with:
 #   pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-08-27-outbound-proxy-policy.md
-2026-08-27-outbound-proxy-policy.md: 9c58dfd00d9b6c0b4438ad3dd4da58d8706f718f
-2026-08-27-outbound-proxy-policy.zh.md: 1aabc716c1bdf348229b260c4d8580fb4edc8637
+2026-08-27-outbound-proxy-policy.md: a35b8a909eaa1526d3268e475de4d3f7e093b6eb
+2026-08-27-outbound-proxy-policy.zh.md: 278d86faa186c36a289eb477f8b741d46a1dfb0c

+ 6 - 8
.agents/notes/implemented/architecture/2026-08-27-outbound-proxy-policy.md

@@ -24,7 +24,7 @@ An earlier revision put it in a new `net/` package group, reasoning that dependi
 
 The plugin that revision shipped is gone with it. It let a composition declare the policy in `cordis.yml`, but no shipped bundle mounted it, so the launcher's path was the only reachable one — and its `Config` was the sole supplier of a configuration branch nothing else could reach.
 
-**Four functions, because the call sites converged rather than the package growing an export each.** An earlier revision exported six: a dispatcher factory, a `node:http` agent factory, a proxy-URL lookup, a policy accessor, an installer, and a child-environment builder. Each existed for one SDK's transport, which is how a transport-policy package turns into a catalogue of other packages' constraints. Review asked whether the call sites could converge instead; they could, and each removal took a whole shape with it. The exporter moved to the SDK's `fetch` delegate, retiring the `node:http` factory. `web-fetch-http` builds its own pinning agent under an annotated exemption, retiring the dispatcher factory. E2B reads `route.proxy`, retiring the proxy-URL lookup.
+**Four functions, because the call sites converged rather than the package growing an export each.** An earlier revision exported six: a dispatcher factory, a `node:http` agent factory, a proxy-URL lookup, a policy accessor, an installer, and a child-environment builder. Each existed for one SDK's transport, which is how a transport-policy package turns into a catalogue of other packages' constraints. Review asked whether the call sites could converge instead; they could, and each removal took a whole shape with it. Telemetry stopped being routed at all, retiring the `node:http` factory. `web-fetch-http` builds its own pinning agent under an annotated exemption, retiring the dispatcher factory. E2B reads `route.proxy`, retiring the proxy-URL lookup.
 
 What remains is `installProxyFromEnvironment`, `proxyRouteFor`, `proxyEnvironmentForChild`, and `clearedProxyEnv` — one per way a caller can need the policy, none per SDK. Installation absorbed resolution and diagnostic reporting, which no caller needed apart: a resolved policy that is not installed routes nothing.
 
@@ -46,15 +46,13 @@ The URL-level policy is untouched: `http(s)` only, no embedded credentials, the
 
 This accepts a documented seam. Such a context matches bypass entries by Node's rules, which differ from this package's in separators and IPv4-range support, and the flag exists only on Node 22.21+ and 24+.
 
-**Two SDKs do not reach `globalThis.fetch`, and reading their code said otherwise.** The audit first classified the OTLP exporter and the E2B SDK as covered, on a grep that found `globalThis.fetch` in `@opentelemetry/otlp-exporter-base`. That match is the *browser* transport; on Node the delegate selects `http-exporter-transport`, which posts through `node:http` — where a global dispatcher does not reach. E2B is a second shape again: it builds its own undici `Agent`/`ProxyAgent` and takes a `proxy` URL that it never reads from the environment. Both were measured direct, and both were fixed by moving the call site onto a transport the dispatcher already covers rather than by giving this package a second export for each.
+**Two SDKs do not reach `globalThis.fetch`, and reading their code said otherwise.** The audit first classified the OTLP exporter and the E2B SDK as covered, on a grep that found `globalThis.fetch` in `@opentelemetry/otlp-exporter-base`. That match is the *browser* transport; on Node the delegate selects `http-exporter-transport`, which posts through `node:http` — where a global dispatcher does not reach. E2B is a second shape again: it builds its own undici `Agent`/`ProxyAgent` and takes a `proxy` URL that it never reads from the environment. Both were measured direct. E2B is handed `route.proxy` from `proxyRouteFor`, the same call `web-fetch-http` makes. Telemetry is deliberately left direct, and that exclusion is the more interesting half.
 
-The exporter now composes `OTLPExporterBase` with `createLegacyOtlpBrowserExportDelegate` — a published entry point of the same SDK package, and the one that posts through `fetch`. E2B is handed `route.proxy` from `proxyRouteFor`, the same call `web-fetch-http` makes.
+**Telemetry stays direct on purpose.** Routing it needs one of two things, and both cost more than the channel is worth. An `http.Agent` reads the environment through `proxyEnv`, which arrived in Node 22.21 and 24.5 — inside the engines range, so 22.19, 22.20, and 24.0–24.4 would stay direct regardless, and the proxy package would have to keep a `createNodeHttpAgent` export for a path that works on some runtimes. Replacing the transport with the SDK's `fetch` delegate covers every runtime, but that delegate has no compression, and the shipped `base` bundle enables gzip: a realistic OTLP batch measures 6.4x smaller with it. An attempt that refused `exporter.compression` instead broke every test that boots the shipped bundle, and one that gzipped at the serializer worked but put transport code in a telemetry plugin to keep it working.
 
-The `fetch` transport has no compression, and the shipped `base` bundle enables gzip — a realistic OTLP batch measures 6.4x smaller with it. Dropping it to gain proxy support would have traded one deployment's problem for every deployment's, and the first attempt did exactly that: it refused `exporter.compression` at load, which broke every test that boots the shipped bundle. This package gzips at the serializer instead, the one seam before the body reaches the transport, and declares `Content-Encoding` itself. `keepAlive` and `httpAgentOptions` have no such seam — they configure a connection pool `fetch` does not expose — so those two are refused at load rather than accepted and ignored.
+Weighed against that, telemetry is the one outbound channel whose loss costs the user nothing: no tool, no model request, and no session depends on it, and an export that cannot connect is already dropped silently. A user behind a mandatory proxy is left exactly where they were before this change rather than regressed. `egress.spec.ts` now asserts the exclusion — an SDK upgrade that moved the exporter onto `fetch` would start routing telemetry through a proxy silently, and that case is what makes it visible.
 
-In exchange the Node-version floor disappears: `proxyEnv` on an `http.Agent` needs 22.21 or 24.5, inside the engines range, so telemetry used to stay direct on 22.19, 22.20, and 24.0–24.4.
-
-**Every call site carries an egress test, because reading the code was not enough.** `egress.spec.ts` in each owning package drives that site's real code path at an unresolvable `.invalid` host through a fake proxy and asserts the proxy saw the request. Nine of them cover the search backends, pi-ai discovery, MCP over HTTP, telemetry, E2B, a spawned child Node, and a worker thread. The gate below cannot see inside a dependency; these can, and they are what turns "an SDK changed its transport" from a silent regression into a failing test.
+**Every call site carries an egress test, because reading the code was not enough.** `egress.spec.ts` in each owning package drives that site's real code path at an unresolvable `.invalid` host through a fake proxy and asserts the proxy saw the request. Nine of them cover the search backends, pi-ai discovery, MCP over HTTP, E2B, a spawned child Node, a worker thread, and telemetry's exclusion. The gate below cannot see inside a dependency; these can, and they are what turns "an SDK changed its transport" from a silent regression into a failing test.
 
 **A gate keeps the defect from returning.** `verify-no-bare-dispatcher` parses the TypeScript AST — `scripts/AGENTS.md` requires syntax-aware discovery, and a line-wise regex missed both the `{ dispatcher }` shorthand this repository already uses and a `new Alias(...)` behind a renamed import. It rejects an undici agent construction and an explicit `dispatcher` option outside the owning package. `proxyRouteFor(url)` is the sanctioned replacement, and the one call site that genuinely owns its transport — `web-fetch-http`, pinning a request to addresses it validated — says so with a `proxy-exempt:` comment. The rule exists because `web-fetch-http`'s original `new Agent` was entirely reasonable when it was written — proxying simply did not exist yet, and nothing would have caught it.
 
@@ -94,6 +92,6 @@ The suite is hermetic against the developer's own environment: `plugin.spec.ts`
 
 `verify-no-bare-dispatcher.spec.ts` proves the gate rejects the exact shape this package was introduced to fix, accepts `proxyRouteFor`, accepts an annotated exemption, and passes on the current tree.
 
-The egress suite carries the negative case for telemetry — a `node:http` request under the same installed policy reaches no proxy — so a return to the SDK's Node transport cannot quietly un-proxy it. Its positive case no longer branches on the runtime, because `fetch` reaches the dispatcher on every supported Node. A parity suite checks `proxyForUrl` against where a real `fetch` actually went for every form in the documented `NO_PROXY` vocabulary; since the dispatcher routes by that same predicate, what it now catches is a form `bypassesProxy` reads differently from how the vocabulary documents it, and any future dispatcher that reintroduces a second matcher.
+The egress suite carries telemetry's case in the negative: the shipped backend exports under an installed policy and the fake proxy sees nothing, so the deliberate exclusion is asserted rather than merely documented. A parity suite checks `proxyForUrl` against where a real `fetch` actually went for every form in the documented `NO_PROXY` vocabulary; since the dispatcher routes by that same predicate, what it now catches is a form `bypassesProxy` reads differently from how the vocabulary documents it, and any future dispatcher that reintroduces a second matcher.
 
 No recorded-session snapshot changes: nothing here alters a model-visible input or product-user-visible transcript output.

+ 6 - 8
.agents/notes/implemented/architecture/2026-08-27-outbound-proxy-policy.zh.md

@@ -24,7 +24,7 @@ Node 内置的 `fetch` 会忽略 `HTTP_PROXY` 与 `HTTPS_PROXY`。开发者运
 
 那次修订一并引入的插件也随之删除。它让某个组合可以把策略写进 `cordis.yml`,但没有任何随附 bundle 挂载它,因此启动器那条路径是唯一可达的——而它的 `Config` 是那条配置分支唯一的供给方,别处无从到达。
 
-**四个函数——收敛的是调用方,而不是让本包为每个 SDK 各加一个导出。** 早先一版导出六个:dispatcher 工厂、`node:http` agent 工厂、代理 URL 查询、策略访问器、安装器与子进程环境构造器。每一个都为某个 SDK 的传输而存在,而这正是一个传输策略包退化成「别的包的约束目录」的过程。Review 问能不能反过来让调用方收敛;能,而且每删掉一个导出都带走了一整种写法。导出器改用 SDK 的 `fetch` delegate,`node:http` agent 工厂随之退场。`web-fetch-http` 在带注释的豁免下自建 pin agent,dispatcher 工厂随之退场。E2B 读 `route.proxy`,代理 URL 查询随之退场。
+**四个函数——收敛的是调用方,而不是让本包为每个 SDK 各加一个导出。** 早先一版导出六个:dispatcher 工厂、`node:http` agent 工厂、代理 URL 查询、策略访问器、安装器与子进程环境构造器。每一个都为某个 SDK 的传输而存在,而这正是一个传输策略包退化成「别的包的约束目录」的过程。Review 问能不能反过来让调用方收敛;能,而且每删掉一个导出都带走了一整种写法。遥测不再被路由,`node:http` agent 工厂随之退场。`web-fetch-http` 在带注释的豁免下自建 pin agent,dispatcher 工厂随之退场。E2B 读 `route.proxy`,代理 URL 查询随之退场。
 
 剩下的是 `installProxyFromEnvironment`、`proxyRouteFor`、`proxyEnvironmentForChild` 与 `clearedProxyEnv`——按「调用方需要策略的方式」各一个,而不是按 SDK 各一个。安装吸收了解析与诊断上报,因为没有调用方需要把它们分开:解析出来却不安装的策略什么也路由不了。
 
@@ -46,15 +46,13 @@ URL 层策略未受影响:仅 `http(s)`、禁止内嵌凭据、长度上限与
 
 这接受了一处已记录的接缝。此类上下文按 Node 自己的规则匹配绕过条目,其分隔符与 IPv4 区间支持与本包不同,且该标志仅存在于 Node 22.21+ 与 24+。
 
-**有两个 SDK 并不落到 `globalThis.fetch`,而读代码给出的答案是相反的。** 审计最初把 OTLP 导出器与 E2B SDK 判为已覆盖,依据是在 `@opentelemetry/otlp-exporter-base` 里 grep 到了 `globalThis.fetch`。那处命中属于**浏览器**传输;在 Node 上 delegate 选择的是 `http-exporter-transport`,它通过 `node:http` 投递——那里全局 dispatcher 触及不到。E2B 又是另一种形态:它自建 undici `Agent`/`ProxyAgent`,并接受一个自己从不从环境读取的 `proxy` URL。两者都实测为直连,而修复方式不是给本包各加一个导出,而是把调用点搬到 dispatcher 本就覆盖的传输上
+**有两个 SDK 并不落到 `globalThis.fetch`,而读代码给出的答案是相反的。** 审计最初把 OTLP 导出器与 E2B SDK 判为已覆盖,依据是在 `@opentelemetry/otlp-exporter-base` 里 grep 到了 `globalThis.fetch`。那处命中属于**浏览器**传输;在 Node 上 delegate 选择的是 `http-exporter-transport`,它通过 `node:http` 投递——那里全局 dispatcher 触及不到。E2B 又是另一种形态:它自建 undici `Agent`/`ProxyAgent`,并接受一个自己从不从环境读取的 `proxy` URL。两者都实测为直连。E2B 接收 `proxyRouteFor` 给出的 `route.proxy`,与 `web-fetch-http` 调的是同一个函数。遥测则被有意保留为直连,而这个排除项才是更值得说的一半
 
-导出器改为用 `OTLPExporterBase` 组合 `createLegacyOtlpBrowserExportDelegate`——同一个 SDK 包的公开入口,也是通过 `fetch` 投递的那一个。E2B 则接收 `proxyRouteFor` 给出的 `route.proxy`,与 `web-fetch-http` 调的是同一个函数
+**遥测的直连是有意为之。** 要让它走代理只有两条路,代价都超过这条通道本身的价值。`http.Agent` 通过 `proxyEnv` 读取环境,而该选项自 Node 22.21 与 24.5 才有——落在 engines 范围之内,因此 22.19、22.20 与 24.0–24.4 无论如何仍是直连,而代理包还得为一条只在部分运行时生效的路径保留 `createNodeHttpAgent` 导出。改用 SDK 的 `fetch` delegate 替换传输可以覆盖所有运行时,但该 delegate 没有压缩能力,而随附的 `base` bundle 启用了 gzip:实测一批真实规模的 OTLP 数据启用后体积只有 1/6.4。曾有一版转而在加载期拒绝 `exporter.compression`,结果凡是启动随附 bundle 的测试全部失败;另一版在 serializer 处 gzip 确实能跑通,但代价是把传输层代码塞进了遥测插件
 
-`fetch` 传输没有压缩能力,而随附的 `base` bundle 启用了 gzip——实测一批真实规模的 OTLP 数据启用后体积只有 1/6.4。为了拿到代理支持而丢掉它,等于用每个部署的代价去换一个部署的问题;第一版正是这么做的:它在加载期拒绝 `exporter.compression`,结果凡是启动随附 bundle 的测试全部失败。改为由本包在 serializer 处 gzip——那是请求体抵达传输前的唯一接缝——并自行声明 `Content-Encoding`。`keepAlive` 与 `httpAgentOptions` 没有这样的接缝,它们配置的是 `fetch` 不暴露的连接池,因此这两个仍在加载期拒绝,而不是被接受后忽略
+与之相比,遥测是唯一一条丢失了对用户毫无代价的出网通道:没有任何工具、模型请求或会话依赖它,而连不上的导出本就被静默丢弃。处在强制代理后的用户,只是停留在本次改动之前的状态,而不是被弄坏。`egress.spec.ts` 现在断言这一排除——若某次 SDK 升级把导出器挪到 `fetch` 上,遥测就会开始静默走代理,而该用例正是让这件事暴露出来的东西
 
-换来的是 Node 版本下限消失:`http.Agent` 的 `proxyEnv` 需要 22.21 或 24.5,而这落在 engines 范围之内,因此遥测过去在 22.19、22.20 与 24.0–24.4 上一直是直连。
-
-**每个出网点都配一份出网测试,因为读代码不够。** 各所属包中的 `egress.spec.ts` 驱动该点的真实代码路径,目标是无法解析的 `.invalid` 主机,穿过一个假代理,并断言代理确实收到了请求。九份测试覆盖搜索后端、pi-ai 发现、走 HTTP 的 MCP、遥测、E2B、派生的子 Node 与 worker 线程。下面那条门禁看不进依赖内部;这些能,它们把「某个 SDK 换了传输」从静默回归变成失败的测试。
+**每个出网点都配一份出网测试,因为读代码不够。** 各所属包中的 `egress.spec.ts` 驱动该点的真实代码路径,目标是无法解析的 `.invalid` 主机,穿过一个假代理,并断言代理确实收到了请求。九份测试覆盖搜索后端、pi-ai 发现、走 HTTP 的 MCP、E2B、派生的子 Node、worker 线程,以及遥测的排除。下面那条门禁看不进依赖内部;这些能,它们把「某个 SDK 换了传输」从静默回归变成失败的测试。
 
 **用门禁防止该缺陷复现。** `verify-no-bare-dispatcher` 解析 TypeScript AST——`scripts/AGENTS.md` 要求 source-ownership 门禁使用语法感知发现,而逐行正则漏掉了本仓库已在使用的 `{ dispatcher }` 简写,以及重命名导入后的 `new Alias(...)`。它在所属包之外拒绝 undici agent 构造与显式 `dispatcher` 选项。`proxyRouteFor(url)` 是受支持的替代;唯一一处确实自有传输的调用点——`web-fetch-http`,它把请求钉在已校验的地址上——用 `proxy-exempt:` 注释说明。这条规则之所以存在,是因为 `web-fetch-http` 里原本那行 `new Agent` 在写下时完全合理——那时根本还没有代理这回事,也没有任何机制会拦下它。
 
@@ -94,6 +92,6 @@ userland undici 能触及 Node 内置的 `fetch`,依赖于两者都会写入 l
 
 `verify-no-bare-dispatcher.spec.ts` 证明该门禁能拒掉本包所要修复的那种写法、接受 `proxyRouteFor`、接受带注释的豁免,并在当前代码树上通过。
 
-出网测试为遥测保留了负向用例——在同一份已安装策略下发一个 `node:http` 请求,触及不到代理——因此改回 SDK 的 Node 传输无法悄悄把遥测变回直连。其正向用例不再按运行时分支,因为在所有受支持的 Node 上 `fetch` 都会落到 dispatcher。另有一组一致性测试,对文档所述 `NO_PROXY` 词汇中的每种形态,把 `proxyForUrl` 的判断与真实 `fetch` 的实际去向相互核对;由于 dispatcher 正是按同一谓词路由,它现在能抓住的是 `bypassesProxy` 对某种形态的读法与词汇文档不一致,以及未来任何重新引入第二个匹配器的 dispatcher。
+出网测试以负向形式承载遥测这一项:随附后端在已安装策略下执行导出,而假代理什么也没收到——这个有意的排除因此是被断言的,而不只是被记录的。另有一组一致性测试,对文档所述 `NO_PROXY` 词汇中的每种形态,把 `proxyForUrl` 的判断与真实 `fetch` 的实际去向相互核对;由于 dispatcher 正是按同一谓词路由,它现在能抓住的是 `bypassesProxy` 对某种形态的读法与词汇文档不一致,以及未来任何重新引入第二个匹配器的 dispatcher。
 
 无录制会话快照变更:本次改动不影响任何模型可见输入或产品用户可见的 transcript 输出。

+ 1 - 1
THIRD_PARTY_NOTICES.md

@@ -48,8 +48,8 @@ External packages that a workspace package resolves at runtime. The tier covers
 | [`@openai/codex`](https://github.com/openai/codex) | Apache-2.0 |
 | [`@opentelemetry/api`](https://github.com/open-telemetry/opentelemetry-js) | Apache-2.0 |
 | [`@opentelemetry/api-logs`](https://github.com/open-telemetry/opentelemetry-js) | Apache-2.0 |
+| [`@opentelemetry/exporter-logs-otlp-http`](https://github.com/open-telemetry/opentelemetry-js) | Apache-2.0 |
 | [`@opentelemetry/otlp-exporter-base`](https://github.com/open-telemetry/opentelemetry-js) | Apache-2.0 |
-| [`@opentelemetry/otlp-transformer`](https://github.com/open-telemetry/opentelemetry-js) | Apache-2.0 |
 | [`@opentelemetry/resources`](https://github.com/open-telemetry/opentelemetry-js) | Apache-2.0 |
 | [`@opentelemetry/sdk-logs`](https://github.com/open-telemetry/opentelemetry-js) | Apache-2.0 |
 | [`@shikijs/langs`](https://github.com/shikijs/shiki) | MIT |

+ 2 - 2
docs/config-catalog.i18n.yaml

@@ -2,5 +2,5 @@
 # side as of the last confirmed-consistent state. Both languages carry equal authority;
 # after editing either side, bring the other along and re-record with:
 #   pnpm run verify-translation-pairing --write docs/config-catalog.md
-config-catalog.md: e451d76631252a1457bfc225784adc096c9ad548
-config-catalog.zh.md: b54d57d3a63693d8f6e2e83081c7116b6c5ced5c
+config-catalog.md: 15a0a279021c76232c4720991e1ed7f105696cbe
+config-catalog.zh.md: 5145f7e91db48f8279d5ca6cf02d4b4a8f505f69

+ 7 - 20
docs/config-catalog.md

@@ -1967,20 +1967,13 @@ export interface Config {
   mode?: SessionTelemetryMode
   /**
    * Passed verbatim to the SDK's OTLP/HTTP log exporter — the complete
-   * `OTLPExporterConfigBase` shape (`headers`, `timeoutMillis`,
-   * `concurrencyLimit`, …), owned and documented by the SDK. `url` is the
-   * one field this package requires and validates itself.
-   *
-   * The transport is the SDK's `fetch` one, so `keepAlive` and
-   * `httpAgentOptions` — which configure its `node:http` transport — are
-   * refused at load rather than ignored. `compression` is honored by this
-   * package instead of by that transport.
-   */
-  exporter?: OTLPExporterConfigBase & {
+   * `OTLPExporterNodeConfigBase` shape (`headers`, `timeoutMillis`,
+   * `compression`, `keepAlive`, …), owned and documented by the SDK. `url`
+   * is the one field this package requires and validates itself.
+   */
+  exporter?: OTLPExporterNodeConfigBase & {
     /** Full logs endpoint (e.g. `https://collector.example.com/v1/logs`). Required outside `DISABLED`; validated at load. */
     url?: string
-    /** Request body compression, applied by this package rather than by the SDK transport. @default 'none' */
-    compression?: SupportedCompression
   }
   /**
    * Passed verbatim to `BatchLogRecordProcessor` (minus the exporter slot,
@@ -1997,17 +1990,11 @@ export enum SessionTelemetryMode {
   FEEDBACK_ONLY = 'FEEDBACK_ONLY',
   DISABLED = 'DISABLED',
 }
-
-/**
- * Request body encodings this package applies. Narrower than the SDK's `CompressionAlgorithm`,
- * which also spells `deflate`: the `fetch` transport offers no seam to apply that one.
- */
-export type SupportedCompression = 'gzip' | 'none'
 ```
 
-Depends on: `BatchLogRecordProcessorOptions` (`@opentelemetry/sdk-logs`) · `OTLPExporterConfigBase` (`@opentelemetry/otlp-exporter-base`)
+Depends on: `BatchLogRecordProcessorOptions` (`@opentelemetry/sdk-logs`) · `OTLPExporterNodeConfigBase` (`@opentelemetry/otlp-exporter-base`)
 
-Source: [`packages/session/session-telemetry-otel/src/index.ts:96`](../packages/session/session-telemetry-otel/src/index.ts)
+Source: [`packages/session/session-telemetry-otel/src/index.ts:91`](../packages/session/session-telemetry-otel/src/index.ts)
 
 <a id="deepseek-aidsh-session-title"></a>
 

+ 7 - 20
docs/config-catalog.zh.md

@@ -1969,20 +1969,13 @@ export interface Config {
   mode?: SessionTelemetryMode
   /**
    * Passed verbatim to the SDK's OTLP/HTTP log exporter — the complete
-   * `OTLPExporterConfigBase` shape (`headers`, `timeoutMillis`,
-   * `concurrencyLimit`, …), owned and documented by the SDK. `url` is the
-   * one field this package requires and validates itself.
-   *
-   * The transport is the SDK's `fetch` one, so `keepAlive` and
-   * `httpAgentOptions` — which configure its `node:http` transport — are
-   * refused at load rather than ignored. `compression` is honored by this
-   * package instead of by that transport.
-   */
-  exporter?: OTLPExporterConfigBase & {
+   * `OTLPExporterNodeConfigBase` shape (`headers`, `timeoutMillis`,
+   * `compression`, `keepAlive`, …), owned and documented by the SDK. `url`
+   * is the one field this package requires and validates itself.
+   */
+  exporter?: OTLPExporterNodeConfigBase & {
     /** Full logs endpoint (e.g. `https://collector.example.com/v1/logs`). Required outside `DISABLED`; validated at load. */
     url?: string
-    /** Request body compression, applied by this package rather than by the SDK transport. @default 'none' */
-    compression?: SupportedCompression
   }
   /**
    * Passed verbatim to `BatchLogRecordProcessor` (minus the exporter slot,
@@ -1999,17 +1992,11 @@ export enum SessionTelemetryMode {
   FEEDBACK_ONLY = 'FEEDBACK_ONLY',
   DISABLED = 'DISABLED',
 }
-
-/**
- * Request body encodings this package applies. Narrower than the SDK's `CompressionAlgorithm`,
- * which also spells `deflate`: the `fetch` transport offers no seam to apply that one.
- */
-export type SupportedCompression = 'gzip' | 'none'
 ```
 
-依赖:`BatchLogRecordProcessorOptions`(`@opentelemetry/sdk-logs`)· `OTLPExporterConfigBase`(`@opentelemetry/otlp-exporter-base`)
+依赖:`BatchLogRecordProcessorOptions`(`@opentelemetry/sdk-logs`)· `OTLPExporterNodeConfigBase`(`@opentelemetry/otlp-exporter-base`)
 
-来源:[`packages/session/session-telemetry-otel/src/index.ts:96`](../packages/session/session-telemetry-otel/src/index.ts)
+来源:[`packages/session/session-telemetry-otel/src/index.ts:91`](../packages/session/session-telemetry-otel/src/index.ts)
 
 <a id="deepseek-aidsh-session-title"></a>
 

+ 2 - 2
docs/user/guide/network-proxy.i18n.yaml

@@ -2,5 +2,5 @@
 # side as of the last confirmed-consistent state. Both languages carry equal authority;
 # after editing either side, bring the other along and re-record with:
 #   pnpm run verify-translation-pairing --write docs/user/guide/network-proxy.md
-network-proxy.md: 3561ec5b0dfc4290ab29dfe66fc91b19031fa31d
-network-proxy.zh.md: 928f67db215650f2761ae5c929de3605b76b2520
+network-proxy.md: 127ee0f2c296d29a9ddd6e8b0f041fca4de4b394
+network-proxy.zh.md: a6efbd32bed7cb07b9e75e71d03b4ca876fc384d

+ 1 - 0
docs/user/guide/network-proxy.md

@@ -67,6 +67,7 @@ Not every request DSH makes goes through the proxy:
 
 - **Anything on this machine.** Loopback is always direct: `localhost`, the whole `127.0.0.0/8` range, `::1`, and `0.0.0.0`. A proxy cannot usefully reach a service that only listens locally.
 - **Code the model writes.** The workflow and code-runtime workers never receive the proxy settings, so a script the model authors cannot read a proxy URL that may carry a password. Such a script reaches the network only if it configures that itself.
+- **Usage telemetry.** The OTLP exporter uses Node's own HTTP client rather than the one a proxy configures, so telemetry connects directly and simply fails where direct egress is blocked. Nothing you do in DSH depends on it. Set `DSH_TELEMETRY_MODE=DISABLED` to turn it off entirely.
 - **`web_fetch` to a literal private address.** A URL naming an address like `http://10.0.0.5/` is refused rather than handed to the proxy, the same refusal it gets with no proxy configured.
 
 ## Check that it worked

+ 1 - 0
docs/user/guide/network-proxy.zh.md

@@ -67,6 +67,7 @@ Node 只在进程启动时读取该变量,所以要在运行 `dsh` 之前导
 
 - **本机上的一切。** loopback 始终直连:`localhost`、整个 `127.0.0.0/8` 段、`::1` 与 `0.0.0.0`。代理无法有意义地访问一个只在本地监听的服务。
 - **模型编写的代码。** workflow 与 code-runtime worker 从不接收代理配置,因此模型编写的脚本读不到可能携带密码的代理 URL。这类脚本只有自行配置才能联网。
+- **使用情况遥测。** OTLP 导出器用的是 Node 自带的 HTTP 客户端,而不是代理所配置的那个,因此遥测直连;在禁止直连出网的环境里它只会失败。DSH 的任何功能都不依赖它。设 `DSH_TELEMETRY_MODE=DISABLED` 可完全关闭。
 - **`web_fetch` 访问字面量私网地址。** 形如 `http://10.0.0.5/` 的 URL 会被拒绝而非交给代理,与未配置代理时得到的拒绝相同。
 
 ## 验证是否生效

+ 3 - 4
packages/session/session-telemetry-otel/package.json

@@ -29,11 +29,11 @@
   "dependencies": {
     "@opentelemetry/api": "^1.9.1",
     "@opentelemetry/api-logs": "^0.220.0",
+    "@opentelemetry/exporter-logs-otlp-http": "^0.220.0",
     "@opentelemetry/otlp-exporter-base": "^0.220.0",
     "@opentelemetry/resources": "^2.9.0",
     "@opentelemetry/sdk-logs": "^0.220.0",
-    "@deepseek-ai/schemastery": "workspace:^",
-    "@opentelemetry/otlp-transformer": "^0.220.0"
+    "@deepseek-ai/schemastery": "workspace:^"
   },
   "peerDependencies": {
     "@deepseek-ai/dsh-command-feedback": "workspace:^",
@@ -41,8 +41,7 @@
     "@deepseek-ai/dsh-session": "workspace:^",
     "@deepseek-ai/dsh-session-telemetry": "workspace:^",
     "@deepseek-ai/dsh-anonymous-user-id": "workspace:^",
-    "@deepseek-ai/cordis": "workspace:^",
-    "@deepseek-ai/dsh-http-proxy": "workspace:^"
+    "@deepseek-ai/cordis": "workspace:^"
   },
   "devDependencies": {
     "@deepseek-ai/cordis-plugin-logger-console": "workspace:^",

+ 8 - 104
packages/session/session-telemetry-otel/src/index.ts

@@ -13,7 +13,6 @@
  */
 
 import { createRequire } from 'node:module'
-import { gzipSync } from 'node:zlib'
 import z from '@deepseek-ai/schemastery'
 import type { Context } from '@deepseek-ai/cordis'
 import type {} from '@deepseek-ai/dsh-command-feedback'
@@ -32,12 +31,8 @@ import {
   LoggerProvider,
   type BatchLogRecordProcessorOptions,
 } from '@opentelemetry/sdk-logs'
-import { OTLPExporterBase } from '@opentelemetry/otlp-exporter-base'
-import { createLegacyOtlpBrowserExportDelegate } from '@opentelemetry/otlp-exporter-base/browser-http'
-import { JsonLogsSerializer } from '@opentelemetry/otlp-transformer'
-import type { ISerializer } from '@opentelemetry/otlp-transformer'
-import type { OTLPExporterConfigBase } from '@opentelemetry/otlp-exporter-base'
-import type { ReadableLogRecord } from '@opentelemetry/sdk-logs'
+import { OTLPLogExporter } from '@opentelemetry/exporter-logs-otlp-http'
+import type { OTLPExporterNodeConfigBase } from '@opentelemetry/otlp-exporter-base'
 import { SeverityNumber, type AnyValue, type Logger } from '@opentelemetry/api-logs'
 import { resourceFromAttributes } from '@opentelemetry/resources'
 
@@ -98,20 +93,13 @@ export interface Config {
   mode?: SessionTelemetryMode
   /**
    * Passed verbatim to the SDK's OTLP/HTTP log exporter — the complete
-   * `OTLPExporterConfigBase` shape (`headers`, `timeoutMillis`,
-   * `concurrencyLimit`, …), owned and documented by the SDK. `url` is the
-   * one field this package requires and validates itself.
-   *
-   * The transport is the SDK's `fetch` one, so `keepAlive` and
-   * `httpAgentOptions` — which configure its `node:http` transport — are
-   * refused at load rather than ignored. `compression` is honored by this
-   * package instead of by that transport.
+   * `OTLPExporterNodeConfigBase` shape (`headers`, `timeoutMillis`,
+   * `compression`, `keepAlive`, …), owned and documented by the SDK. `url`
+   * is the one field this package requires and validates itself.
    */
-  exporter?: OTLPExporterConfigBase & {
+  exporter?: OTLPExporterNodeConfigBase & {
     /** Full logs endpoint (e.g. `https://collector.example.com/v1/logs`). Required outside `DISABLED`; validated at load. */
     url?: string
-    /** Request body compression, applied by this package rather than by the SDK transport. @default 'none' */
-    compression?: SupportedCompression
   }
   /**
    * Passed verbatim to `BatchLogRecordProcessor` (minus the exporter slot,
@@ -143,50 +131,6 @@ export const DEFAULT_SHUTDOWN_TIMEOUT_MILLIS = 3_000
 // protocol limit, not a deployment default.
 const MAX_TIMER_DELAY_MILLIS = 2_147_483_647
 
-/**
- * Exporter options the SDK defines only for its `node:http` transport. They reach the `fetch`
- * transport this package uses, which silently ignores every one of them.
- */
-const NODE_TRANSPORT_ONLY_EXPORTER_OPTIONS = ['keepAlive', 'httpAgentOptions'] as const
-
-/** The one encoding {@link gzipSerializer} applies, spelled as the OTLP `Content-Encoding` spells it. */
-const GZIP = 'gzip'
-
-/**
- * Request body encodings this package applies. Narrower than the SDK's `CompressionAlgorithm`,
- * which also spells `deflate`: the `fetch` transport offers no seam to apply that one.
- */
-export type SupportedCompression = 'gzip' | 'none'
-
-/** {@link SupportedCompression} as values, for the load-time check on a configuration typed `any`. */
-const SUPPORTED_COMPRESSION: readonly string[] = [GZIP, 'none'] satisfies SupportedCompression[]
-
-/**
- * Wrap a serializer so every batch it produces is gzipped.
- *
- * The SDK compresses in its `node:http` transport, which the `fetch` transport this package uses
- * does not have; serialization is the one seam before the body reaches that transport. The shipped
- * profile enables gzip, and a realistic batch measures over six times smaller with it, so dropping
- * compression to gain proxy support would trade one deployment's problem for every deployment's.
- *
- * `gzipSync` runs on the export path, but a batch is bounded by `maxExportBatchSize` and exports are
- * already off the request path — the batch processor schedules them.
- *
- * @param serializer - the SDK serializer producing the uncompressed request body.
- * @returns a serializer producing the gzipped body, deserializing responses unchanged.
- */
-function gzipSerializer<Request, Response>(serializer: ISerializer<Request, Response>): ISerializer<Request, Response> {
-  return {
-    ...serializer,
-    serializeRequest: (request) => {
-      const serialized = serializer.serializeRequest(request)
-      // The SDK returns nothing for a batch it could not serialize. Gzipping that would post an
-      // empty frame the collector accepts as a valid, empty export.
-      return serialized === undefined ? undefined : gzipSync(serialized)
-    },
-  }
-}
-
 /** Severity mapping from the Service Definition's three-level vocabulary to OTel severity numbers. */
 const SEVERITY: Record<SessionTelemetrySeverity, { severityNumber: SeverityNumber; severityText: string }> = {
   info: { severityNumber: SeverityNumber.INFO, severityText: 'INFO' },
@@ -223,8 +167,7 @@ export class OpenTelemetrySessionBackend extends SessionTelemetryBackend {
       return
     }
 
-    const exporter = config.exporter ?? {}
-    const url = exporter.url
+    const url = config.exporter?.url
     if (url === undefined || url.length === 0) {
       throw new Error('session-telemetry-otel: exporter.url is required (the full OTLP logs endpoint)')
     }
@@ -238,20 +181,6 @@ export class OpenTelemetrySessionBackend extends SessionTelemetryBackend {
     if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') {
       throw new Error(`session-telemetry-otel: exporter.url must be http(s), got ${parsed.protocol}`)
     }
-    // `keepAlive` and `httpAgentOptions` configure the SDK's `node:http` transport, which this
-    // package does not use — the `fetch` transport is what reaches a configured proxy. The exporter
-    // would accept and ignore them, so a deployment would believe it had tuned a connection it had
-    // not. `compression` is the third such option and is honored instead of refused, below.
-    const nodeOnly = NODE_TRANSPORT_ONLY_EXPORTER_OPTIONS.filter(name => name in exporter)
-    if (nodeOnly.length > 0) {
-      throw new Error(`session-telemetry-otel: exporter.${nodeOnly.join(', exporter.')} not supported: telemetry is exported through fetch, whose connections Node owns; ${nodeOnly.length === 1 ? 'that option belongs' : 'those options belong'} to the node:http transport this package no longer builds an agent for`)
-    }
-    // Compared as strings because that is what arrives: the schema validates this object as `any`,
-    // so a cordis.yml may name any algorithm, including one the SDK's enum does not spell.
-    const compression: string = exporter.compression ?? 'none'
-    if (!SUPPORTED_COMPRESSION.includes(compression)) {
-      throw new Error(`session-telemetry-otel: exporter.compression must be one of ${SUPPORTED_COMPRESSION.map(value => JSON.stringify(value)).join(', ')}, got ${JSON.stringify(compression)}`)
-    }
     // The one processor field checked beyond the SDK's own validation: the
     // SDK accepts a non-positive batch size, but its shutdown drain then
     // splices empty batches without consuming the queue — dispose would hang
@@ -283,32 +212,7 @@ export class OpenTelemetrySessionBackend extends SessionTelemetryBackend {
           // ignore the rest. App identity travels in the Resource
           // (service.name/version); the transport-level user-agent is the
           // SDK's own, per the axiom.
-          //
-          // The delegate is the SDK's `fetch` one rather than its Node `node:http` one. Both are
-          // published entry points of the same package; the `fetch` transport reaches undici's
-          // global dispatcher, so a configured proxy carries telemetry with no proxy-aware code
-          // here and with no Node-version floor. The Node transport would need an `http.Agent`,
-          // and Node only learned to route one from the environment in 22.21 and 24.5.
-          //
-          // What that costs: `compression` is a Node-transport option and has no effect here.
-          //
-          // The delegate is deprecated in favour of `createOtlpFetchExportDelegate`, which the SDK
-          // exports from no public subpath at 0.220 — this legacy wrapper is the only supported way
-          // to reach it, and does nothing but call it. Composing the public
-          // `createOtlpNetworkExportDelegate` instead would mean owning the fetch transport and its
-          // retry wrapper, both SDK-internal.
-          exporter: new OTLPExporterBase<ReadableLogRecord[]>(
-            // oxlint-disable-next-line typescript/no-deprecated -- the SDK exports its replacement from no public subpath at 0.220.
-            createLegacyOtlpBrowserExportDelegate(
-              exporter,
-              compression === GZIP ? gzipSerializer(JsonLogsSerializer) : JsonLogsSerializer,
-              'v1/logs',
-              {
-                'Content-Type': 'application/json',
-                ...compression === GZIP ? { 'Content-Encoding': GZIP } : {},
-              },
-            ),
-          ),
+          exporter: new OTLPLogExporter(config.exporter),
         }),
       ],
     })

+ 32 - 39
packages/session/session-telemetry-otel/tests/egress.spec.ts

@@ -1,7 +1,13 @@
-import http, { createServer, type Server } from 'node:http'
+import { createServer, type Server } from 'node:http'
 import type { AddressInfo } from 'node:net'
+import { mkdtempSync, rmSync } from 'node:fs'
+import { tmpdir } from 'node:os'
+import { join } from 'node:path'
 import { afterAll, beforeAll, describe, expect, it } from 'vitest'
 import { installProxyFromEnvironment } from '@deepseek-ai/dsh-http-proxy'
+import { Context } from '@deepseek-ai/cordis'
+import SessionStore, { SessionId } from '@deepseek-ai/dsh-session'
+import OpenTelemetrySessionBackend, { SessionTelemetryMode } from '../src/index.ts'
 
 let seen: string[] = []
 let proxy: Server
@@ -21,23 +27,6 @@ beforeAll(async () => {
 })
 afterAll(async () => { await new Promise<void>((r) => { proxy.close(() => { r() }) }) })
 
-/** The launch environment of a user who exported one proxy for both schemes. */
-function proxyEnv(): { get(name: string): { value: string } | undefined } {
-  return { get: name => (name === 'HTTP_PROXY' || name === 'HTTPS_PROXY' ? { value: proxyUrl } : undefined) }
-}
-async function observe(run: () => Promise<unknown>): Promise<string[]> {
-  seen = []
-  const dispose = await installProxyFromEnvironment(proxyEnv(), () => undefined)
-  try { await run().catch(() => undefined) } finally { await dispose() }
-  return seen
-}
-import { mkdtempSync, rmSync } from 'node:fs'
-import { tmpdir } from 'node:os'
-import { join } from 'node:path'
-import { Context } from '@deepseek-ai/cordis'
-import SessionStore, { SessionId } from '@deepseek-ai/dsh-session'
-import OpenTelemetrySessionBackend, { SessionTelemetryMode } from '../src/index.ts'
-
 let home: string
 let previousHome: string | undefined
 beforeAll(() => {
@@ -51,13 +40,18 @@ afterAll(() => {
   rmSync(home, { recursive: true, force: true })
 })
 
+/** The launch environment of a user who exported one proxy for both schemes. */
+function proxyEnv(): { get(name: string): { value: string } | undefined } {
+  return { get: name => (name === 'HTTP_PROXY' || name === 'HTTPS_PROXY' ? { value: proxyUrl } : undefined) }
+}
+
 /** Mount the shipping backend against an unresolvable collector and let it try to export. */
-async function exportThroughBackend(host: string, exporter: Record<string, unknown> = {}): Promise<void> {
+async function exportThroughBackend(host: string): Promise<void> {
   const ctx = new Context()
   await ctx.plugin(SessionStore)
   const fiber = await ctx.plugin(OpenTelemetrySessionBackend, {
     mode: SessionTelemetryMode.FULL,
-    exporter: { url: `http://${host}/v1/logs`, ...exporter },
+    exporter: { url: `http://${host}/v1/logs` },
   })
   const session = ctx.sessions.create(SessionId('egress'), { meta: { cwd: '/tmp/e' } })
   session.append('turn/start', { turn: 1 })
@@ -65,25 +59,24 @@ async function exportThroughBackend(host: string, exporter: Record<string, unkno
   await fiber.dispose()
 }
 
-
 describe('session-telemetry-otel egress', () => {
-  it('exports through the proxy', async () => {
-    const observed = (await observe(() => exportThroughBackend('otel-proxied.invalid'))).join('|')
-    // No runtime gate: the exporter posts through `fetch`, which resolves undici's global
-    // dispatcher on every Node this repository supports. The SDK's own `node:http` transport would
-    // have needed `http.Agent`'s `proxyEnv`, which arrived in 22.21 and 24.5 — inside the engines
-    // range, so telemetry would have stayed direct on 22.19, 22.20, and 24.0–24.4.
-    expect(observed).toContain('otel-proxied.invalid')
-  })
-
-  it('reaches no proxy over node:http — the transport this exporter no longer uses', async () => {
-    const observed = await observe(() => new Promise<void>((resolve) => {
-      // The mechanism behind the case above, asserted rather than described: a global dispatcher is
-      // undici's, and `node:http` never consults it. An exporter built on the SDK's Node transport
-      // would take this path and leave telemetry direct however the proxy is configured.
-      http.get('http://otel-direct.invalid/v1/logs', (response) => { response.resume(); resolve() })
-        .on('error', () => { resolve() })
-    }))
-    expect(observed.join('|')).not.toContain('otel-direct.invalid')
+  it('exports directly, ignoring a configured proxy', async () => {
+    seen = []
+    const dispose = await installProxyFromEnvironment(proxyEnv(), () => undefined)
+    try {
+      await exportThroughBackend('otel-direct.invalid').catch(() => undefined)
+    } finally {
+      await dispose()
+    }
+    // Telemetry is the one outbound path this repository deliberately leaves direct. The SDK's OTLP
+    // exporter posts through `node:http`, which no global dispatcher reaches, and routing it would
+    // mean either an `http.Agent` whose `proxyEnv` option arrives after this project's lowest
+    // supported Node, or replacing the transport and reimplementing the compression the shipped
+    // profile enables. Neither is worth it for a channel whose loss costs the user nothing.
+    //
+    // This case exists so that stays a decision: an SDK upgrade that moved the exporter onto
+    // `fetch` would start routing telemetry through a proxy silently, and this assertion is what
+    // makes that visible instead.
+    expect(seen).toEqual([])
   })
 })

+ 4 - 92
packages/session/session-telemetry-otel/tests/otel.spec.ts

@@ -12,7 +12,6 @@ import { mkdtempSync, rmSync } from 'node:fs'
 import { tmpdir } from 'node:os'
 import { join } from 'node:path'
 import { gunzipSync } from 'node:zlib'
-import { JsonLogsSerializer } from '@opentelemetry/otlp-transformer'
 import { Context } from '@deepseek-ai/cordis'
 import { getOrCreateAnonymousUserId } from '@deepseek-ai/dsh-anonymous-user-id'
 import Loader from '@deepseek-ai/cordis-plugin-loader'
@@ -238,111 +237,24 @@ describe('OpenTelemetrySessionBackend wire', () => {
     const { url, captures } = await mockCollector()
     const ctx = new Context()
     await ctx.plugin(SessionStore)
-    // `headers` is a documented SDK exporter option this package neither reads nor rebuilds; the
-    // advertised verbatim passthrough must hand it (and every other field) to the exporter rather
-    // than silently rebuilding url only.
-    const fiber = await ctx.plugin(OpenTelemetrySessionBackend, {
-      mode: SessionTelemetryMode.FULL,
-      exporter: { url, headers: { 'x-probe': 'passthrough' } },
-    })
-    const session = ctx.sessions.create(SessionId('passthrough'), { meta: {} })
-    session.append('turn/start', { turn: 1 })
-    await fiber.dispose()
-
-    expect(captures.length).toBeGreaterThan(0)
-    expect(captures[0]!.headers['x-probe']).toBe('passthrough')
-    const types = allRecords(captures).flatMap(({ record }) =>
-      record.attributes?.flatMap(a => a.key === 'event.type' ? [a.value.stringValue] : []) ?? [])
-    expect(types).toContain('turn/start')
-  })
-
-  it('gzips the batch when the shipped profile asks for it', async () => {
-    const { url, captures } = await mockCollector()
-    const ctx = new Context()
-    await ctx.plugin(SessionStore)
-    // The shipped `base` bundle sets this, and a realistic batch is over six times smaller with it.
-    // The SDK compresses in its `node:http` transport, which the `fetch` transport used here does
-    // not have, so this package gzips at the serializer and declares the encoding itself.
+    // `compression` is a documented SDK exporter option; the advertised
+    // verbatim passthrough must hand it (and every other field) to the
+    // exporter rather than silently rebuilding url/headers only.
     const fiber = await ctx.plugin(OpenTelemetrySessionBackend, {
       mode: SessionTelemetryMode.FULL,
       exporter: { url, compression: 'gzip' },
-    })
+    } as Config)
     const session = ctx.sessions.create(SessionId('gzip'), { meta: {} })
     session.append('turn/start', { turn: 1 })
     await fiber.dispose()
 
     expect(captures.length).toBeGreaterThan(0)
     expect(captures[0]!.headers['content-encoding']).toBe('gzip')
-    // The collector gunzips the body it received, so the header is not merely asserted alongside a
-    // plaintext payload the encoding would have misdescribed.
     const types = allRecords(captures).flatMap(({ record }) =>
       record.attributes?.flatMap(a => a.key === 'event.type' ? [a.value.stringValue] : []) ?? [])
     expect(types).toContain('turn/start')
   })
 
-  it('sends the batch uncompressed when no compression is configured', async () => {
-    const { url, captures } = await mockCollector()
-    const ctx = new Context()
-    await ctx.plugin(SessionStore)
-    const fiber = await ctx.plugin(OpenTelemetrySessionBackend, { mode: SessionTelemetryMode.FULL, exporter: { url } })
-    ctx.sessions.create(SessionId('plain'), { meta: {} }).append('turn/start', { turn: 1 })
-    await fiber.dispose()
-
-    expect(captures.length).toBeGreaterThan(0)
-    expect(captures[0]!.headers['content-encoding']).toBeUndefined()
-  })
-
-  it('sends nothing when the SDK cannot serialize the batch, rather than an empty gzip frame', async () => {
-    const { url, captures } = await mockCollector()
-    const serialize = vi.spyOn(JsonLogsSerializer, 'serializeRequest').mockReturnValue(undefined)
-    try {
-      const ctx = new Context()
-      await ctx.plugin(SessionStore)
-      const fiber = await ctx.plugin(OpenTelemetrySessionBackend, {
-        mode: SessionTelemetryMode.FULL,
-        exporter: { url, compression: 'gzip' },
-      })
-      ctx.sessions.create(SessionId('unserializable'), { meta: {} }).append('turn/start', { turn: 1 })
-      await fiber.dispose()
-      expect(serialize).toHaveBeenCalled()
-      expect(captures).toEqual([])
-    } finally {
-      serialize.mockRestore()
-    }
-  })
-
-  it('names every node:http option a configuration set, not just the first', async () => {
-    const { url } = await mockCollector()
-    const ctx = new Context()
-    await ctx.plugin(SessionStore)
-    await expect(ctx.plugin(OpenTelemetrySessionBackend, {
-      mode: SessionTelemetryMode.FULL,
-      exporter: { url, keepAlive: true, httpAgentOptions: {} },
-    } as unknown as Config)).rejects.toThrow(/exporter\.keepAlive, exporter\.httpAgentOptions not supported/)
-  })
-
-  it('refuses an exporter option that belongs to the node:http transport', async () => {
-    const { url } = await mockCollector()
-    const ctx = new Context()
-    await ctx.plugin(SessionStore)
-    // `keepAlive` tunes a `node:http` connection pool this package no longer builds. Accepting it
-    // would let a deployment believe it had tuned a connection that does not exist.
-    await expect(ctx.plugin(OpenTelemetrySessionBackend, {
-      mode: SessionTelemetryMode.FULL,
-      exporter: { url, keepAlive: true },
-    } as unknown as Config)).rejects.toThrow(/exporter\.keepAlive not supported/)
-  })
-
-  it('refuses a compression algorithm it cannot apply', async () => {
-    const { url } = await mockCollector()
-    const ctx = new Context()
-    await ctx.plugin(SessionStore)
-    await expect(ctx.plugin(OpenTelemetrySessionBackend, {
-      mode: SessionTelemetryMode.FULL,
-      exporter: { url, compression: 'deflate' },
-    } as unknown as Config)).rejects.toThrow(/exporter\.compression must be one of "gzip", "none"/)
-  })
-
   it('maps warn severity from record policy and leaves the seam flush hint unimplemented', async () => {
     const { url, captures } = await mockCollector()
     const { ctx, fiber } = await boot(url)

+ 0 - 3
packages/session/session-telemetry-otel/tsconfig.json

@@ -31,9 +31,6 @@
     },
     {
       "path": "../../identity/anonymous-user-id"
-    },
-    {
-      "path": "../../util/http-proxy"
     }
   ]
 }

+ 2 - 2
packages/util/http-proxy/README.i18n.yaml

@@ -2,5 +2,5 @@
 # side as of the last confirmed-consistent state. Both languages carry equal authority;
 # after editing either side, bring the other along and re-record with:
 #   pnpm run verify-translation-pairing --write packages/util/http-proxy/README.md
-README.md: 2bad73fa7ab670d73adab6e9a82602b0fa374752
-README.zh.md: 85f49c2e33d2064f49acba9df2c935d81c36b175
+README.md: ffb3b209bbdb19810e1125ec8cd6ce6c01376a8c
+README.zh.md: fce5b914a0854ed723ae71c7e779acff70434c9e

+ 4 - 3
packages/util/http-proxy/README.md

@@ -9,7 +9,7 @@ English | [中文](README.zh.md)
 
 ## Summary
 
-Node's built-in `fetch` ignores `HTTP_PROXY` and `HTTPS_PROXY`, so a harness behind a proxy would connect directly no matter what the user exported — the LLM request, every web search, MCP over HTTP, telemetry, and the sandbox SDK alike. This package resolves one proxy policy from the launcher's environment snapshot and installs it as undici's global dispatcher, which is exactly what `fetch` resolves. Ordinary call sites therefore need no change and no import: they write `fetch()` and are proxied. Four functions cover everything the global dispatcher cannot reach on its own — install the policy, ask where one request goes, hand the policy to a spawned child, and strip it for a replay.
+Node's built-in `fetch` ignores `HTTP_PROXY` and `HTTPS_PROXY`, so a harness behind a proxy would connect directly no matter what the user exported — the LLM request, every web search, MCP over HTTP, and the sandbox SDK alike. This package resolves one proxy policy from the launcher's environment snapshot and installs it as undici's global dispatcher, which is exactly what `fetch` resolves. Ordinary call sites therefore need no change and no import: they write `fetch()` and are proxied. Four functions cover everything the global dispatcher cannot reach on its own — install the policy, ask where one request goes, hand the policy to a spawned child, and strip it for a replay.
 
 ## Table of Contents
 
@@ -41,11 +41,11 @@ Plain `fetch()` is proxied, and so is any SDK that reaches `globalThis.fetch` 
 
 `proxyRouteFor` answers with the transport that answer assumed, not just the answer: its proxied arm carries the dispatcher already routing by this policy. A caller that read the policy and then built its own transport could have an unmount land between the two and send the request somewhere its branch never cleared.
 
-An SDK that builds its own transport reaches none of this. The two this repository ships that did — the OTLP exporter and the E2B SDK — were changed to a transport that does: the exporter now posts through `fetch`, and E2B is handed `route.proxy`.
+An SDK that builds its own transport reaches none of this, and two of the ones this repository ships do. E2B takes a proxy URL of its own and is handed `route.proxy`. The OTLP telemetry exporter posts through `node:http`, and is deliberately left direct — see the limitation below.
 
 Constructing `new Agent(...)` and passing it as `dispatcher` overrides the global one and silently bypasses the proxy. `verify-no-bare-dispatcher` rejects that outside this package. One call site legitimately owns its transport — `web-fetch-http` pins a request to addresses it validated, which is per-request state a process-wide dispatcher cannot hold — and says so with a `proxy-exempt:` comment on the line.
 
-That gate cannot see inside an SDK, so every outbound call site in the repository also carries an `egress.spec.ts` that drives its real code path through a fake proxy and asserts the proxy saw the request. A new call site adds one. It is the only thing that catches an SDK changing transports underneath us — which is exactly how the OTLP and E2B gaps were found.
+That gate cannot see inside an SDK, so every outbound call site in the repository also carries an `egress.spec.ts` that drives its real code path through a fake proxy and asserts the proxy saw the request — or, for telemetry, that it did not. A new call site adds one. It is the only thing that catches an SDK changing transports underneath us, in either direction: it is how the OTLP and E2B gaps were found, and it is what would catch an upgrade that started routing telemetry silently.
 
 ### What the policy reads
 
@@ -109,6 +109,7 @@ These limits define when the package is a poor fit. They are current package con
 - **No SOCKS, PAC, or operating-system proxy detection** — only `http(s)://` proxy URLs from the environment. A macOS or Windows system-proxy setting is not read, so a user who only toggled it in a proxy application must still export the variables; a SOCKS URL is reported and that scheme stays direct rather than borrowing another scheme's proxy.
 - **No custom certificate authority** — a TLS-intercepting corporate proxy needs `NODE_EXTRA_CA_CERTS` set on the process before launch, which this package neither sets nor validates.
 - **A spawned child honors the policy only on a new enough runtime** — it reads the published environment through Node's `NODE_USE_ENV_PROXY` (22.21+, 24+), and the engines range admits 22.19 and 22.20, where such a child stays direct. A child also matches bypass entries with Node's own `NO_PROXY` rules, which differ from this package's in their separators and IPv4-range support. Nothing in this process depends on a Node version: every in-process request reaches the global dispatcher.
+- **Telemetry is direct by design** — the OTLP exporter posts through `node:http`, which no global dispatcher reaches. Routing it would need either an `http.Agent` whose `proxyEnv` option post-dates the lowest supported Node, or the SDK's `fetch` transport, which has no compression while the shipped profile enables gzip. Telemetry is the one channel whose loss costs the user nothing, so it stays where it was; `DSH_TELEMETRY_MODE=DISABLED` turns it off.
 - **A worker that executes model-authored code gets no proxy at all** — neither the `code-runtime` worker nor the `workflow` worker receives proxy configuration, so their own requests go direct. A proxy URL may carry `user:password`, and both run scripts the model wrote.
 - **The regression gate sees source, not dependencies** — `verify-no-bare-dispatcher` parses `packages/*/*/src` and `apps/*/src`; tests, scripts, and the internals of a third-party SDK are outside it. That is why every outbound call site also carries an `egress.spec.ts`.
 

+ 4 - 3
packages/util/http-proxy/README.zh.md

@@ -9,7 +9,7 @@ kind: "package-reference"
 
 ## 概述
 
-Node 内置的 `fetch` 会忽略 `HTTP_PROXY` 与 `HTTPS_PROXY`,因此在代理后面运行的 Harness 无论用户导出了什么都会直连——LLM(大语言模型)请求、每次 web 搜索、走 HTTP 的 MCP、遥测与沙箱 SDK 一概如此。本包从启动器的环境快照解析出一份代理策略,并把它装成 undici 的全局 dispatcher,而这正是 `fetch` 解析的对象。因此普通调用点无需改动、也无需引入本包:写 `fetch()` 就已经走代理。全局 dispatcher 自身够不到的场合由四个函数覆盖——安装策略、询问某个请求怎么发、把策略交给派生的子进程、以及为重放清掉它。
+Node 内置的 `fetch` 会忽略 `HTTP_PROXY` 与 `HTTPS_PROXY`,因此在代理后面运行的 Harness 无论用户导出了什么都会直连——LLM(大语言模型)请求、每次 web 搜索、走 HTTP 的 MCP 与沙箱 SDK 一概如此。本包从启动器的环境快照解析出一份代理策略,并把它装成 undici 的全局 dispatcher,而这正是 `fetch` 解析的对象。因此普通调用点无需改动、也无需引入本包:写 `fetch()` 就已经走代理。全局 dispatcher 自身够不到的场合由四个函数覆盖——安装策略、询问某个请求怎么发、把策略交给派生的子进程、以及为重放清掉它。
 
 ## 目录
 
@@ -41,11 +41,11 @@ Node 内置的 `fetch` 会忽略 `HTTP_PROXY` 与 `HTTPS_PROXY`,因此在代
 
 `proxyRouteFor` 给出的不只是答案,还有该答案所假定的传输:走代理的那一支携带着此刻正按该策略路由的 dispatcher。若调用方先读策略、再自建传输,卸载就可能落在两次读取之间,把请求发往其分支从未放行的去处。
 
-自建传输的 SDK 接触不到上述任何一条。本仓库随附的两个此类 SDK 都已改到能被覆盖的传输上:OTLP 导出器改为通过 `fetch` 投递,E2B 则接收 `route.proxy`
+自建传输的 SDK 接触不到上述任何一条,而本仓库随附的 SDK 里有两个如此。E2B 接受自有代理 URL,现在接收 `route.proxy`。OTLP 遥测导出器通过 `node:http` 投递,被有意保留为直连——见下方限制一节
 
 构造 `new Agent(...)` 再作为 `dispatcher` 传入会覆盖全局 dispatcher,从而静默绕开代理。`verify-no-bare-dispatcher` 会在本包之外拒绝该写法。有一处调用点确实自有传输——`web-fetch-http` 会把请求钉在它已校验过的地址上,而这是进程级 dispatcher 无法承载的单次请求状态——它在该行用 `proxy-exempt:` 注释说明。
 
-该门禁看不进 SDK 内部,因此仓库中每一个出网点都另有一份 `egress.spec.ts`:它驱动该点的真实代码路径穿过一个假代理,并断言代理确实收到了请求。新增出网点就补一份。它是唯一能发现 SDK 在我们脚下更换传输的手段——OTLP 与 E2B 这两个漏洞正是这样被发现的。
+该门禁看不进 SDK 内部,因此仓库中每一个出网点都另有一份 `egress.spec.ts`:它驱动该点的真实代码路径穿过一个假代理,并断言代理确实收到了请求——遥测那份则断言代理什么也没收到。新增出网点就补一份。它是唯一能双向发现 SDK 在我们脚下更换传输的手段:OTLP 与 E2B 这两个漏洞正是这样被发现的,而某次升级若开始静默地把遥测送去代理,也由它拦下
 
 ### 策略读取哪些值
 
@@ -109,6 +109,7 @@ loopback 始终被绕过——`localhost`、整个 `127.0.0.0/8` 段、`::1`、`
 - **不支持 SOCKS、PAC 或操作系统代理探测**——只接受来自环境的 `http(s)://` 代理 URL。不会读取 macOS 或 Windows 的系统代理设置,因此仅在代理软件里拨了开关的用户仍须导出环境变量;SOCKS URL 会被报告,且该协议保持直连,不会借用另一协议的代理。
 - **不支持自定义证书颁发机构**——做 TLS 拦截的企业代理需要在启动前为进程设置 `NODE_EXTRA_CA_CERTS`,本包既不设置也不校验它。
 - **派生的子进程只在足够新的运行时上遵循策略**——它通过 Node 的 `NODE_USE_ENV_PROXY` 读取已发布的环境(22.21+、24+),而 engines 范围允许 22.19 与 22.20,在这两个版本上这样的子进程保持直连。子进程还会按 Node 自己的 `NO_PROXY` 规则匹配绕过条目,其分隔符与 IPv4 区间处理与本包不同。本进程内不依赖任何 Node 版本:每一次进程内请求都会落到全局 dispatcher。
+- **遥测按设计直连**——OTLP 导出器通过 `node:http` 投递,全局 dispatcher 触及不到。要让它走代理,要么依赖 `http.Agent` 的 `proxyEnv`,而该选项晚于本项目支持的最低 Node 版本;要么改用 SDK 的 `fetch` 传输,但它没有压缩能力,而随附配置启用了 gzip。遥测是唯一一条丢失了对用户毫无代价的通道,因此维持原状;`DSH_TELEMETRY_MODE=DISABLED` 可关闭它。
 - **执行模型编写代码的 worker 完全不获得代理**——`code-runtime` worker 与 `workflow` worker 都不接收代理配置,它们自身的请求直连。代理 URL 可能携带 `user:password`,而两者运行的都是模型写的脚本。
 - **防回归门禁只看源码,看不到依赖内部**——`verify-no-bare-dispatcher` 解析 `packages/*/*/src` 与 `apps/*/src`;测试、脚本以及第三方 SDK 的内部都在其之外。这正是每个出网点还各配一份 `egress.spec.ts` 的原因。
 

+ 17 - 2
pnpm-lock.yaml

@@ -7233,10 +7233,10 @@ importers:
       '@opentelemetry/api-logs':
         specifier: ^0.220.0
         version: 0.220.0
-      '@opentelemetry/otlp-exporter-base':
+      '@opentelemetry/exporter-logs-otlp-http':
         specifier: ^0.220.0
         version: 0.220.0(@opentelemetry/api@1.9.1)
-      '@opentelemetry/otlp-transformer':
+      '@opentelemetry/otlp-exporter-base':
         specifier: ^0.220.0
         version: 0.220.0(@opentelemetry/api@1.9.1)
       '@opentelemetry/resources':
@@ -11773,6 +11773,12 @@ packages:
     peerDependencies:
       '@opentelemetry/api': '>=1.0.0 <1.10.0'
 
+  '@opentelemetry/exporter-logs-otlp-http@0.220.0':
+    resolution: {integrity: sha512-8186thl+pTw64iz/qEEen5oJZoZ/gO73XruChdaGlYdWOdBIQ42r+vHLf6a7vIDqTD4b8ZOoMlyxptanECaI9A==}
+    engines: {node: ^18.19.0 || >=20.6.0}
+    peerDependencies:
+      '@opentelemetry/api': ^1.3.0
+
   '@opentelemetry/otlp-exporter-base@0.220.0':
     resolution: {integrity: sha512-CXYo8UD5Mn9YbgebO2EL4wejtA+gxLmLiu6HCk2KH2BR7XhFN6/6p1UlCb23DYCjeYkndevLHuejCCN1yx4+OQ==}
     engines: {node: ^18.19.0 || >=20.6.0}
@@ -17372,6 +17378,15 @@ snapshots:
       '@opentelemetry/api': 1.9.1
       '@opentelemetry/semantic-conventions': 1.43.0
 
+  '@opentelemetry/exporter-logs-otlp-http@0.220.0(@opentelemetry/api@1.9.1)':
+    dependencies:
+      '@opentelemetry/api': 1.9.1
+      '@opentelemetry/api-logs': 0.220.0
+      '@opentelemetry/core': 2.9.0(@opentelemetry/api@1.9.1)
+      '@opentelemetry/otlp-exporter-base': 0.220.0(@opentelemetry/api@1.9.1)
+      '@opentelemetry/otlp-transformer': 0.220.0(@opentelemetry/api@1.9.1)
+      '@opentelemetry/sdk-logs': 0.220.0(@opentelemetry/api@1.9.1)
+
   '@opentelemetry/otlp-exporter-base@0.220.0(@opentelemetry/api@1.9.1)':
     dependencies:
       '@opentelemetry/api': 1.9.1