diff --git a/.agents/notes/implemented/architecture/2026-08-27-outbound-proxy-policy.i18n.yaml b/.agents/notes/implemented/architecture/2026-08-27-outbound-proxy-policy.i18n.yaml index f1a7262652..aa2d48bccc 100644 --- a/.agents/notes/implemented/architecture/2026-08-27-outbound-proxy-policy.i18n.yaml +++ b/.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: 88bfe542d322d2caee5f5e220ed211e0169fa614 -2026-08-27-outbound-proxy-policy.zh.md: f5afee9de40e2032cfd4eda3bc9bfb1c627e71aa +2026-08-27-outbound-proxy-policy.md: 9c58dfd00d9b6c0b4438ad3dd4da58d8706f718f +2026-08-27-outbound-proxy-policy.zh.md: 1aabc716c1bdf348229b260c4d8580fb4edc8637 diff --git a/.agents/notes/implemented/architecture/2026-08-27-outbound-proxy-policy.md b/.agents/notes/implemented/architecture/2026-08-27-outbound-proxy-policy.md index 88bfe542d3..9c58dfd00d 100644 --- a/.agents/notes/implemented/architecture/2026-08-27-outbound-proxy-policy.md +++ b/.agents/notes/implemented/architecture/2026-08-27-outbound-proxy-policy.md @@ -50,7 +50,9 @@ This accepts a documented seam. Such a context matches bypass entries by Node's 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. -Switching the exporter to `fetch` costs `compression`: gzip belongs to the SDK's Node transport, and a realistic OTLP batch measured 6.4x smaller with it. Nothing shipped enabled it, and telemetry that ignores the proxy simply fails inside a corporate network, so routing wins. What the exporter would silently ignore, the plugin now refuses at load — `exporter.compression`, `exporter.keepAlive`, and `exporter.httpAgentOptions` throw with the reason, so no deployment pays the difference without seeing it. 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. +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. + +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. diff --git a/.agents/notes/implemented/architecture/2026-08-27-outbound-proxy-policy.zh.md b/.agents/notes/implemented/architecture/2026-08-27-outbound-proxy-policy.zh.md index f5afee9de4..1aabc716c1 100644 --- a/.agents/notes/implemented/architecture/2026-08-27-outbound-proxy-policy.zh.md +++ b/.agents/notes/implemented/architecture/2026-08-27-outbound-proxy-policy.zh.md @@ -50,7 +50,9 @@ URL 层策略未受影响:仅 `http(s)`、禁止内嵌凭据、长度上限与 导出器改为用 `OTLPExporterBase` 组合 `createLegacyOtlpBrowserExportDelegate`——同一个 SDK 包的公开入口,也是通过 `fetch` 投递的那一个。E2B 则接收 `proxyRouteFor` 给出的 `route.proxy`,与 `web-fetch-http` 调的是同一个函数。 -把导出器换到 `fetch` 的代价是 `compression`:gzip 属于该 SDK 的 Node 传输,实测一批真实规模的 OTLP 数据启用后体积只有 1/6.4。目前没有任何随附配置启用它,而在企业代理网络里,不遵循代理的遥测干脆发不出去,因此路由优先。导出器本会静默忽略的选项,现在由插件在加载期拒绝——`exporter.compression`、`exporter.keepAlive` 与 `exporter.httpAgentOptions` 会带着原因抛错,任何部署都不会在看不见的情况下承担这个差价。换来的是 Node 版本下限消失:`http.Agent` 的 `proxyEnv` 需要 22.21 或 24.5,而这落在 engines 范围之内,因此遥测过去在 22.19、22.20 与 24.0–24.4 上一直是直连。 +`fetch` 传输没有压缩能力,而随附的 `base` bundle 启用了 gzip——实测一批真实规模的 OTLP 数据启用后体积只有 1/6.4。为了拿到代理支持而丢掉它,等于用每个部署的代价去换一个部署的问题;第一版正是这么做的:它在加载期拒绝 `exporter.compression`,结果凡是启动随附 bundle 的测试全部失败。改为由本包在 serializer 处 gzip——那是请求体抵达传输前的唯一接缝——并自行声明 `Content-Encoding`。`keepAlive` 与 `httpAgentOptions` 没有这样的接缝,它们配置的是 `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 换了传输」从静默回归变成失败的测试。 diff --git a/docs/config-catalog.i18n.yaml b/docs/config-catalog.i18n.yaml index ad6e34e92e..78d84cbf06 100644 --- a/docs/config-catalog.i18n.yaml +++ b/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: 6fdd34855e4456237464bfb225b65b55b86fbf2e -config-catalog.zh.md: 9ca1423556ecee7f839c0ef66b5628fd036b6f94 +config-catalog.md: e451d76631252a1457bfc225784adc096c9ad548 +config-catalog.zh.md: b54d57d3a63693d8f6e2e83081c7116b6c5ced5c diff --git a/docs/config-catalog.md b/docs/config-catalog.md index 6fdd34855e..e451d76631 100644 --- a/docs/config-catalog.md +++ b/docs/config-catalog.md @@ -1971,13 +1971,16 @@ export interface Config { * `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 the three options that exist - * only for its `node:http` transport — `compression`, `keepAlive`, and - * `httpAgentOptions` — are refused at load rather than ignored. + * 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 & { /** 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, @@ -1994,11 +1997,17 @@ 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`) -Source: [`packages/session/session-telemetry-otel/src/index.ts:94`](../packages/session/session-telemetry-otel/src/index.ts) +Source: [`packages/session/session-telemetry-otel/src/index.ts:96`](../packages/session/session-telemetry-otel/src/index.ts) diff --git a/docs/config-catalog.zh.md b/docs/config-catalog.zh.md index 9ca1423556..b54d57d3a6 100644 --- a/docs/config-catalog.zh.md +++ b/docs/config-catalog.zh.md @@ -1973,13 +1973,16 @@ export interface Config { * `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 the three options that exist - * only for its `node:http` transport — `compression`, `keepAlive`, and - * `httpAgentOptions` — are refused at load rather than ignored. + * 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 & { /** 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, @@ -1996,11 +1999,17 @@ 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`) -来源:[`packages/session/session-telemetry-otel/src/index.ts:94`](../packages/session/session-telemetry-otel/src/index.ts) +来源:[`packages/session/session-telemetry-otel/src/index.ts:96`](../packages/session/session-telemetry-otel/src/index.ts) diff --git a/packages/session/session-telemetry-otel/src/index.ts b/packages/session/session-telemetry-otel/src/index.ts index b9d5e5477b..ad84f0b5cc 100644 --- a/packages/session/session-telemetry-otel/src/index.ts +++ b/packages/session/session-telemetry-otel/src/index.ts @@ -13,6 +13,7 @@ */ 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' @@ -34,6 +35,7 @@ import { 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 { SeverityNumber, type AnyValue, type Logger } from '@opentelemetry/api-logs' @@ -100,13 +102,16 @@ export interface Config { * `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 the three options that exist - * only for its `node:http` transport — `compression`, `keepAlive`, and - * `httpAgentOptions` — are refused at load rather than ignored. + * 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 & { /** 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, @@ -142,7 +147,45 @@ 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 = ['compression', 'keepAlive', 'httpAgentOptions'] as const +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(serializer: ISerializer): ISerializer { + 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 = { @@ -195,12 +238,19 @@ 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}`) } - // Options that exist only for the SDK's `node:http` transport, which this package no longer - // uses. The exporter would accept and ignore each one, so a deployment that asked for gzip - // would quietly send uncompressed batches; refusing at load is what makes the change visible. + // `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 so a configured proxy carries it, and the node:http transport those options belong to would need an http.Agent this package no longer builds`) + 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 @@ -251,9 +301,12 @@ export class OpenTelemetrySessionBackend extends SessionTelemetryBackend { // oxlint-disable-next-line typescript/no-deprecated -- the SDK exports its replacement from no public subpath at 0.220. createLegacyOtlpBrowserExportDelegate( exporter, - JsonLogsSerializer, + compression === GZIP ? gzipSerializer(JsonLogsSerializer) : JsonLogsSerializer, 'v1/logs', - { 'Content-Type': 'application/json' }, + { + 'Content-Type': 'application/json', + ...compression === GZIP ? { 'Content-Encoding': GZIP } : {}, + }, ), ), }), diff --git a/packages/session/session-telemetry-otel/tests/otel.spec.ts b/packages/session/session-telemetry-otel/tests/otel.spec.ts index d8a9eec2db..4563bc7475 100644 --- a/packages/session/session-telemetry-otel/tests/otel.spec.ts +++ b/packages/session/session-telemetry-otel/tests/otel.spec.ts @@ -12,6 +12,7 @@ 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' @@ -255,17 +256,91 @@ describe('OpenTelemetrySessionBackend wire', () => { 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. + const fiber = await ctx.plugin(OpenTelemetrySessionBackend, { + mode: SessionTelemetryMode.FULL, + exporter: { url, compression: 'gzip' }, + }) + 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) - // Telemetry goes through `fetch` so a configured proxy carries it, and that transport ignores - // `compression`. Accepting the option would send uncompressed batches while the configuration - // said gzip; the deployment has to see the trade rather than pay it silently. + // `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, compression: 'gzip' }, - } as unknown as Config)).rejects.toThrow(/exporter\.compression not supported/) + 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 () => {