refactor: apply repository naming contract

Apply the accepted pre-release package, service, type, directory, and role renames as one repository-wide change.
This commit is contained in:
Tianyi Cui
2026-08-13 00:36:22 +08:00
parent 101df7cf58
commit a2d0f7f411
3281 changed files with 21730 additions and 21592 deletions

View File

@@ -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/session/session-telemetry-otel/README.md
README.md: 230efa3c8c77f1fea407cc02e493abea405169c9
README.zh.md: 79ef1131ad69ec2d10b9fe8ae3ea812f2b723c8e
README.md: 351690b5941bfe33cfbbf591269683f706701978
README.zh.md: e7031ff971cc8c9f2d1ce436513fbef1b9f27149

View File

@@ -2,13 +2,13 @@
English | [中文](README.zh.md)
The OpenTelemetry backend for [the telemetry seam](../session-telemetry/) — the only entry a deployment loads. Its `mode` decides whether the seam follows session events live, replays the canonical log only at recorded feedback, or keeps telemetry local. Uploading modes compose the OTel JS SDK as-is (`LoggerProvider` → `BatchLogRecordProcessor` → OTLP/HTTP log exporter) and map each handed-over record onto `logger.emit()`, under two instrumentation scopes: ledger records on `@deepseek-ai/dsh-session-telemetry-otel`, operational records on `@deepseek-ai/dsh-session-telemetry-otel/ops`. Resource identity contains `service.name`/`service.version` from `dsh-llm`'s `APP_IDENTITY` plus this package's anonymous `user.id` (`$DSH_HOME/.userid`, a random UUID created on first use and reset by deleting the file), carried once per export batch rather than per record.
The OpenTelemetry backend for [the telemetry seam](../session-telemetry/) — the only entry a deployment loads. Its `mode` decides whether the seam follows session events live, replays the canonical log only at recorded feedback, or keeps telemetry local. Uploading modes compose the OTel JS SDK as-is (`LoggerProvider` → `BatchLogRecordProcessor` → OTLP/HTTP log exporter) and map each handed-over record onto `logger.emit()`, under two instrumentation scopes: ledger records on `@deepseek-ai/dsh-session-sessionTelemetry-otel`, operational records on `@deepseek-ai/dsh-session-sessionTelemetry-otel/ops`. Resource identity contains `service.name`/`service.version` from `dsh-llm`'s `APP_IDENTITY` plus this package's anonymous `user.id` (`$DSH_HOME/.anonymous-user-id`, a random UUID created on first use and reset by deleting the file), carried once per export batch rather than per record.
## Config
```yaml
- id: telemetry-otel
name: '@deepseek-ai/dsh-session-telemetry-otel'
- id: sessionTelemetry-otel
name: '@deepseek-ai/dsh-session-sessionTelemetry-otel'
config:
mode: FULL # explicit opt-in; default: DISABLED
shutdownTimeoutMillis: 3000 # optional; defaults to 3000
@@ -23,19 +23,19 @@ The OpenTelemetry backend for [the telemetry seam](../session-telemetry/) — th
|---|---|
| `FULL` | Each projected record, including lifecycle ops records, is handed to the OTel SDK immediately. |
| `FEEDBACK_ONLY` | Each `feedback/record` replays, projects, and redacts the canonical session-log suffix through that event. Later records wait for another feedback event and remain local if none arrives. |
| `DISABLED` | Default. No coordinator, provider, processor, or exporter is constructed. No telemetry record leaves the process. A `feedback/record` logs `session telemetry is DISABLED; nothing will be shared and this feedback remains local`; the event remains in the local session log. |
| `DISABLED` | Default. No coordinator, provider, processor, or exporter is constructed. No telemetry record leaves the process. A `feedback/record` logs `session sessionTelemetry is DISABLED; nothing will be shared and this feedback remains local`; the event remains in the local session log. |
Programmatic TypeScript configuration uses the exported `TelemetryMode` enum (`TelemetryMode.FULL`, `TelemetryMode.FEEDBACK_ONLY`, or `TelemetryMode.DISABLED`); raw string literals are not assignable. Serialized Cordis configuration continues to use the string values shown above.
Programmatic TypeScript configuration uses the exported `SessionTelemetryMode` enum (`SessionTelemetryMode.FULL`, `SessionTelemetryMode.FEEDBACK_ONLY`, or `SessionTelemetryMode.DISABLED`); raw string literals are not assignable. Serialized Cordis configuration continues to use the string values shown above.
Upload authorization is positive and fail-closed. An unknown direct-construction mode fails before transport configuration is read. Only `FULL` accepts direct `ctx.telemetry.emit()` calls. `FEEDBACK_ONLY` gives its on-demand coordinator a private backend capability and treats only the exact `feedback/record` object already stored at `session.events[event.seq]` as consent; an independently emitted bus value is ignored. `DISABLED` never constructs the SDK pipeline, even when exporter options are present.
Upload authorization is positive and fail-closed. An unknown direct-construction mode fails before transport configuration is read. Only `FULL` accepts direct `ctx.sessionTelemetry.emit()` calls. `FEEDBACK_ONLY` gives its on-demand coordinator a private backend capability and treats only the exact `feedback/record` object already stored at `session.events[event.seq]` as consent; an independently emitted bus value is ignored. `DISABLED` never constructs the SDK pipeline, even when exporter options are present.
The mounted service discloses the resolved mode through the seam's [`TelemetrySharingStatus`](../session-telemetry/README.md#the-sharing-disclosure) `sharing` property (`full` / `feedback-only` / `disabled`), so the `/feedback` acknowledgement can report whether and how the session is shared. The disclosure is set in the constructor and is independent of capture: even `DISABLED` discloses `disabled`.
The mounted service discloses the resolved mode through the seam's [`SessionTelemetrySharingStatus`](../session-telemetry/README.md#the-sharing-disclosure) `sharing` property (`full` / `feedback-only` / `disabled`), so the `/feedback` acknowledgement can report whether and how the session is shared. The disclosure is set in the constructor and is independent of capture: even `DISABLED` discloses `disabled`.
`exporter.url` is required in `FULL` and `FEEDBACK_ONLY`, has no default, and must parse as `http(s)`; it is optional and unused in `DISABLED`. In uploading modes, `shutdownTimeoutMillis` is a positive finite DSH-owned outer deadline that defaults to 3000 ms, and a non-positive-integer `processor.maxExportBatchSize` also fails at plugin load because the SDK accepts it but then hangs on shutdown. Both SDK blocks pass through whole: every `OTLPExporterNodeConfigBase` field (`headers`, `timeoutMillis`, `compression`, `keepAlive`, …) reaches the exporter, and batching, export cadence (`scheduledDelayMillis`), retry, queue bounds, and loss policy under sustained failure are SDK behavior tuned through `processor`. The backend implements no `flush()`: the batch processor owns ordinary flushing. During shutdown, OTel awaits `exporter.forceFlush()` before the processor's `exportTimeoutMillis`-bounded completion promise; if that transport promise never settles, this package abandons the wait at `shutdownTimeoutMillis`, logs the contained shutdown failure through the coordinator, and lets application teardown continue. The deadline cannot cancel the SDK transport, so records still pending then may be lost at process exit.
## What leaves the machine
In uploading modes, records carry the complete `event.data` as the seam's `telemetry/record` waterfall returns it — user and assistant message content, tool arguments and results (command output, file contents), the full system prompt and tool schemas (`request/header`), todo text, compaction summaries, hook `stderrSummary`, feedback text, and the session `cwd` (a local path). The seam ships no redaction rules: with no `telemetry/record` listener mounted, that is the raw captured copy, so a deployment exporting beyond a trusted boundary mounts its own rules (see [the seam README](../session-telemetry/README.md#the-redact-waterfall)). `FULL` runs redaction at append time; `FEEDBACK_ONLY` retains no telemetry copy and runs the currently mounted rules when feedback triggers canonical-log replay. Provider credentials never appear regardless: adapter API keys are constructor parameters, not session events, so they are structurally absent from the log and therefore from telemetry. `DISABLED` does not construct the SDK pipeline or hand any capture to a backend.
In uploading modes, records carry the complete `event.data` as the seam's `sessionTelemetry/record` waterfall returns it — user and assistant message content, tool arguments and results (command output, file contents), the full system prompt and tool schemas (`request/header`), todo text, compaction summaries, hook `stderrSummary`, feedback text, and the session `cwd` (a local path). The seam ships no redaction rules: with no `sessionTelemetry/record` listener mounted, that is the raw captured copy, so a deployment exporting beyond a trusted boundary mounts its own rules (see [the seam README](../session-telemetry/README.md#the-redact-waterfall)). `FULL` runs redaction at append time; `FEEDBACK_ONLY` retains no telemetry copy and runs the currently mounted rules when feedback triggers canonical-log replay. Provider credentials never appear regardless: adapter API keys are constructor parameters, not session events, so they are structurally absent from the log and therefore from telemetry. `DISABLED` does not construct the SDK pipeline or hand any capture to a backend.
## Field mapping

View File

@@ -2,13 +2,13 @@
[English](README.md) | 中文
[遥测(telemetry)seam](../session-telemetry/) 的 OpenTelemetry 后端,也是部署方唯一要加载的条目。其 `mode` 决定 seam 是实时跟随会话事件、仅在记录反馈时回放权威日志,还是将遥测留在本地。上传模式会原样组合 OTel JS SDK(`LoggerProvider` → `BatchLogRecordProcessor` → OTLP/HTTP 日志导出器),把每条已交接记录映射到 `logger.emit()`,并使用两个插桩作用域(instrumentation scope):ledger 记录挂在 `@deepseek-ai/dsh-session-telemetry-otel` 下,运维记录挂在 `@deepseek-ai/dsh-session-telemetry-otel/ops` 下。资源身份包含 `service.name`/`service.version`(来自 `dsh-llm` 的 `APP_IDENTITY`),以及本包的匿名 `user.id`(`$DSH_HOME/.userid`;首次使用时创建的随机 UUID,删除该文件可重置);这些身份随每个导出批次携带一次,而非逐条记录携带。
[遥测(telemetry)seam](../session-telemetry/) 的 OpenTelemetry 后端,也是部署方唯一要加载的条目。其 `mode` 决定 seam 是实时跟随会话事件、仅在记录反馈时回放权威日志,还是将遥测留在本地。上传模式会原样组合 OTel JS SDK(`LoggerProvider` → `BatchLogRecordProcessor` → OTLP/HTTP 日志导出器),把每条已交接记录映射到 `logger.emit()`,并使用两个插桩作用域(instrumentation scope):ledger 记录挂在 `@deepseek-ai/dsh-session-sessionTelemetry-otel` 下,运维记录挂在 `@deepseek-ai/dsh-session-sessionTelemetry-otel/ops` 下。资源身份包含 `service.name`/`service.version`(来自 `dsh-llm` 的 `APP_IDENTITY`),以及本包的匿名 `user.id`(`$DSH_HOME/.anonymous-user-id`;首次使用时创建的随机 UUID,删除该文件可重置);这些身份随每个导出批次携带一次,而非逐条记录携带。
## 配置
```yaml
- id: telemetry-otel
name: '@deepseek-ai/dsh-session-telemetry-otel'
- id: sessionTelemetry-otel
name: '@deepseek-ai/dsh-session-sessionTelemetry-otel'
config:
mode: FULL # explicit opt-in; default: DISABLED
shutdownTimeoutMillis: 3000 # optional; defaults to 3000
@@ -23,19 +23,19 @@
|---|---|
| `FULL` | 每条已投影记录都立即交给 OTel SDK,包括生命周期运维记录。 |
| `FEEDBACK_ONLY` | 每个 `feedback/record` 都会回放权威会话日志中截至该事件的后缀,并进行投影与脱敏。后续记录等待下一个反馈事件;如果没有后续反馈,则留在本地。 |
| `DISABLED` | 默认值。不构造协调器、提供方、处理器或导出器。没有遥测记录会离开进程。`feedback/record` 会记录 `session telemetry is DISABLED; nothing will be shared and this feedback remains local`;该事件留在本地会话日志中。 |
| `DISABLED` | 默认值。不构造协调器、提供方、处理器或导出器。没有遥测记录会离开进程。`feedback/record` 会记录 `session sessionTelemetry is DISABLED; nothing will be shared and this feedback remains local`;该事件留在本地会话日志中。 |
程序化 TypeScript 配置使用导出的 `TelemetryMode` 枚举(`TelemetryMode.FULL`、`TelemetryMode.FEEDBACK_ONLY` 或 `TelemetryMode.DISABLED`);原始字符串字面量不可赋值。序列化后的 Cordis 配置继续使用上表所示的字符串值。
程序化 TypeScript 配置使用导出的 `SessionTelemetryMode` 枚举(`SessionTelemetryMode.FULL`、`SessionTelemetryMode.FEEDBACK_ONLY` 或 `SessionTelemetryMode.DISABLED`);原始字符串字面量不可赋值。序列化后的 Cordis 配置继续使用上表所示的字符串值。
上传授权采用显式许可,且为 fail-closed。通过直接构造传入未知模式时,会在读取传输配置前失败。只有 `FULL` 接受对 `ctx.telemetry.emit()` 的直接调用。`FEEDBACK_ONLY` 向其按需协调器提供私有后端能力,并且仅在 `feedback/record` 对象已经存储于 `session.events[event.seq]` 且对象身份完全相同时,才将其视为同意;独立发出的总线值会被忽略。即使存在导出器选项,`DISABLED` 也绝不会构造 SDK 流水线。
上传授权采用显式许可,且为 fail-closed。通过直接构造传入未知模式时,会在读取传输配置前失败。只有 `FULL` 接受对 `ctx.sessionTelemetry.emit()` 的直接调用。`FEEDBACK_ONLY` 向其按需协调器提供私有后端能力,并且仅在 `feedback/record` 对象已经存储于 `session.events[event.seq]` 且对象身份完全相同时,才将其视为同意;独立发出的总线值会被忽略。即使存在导出器选项,`DISABLED` 也绝不会构造 SDK 流水线。
已挂载的服务通过 seam 的 [`TelemetrySharingStatus`](../session-telemetry/README.md#the-sharing-disclosure) `sharing` 属性披露解析后的模式(`full` / `feedback-only` / `disabled`),因此 `/feedback` 的确认文本可以报告会话是否以及如何被共享。该披露在构造函数中设置,与采集相互独立:即使 `DISABLED` 也会披露 `disabled`。
已挂载的服务通过 seam 的 [`SessionTelemetrySharingStatus`](../session-telemetry/README.md#the-sharing-disclosure) `sharing` 属性披露解析后的模式(`full` / `feedback-only` / `disabled`),因此 `/feedback` 的确认文本可以报告会话是否以及如何被共享。该披露在构造函数中设置,与采集相互独立:即使 `DISABLED` 也会披露 `disabled`。
`exporter.url` 在 `FULL` 与 `FEEDBACK_ONLY` 中必填,无默认值,且必须能解析为 `http(s)`;在 `DISABLED` 中可省略且不使用。在上传模式中,`shutdownTimeoutMillis` 是由 DSH 管理的有限正数外层截止时间,默认值为 3000 ms;`processor.maxExportBatchSize` 不是正整数时也会在插件加载时失败,因为 SDK 会接受该值,随后却在关闭时挂起。两个 SDK 配置块都整体透传(passthrough):`OTLPExporterNodeConfigBase` 的每个字段(`headers`、`timeoutMillis`、`compression`、`keepAlive` 等)都会到达导出器;批处理、导出节奏(`scheduledDelayMillis`)、重试、队列上限,以及持续失败下的丢失策略,都是通过 `processor` 调节的 SDK 行为。该后端不实现 `flush()`:常规 flush 由批处理器负责。关闭期间,OTel 会先等待 `exporter.forceFlush()`,再等待受处理器 `exportTimeoutMillis` 限制的完成 promise;如果该传输 promise 始终不结算,本包会在 `shutdownTimeoutMillis` 到期时放弃等待,通过协调器记录已隔离的关闭失败,并让应用继续拆卸。该截止时间无法取消 SDK 传输,因此届时仍待处理的记录可能在进程退出时丢失。
## 哪些数据会离开本机
在上传模式中,记录携带完整的 `event.data`,内容以 seam 的 `telemetry/record` waterfall(瀑布式事件)返回的结果为准:用户与 assistant 消息内容、工具参数与工具结果(命令输出、文件内容)、完整的系统提示词与工具 schema(`request/header`)、todo 文本、压缩(compaction)摘要、钩子的 `stderrSummary`、反馈文本,以及会话 `cwd`(一个本地路径)。seam 不带任何脱敏规则:未挂载 `telemetry/record` 监听器时,导出的就是捕获原样的副本,因此向可信边界之外导出的部署方要挂载自己的规则(见 [seam README](../session-telemetry/README.md#the-redact-waterfall))。`FULL` 在追加时运行脱敏;`FEEDBACK_ONLY` 不保留遥测副本,而是在反馈触发权威日志回放时运行当时挂载的规则。无论如何,提供方凭据都不会出现:适配器的 API key 是构造函数参数而非会话事件,因此它们在结构上就不存在于日志中,也就不存在于遥测中。`DISABLED` 不会构造 SDK 流水线,也不会将任何捕获内容交给后端。
在上传模式中,记录携带完整的 `event.data`,内容以 seam 的 `sessionTelemetry/record` waterfall(瀑布式事件)返回的结果为准:用户与 assistant 消息内容、工具参数与工具结果(命令输出、文件内容)、完整的系统提示词与工具 schema(`request/header`)、todo 文本、压缩(compaction)摘要、钩子的 `stderrSummary`、反馈文本,以及会话 `cwd`(一个本地路径)。seam 不带任何脱敏规则:未挂载 `sessionTelemetry/record` 监听器时,导出的就是捕获原样的副本,因此向可信边界之外导出的部署方要挂载自己的规则(见 [seam README](../session-telemetry/README.md#the-redact-waterfall))。`FULL` 在追加时运行脱敏;`FEEDBACK_ONLY` 不保留遥测副本,而是在反馈触发权威日志回放时运行当时挂载的规则。无论如何,提供方凭据都不会出现:适配器的 API key 是构造函数参数而非会话事件,因此它们在结构上就不存在于日志中,也就不存在于遥测中。`DISABLED` 不会构造 SDK 流水线,也不会将任何捕获内容交给后端。
## 字段映射

View File

@@ -46,7 +46,7 @@
"@deepseek-ai/dsh-llm": "workspace:^",
"@deepseek-ai/dsh-session": "workspace:^",
"@deepseek-ai/dsh-session-telemetry": "workspace:^",
"@deepseek-ai/dsh-user-id": "workspace:^",
"@deepseek-ai/dsh-anonymous-user-id": "workspace:^",
"@deepseek-ai/cordis": "workspace:^"
},
"devDependencies": {
@@ -56,7 +56,7 @@
"@deepseek-ai/dsh-llm": "workspace:^",
"@deepseek-ai/dsh-session": "workspace:^",
"@deepseek-ai/dsh-session-telemetry": "workspace:^",
"@deepseek-ai/dsh-user-id": "workspace:^",
"@deepseek-ai/dsh-anonymous-user-id": "workspace:^",
"@deepseek-ai/cordis": "workspace:^"
}
}

View File

@@ -17,15 +17,15 @@ import z from '@deepseek-ai/schemastery'
import type { Context } from '@deepseek-ai/cordis'
import type {} from '@deepseek-ai/dsh-command-feedback'
import {
Telemetry,
TelemetryCoordinator,
type TelemetryBackend,
type TelemetryRecord,
type TelemetrySeverity,
type TelemetrySharingStatus,
SessionTelemetryBackend,
SessionTelemetryCoordinator,
type SessionTelemetrySink,
type SessionTelemetryRecord,
type SessionTelemetrySeverity,
type SessionTelemetrySharingStatus,
} from '@deepseek-ai/dsh-session-telemetry'
import { APP_IDENTITY } from '@deepseek-ai/dsh-llm'
import { getOrCreateAnonymousUserId } from '@deepseek-ai/dsh-user-id'
import { getOrCreateAnonymousUserId } from '@deepseek-ai/dsh-anonymous-user-id'
import {
BatchLogRecordProcessor,
LoggerProvider,
@@ -41,26 +41,26 @@ import { resourceFromAttributes } from '@opentelemetry/resources'
const { version } = createRequire(import.meta.url)('../package.json') as { version: string }
/** Session-sharing policy selected by {@link Config.mode}. */
export enum TelemetryMode {
export enum SessionTelemetryMode {
FULL = 'FULL',
FEEDBACK_ONLY = 'FEEDBACK_ONLY',
DISABLED = 'DISABLED',
}
/** Default session-sharing policy for schema and direct construction. */
export const DEFAULT_TELEMETRY_MODE = TelemetryMode.DISABLED
export const DEFAULT_TELEMETRY_MODE = SessionTelemetryMode.DISABLED
const DISABLED_FEEDBACK_WARNING = 'session telemetry is DISABLED; nothing will be shared and this feedback remains local'
const NON_CANONICAL_FEEDBACK_WARNING = 'session telemetry ignored a feedback event absent from the canonical session log'
const DROP_RECORD: TelemetryBackend['emit'] = () => {}
const DROP_RECORD: SessionTelemetrySink['emit'] = () => {}
/** Resolve the default and reject unknown runtime values before transport setup. */
function resolveMode(mode: TelemetryMode | undefined): TelemetryMode {
function resolveMode(mode: SessionTelemetryMode | undefined): SessionTelemetryMode {
const resolved = mode ?? DEFAULT_TELEMETRY_MODE
switch (resolved) {
case TelemetryMode.FULL:
case TelemetryMode.FEEDBACK_ONLY:
case TelemetryMode.DISABLED:
case SessionTelemetryMode.FULL:
case SessionTelemetryMode.FEEDBACK_ONLY:
case SessionTelemetryMode.DISABLED:
return resolved
default:
return assertNever(resolved)
@@ -73,11 +73,11 @@ function assertNever(value: never): never {
}
/** Map the serialized mode onto the seam's backend-independent sharing vocabulary. */
function sharingStatusFor(mode: TelemetryMode): TelemetrySharingStatus {
function sharingStatusFor(mode: SessionTelemetryMode): SessionTelemetrySharingStatus {
switch (mode) {
case TelemetryMode.FULL: return 'full'
case TelemetryMode.FEEDBACK_ONLY: return 'feedback-only'
case TelemetryMode.DISABLED: return 'disabled'
case SessionTelemetryMode.FULL: return 'full'
case SessionTelemetryMode.FEEDBACK_ONLY: return 'feedback-only'
case SessionTelemetryMode.DISABLED: return 'disabled'
/* v8 ignore next 2 -- resolveMode already rejected unknown values before this switch; the closed enum cannot reach the default. */
default: return assertNever(mode)
}
@@ -90,7 +90,7 @@ function sharingStatusFor(mode: TelemetryMode): TelemetrySharingStatus {
*/
export interface Config {
/** Sharing policy; defaults to local-only `DISABLED` behavior. */
mode?: TelemetryMode
mode?: SessionTelemetryMode
/**
* Passed verbatim to the SDK's OTLP/HTTP log exporter — the complete
* `OTLPExporterNodeConfigBase` shape (`headers`, `timeoutMillis`,
@@ -118,7 +118,7 @@ export interface Config {
* silently drop every field this plugin did not repeat.
*/
export const Config: z<Config> = z.object({
mode: z.union(Object.values(TelemetryMode)).default(DEFAULT_TELEMETRY_MODE),
mode: z.union(Object.values(SessionTelemetryMode)).default(DEFAULT_TELEMETRY_MODE),
exporter: z.any(),
processor: z.any(),
shutdownTimeoutMillis: z.number(),
@@ -132,7 +132,7 @@ export const DEFAULT_SHUTDOWN_TIMEOUT_MILLIS = 3_000
const MAX_TIMER_DELAY_MILLIS = 2_147_483_647
/** Severity mapping from the Service Definition's three-level vocabulary to OTel severity numbers. */
const SEVERITY: Record<TelemetrySeverity, { severityNumber: SeverityNumber; severityText: string }> = {
const SEVERITY: Record<SessionTelemetrySeverity, { severityNumber: SeverityNumber; severityText: string }> = {
info: { severityNumber: SeverityNumber.INFO, severityText: 'INFO' },
warn: { severityNumber: SeverityNumber.WARN, severityText: 'WARN' },
error: { severityNumber: SeverityNumber.ERROR, severityText: 'ERROR' },
@@ -141,23 +141,23 @@ const SEVERITY: Record<TelemetrySeverity, { severityNumber: SeverityNumber; seve
/**
* The backend plugin — the only entry a deployment loads. It always registers
* the `telemetry` service (duplicate load throws). Uploading modes wire the SDK
* pipeline and compose {@link TelemetryCoordinator}; `DISABLED` constructs no
* pipeline and compose {@link SessionTelemetryCoordinator}; `DISABLED` constructs no
* SDK state and listens only to warn when recorded feedback stays local.
*/
export class TelemetryOtel extends Telemetry {
export class OpenTelemetrySessionBackend extends SessionTelemetryBackend {
static inject = ['sessions']
static Config = Config
private readonly directEmit: TelemetryBackend['emit']
private readonly directEmit: SessionTelemetrySink['emit']
private readonly provider: LoggerProvider | undefined
private readonly shutdownTimeoutMillis: number
override readonly sharing: TelemetrySharingStatus
override readonly sharing: SessionTelemetrySharingStatus
constructor(ctx: Context, config: Config) {
const mode = resolveMode(config.mode)
super(ctx)
this.sharing = sharingStatusFor(mode)
if (mode === TelemetryMode.DISABLED) {
if (mode === SessionTelemetryMode.DISABLED) {
this.directEmit = DROP_RECORD
this.provider = undefined
this.shutdownTimeoutMillis = DEFAULT_SHUTDOWN_TIMEOUT_MILLIS
@@ -218,7 +218,7 @@ export class TelemetryOtel extends Telemetry {
})
const ledger = this.provider.getLogger('@deepseek-ai/dsh-session-telemetry-otel', version)
const ops = this.provider.getLogger('@deepseek-ai/dsh-session-telemetry-otel/ops', version)
const enqueue: TelemetryBackend['emit'] = (record) => {
const enqueue: SessionTelemetrySink['emit'] = (record) => {
const logger: Logger = record.channel === 'ops' ? ops : ledger
logger.emit({
timestamp: record.time,
@@ -230,17 +230,17 @@ export class TelemetryOtel extends Telemetry {
attributes: record.attributes,
})
}
const backend: TelemetryBackend = {
const backend: SessionTelemetrySink = {
emit: enqueue,
shutdown: () => this.shutdown(),
}
if (mode === TelemetryMode.FULL) {
if (mode === SessionTelemetryMode.FULL) {
this.directEmit = enqueue
new TelemetryCoordinator(ctx, backend, 'live')
new SessionTelemetryCoordinator(ctx, backend, 'live')
return
}
this.directEmit = DROP_RECORD
const coordinator = new TelemetryCoordinator(ctx, backend, 'on-demand')
const coordinator = new SessionTelemetryCoordinator(ctx, backend, 'on-demand')
ctx.on('session/event', (session, event) => {
if (event.type !== 'feedback/record') return
// Consent is the committed record, not an independently emitted bus value.
@@ -258,7 +258,7 @@ export class TelemetryOtel extends Telemetry {
* backend capability created only for the canonical feedback listener.
* @param record - the logical record offered directly to the service.
*/
emit(record: TelemetryRecord): void {
emit(record: SessionTelemetryRecord): void {
this.directEmit(record)
}
@@ -298,4 +298,4 @@ export class TelemetryOtel extends Telemetry {
}
}
export default TelemetryOtel
export default OpenTelemetrySessionBackend

View File

@@ -14,11 +14,11 @@ import { describe, expect, it } from 'vitest'
import { LOADER_SMOKE_TEST_TIMEOUT_MS, runLoaderSmoke } from '@deepseek-ai/dsh-loader-smoke'
const driver = fileURLToPath(new URL(
'../../../../examples/headless-agent/tests/fixtures/telemetry-otel-driver.ts',
'../../../../examples/headless-agent/tests/fixtures/session-telemetry-otel-driver.ts',
import.meta.url,
))
const configPath = fileURLToPath(new URL(
'../../../../examples/headless-agent/tests/fixtures/telemetry-otel.cordis.yml',
'../../../../examples/headless-agent/tests/fixtures/session-telemetry-otel.cordis.yml',
import.meta.url,
))
const repoTsconfig = fileURLToPath(new URL('../../../../tsconfig.json', import.meta.url))

View File

@@ -13,11 +13,11 @@ import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { gunzipSync } from 'node:zlib'
import { Context } from '@deepseek-ai/cordis'
import { getOrCreateAnonymousUserId } from '@deepseek-ai/dsh-user-id'
import { getOrCreateAnonymousUserId } from '@deepseek-ai/dsh-anonymous-user-id'
import Loader from '@deepseek-ai/cordis-plugin-loader'
import { recordFeedback } from '@deepseek-ai/dsh-command-feedback'
import SessionStore, { SessionId } from '@deepseek-ai/dsh-session'
import TelemetryOtel, { Config, DEFAULT_TELEMETRY_MODE, TelemetryMode } from '../src/index.ts'
import OpenTelemetrySessionBackend, { Config, DEFAULT_TELEMETRY_MODE, SessionTelemetryMode } from '../src/index.ts'
interface Capture {
headers: import('node:http').IncomingHttpHeaders
@@ -98,8 +98,8 @@ async function mockCollector(
async function boot(url: string) {
const ctx = new Context()
await ctx.plugin(SessionStore)
const fiber = await ctx.plugin(TelemetryOtel, {
mode: TelemetryMode.FULL,
const fiber = await ctx.plugin(OpenTelemetrySessionBackend, {
mode: SessionTelemetryMode.FULL,
exporter: { url, headers: { authorization: 'Bearer test-token' } },
})
return { ctx, fiber }
@@ -118,14 +118,14 @@ function eventTypes(captures: Capture[]): string[] {
: []) ?? [])
}
describe('TelemetryOtel wire', () => {
describe('OpenTelemetrySessionBackend wire', () => {
it('ships session records and the ops shutdown marker through the real SDK pipeline', async () => {
const { url, captures } = await mockCollector()
const { ctx, fiber } = await boot(url)
const session = ctx.sessions.create(SessionId('wire'), { meta: { cwd: '/tmp/w' } })
session.append('turn/start', { turn: 1 })
session.append('turn/end', { turn: 1, reason: { kind: 'error', error: { message: 'boom', code: 'UNKNOWN' } } })
ctx.telemetry.emit({
ctx.sessionTelemetry.emit({
channel: 'ledger',
time: Date.now(),
severity: 'info',
@@ -180,8 +180,8 @@ describe('TelemetryOtel wire', () => {
})
const ctx = new Context()
await ctx.plugin(SessionStore)
const fiber = await ctx.plugin(TelemetryOtel, {
mode: TelemetryMode.FULL,
const fiber = await ctx.plugin(OpenTelemetrySessionBackend, {
mode: SessionTelemetryMode.FULL,
exporter: { url },
processor: { scheduledDelayMillis: 10 },
})
@@ -211,8 +211,8 @@ describe('TelemetryOtel wire', () => {
})
const ctx = new Context()
await ctx.plugin(SessionStore)
const fiber = await ctx.plugin(TelemetryOtel, {
mode: TelemetryMode.FULL,
const fiber = await ctx.plugin(OpenTelemetrySessionBackend, {
mode: SessionTelemetryMode.FULL,
exporter: { url, timeoutMillis: 60_000 },
processor: { scheduledDelayMillis: 10, exportTimeoutMillis: 60_000 },
shutdownTimeoutMillis: 50,
@@ -240,8 +240,8 @@ describe('TelemetryOtel wire', () => {
// `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(TelemetryOtel, {
mode: TelemetryMode.FULL,
const fiber = await ctx.plugin(OpenTelemetrySessionBackend, {
mode: SessionTelemetryMode.FULL,
exporter: { url, compression: 'gzip' },
} as Config)
const session = ctx.sessions.create(SessionId('gzip'), { meta: {} })
@@ -258,12 +258,12 @@ describe('TelemetryOtel wire', () => {
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)
ctx.on('telemetry/record', (_record, next) => ({ ...next(), severity: 'warn' }))
ctx.on('session-telemetry/record', (_record, next) => ({ ...next(), severity: 'warn' }))
const session = ctx.sessions.create(SessionId('warn'), { meta: {} })
session.append('turn/start', { turn: 1 })
// No flush(): the coordinator's optional-call forwarding no-ops, and the
// batch processor owns export cadence end to end (see the backend note).
expect('flush' in ctx.telemetry && ctx.telemetry.flush !== undefined).toBe(false)
expect('flush' in ctx.sessionTelemetry && ctx.sessionTelemetry.flush !== undefined).toBe(false)
await fiber.dispose()
const start = allRecords(captures).find(r =>
r.record.attributes?.some(a => a.key === 'event.type' && a.value.stringValue === 'turn/start'))
@@ -274,12 +274,12 @@ describe('TelemetryOtel wire', () => {
const { url, captures } = await mockCollector()
const ctx = new Context()
await ctx.plugin(SessionStore)
const fiber = await ctx.plugin(TelemetryOtel, {
mode: TelemetryMode.FEEDBACK_ONLY,
const fiber = await ctx.plugin(OpenTelemetrySessionBackend, {
mode: SessionTelemetryMode.FEEDBACK_ONLY,
exporter: { url },
})
ctx.on('telemetry/record', (_record, next) => {
ctx.telemetry.emit({
ctx.on('session-telemetry/record', (_record, next) => {
ctx.sessionTelemetry.emit({
channel: 'ledger',
time: Date.now(),
severity: 'info',
@@ -310,13 +310,13 @@ describe('TelemetryOtel wire', () => {
const ctx = new Context()
await ctx.plugin(SessionStore)
const warn = vi.spyOn(ctx.logger, 'warn').mockImplementation(() => {})
const fiber = await ctx.plugin(TelemetryOtel, {
mode: TelemetryMode.FEEDBACK_ONLY,
const fiber = await ctx.plugin(OpenTelemetrySessionBackend, {
mode: SessionTelemetryMode.FEEDBACK_ONLY,
exporter: { url },
})
const session = ctx.sessions.create(SessionId('no-feedback'), { meta: {} })
session.append('turn/start', { turn: 1 })
ctx.telemetry.emit({
ctx.sessionTelemetry.emit({
channel: 'ledger',
time: Date.now(),
severity: 'info',
@@ -342,8 +342,8 @@ describe('TelemetryOtel wire', () => {
const ctx = new Context()
await ctx.plugin(SessionStore)
const warn = vi.spyOn(ctx.logger, 'warn').mockImplementation(() => {})
const fiber = await ctx.plugin(TelemetryOtel, {
mode: TelemetryMode.DISABLED,
const fiber = await ctx.plugin(OpenTelemetrySessionBackend, {
mode: SessionTelemetryMode.DISABLED,
exporter: { url },
processor: { maxExportBatchSize: 0 },
})
@@ -354,14 +354,14 @@ describe('TelemetryOtel wire', () => {
expect(warn).toHaveBeenCalledWith(
'session telemetry is DISABLED; nothing will be shared and this feedback remains local',
)
ctx.telemetry.emit({
ctx.sessionTelemetry.emit({
channel: 'ledger',
time: 0,
severity: 'info',
attributes: {},
body: null,
})
await ctx.telemetry.shutdown()
await ctx.sessionTelemetry.shutdown()
await fiber.dispose()
recordFeedback(session, 'after disposal')
expect(warn).toHaveBeenCalledTimes(1)
@@ -373,27 +373,27 @@ describe('TelemetryOtel wire', () => {
const fullCtx = new Context()
await fullCtx.plugin(SessionStore)
const full = await fullCtx.plugin(TelemetryOtel, { mode: TelemetryMode.FULL, exporter: { url } })
expect(fullCtx.telemetry.sharing).toBe('full')
const full = await fullCtx.plugin(OpenTelemetrySessionBackend, { mode: SessionTelemetryMode.FULL, exporter: { url } })
expect(fullCtx.sessionTelemetry.sharing).toBe('full')
await full.dispose()
const gatedCtx = new Context()
await gatedCtx.plugin(SessionStore)
const gated = await gatedCtx.plugin(TelemetryOtel, { mode: TelemetryMode.FEEDBACK_ONLY, exporter: { url } })
expect(gatedCtx.telemetry.sharing).toBe('feedback-only')
const gated = await gatedCtx.plugin(OpenTelemetrySessionBackend, { mode: SessionTelemetryMode.FEEDBACK_ONLY, exporter: { url } })
expect(gatedCtx.sessionTelemetry.sharing).toBe('feedback-only')
await gated.dispose()
const disabledCtx = new Context()
await disabledCtx.plugin(SessionStore)
const disabled = await disabledCtx.plugin(TelemetryOtel, { mode: TelemetryMode.DISABLED })
expect(disabledCtx.telemetry.sharing).toBe('disabled')
const disabled = await disabledCtx.plugin(OpenTelemetrySessionBackend, { mode: SessionTelemetryMode.DISABLED })
expect(disabledCtx.sessionTelemetry.sharing).toBe('disabled')
await disabled.dispose()
// An omitted mode is DISABLED, so the default also shares nothing.
const defaultCtx = new Context()
await defaultCtx.plugin(SessionStore)
const defaulted = await defaultCtx.plugin(TelemetryOtel, {})
expect(defaultCtx.telemetry.sharing).toBe('disabled')
const defaulted = await defaultCtx.plugin(OpenTelemetrySessionBackend, {})
expect(defaultCtx.sessionTelemetry.sharing).toBe('disabled')
await defaulted.dispose()
// No record was emitted by any mode, so nothing reached the collector.
@@ -405,7 +405,7 @@ describe('TelemetryOtel wire', () => {
const ctx = new Context()
await ctx.plugin(SessionStore)
const warn = vi.spyOn(ctx.logger, 'warn').mockImplementation(() => {})
new TelemetryOtel(ctx, {
new OpenTelemetrySessionBackend(ctx, {
exporter: { url },
processor: { maxExportBatchSize: 0 },
})
@@ -421,32 +421,32 @@ describe('TelemetryOtel wire', () => {
})
})
describe('TelemetryOtel config fails loud', () => {
describe('OpenTelemetrySessionBackend config fails loud', () => {
it('exposes modes through the nominal enum', () => {
expectTypeOf<Config['mode']>().toEqualTypeOf<TelemetryMode | undefined>()
expectTypeOf<'FULL'>().not.toExtend<TelemetryMode>()
expectTypeOf<TelemetryMode.FULL>().toExtend<TelemetryMode>()
expect(DEFAULT_TELEMETRY_MODE).toBe(TelemetryMode.DISABLED)
expectTypeOf<Config['mode']>().toEqualTypeOf<SessionTelemetryMode | undefined>()
expectTypeOf<'FULL'>().not.toExtend<SessionTelemetryMode>()
expectTypeOf<SessionTelemetryMode.FULL>().toExtend<SessionTelemetryMode>()
expect(DEFAULT_TELEMETRY_MODE).toBe(SessionTelemetryMode.DISABLED)
expect(Config({}).mode).toBe(DEFAULT_TELEMETRY_MODE)
})
it.each([
[{ mode: TelemetryMode.FULL }, /exporter\.url is required/],
[{ mode: TelemetryMode.FULL, exporter: { url: '' } }, /exporter\.url is required/],
[{ mode: TelemetryMode.FULL, exporter: { url: 'not a url' } }, /not a valid URL/],
[{ mode: TelemetryMode.FULL, exporter: { url: 'ftp://collector' } }, /must be http\(s\)/],
[{ mode: TelemetryMode.FEEDBACK_ONLY }, /exporter\.url is required/],
[{ mode: SessionTelemetryMode.FULL }, /exporter\.url is required/],
[{ mode: SessionTelemetryMode.FULL, exporter: { url: '' } }, /exporter\.url is required/],
[{ mode: SessionTelemetryMode.FULL, exporter: { url: 'not a url' } }, /not a valid URL/],
[{ mode: SessionTelemetryMode.FULL, exporter: { url: 'ftp://collector' } }, /must be http\(s\)/],
[{ mode: SessionTelemetryMode.FEEDBACK_ONLY }, /exporter\.url is required/],
[{ mode: 'INVALID' }, /INVALID/],
// The SDK accepts a non-positive batch size but its shutdown drain then
// splices empty batches forever — dispose would hang, so reject at load.
[{ mode: TelemetryMode.FULL, exporter: { url: 'http://c/v1/logs' }, processor: { maxExportBatchSize: 0 } }, /maxExportBatchSize/],
[{ mode: TelemetryMode.FULL, exporter: { url: 'http://c/v1/logs' }, processor: { maxExportBatchSize: 0.5 } }, /maxExportBatchSize/],
[{ mode: TelemetryMode.FULL, exporter: { url: 'http://c/v1/logs' }, shutdownTimeoutMillis: 0 }, /shutdownTimeoutMillis/],
[{ mode: TelemetryMode.FULL, exporter: { url: 'http://c/v1/logs' }, shutdownTimeoutMillis: Number.POSITIVE_INFINITY }, /shutdownTimeoutMillis/],
[{ mode: SessionTelemetryMode.FULL, exporter: { url: 'http://c/v1/logs' }, processor: { maxExportBatchSize: 0 } }, /maxExportBatchSize/],
[{ mode: SessionTelemetryMode.FULL, exporter: { url: 'http://c/v1/logs' }, processor: { maxExportBatchSize: 0.5 } }, /maxExportBatchSize/],
[{ mode: SessionTelemetryMode.FULL, exporter: { url: 'http://c/v1/logs' }, shutdownTimeoutMillis: 0 }, /shutdownTimeoutMillis/],
[{ mode: SessionTelemetryMode.FULL, exporter: { url: 'http://c/v1/logs' }, shutdownTimeoutMillis: Number.POSITIVE_INFINITY }, /shutdownTimeoutMillis/],
])('rejects %j at plugin load', async (config, message) => {
const ctx = new Context()
await ctx.plugin(SessionStore)
await expect(ctx.plugin(TelemetryOtel, config as Config)).rejects.toThrow(message)
await expect(ctx.plugin(OpenTelemetrySessionBackend, config as Config)).rejects.toThrow(message)
})
it('rejects an unknown direct mode before reading transport config', async () => {
@@ -461,7 +461,7 @@ describe('TelemetryOtel config fails loud', () => {
},
} as unknown as Config
expect(() => new TelemetryOtel(ctx, config)).toThrow(/unsupported mode "INVALID"/)
expect(() => new OpenTelemetrySessionBackend(ctx, config)).toThrow(/unsupported mode "INVALID"/)
expect(exporterRead).toBe(false)
})
@@ -472,7 +472,7 @@ describe('TelemetryOtel config fails loud', () => {
throw new Error('transport config was read')
})
const config = {
mode: TelemetryMode.DISABLED,
mode: SessionTelemetryMode.DISABLED,
get exporter() {
return transportRead()
},
@@ -484,7 +484,7 @@ describe('TelemetryOtel config fails loud', () => {
},
} as unknown as Config
new TelemetryOtel(ctx, config)
new OpenTelemetrySessionBackend(ctx, config)
expect(transportRead).not.toHaveBeenCalled()
await ctx.fiber.dispose()
})
@@ -494,21 +494,21 @@ describe('dsh-session-telemetry-otel real-load-path guard', () => {
it('keeps the Service class with inject/Config through unwrapExports', async () => {
const module = await import('../src/index.ts')
const loader = Object.create(Loader.prototype) as Loader
const unwrapped = loader.unwrapExports(module) as typeof TelemetryOtel
expect(unwrapped).toBe(TelemetryOtel)
const unwrapped = loader.unwrapExports(module) as typeof OpenTelemetrySessionBackend
expect(unwrapped).toBe(OpenTelemetrySessionBackend)
expect(unwrapped.inject).toEqual(['sessions'])
expect(typeof unwrapped.Config).toBe('function')
})
it('boots through the unwrapped class and registers ctx.telemetry', async () => {
it('boots through the unwrapped class and registers ctx.sessionTelemetry', async () => {
const { url } = await mockCollector()
const module = await import('../src/index.ts')
const loader = Object.create(Loader.prototype) as Loader
const unwrapped = loader.unwrapExports(module) as Parameters<Context['plugin']>[0]
const ctx = new Context()
await ctx.plugin(SessionStore)
const fiber = await ctx.plugin(unwrapped, { mode: TelemetryMode.FULL, exporter: { url } })
expect(ctx.telemetry).toBeInstanceOf(TelemetryOtel)
const fiber = await ctx.plugin(unwrapped, { mode: SessionTelemetryMode.FULL, exporter: { url } })
expect(ctx.sessionTelemetry).toBeInstanceOf(OpenTelemetrySessionBackend)
await fiber.dispose()
})
})

View File

@@ -30,10 +30,10 @@
"path": "../session-telemetry"
},
{
"path": "../user-id"
"path": "../../identity/anonymous-user-id"
},
{
"path": "../../support/invariants"
"path": "../../runtime-diagnostics/invariants"
}
]
}