diff --git a/docs/design/telemetry-resource-attributes-design.md b/docs/design/telemetry-resource-attributes-design.md new file mode 100644 index 00000000000..01ec84efa5b --- /dev/null +++ b/docs/design/telemetry-resource-attributes-design.md @@ -0,0 +1,762 @@ +# Telemetry: Custom Resource Attributes + Metric Cardinality Controls + +> 配套 issue: [#4365](https://github.com/QwenLM/qwen-code/issues/4365) +> 父 issue: [#3731](https://github.com/QwenLM/qwen-code/issues/3731) +> 基于 2026-05-21 对 qwen-code main 分支的代码复核 + +## 1. 背景 + +qwen-code 已经接入 OpenTelemetry SDK,但 Resource 构造方式让它在两个常见生产场景下不可用: + +1. **无法附加自定义维度**:运维侧想给所有 telemetry 数据打 `team` / `env` / `cost_center` / `user_id` 标签,今天没有任何机制可以做到。即使设置标准的 `OTEL_RESOURCE_ATTRIBUTES` 环境变量也**完全不生效**。 +2. **指标基数(cardinality)失控**:`session.id` 被注入到了 Resource 层,会自动附着到每条 metric 数据点。每个 CLI session 产生一个新值,指标后端(Prometheus / 阿里云 ARMS Metric / VictoriaMetrics)会被无界 time-series 撑爆。 + +这两个问题耦合在一起:解决前者会让用户**更容易**给数据加高基数的字段,所以必须配套提供后者。 + +## 2. 现状 + +### 2.1 Resource 构造 + +`packages/core/src/telemetry/sdk.ts:156-161`: + +```ts +const resource = resourceFromAttributes({ + [SemanticResourceAttributes.SERVICE_NAME]: SERVICE_NAME, + [SemanticResourceAttributes.SERVICE_VERSION]: + config.getCliVersion() || 'unknown', + 'session.id': config.getSessionId(), +}); +``` + +`sdk.ts:274-278`: + +```ts +sdk = new NodeSDK({ + resource, + // Disable async host/process/env resource detectors: they leave attributes + // pending and trigger an OTel diag.error on any resource attribute read + // before the detectors settle (e.g. during HttpInstrumentation span creation). + autoDetectResources: false, + ... +}); +``` + +`autoDetectResources: false` 关闭了标准 OTel 的 `envDetector`——也就是平时会读取 `OTEL_RESOURCE_ATTRIBUTES` 和 `OTEL_SERVICE_NAME` 的那一层。这是有原因的(detector 异步,会在 settle 前触发 `diag.error`),但副作用是这两个标准环境变量在 qwen-code 里**完全无效**。 + +### 2.2 `session.id` 实际是三重注入 + +| 位置 | 行号 | 影响 | +| --------------------------- | ------------------------ | ------------------------------------- | +| Resource | `sdk.ts:160` | 所有 signal(spans / logs / metrics) | +| Per-span | `session-tracing.ts:169` | spans | +| Per-log | `loggers.ts:128` | logs | +| **`getCommonAttributes()`** | `metrics.ts:57` | **每条 metric record 显式叠加** | + +也就是说**单独把 `session.id` 从 Resource 拿掉是不够的**——`metrics.ts:57` 的 `baseMetricDefinition.getCommonAttributes()` 会被 30+ 个 metric 调用点 `...spread` 进去,再次塞回 `session.id`。 + +```ts +// metrics.ts:55-59 +const baseMetricDefinition = { + getCommonAttributes: (config: Config): Attributes => ({ + 'session.id': config.getSessionId(), + }), +}; +``` + +好消息:所有 metric 调用点(30+ 个)都走这一个函数,是天然的 chokepoint。 + +### 2.3 config resolver 模式 + +`packages/core/src/telemetry/config.ts:resolveTelemetrySettings()` 用统一的优先级链: + +``` +argv (highest) > QWEN_* env > OTEL_* env > settings.json (lowest) +``` + +新加项照搬这个 pattern。 + +### 2.4 settings schema 现状 + +`packages/cli/src/config/settingsSchema.ts:998-1018` 定义 `telemetry` 的 JSON schema: + +```ts +telemetry: { + type: 'object', + // ... + jsonSchemaOverride: { + type: 'object', + properties: { + includeSensitiveSpanAttributes: { ... }, + }, + additionalProperties: true, // ← 今天对其他 telemetry.* key 不校验 + }, +} +``` + +`additionalProperties: true` 意味着今天 schema 对 `otlpEndpoint` / `otlpProtocol` / `resourceAttributes` 等其他字段全部放行不校验。新加 `resourceAttributes` / `metrics` 字段时,应同步在这里补 schema,方便 IDE 自动补全和 settings UI 渲染。 + +### 2.5 不在本设计范围的代码路径 + +`packages/core/src/telemetry/qwen-logger/qwen-logger.ts` 是 qwen-code 的**第一方使用上报通道**(基于阿里 RUM 内部协议 `RumResourceEvent`),与 OTel SDK 完全独立。它有自己的 endpoint、proxy 和数据模型,**不受本设计影响**。详见第 3 节。 + +### 2.6 已支持 / 未支持的 `OTEL_*` 环境变量 + +| 环境变量 | 现状 | +| --------------------------------------------------- | --------------------------------- | +| `OTEL_EXPORTER_OTLP_ENDPOINT` | ✅ 支持(`config.ts:79`) | +| `OTEL_EXPORTER_OTLP_{TRACES,LOGS,METRICS}_ENDPOINT` | ✅ 支持 | +| `OTEL_EXPORTER_OTLP_HEADERS` | ✅ 底层 exporter 直接读取 | +| `OTEL_TRACES_SAMPLER` | ✅ 支持(`tracer.ts:247`) | +| **`OTEL_RESOURCE_ATTRIBUTES`** | ❌ 完全不支持 | +| **`OTEL_SERVICE_NAME`** | ❌ 完全不支持 | +| **`OTEL_METRICS_INCLUDE_*`** | ❌ 完全不支持(claude-code 风格) | + +## 3. 目标 / 非目标 + +### 3.1 目标 + +- 让运维通过标准 `OTEL_RESOURCE_ATTRIBUTES` 和自家 `settings.json` 给所有 OTLP 导出的 span / log / metric 附加自定义 resource attributes +- 让 `OTEL_SERVICE_NAME` 按 OTel 规范工作(包括与 `OTEL_RESOURCE_ATTRIBUTES` 里的 `service.name` 的优先级) +- 默认情况下,metric 上**不**携带 `session.id`(保护后端基数) +- 提供显式开关让需要 metric-level session correlation 的用户重新打开 +- 保留 spans 和 logs 上的 `session.id`(trace correlation 必须) +- 保留 `autoDetectResources: false`,不退化 `diag.error` 那个已修的 bug +- 配套更新 `settingsSchema.ts` 让新字段对 settings UI 和 IDE 可见 + +### 3.2 非目标 + +- **`qwen-logger` 第一方上报**:完全独立的 RUM 通道,不在本设计范围。其上报字段(device id、user agent 等)由 RUM 协议决定,不应被用户 resource attribute 干扰。若未来要给 `qwen-logger` 增加自定义维度,是另一条独立的设计。 +- **Per-span 动态 attribute hook**:让用户写代码 / hook 给每个 span 计算 attribute。claude-code 也没解决这块,复杂度高、收益低。 +- **`service.version` cardinality 控制**:版本变化频率有限(月级),time series 增长可控。需要时走 v2,引入 OTel View API。 +- **Agent SDK 形态的 per-query resource attrs**:qwen-code 目前没有 SDK 调用场景。 +- **OTLP 请求头(auth headers)配置**:是另一条 issue 线(#3731 P1),与本设计独立。 +- **CLI flag 形式的 resource attribute**:env var + settings.json 已覆盖临时与基线两种场景,CLI flag 会让命令行变得啰嗦,无明显增益。 + +## 4. 设计 + +### 4.1 总体分层 + +``` +┌─ Resource(sdk.ts:156)────────────────────────────────────────┐ +│ service.name ← OTEL_SERVICE_NAME │ +│ > OTEL_RESOURCE_ATTRIBUTES.service.name│ +│ > 'qwen-code' │ +│ service.version ← config.getCliVersion() [reserved] │ +│ ...user attrs ← OTEL_RESOURCE_ATTRIBUTES │ +│ + settings.resourceAttributes │ +│ ✗ session.id 移走 │ +└────────────────────────────────────────────────────────────────┘ + │ + ├──→ Spans + session.id(session-tracing.ts:169,保留) + ├──→ Logs + session.id(loggers.ts:128,保留) + └──→ Metrics + getCommonAttributes() — 默认 {} + toggle ON: { session.id } +``` + +### 4.2 优先级 / merge 顺序 + +#### 一般 attribute + +低 → 高: + +1. `OTEL_RESOURCE_ATTRIBUTES`(标准 OTel env var) +2. `settings.telemetry.resourceAttributes` +3. 内建保留键(覆盖以上任何同名) + +**理由**:环境变量是 ops-time 临时覆盖(CI / 单机 debug),settings.json 是 fleet-baked 基线,内建是产品契约——基线优先级应高于临时变量,内建优先级应高于一切。 + +#### `service.name` 特殊处理 + +`service.name` 必须遵守 [OTel 规范](https://opentelemetry.io/docs/specs/otel/configuration/sdk-environment-variables/): + +> **`OTEL_SERVICE_NAME` takes precedence over `service.name` defined with the `OTEL_RESOURCE_ATTRIBUTES` variable.** + +因此对 `service.name` 单独应用这条优先级链(高 → 低): + +1. `OTEL_SERVICE_NAME`(最高,标准 OTel 规范规定) +2. `settings.resourceAttributes.service.name`(settings 优先于 env,沿用本设计一般规则) +3. `OTEL_RESOURCE_ATTRIBUTES.service.name` +4. 内建默认 `'qwen-code'` + +`service.name` 允许通过 settings 覆盖——它是 service 身份,企业 fleet 用统一 settings.json 配置 service.name 是常见且合理的做法,禁止反而会阻断 GitOps 分发场景。`OTEL_SERVICE_NAME` 作为标准 OTel 规范规定的"最高优先级"通道,仍然可以在 CI / 单机调试时临时覆盖 settings。 + +具体规则: + +| 来源 | 写入 `service.name` 是否生效 | +| ------------------------------------------------------- | -------------------------------------- | +| `OTEL_SERVICE_NAME=foo` | ✅ 最高优先级(覆盖任何其他来源) | +| `settings.resourceAttributes={ "service.name": "foo" }` | ✅ 仅在没有 `OTEL_SERVICE_NAME` 时生效 | +| `OTEL_RESOURCE_ATTRIBUTES=service.name=foo` | ✅ 仅在以上两者都没有时生效 | + +### 4.3 保留键策略 + +| 键 | 用户能否覆盖 | 理由 | +| ----------------- | ----------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------- | +| `service.name` | ✅ env var + settings 都可(见 §4.2 优先级链) | service 身份,应允许 ops 控制 | +| `service.version` | ❌ 任何来源都丢弃 + warn | 遥测可信度——不允许用户谎报版本 | +| `session.id` | ❌ 任何来源都丢弃 + warn(在 metric 上额外有 toggle 控制 runtime 注入) | runtime-only;用户写到 Resource 会绕过 metric cardinality toggle(Resource attr 自动附到所有 signal) | +| `qwen.*` 前缀 | ⚠️ 不强制保留,但 docs 建议留给产品自用 | 避免未来内建 attr 与用户 attr 冲突 | + +**保留键以常量集中维护**: + +```ts +// telemetry/resource-attributes.ts (new file) +/** Keys that cannot be overridden from any source (env or settings). */ +export const RESERVED_RESOURCE_ATTRIBUTE_KEYS = new Set([ + 'service.version', + 'session.id', +]); +``` + +`service.name` **不**在 RESERVED 列表里——它走自己的优先级链(§4.2),不属于"全局禁止覆盖"语义。RESERVED 是"任何来源写了都警告并丢弃",统一适用于 env 和 settings 两个入口。 + +### 4.4 `OTEL_RESOURCE_ATTRIBUTES` 解析 + +同步实现,绕开 OTel 自带的异步 envDetector: + +```ts +function parseOtelResourceAttributes( + raw: string | undefined, +): Record { + if (!raw) return {}; + const out: Record = {}; + for (const pair of raw.split(',')) { + const trimmed = pair.trim(); + if (!trimmed) continue; + const idx = trimmed.indexOf('='); + if (idx <= 0) { + diag.warn( + `Skipping malformed OTEL_RESOURCE_ATTRIBUTES entry: ${trimmed}`, + ); + continue; + } + const key = trimmed.slice(0, idx).trim(); + const valueRaw = trimmed.slice(idx + 1).trim(); + if (!key) continue; + let value: string; + try { + value = decodeURIComponent(valueRaw); + } catch { + diag.warn( + `Invalid percent-encoding in OTEL_RESOURCE_ATTRIBUTES for key "${key}", using raw value`, + ); + value = valueRaw; + } + out[key] = value; // duplicate keys: last wins (matches OTel reference impls) + } + return out; +} +``` + +格式严格按 OTel 规范:`key1=val1,key2=val2`,值 percent-encoded。 + +### 4.5 Metric attribute filter + +唯一改动点 `metrics.ts:55-59`: + +```ts +const baseMetricDefinition = { + getCommonAttributes: (config: Config): Attributes => { + const out: Attributes = {}; + if (config.getTelemetryMetricsIncludeSessionId()) { + out['session.id'] = config.getSessionId(); + } + return out; + }, +}; +``` + +调用点(30+ 个)零改动——`...spread` 一个空对象等价于不展开任何字段。 + +### 4.6 边界情况与校验 + +| 输入 | 行为 | +| ---------------------------------------------------------------- | ----------------------------------------------------------------------- | +| `OTEL_RESOURCE_ATTRIBUTES=""` (空字符串) | 返回 `{}`,正常启动 | +| `OTEL_RESOURCE_ATTRIBUTES="a"` (无 `=`) | 跳过该项 + `diag.warn`,继续解析其余 | +| `OTEL_RESOURCE_ATTRIBUTES="=val"` (空 key) | 跳过该项,继续解析其余 | +| `OTEL_RESOURCE_ATTRIBUTES="a=,b=2"` (空 value) | `a=''`, `b='2'`(OTel 规范允许空 value) | +| `OTEL_RESOURCE_ATTRIBUTES="a=val%ZZbad"` (无效 percent-encoding) | 保留原始 `val%ZZbad` + `diag.warn` | +| `OTEL_RESOURCE_ATTRIBUTES="a=1,a=2"` (duplicate key) | 后写胜出 `a=2`(与 OTel SDK 参考实现一致) | +| `OTEL_RESOURCE_ATTRIBUTES="a=1, b=2 "` (含空格) | 自动 trim | +| `OTEL_RESOURCE_ATTRIBUTES=service.version=x` | 静默丢弃 `service.version` + `diag.warn`,保留其他键 | +| `settings.resourceAttributes={ "service.name": "x" }` | 接受(settings 可设 service.name,见 §4.2) | +| `settings.resourceAttributes={ "service.version": "x" }` | 静默丢弃 + `diag.warn` | +| `settings.resourceAttributes={ "team": 123 }` (非 string) | TypeScript 类型阻挡;runtime 传入则 settings JSON schema validator 拒绝 | +| Resource 总大小 > OTel 限制 (4KB?) | 由底层 OTel SDK 处理,不在本层校验 | + +**为什么不在本层做 attribute key 命名校验**(如 OTel 推荐的 `[a-z][a-z0-9_.]*` 模式):OTel SDK 自己会在 export 时校验,本层重复校验既慢又容易和 SDK 行为偏移。我们只做格式解析,不做语义校验。 + +**RESERVED 键的强制保护对两个入口都生效**: + +```ts +// 应用于 env-parsed attrs +for (const k of RESERVED_RESOURCE_ATTRIBUTE_KEYS) { + if (k in envAttrs) { + diag.warn(`OTEL_RESOURCE_ATTRIBUTES cannot override "${k}"; ignoring`); + delete envAttrs[k]; + } +} + +// 应用于 settings attrs +for (const k of RESERVED_RESOURCE_ATTRIBUTE_KEYS) { + if (k in settingsAttrs) { + diag.warn( + `settings.telemetry.resourceAttributes cannot override "${k}"; ignoring`, + ); + delete settingsAttrs[k]; + } +} +``` + +### 4.7 生命周期与多进程 + +- **SDK init 时机**:Resource 在 `initializeTelemetry()` 时一次性构造,**进程内不可变**。这与 OTel SDK 设计一致。 +- **Subagent fork**:qwen-code 的 subagent 是同进程内的 (`subagent-runtime.ts`),共享 Resource。若未来引入跨进程 subagent,子进程会**重新 init SDK**,重新读 env var 和 settings——只要 env 透传过去,行为一致。 +- **Hot reload**:settings 修改后**不会重新构造 Resource**。需要操作员重启 CLI 才能生效。文档应明确说明。 +- **`refreshSessionContext()`** (`sdk.ts:306`):仅刷新 session ALS context,**不重建 Resource**——因为 Resource 上已经没有 `session.id` 了(本设计的核心改动之一)。 + +## 5. Config schema 改动 + +### 5.1 `TelemetrySettings` 接口(`packages/core/src/config/config.ts:293`) + +```ts +export interface TelemetrySettings { + // ... existing fields + /** Static resource attributes attached to every span/log/metric. */ + resourceAttributes?: Record; + /** Per-signal cardinality controls. */ + metrics?: { + /** Include session.id on metric data points (default: false). */ + includeSessionId?: boolean; + }; +} +``` + +### 5.2 `Config` getter(同文件) + +```ts +class Config { + getTelemetryResourceAttributes(): Record { + return this.telemetrySettings.resourceAttributes ?? {}; + } + getTelemetryMetricsIncludeSessionId(): boolean { + return this.telemetrySettings.metrics?.includeSessionId ?? false; + } +} +``` + +### 5.3 `resolveTelemetrySettings()` 新增 + +```ts +const envResourceAttrs = parseOtelResourceAttributes( + env['OTEL_RESOURCE_ATTRIBUTES'], +); +const settingsResourceAttrs = { ...(settings.resourceAttributes ?? {}) }; + +// Strip RESERVED keys from both sources (warn if user tried to set them). +for (const k of RESERVED_RESOURCE_ATTRIBUTE_KEYS) { + if (k in envResourceAttrs) { + diag.warn(`OTEL_RESOURCE_ATTRIBUTES cannot override "${k}"; ignoring`); + delete envResourceAttrs[k]; + } + if (k in settingsResourceAttrs) { + diag.warn( + `settings.telemetry.resourceAttributes cannot override "${k}"; ignoring`, + ); + delete settingsResourceAttrs[k]; + } +} + +// Merge: env < settings (settings wins on conflict). +const merged: Record = { + ...envResourceAttrs, + ...settingsResourceAttrs, +}; + +// service.name precedence: OTEL_SERVICE_NAME (env-only escape) wins over +// everything else. settings already overwrote env in the spread above. +if (env['OTEL_SERVICE_NAME']) { + merged['service.name'] = env['OTEL_SERVICE_NAME']; +} + +const resourceAttributes = merged; + +const metricsIncludeSessionId = + parseBooleanEnvFlag(env['QWEN_TELEMETRY_METRICS_INCLUDE_SESSION_ID']) ?? + settings.metrics?.includeSessionId ?? + false; + +return { + // ... existing fields + resourceAttributes, + metrics: { includeSessionId: metricsIncludeSessionId }, +}; +``` + +### 5.4 `sdk.ts` Resource 构造改动 + +```ts +const userAttrs = config.getTelemetryResourceAttributes(); +// service.version is always built-in; service.name flows through userAttrs +// (it was already resolved with OTEL_SERVICE_NAME precedence in resolver). +const builtinServiceName = userAttrs['service.name'] ?? SERVICE_NAME; +const { 'service.name': _, 'service.version': __, ...nonReserved } = userAttrs; + +const resource = resourceFromAttributes({ + ...nonReserved, + [SemanticResourceAttributes.SERVICE_NAME]: builtinServiceName, + [SemanticResourceAttributes.SERVICE_VERSION]: + config.getCliVersion() || 'unknown', + // session.id deliberately NOT placed on Resource — see design doc §4.1 +}); +``` + +### 5.5 `settingsSchema.ts` 改动 + +`packages/cli/src/config/settingsSchema.ts:998-1018` 的 `telemetry.jsonSchemaOverride.properties` 加: + +```ts +{ + // ... existing includeSensitiveSpanAttributes + resourceAttributes: { + type: 'object', + additionalProperties: { type: 'string' }, + description: + 'Static resource attributes attached to all telemetry data. ' + + 'Keys must be strings; values must be strings. ' + + 'Reserved keys (service.name, service.version) are silently dropped.', + default: {}, + }, + metrics: { + type: 'object', + additionalProperties: false, + properties: { + includeSessionId: { + type: 'boolean', + default: false, + description: + 'Include session.id on every metric data point. ' + + 'WARNING: each CLI session creates a new value, causing unbounded ' + + 'metric time-series fan-out. Only enable for short-term debugging.', + }, + }, + }, +} +``` + +也要把 `additionalProperties: true` 重新评估——目前是 permissive,可以保留也可以转 strict。建议保留 permissive,避免对其他未在 schema 中声明的 `telemetry.*` 字段产生破坏性变更,但 docs 里明确"未声明字段会被忽略"。 + +## 6. 文件改动清单 + +| 文件 | 改动 | +| -------------------------------------------------------------- | -------------------------------------------------------------------------- | +| `packages/core/src/telemetry/sdk.ts` | 改 Resource 构造(合并 user attrs,删 `session.id`) | +| `packages/core/src/telemetry/resource-attributes.ts` (新文件) | `parseOtelResourceAttributes()` + `RESERVED_RESOURCE_ATTRIBUTE_KEYS` 常量 | +| `packages/core/src/telemetry/config.ts` | resolver 加 `resourceAttributes` + `metrics.includeSessionId` 解析与 merge | +| `packages/core/src/telemetry/metrics.ts` | `getCommonAttributes()` 加 toggle gate | +| `packages/core/src/config/config.ts` | `TelemetrySettings` schema + 两个 getter | +| `packages/cli/src/config/settingsSchema.ts` | `jsonSchemaOverride` 加 `resourceAttributes` + `metrics` | +| `docs/developers/development/telemetry.md` | 加 "Resource attributes" + "Cardinality controls" 两节 + 迁移说明 + 示例 | +| `packages/core/src/telemetry/resource-attributes.test.ts` (新) | 解析器单元测试(覆盖 §4.6 全部用例) | +| `packages/core/src/telemetry/sdk.test.ts` | merge 优先级 / 保留键 / `OTEL_SERVICE_NAME` | +| `packages/core/src/telemetry/metrics.test.ts` | toggle off/on 时 `session.id` 出现与否 | +| `packages/core/src/telemetry/config.test.ts` | env / settings 合并 | +| `CHANGELOG.md` 或 release notes | PR 2 的 breaking change 说明 | + +## 7. 分 PR 拆分 + +按 review 友好性与 blast radius 分三个 PR: + +### PR 1 — Custom resource attributes(additive,零破坏) + +- 新文件 `resource-attributes.ts`:`parseOtelResourceAttributes()` + `RESERVED_RESOURCE_ATTRIBUTE_KEYS` +- `TelemetrySettings.resourceAttributes` 字段 + resolver merge 逻辑 +- `OTEL_SERVICE_NAME` / `OTEL_RESOURCE_ATTRIBUTES` 接入,按 §4.2 优先级 +- 合并进 Resource(`sdk.ts`) +- `settingsSchema.ts` 加 `resourceAttributes` JSON schema +- **不动** `session.id` 在 Resource 上的位置 +- Docs 加 "Resource attributes" 一节 + +**风险**:低。完全 additive,不改任何现有行为。除非用户主动设置环境变量或 settings,否则导出的数据无变化。 + +### PR 2 — Cardinality controls(semantic break) + +- 从 Resource 删 `session.id` (`sdk.ts:160` 那一行) +- 加 `metrics.includeSessionId` toggle(settings + env)+ `getCommonAttributes()` gate +- `settingsSchema.ts` 加 `metrics` JSON schema +- CHANGELOG / 迁移说明 +- 快照测试锁定 metric attribute 集合(防回归) +- Docs 加 "Cardinality controls" 一节 + 迁移指南 + +**风险**:中等。任何依赖 metric 上 `session.id` 的 Prometheus query / Grafana dashboard / 告警规则会失效。需要显式 release note 与 1-2 个版本的迁移窗口。 + +**Opt-in 过渡方案**(候选,本期建议**不采用**): + +> PR 2 可先以"opt-out"形式落地——默认仍把 `session.id` 注入 metric,但加 warn log "this default will flip in v0.X"。一个 release 后再翻转默认。 + +不建议采用的原因:(1)当前 qwen-code 用户群不大,破坏面有限;(2)这是 cardinality bug,越早默认安全越好;(3)双段式发布会增加文档负担。如果父 issue owner 想要保守一些,可以采纳。 + +### PR 3 — Docs polish + samples(cleanup) + +- `docs/developers/development/telemetry.md` 补示例(见 §10) +- 阿里云 ARMS / Prometheus / Grafana 接入示例 +- 把所有典型 use case 的 settings.json 片段加进去 + +## 8. 测试计划 + +### 8.1 `parseOtelResourceAttributes()` 单元测试 + +参数化覆盖 §4.6 表格全部行(建议用 vitest `it.each`): + +```ts +it.each([ + ['', {}], + ['a=1', { a: '1' }], + ['a=1,b=2', { a: '1', b: '2' }], + ['a=hello%20world', { a: 'hello world' }], + ['a=val%ZZbad', { a: 'val%ZZbad' }], // invalid percent + ['malformed', {}], + ['=val', {}], + ['a=', { a: '' }], + ['a=1,a=2', { a: '2' }], + [' a = 1 , b = 2 ', { a: '1', b: '2' }], +])('parses %j → %j', (input, expected) => { + expect(parseOtelResourceAttributes(input)).toEqual(expected); +}); +``` + +### 8.2 Resolver merge 测试 + +| 场景 | 期望 `service.name` | 期望 user attr | +| ----------------------------------------------------------------------- | ----------------------------------------------------- | ------------------------------------ | +| 全空 | `'qwen-code'` | 不存在 | +| 仅 env `OTEL_SERVICE_NAME=A` | `'A'` | — | +| 仅 env `OTEL_RESOURCE_ATTRIBUTES=service.name=B` | `'B'` | — | +| `OTEL_SERVICE_NAME=A` + `OTEL_RESOURCE_ATTRIBUTES=service.name=B` | `'A'`(OTEL_SERVICE_NAME 优先) | — | +| `OTEL_SERVICE_NAME=A` + `settings={service.name:C}` | `'A'`(OTEL_SERVICE_NAME 优先) | — | +| `OTEL_RESOURCE_ATTRIBUTES=service.name=B` + `settings={service.name:C}` | `'C'`(settings 优先于 env,无 OTEL_SERVICE_NAME 时) | — | +| `OTEL_RESOURCE_ATTRIBUTES=team=x` + `settings={team:y}` | `'qwen-code'` | `team='y'`(settings 优先) | +| `OTEL_RESOURCE_ATTRIBUTES=service.version=fake` | `'qwen-code'` + warn | service.version 仍为真实 cli version | +| `settings={service.version:fake}` | `'qwen-code'` + warn | service.version 仍为真实 cli version | + +### 8.3 Resource 内容快照测试 + +用 `InMemorySpanExporter` 拿一个 span,断言: + +```ts +expect(span.resource.attributes['service.name']).toBe('qwen-code'); +expect(span.resource.attributes['service.version']).toBe(EXPECTED_VERSION); +expect(span.resource.attributes['session.id']).toBeUndefined(); // 关键 +expect(span.resource.attributes['team']).toBe('platform'); // 用户加的 +``` + +### 8.4 Metric attribute toggle 测试 + +```ts +it('does not emit session.id on metrics by default', async () => { + // emit one tool call counter + recordToolCallMetrics(...); + const data = await metricReader.collect(); + const dp = data.resourceMetrics.scopeMetrics[0].metrics[0].dataPoints[0]; + expect(dp.attributes['session.id']).toBeUndefined(); +}); + +it('emits session.id when toggle is true', async () => { + config.telemetrySettings.metrics = { includeSessionId: true }; + recordToolCallMetrics(...); + const data = await metricReader.collect(); + const dp = data.resourceMetrics.scopeMetrics[0].metrics[0].dataPoints[0]; + expect(dp.attributes['session.id']).toBe(KNOWN_SESSION_ID); +}); +``` + +### 8.5 Spans / Logs 行为保持测试 + +- spans 仍有 `session.id`(不受 metric toggle 影响) +- logs 仍有 `session.id`(不受 metric toggle 影响) + +### 8.6 回归保护 + +- `autoDetectResources: false` 保持不变(assertion on config) +- 启动期间不出现新增 `diag.error`(捕获 OTel diag 日志做 assertion) +- 现有所有 telemetry 测试通过(CI) + +### 8.7 Diag warn 测试 + +校验下列输入都触发 `diag.warn` 一次: + +- `settings.resourceAttributes = { 'service.version': 'x' }`(reserved) +- `OTEL_RESOURCE_ATTRIBUTES=service.version=x`(reserved,env 也要 warn) +- `OTEL_RESOURCE_ATTRIBUTES=malformed`(无 `=`) +- `OTEL_RESOURCE_ATTRIBUTES=a=val%ZZ`(无效 percent-encoding) + +校验下列输入**不**触发 warn(合法路径): + +- `settings.resourceAttributes = { 'service.name': 'x' }`(settings 允许设 service.name) +- `OTEL_SERVICE_NAME=foo` + `settings.resourceAttributes = { 'service.name': 'bar' }`(OTEL_SERVICE_NAME 优先即可,不需要 warn) + +## 9. 迁移 / 破坏性变更 + +### 9.1 破坏性变更(PR 2) + +**指标上的 `session.id` 默认消失**。这会影响: + +- Prometheus query 中 `by (session_id)` / `group_left(session_id)` 的聚合 +- Grafana dashboard 中按 session 切片的图 +- 任何按 session.id 做告警分组的规则 + +注:spans 和 logs 上的 `session.id` **不受影响**。 + +### 9.2 迁移路径 + +文档里给两个选项: + +**选项 A**:恢复旧行为(短期 debug 推荐) + +```bash +export QWEN_TELEMETRY_METRICS_INCLUDE_SESSION_ID=true +``` + +或 `settings.json`: + +```json +{ + "telemetry": { + "metrics": { "includeSessionId": true } + } +} +``` + +⚠️ **警告**:长期开启会让 metric time-series 数量 = 历史 session 数量,撑爆后端。仅短期 debug 用。 + +**选项 B**:改用 spans / logs 做 session 切片(推荐) + +- spans / logs 上仍有 `session.id`,可在 trace backend(如 Jaeger / Aliyun ARMS Tracing)/ log backend(如 Loki / SLS)按 session 切片 +- 这两类数据本来就是 per-event 存储,cardinality 不会爆炸 +- 适合做 session-level drill-down 分析 + +### 9.3 Release note 模板 + +``` +**Breaking change (metric attribute):** + +The `session.id` attribute is no longer attached to metric data +points by default. This protects metric backends from unbounded +time-series fan-out. + +- Spans and logs are unaffected — `session.id` is still present. +- To restore the previous behavior (short-term debugging only), set + `QWEN_TELEMETRY_METRICS_INCLUDE_SESSION_ID=true` or in settings.json: + `telemetry.metrics.includeSessionId: true`. +- For long-term session correlation, query against trace / log + backends instead of metric backends. + +See docs/developers/development/telemetry.md "Migration" for details. +``` + +## 10. 示例配置(用于文档) + +### 10.1 按 team / env 切片所有 telemetry + +```bash +export OTEL_RESOURCE_ATTRIBUTES="team=platform,env=prod,cost_center=eng-123" +``` + +效果:所有 span / log / metric 都带 `team=platform` `env=prod` `cost_center=eng-123`。 + +### 10.2 用 `OTEL_SERVICE_NAME` 在共享 collector 中路由 + +```bash +export OTEL_SERVICE_NAME=qwen-code-ci +``` + +效果:`service.name=qwen-code-ci`,多租户 OTel collector 可按 service.name 路由到不同后端。 + +### 10.3 Fleet baseline + 单机 override + +公司 fleet 的 `~/.qwen/settings.json`(GitOps 分发): + +```json +{ + "telemetry": { + "resourceAttributes": { + "deployment.environment": "production", + "service.namespace": "engineering-tooling" + } + } +} +``` + +单机 ops 临时覆盖(不修改 settings): + +```bash +export OTEL_RESOURCE_ATTRIBUTES="debug_run=true" +# settings 里的 deployment.environment / service.namespace 仍然生效 +# 同时这次运行额外带 debug_run=true +``` + +### 10.4 短期 debug 打开 metric session.id + +```bash +# 一次性 debug run +QWEN_TELEMETRY_METRICS_INCLUDE_SESSION_ID=true qwen "投资分析" +``` + +完事即关闭,不要持久化到 settings。 + +### 10.5 阿里云 ARMS Metric 接入(推荐配置) + +```json +{ + "telemetry": { + "enabled": true, + "otlpEndpoint": "http:///api/v1/...", + "otlpProtocol": "http", + "resourceAttributes": { + "team": "platform", + "deployment.environment": "production" + }, + "metrics": { + "includeSessionId": false + } + } +} +``` + +## 11. 与 claude-code 实现的对比 + +| 维度 | claude-code | qwen-code 本设计 | 决策依据 | +| -------------------------- | ------------------------------------------------ | ------------------------------------------------ | -------------------------------------------------- | +| 标准 OTel env var | `OTEL_RESOURCE_ATTRIBUTES` / `OTEL_SERVICE_NAME` | ✅ 一致 | 标准契约 | +| `OTEL_SERVICE_NAME` 优先级 | 遵守 OTel 规范 | ✅ 遵守 | spec 明确规定 | +| Cardinality 开关命名 | `OTEL_METRICS_INCLUDE_*` | `QWEN_TELEMETRY_METRICS_INCLUDE_*` | 不污染标准 OTel 命名空间 | +| 开关作用域 | 仅 metric | ✅ 仅 metric | spans / logs 是 per-event,无 cardinality 爆炸问题 | +| 默认值 | 高基数 attribute 默认 false | ✅ 默认 false | 安全优先 | +| Per-attribute granularity | 每 attribute 一个 toggle | ✅ 一致 | 灵活,符合实际诊断需求 | +| settings.json 等价物 | ❌ 无 | ✅ 有 `telemetry.resourceAttributes` + `metrics` | 企业 fleet 部署 base config | +| Per-span 动态 hook | ❌ 无 | ❌ 无 | 复杂度高,claude-code 也没解,本期不做 | +| 多租户 `account_uuid` | 有 | ❌ 无 | qwen-code metric 里没有此 attr | +| Agent SDK `options.env` | 有 | ❌ 无 | qwen-code 没有等价模式 | +| 保留键策略 | 不允许覆盖 built-in id | ✅ 一致 | 遥测可信度 | +| 第一方上报通道 | claude-code 也有独立第一方通道(与 OTel 隔离) | ✅ qwen-logger 同样隔离 | 第一方与第三方通道职责分离 | + +**最值得借的两点**: + +1. **命名约定**:`*_INCLUDE_*` 一眼能看出语义,比反义命名(`*_EXCLUDE_*` / `*_DROP_*`)清晰 +2. **范围克制**:只 gate metric,不 gate span/log——claude-code 显然踩过这个边界,我们直接受益 + +**qwen-code 做得更好的点**: + +- settings.json 支持:claude-code 完全靠 env var,对企业 fleet 场景不友好 +- 明确的保留键策略(`service.version` 不可覆盖):减少遥测被污染的可能 +- 第一方上报隔离:qwen-logger 走独立通道,与用户 OTLP 设置完全解耦 + +## 12. 未来工作(v2 + 候选) + +- **`service.version` cardinality 控制**:用 OTel View API 在 metric 层 drop attribute +- **更多 cardinality toggle**:未来若 metric 上引入 `user.account_uuid` / `model` 等,按需补 toggle +- **Per-span 动态 attribute hook**:可借鉴 qwen-code 自家 hooks 系统,加 `OnSpanStart(span, context) => attrs` 回调。需要独立设计。 +- **Resource attribute schema 校验**:限制 key 命名空间(如禁止覆盖 `service.*` 前缀以外的内建 attr),目前靠保留键列表硬编码够用。 +- **Hot reload Resource**:当 settings.json 在进程内被修改(设想 qwen-serve daemon 场景),目前不会重建 Resource。若 daemon 场景成熟,可以增加一条 reload 路径。 +- **跨进程 subagent context 传播**:subagent 跨进程时,把 parent 的 trace context(包括 resource)通过 OTel context propagation 标准 header 传过去。需要独立设计。 diff --git a/docs/developers/development/telemetry.md b/docs/developers/development/telemetry.md index 1cd31a96916..a0069fe671e 100644 --- a/docs/developers/development/telemetry.md +++ b/docs/developers/development/telemetry.md @@ -66,6 +66,8 @@ These settings can be overridden by environment variables or CLI flags. | `outfile` | `QWEN_TELEMETRY_OUTFILE` | `--telemetry-outfile ` | Save telemetry to file (overrides OTLP export) | file path | - | | `logPrompts` | `QWEN_TELEMETRY_LOG_PROMPTS` | `--telemetry-log-prompts` / `--no-telemetry-log-prompts` | Include prompts in telemetry logs | `true`/`false` | `true` | | `includeSensitiveSpanAttributes` | `QWEN_TELEMETRY_INCLUDE_SENSITIVE_SPAN_ATTRIBUTES` | - | Include user prompts, system prompts, tool I/O, and model output as native span attributes (in addition to log-to-span bridge spans) | `true`/`false` | `false` | +| `resourceAttributes` | `OTEL_RESOURCE_ATTRIBUTES` (+ `OTEL_SERVICE_NAME`) | - | Static resource attributes attached to every exported span / log / metric. See [Resource attributes](#resource-attributes) below. | `key=value,…` | `{}` | +| `metrics.includeSessionId` | `QWEN_TELEMETRY_METRICS_INCLUDE_SESSION_ID` | - | Include `session.id` on metric data points. **Disabled by default** to protect metric backends from time-series fan-out. | `true`/`false` | `false` | **Note on boolean environment variables:** For the boolean settings (`enabled`, `logPrompts`, `includeSensitiveSpanAttributes`), setting the @@ -125,6 +127,141 @@ The `QWEN_TELEMETRY_OTLP_*` variants take precedence over the `OTEL_*` variants. For detailed information about all configuration options, see the [Configuration Guide](./cli/configuration.md). +### Resource attributes + +Resource attributes are static key-value pairs attached to every span, log, +and metric exported via OTLP. Use them to slice telemetry by team, environment, +deployment region, or any other dimension your backend cares about. + +Two sources, merged in priority order (lowest → highest): + +1. The standard `OTEL_RESOURCE_ATTRIBUTES` env var +2. `telemetry.resourceAttributes` in `.qwen/settings.json` (overrides env on + key conflict) + +`OTEL_SERVICE_NAME` is a separate escape hatch — when set, it overrides +`service.name` from any other source (per the OpenTelemetry spec). + +#### Examples + +**Slice all telemetry by team / environment:** + +```bash +export OTEL_RESOURCE_ATTRIBUTES="team=platform,env=prod,cost_center=eng-123" +``` + +**Route to a per-tenant collector via `service.name`:** + +```bash +export OTEL_SERVICE_NAME=qwen-code-ci +``` + +**Fleet baseline (`~/.qwen/settings.json`) + per-host override:** + +```json +{ + "telemetry": { + "resourceAttributes": { + "deployment.environment": "production", + "service.namespace": "engineering-tooling" + } + } +} +``` + +```bash +# Add a one-off tag without touching settings: +export OTEL_RESOURCE_ATTRIBUTES="debug_run=true" +``` + +#### Reserved keys + +Some keys are runtime-controlled and cannot be overridden: + +- `service.version` — always set to the running CLI version. Setting it from + any source is silently dropped with a warning. +- `session.id` — runtime-injected per session. User-provided values from + either env or settings are dropped with a warning. The reason is that + Resource attributes auto-attach to every metric data point; allowing user + override would bypass [Cardinality controls](#cardinality-controls) below. + Spans and logs always carry `session.id`. + +`service.name` is **not** reserved; it follows the precedence chain above. + +#### Format + +`OTEL_RESOURCE_ATTRIBUTES` follows the OpenTelemetry spec: +`key1=value1,key2=value2` with values percent-encoded. Spaces in values must +be encoded as `%20`, **commas as `%2C`** (unencoded commas split the value at +the wrong boundary and the second half is dropped as malformed). Malformed +pairs are skipped with a warning rather than failing telemetry startup. + +#### Troubleshooting: when a user-provided attribute appears not to take effect + +Reserved keys (`service.version`, `session.id`), malformed pairs, non-string +settings values, and invalid percent-encoding are all silently dropped with a +warning logged via the OpenTelemetry diagnostics channel. That channel routes +to the debug log file (`~/.qwen/log/otel-*.log`), **not** the console, so the +behavior can look like silent failure. + +If a custom resource attribute isn't appearing on exported telemetry: + +1. Check `~/.qwen/log/otel-*.log` for lines matching `cannot override` (reserved + key dropped), `Skipping malformed` (bad env var pair), or `must be a string` + (non-string settings value). +2. Verify the env var is set in the qwen-code process's environment (not just + your shell) and that values are percent-encoded. +3. Confirm `telemetry.enabled` is `true` — telemetry init only runs if enabled. + +### Cardinality controls + +Metrics are aggregated by attribute set at the backend — every distinct +combination of attribute values produces a new time series. Attaching a +high-cardinality field like `session.id` to a metric causes time-series fan-out +proportional to the number of sessions, which quickly exhausts metric backend +storage. + +To prevent this, Qwen Code keeps high-cardinality attributes off metric data +points by default. Spans and logs are per-event and unaffected, so they +continue to carry `session.id` for trace and log correlation. + +#### `telemetry.metrics.includeSessionId` (default: `false`) + +Setting this to `true` (via settings or +`QWEN_TELEMETRY_METRICS_INCLUDE_SESSION_ID=true`) re-attaches `session.id` to +every metric data point. + +⚠️ **Warning:** each CLI session creates a new value. Leaving this on for a +fleet will blow up metric storage. Recommended only for short-term debugging. +For long-term session correlation, query trace or log backends instead. + +#### Migration from earlier versions + +Prior to this release, `session.id` was attached to metrics by default. If +your Prometheus queries / Grafana dashboards / alert rules reference +`session_id` on a metric, you have two options: + +**Option A** — restore the previous behavior for short-term debugging: + +```bash +export QWEN_TELEMETRY_METRICS_INCLUDE_SESSION_ID=true +``` + +or: + +```json +{ + "telemetry": { + "metrics": { "includeSessionId": true } + } +} +``` + +**Option B (recommended)** — move session-level analysis off metrics. Spans +and logs still carry `session.id`, and trace / log backends (Jaeger, Tempo, +Loki, Aliyun SLS / ARMS Tracing) handle per-session slicing natively without +cardinality pressure. + ## Aliyun Telemetry ### Manual OTLP Export diff --git a/packages/cli/src/config/settingsSchema.ts b/packages/cli/src/config/settingsSchema.ts index 2c2f274a333..46726450ae8 100644 --- a/packages/cli/src/config/settingsSchema.ts +++ b/packages/cli/src/config/settingsSchema.ts @@ -1012,6 +1012,26 @@ const SETTINGS_SCHEMA = { type: 'boolean', default: false, }, + resourceAttributes: { + description: + 'Static resource attributes attached to every span/log/metric the SDK exports (OTLP or file outfile — they share the same Resource). Merged with the OTEL_RESOURCE_ATTRIBUTES env var; settings win on key conflict. Reserved keys (service.version, session.id) are dropped with a warning.', + type: 'object', + additionalProperties: { type: 'string' }, + default: {}, + }, + metrics: { + description: 'Per-signal cardinality controls for exported metrics.', + type: 'object', + additionalProperties: false, + properties: { + includeSessionId: { + description: + 'Include session.id on every metric data point. WARNING: each CLI session creates a new value, causing unbounded metric time-series fan-out at the backend. Only enable for short-term debugging — spans and logs still carry session.id.', + type: 'boolean', + default: false, + }, + }, + }, }, additionalProperties: true, }, diff --git a/packages/core/src/config/config.ts b/packages/core/src/config/config.ts index 03ec0cd0e0f..733f8bab3e8 100644 --- a/packages/core/src/config/config.ts +++ b/packages/core/src/config/config.ts @@ -304,6 +304,37 @@ export interface TelemetrySettings { logPrompts?: boolean; includeSensitiveSpanAttributes?: boolean; outfile?: string; + /** + * Static resource attributes attached to every span/log/metric the SDK + * exports (OTLP or file outfile — they share the same Resource). + * Merged with `OTEL_RESOURCE_ATTRIBUTES`; settings win on key conflict. + * Reserved keys (`service.version`, `session.id`) are dropped with a + * `diag.warn`. + */ + resourceAttributes?: Record; + /** Per-signal cardinality controls. */ + metrics?: TelemetryMetricsSettings; + /** + * Human-readable diagnostics produced while resolving + * `resourceAttributes` (drops, coercions, reserved-key strips). + * Populated by `resolveTelemetrySettings()`; the SDK emits a one-time + * console summary at startup when this is non-empty so users notice + * silent drops without scanning the OTel debug log. + * + * Not a user-settable field — operators should leave it unset. + */ + resourceAttributeWarnings?: string[]; +} + +export interface TelemetryMetricsSettings { + /** + * Include `session.id` on every metric data point. Default: false. + * + * WARNING: each CLI session creates a new value, causing unbounded + * metric time-series fan-out at the backend. Only enable for + * short-term debugging — spans and logs still carry session.id. + */ + includeSessionId?: boolean; } export interface OutputSettings { @@ -987,6 +1018,9 @@ export class Config { includeSensitiveSpanAttributes: params.telemetry?.includeSensitiveSpanAttributes ?? false, outfile: params.telemetry?.outfile, + resourceAttributes: params.telemetry?.resourceAttributes, + metrics: params.telemetry?.metrics, + resourceAttributeWarnings: params.telemetry?.resourceAttributeWarnings, }; this.gitCoAuthor = { ...normalizeGitCoAuthor(params.gitCoAuthor), @@ -2807,6 +2841,18 @@ export class Config { return this.telemetrySettings.target ?? DEFAULT_TELEMETRY_TARGET; } + getTelemetryResourceAttributes(): Record { + return this.telemetrySettings.resourceAttributes ?? {}; + } + + getTelemetryMetricsIncludeSessionId(): boolean { + return this.telemetrySettings.metrics?.includeSessionId ?? false; + } + + getTelemetryResourceAttributeWarnings(): readonly string[] { + return this.telemetrySettings.resourceAttributeWarnings ?? []; + } + getTelemetryOutfile(): string | undefined { return this.telemetrySettings.outfile; } diff --git a/packages/core/src/telemetry/config.test.ts b/packages/core/src/telemetry/config.test.ts index 4c041db6bc9..2cb99d9f577 100644 --- a/packages/core/src/telemetry/config.test.ts +++ b/packages/core/src/telemetry/config.test.ts @@ -70,6 +70,8 @@ describe('telemetry/config helpers', () => { otlpTracesEndpoint: undefined, otlpLogsEndpoint: undefined, otlpMetricsEndpoint: undefined, + resourceAttributes: undefined, + metrics: { includeSessionId: false }, }); }); @@ -113,6 +115,8 @@ describe('telemetry/config helpers', () => { logPrompts: true, includeSensitiveSpanAttributes: true, outfile: 'env.log', + resourceAttributes: undefined, + metrics: { includeSessionId: false }, }); const resolvedArgv = await resolveTelemetrySettings({ @@ -131,6 +135,8 @@ describe('telemetry/config helpers', () => { logPrompts: false, includeSensitiveSpanAttributes: true, outfile: 'argv.log', + resourceAttributes: undefined, + metrics: { includeSessionId: false }, }); }); @@ -233,4 +239,182 @@ describe('telemetry/config helpers', () => { ); }); }); + + describe('resolveTelemetrySettings — resource attributes', () => { + it('returns undefined resourceAttributes when nothing set', async () => { + const resolved = await resolveTelemetrySettings({}); + expect(resolved.resourceAttributes).toBeUndefined(); + }); + + it('parses OTEL_RESOURCE_ATTRIBUTES from env', async () => { + const resolved = await resolveTelemetrySettings({ + env: { OTEL_RESOURCE_ATTRIBUTES: 'team=platform,env=prod' }, + }); + expect(resolved.resourceAttributes).toEqual({ + team: 'platform', + env: 'prod', + }); + }); + + it('merges settings on top of env (settings wins)', async () => { + const resolved = await resolveTelemetrySettings({ + env: { OTEL_RESOURCE_ATTRIBUTES: 'team=x,env=prod' }, + settings: { resourceAttributes: { team: 'y' } }, + }); + expect(resolved.resourceAttributes).toEqual({ + team: 'y', + env: 'prod', + }); + }); + + it('reads service.name from OTEL_SERVICE_NAME alone', async () => { + const resolved = await resolveTelemetrySettings({ + env: { OTEL_SERVICE_NAME: 'A' }, + }); + expect(resolved.resourceAttributes).toEqual({ 'service.name': 'A' }); + }); + + it('reads service.name from OTEL_RESOURCE_ATTRIBUTES alone', async () => { + const resolved = await resolveTelemetrySettings({ + env: { OTEL_RESOURCE_ATTRIBUTES: 'service.name=B' }, + }); + expect(resolved.resourceAttributes).toEqual({ 'service.name': 'B' }); + }); + + it('drops user-provided session.id from env with warning', async () => { + const resolved = await resolveTelemetrySettings({ + env: { OTEL_RESOURCE_ATTRIBUTES: 'session.id=spoofed,team=x' }, + }); + expect(resolved.resourceAttributes).toEqual({ team: 'x' }); + }); + + it('drops user-provided session.id from settings with warning', async () => { + const resolved = await resolveTelemetrySettings({ + settings: { + resourceAttributes: { 'session.id': 'spoofed', team: 'x' }, + }, + }); + expect(resolved.resourceAttributes).toEqual({ team: 'x' }); + }); + + it('trims whitespace-only OTEL_SERVICE_NAME (treats as unset)', async () => { + const resolved = await resolveTelemetrySettings({ + env: { OTEL_SERVICE_NAME: ' ' }, + }); + // No user attrs → resourceAttributes stays undefined. + expect(resolved.resourceAttributes).toBeUndefined(); + }); + + it('exposes resourceAttributeWarnings when input has issues', async () => { + const resolved = await resolveTelemetrySettings({ + env: { + OTEL_RESOURCE_ATTRIBUTES: 'bogus,service.version=1,team=ok', + }, + settings: { + resourceAttributes: { + '': 'empty-key', + // @ts-expect-error — runtime defensive path against bad JSON. + count: 42, + }, + }, + }); + expect(resolved.resourceAttributeWarnings).toBeDefined(); + // Expect at least: malformed pair, reserved service.version, empty key, non-string value. + expect(resolved.resourceAttributeWarnings!.length).toBeGreaterThanOrEqual( + 4, + ); + }); + + it('leaves resourceAttributeWarnings undefined when input is clean', async () => { + const resolved = await resolveTelemetrySettings({ + env: { OTEL_RESOURCE_ATTRIBUTES: 'team=platform,env=prod' }, + }); + expect(resolved.resourceAttributeWarnings).toBeUndefined(); + }); + + it('drops non-string settings values', async () => { + const resolved = await resolveTelemetrySettings({ + settings: { + resourceAttributes: { + team: 'platform', + // @ts-expect-error — runtime defensive path against bad JSON. + count: 42, + }, + }, + }); + expect(resolved.resourceAttributes).toEqual({ team: 'platform' }); + }); + + it('OTEL_SERVICE_NAME wins over OTEL_RESOURCE_ATTRIBUTES.service.name', async () => { + const resolved = await resolveTelemetrySettings({ + env: { + OTEL_SERVICE_NAME: 'A', + OTEL_RESOURCE_ATTRIBUTES: 'service.name=B', + }, + }); + expect(resolved.resourceAttributes?.['service.name']).toBe('A'); + }); + + it('OTEL_SERVICE_NAME wins over settings.resourceAttributes.service.name', async () => { + const resolved = await resolveTelemetrySettings({ + env: { OTEL_SERVICE_NAME: 'A' }, + settings: { resourceAttributes: { 'service.name': 'C' } }, + }); + expect(resolved.resourceAttributes?.['service.name']).toBe('A'); + }); + + it('settings.service.name wins over env.OTEL_RESOURCE_ATTRIBUTES.service.name when no OTEL_SERVICE_NAME', async () => { + const resolved = await resolveTelemetrySettings({ + env: { OTEL_RESOURCE_ATTRIBUTES: 'service.name=B' }, + settings: { resourceAttributes: { 'service.name': 'C' } }, + }); + expect(resolved.resourceAttributes?.['service.name']).toBe('C'); + }); + + it('strips service.version from env source', async () => { + const resolved = await resolveTelemetrySettings({ + env: { OTEL_RESOURCE_ATTRIBUTES: 'service.version=fake,team=x' }, + }); + expect(resolved.resourceAttributes).toEqual({ team: 'x' }); + }); + + it('strips service.version from settings source', async () => { + const resolved = await resolveTelemetrySettings({ + settings: { + resourceAttributes: { 'service.version': 'fake', team: 'x' }, + }, + }); + expect(resolved.resourceAttributes).toEqual({ team: 'x' }); + }); + }); + + describe('resolveTelemetrySettings — metrics.includeSessionId', () => { + it('defaults to false', async () => { + const resolved = await resolveTelemetrySettings({}); + expect(resolved.metrics?.includeSessionId).toBe(false); + }); + + it('reads from settings', async () => { + const resolved = await resolveTelemetrySettings({ + settings: { metrics: { includeSessionId: true } }, + }); + expect(resolved.metrics?.includeSessionId).toBe(true); + }); + + it('reads from env (override settings)', async () => { + const resolved = await resolveTelemetrySettings({ + env: { QWEN_TELEMETRY_METRICS_INCLUDE_SESSION_ID: 'true' }, + settings: { metrics: { includeSessionId: false } }, + }); + expect(resolved.metrics?.includeSessionId).toBe(true); + }); + + it('explicit env=false overrides settings=true', async () => { + const resolved = await resolveTelemetrySettings({ + env: { QWEN_TELEMETRY_METRICS_INCLUDE_SESSION_ID: 'false' }, + settings: { metrics: { includeSessionId: true } }, + }); + expect(resolved.metrics?.includeSessionId).toBe(false); + }); + }); }); diff --git a/packages/core/src/telemetry/config.ts b/packages/core/src/telemetry/config.ts index 013337b8b74..784bf124fb2 100644 --- a/packages/core/src/telemetry/config.ts +++ b/packages/core/src/telemetry/config.ts @@ -7,6 +7,12 @@ import type { TelemetrySettings } from '../config/config.js'; import { FatalConfigError } from '../utils/errors.js'; import { TelemetryTarget } from './index.js'; +import type { ResourceAttributeWarnings } from './resource-attributes.js'; +import { + coerceStringResourceAttributes, + parseOtelResourceAttributes, + stripReservedResourceAttributes, +} from './resource-attributes.js'; /** * Parse a boolean environment flag. Accepts 'true'/'1' as true. @@ -126,6 +132,49 @@ export async function resolveTelemetrySettings(options: { env['OTEL_EXPORTER_OTLP_METRICS_ENDPOINT'] ?? settings.otlpMetricsEndpoint; + // Resource attributes: merge OTEL_RESOURCE_ATTRIBUTES (lowest), then + // settings.resourceAttributes (settings wins on key conflict). RESERVED + // keys (`service.version`, `session.id`) are stripped from both sources + // with a `diag.warn`. OTEL_SERVICE_NAME is a standard escape hatch that + // overrides service.name from any other source. All drops/coercions are + // accumulated into `resourceAttributeWarnings` so the SDK can emit a + // one-time user-visible summary at telemetry init. + const resourceAttributeWarnings: ResourceAttributeWarnings = []; + const envResourceAttrs = stripReservedResourceAttributes( + parseOtelResourceAttributes( + env['OTEL_RESOURCE_ATTRIBUTES'], + resourceAttributeWarnings, + ), + 'OTEL_RESOURCE_ATTRIBUTES', + resourceAttributeWarnings, + ); + const settingsResourceAttrs = stripReservedResourceAttributes( + coerceStringResourceAttributes( + settings.resourceAttributes, + resourceAttributeWarnings, + ), + 'settings.telemetry.resourceAttributes', + resourceAttributeWarnings, + ); + const mergedResourceAttrs: Record = { + ...envResourceAttrs, + ...settingsResourceAttrs, + }; + // Trim OTEL_SERVICE_NAME so a whitespace-only value (`' '`, `'\t'`) is + // treated as unset rather than producing a blank service name on Resource. + const otelServiceName = env['OTEL_SERVICE_NAME']?.trim(); + if (otelServiceName) { + mergedResourceAttrs['service.name'] = otelServiceName; + } + const resourceAttributes = Object.keys(mergedResourceAttrs).length + ? mergedResourceAttrs + : undefined; + + const metricsIncludeSessionId = + parseBooleanEnvFlag(env['QWEN_TELEMETRY_METRICS_INCLUDE_SESSION_ID']) ?? + settings.metrics?.includeSessionId ?? + false; + return { enabled, target, @@ -137,5 +186,10 @@ export async function resolveTelemetrySettings(options: { logPrompts, includeSensitiveSpanAttributes, outfile, + resourceAttributes, + metrics: { includeSessionId: metricsIncludeSessionId }, + resourceAttributeWarnings: resourceAttributeWarnings.length + ? resourceAttributeWarnings + : undefined, }; } diff --git a/packages/core/src/telemetry/metrics.test.ts b/packages/core/src/telemetry/metrics.test.ts index 2ee3a96e258..d1c68971a95 100644 --- a/packages/core/src/telemetry/metrics.test.ts +++ b/packages/core/src/telemetry/metrics.test.ts @@ -145,7 +145,6 @@ describe('Telemetry Metrics', () => { }); expect(mockCounterAddFn).toHaveBeenCalledWith(1, { - 'session.id': 'test-session-id', tokens_after: 100, tokens_before: 200, }); @@ -156,6 +155,7 @@ describe('Telemetry Metrics', () => { const mockConfig = { getSessionId: () => 'test-session-id', getTelemetryEnabled: () => true, + getTelemetryMetricsIncludeSessionId: () => false, } as unknown as Config; it('should not record metrics if not initialized', () => { @@ -173,11 +173,8 @@ describe('Telemetry Metrics', () => { type: 'input', }); expect(mockCounterAddFn).toHaveBeenCalledTimes(2); - expect(mockCounterAddFn).toHaveBeenNthCalledWith(1, 1, { - 'session.id': 'test-session-id', - }); + expect(mockCounterAddFn).toHaveBeenNthCalledWith(1, 1, {}); expect(mockCounterAddFn).toHaveBeenNthCalledWith(2, 100, { - 'session.id': 'test-session-id', model: 'gemini-pro', type: 'input', }); @@ -192,7 +189,6 @@ describe('Telemetry Metrics', () => { type: 'output', }); expect(mockCounterAddFn).toHaveBeenCalledWith(50, { - 'session.id': 'test-session-id', model: 'gemini-pro', type: 'output', }); @@ -202,7 +198,6 @@ describe('Telemetry Metrics', () => { type: 'thought', }); expect(mockCounterAddFn).toHaveBeenCalledWith(25, { - 'session.id': 'test-session-id', model: 'gemini-pro', type: 'thought', }); @@ -212,7 +207,6 @@ describe('Telemetry Metrics', () => { type: 'cache', }); expect(mockCounterAddFn).toHaveBeenCalledWith(75, { - 'session.id': 'test-session-id', model: 'gemini-pro', type: 'cache', }); @@ -227,7 +221,6 @@ describe('Telemetry Metrics', () => { type: 'input', }); expect(mockCounterAddFn).toHaveBeenCalledWith(200, { - 'session.id': 'test-session-id', model: 'gemini-ultra', type: 'input', }); @@ -238,6 +231,7 @@ describe('Telemetry Metrics', () => { const mockConfig = { getSessionId: () => 'test-session-id', getTelemetryEnabled: () => true, + getTelemetryMetricsIncludeSessionId: () => false, } as unknown as Config; it('should not record metrics if not initialized', () => { @@ -260,11 +254,8 @@ describe('Telemetry Metrics', () => { }); expect(mockCounterAddFn).toHaveBeenCalledTimes(2); - expect(mockCounterAddFn).toHaveBeenNthCalledWith(1, 1, { - 'session.id': 'test-session-id', - }); + expect(mockCounterAddFn).toHaveBeenNthCalledWith(1, 1, {}); expect(mockCounterAddFn).toHaveBeenNthCalledWith(2, 1, { - 'session.id': 'test-session-id', operation: FileOperation.CREATE, lines: 10, mimetype: 'text/plain', @@ -280,7 +271,6 @@ describe('Telemetry Metrics', () => { operation: FileOperation.READ, }); expect(mockCounterAddFn).toHaveBeenCalledWith(1, { - 'session.id': 'test-session-id', operation: FileOperation.READ, }); }); @@ -294,7 +284,6 @@ describe('Telemetry Metrics', () => { mimetype: 'application/javascript', }); expect(mockCounterAddFn).toHaveBeenCalledWith(1, { - 'session.id': 'test-session-id', operation: FileOperation.UPDATE, mimetype: 'application/javascript', }); @@ -309,7 +298,6 @@ describe('Telemetry Metrics', () => { }); expect(mockCounterAddFn).toHaveBeenCalledWith(1, { - 'session.id': 'test-session-id', operation: FileOperation.UPDATE, }); }); @@ -326,7 +314,6 @@ describe('Telemetry Metrics', () => { }); expect(mockCounterAddFn).toHaveBeenCalledWith(1, { - 'session.id': 'test-session-id', operation: FileOperation.UPDATE, lines: 10, mimetype: 'text/plain', @@ -343,7 +330,6 @@ describe('Telemetry Metrics', () => { }); expect(mockCounterAddFn).toHaveBeenCalledWith(1, { - 'session.id': 'test-session-id', operation: FileOperation.UPDATE, }); }); @@ -353,6 +339,7 @@ describe('Telemetry Metrics', () => { const mockConfig = { getSessionId: () => 'test-session-id', getTelemetryEnabled: () => true, + getTelemetryMetricsIncludeSessionId: () => false, } as unknown as Config; describe('recordStartupPerformance', () => { @@ -361,6 +348,7 @@ describe('Telemetry Metrics', () => { const mockConfigDisabled = { getSessionId: () => 'test-session-id', getTelemetryEnabled: () => false, // Disable telemetry to disable performance monitoring + getTelemetryMetricsIncludeSessionId: () => false, } as unknown as Config; initializeMetricsModule(mockConfigDisabled); @@ -390,7 +378,6 @@ describe('Telemetry Metrics', () => { }); expect(mockHistogramRecordFn).toHaveBeenCalledWith(150, { - 'session.id': 'test-session-id', phase: 'settings_loading', auth_type: 'gemini', telemetry_enabled: true, @@ -405,7 +392,6 @@ describe('Telemetry Metrics', () => { recordStartupPerformanceModule(mockConfig, 50, { phase: 'cleanup' }); expect(mockHistogramRecordFn).toHaveBeenCalledWith(50, { - 'session.id': 'test-session-id', phase: 'cleanup', }); }); @@ -427,7 +413,6 @@ describe('Telemetry Metrics', () => { expect(mockHistogramRecordFn).toHaveBeenCalledWith( floatingPointDuration, { - 'session.id': 'test-session-id', phase: 'total_startup', is_tty: true, has_question: false, @@ -447,7 +432,6 @@ describe('Telemetry Metrics', () => { }); expect(mockHistogramRecordFn).toHaveBeenCalledWith(15728640, { - 'session.id': 'test-session-id', memory_type: 'heap_used', component: 'startup', }); @@ -472,17 +456,14 @@ describe('Telemetry Metrics', () => { expect(mockHistogramRecordFn).toHaveBeenCalledTimes(3); // One for each call expect(mockHistogramRecordFn).toHaveBeenNthCalledWith(1, 31457280, { - 'session.id': 'test-session-id', memory_type: 'heap_total', component: 'api_call', }); expect(mockHistogramRecordFn).toHaveBeenNthCalledWith(2, 2097152, { - 'session.id': 'test-session-id', memory_type: 'external', component: 'tool_execution', }); expect(mockHistogramRecordFn).toHaveBeenNthCalledWith(3, 41943040, { - 'session.id': 'test-session-id', memory_type: 'rss', component: 'memory_monitor', }); @@ -497,7 +478,6 @@ describe('Telemetry Metrics', () => { }); expect(mockHistogramRecordFn).toHaveBeenCalledWith(15728640, { - 'session.id': 'test-session-id', memory_type: 'heap_used', }); }); @@ -513,7 +493,6 @@ describe('Telemetry Metrics', () => { }); expect(mockHistogramRecordFn).toHaveBeenCalledWith(85.5, { - 'session.id': 'test-session-id', component: 'tool_execution', }); }); @@ -524,9 +503,7 @@ describe('Telemetry Metrics', () => { recordCpuUsageModule(mockConfig, 42.3, {}); - expect(mockHistogramRecordFn).toHaveBeenCalledWith(42.3, { - 'session.id': 'test-session-id', - }); + expect(mockHistogramRecordFn).toHaveBeenCalledWith(42.3, {}); }); }); @@ -537,9 +514,7 @@ describe('Telemetry Metrics', () => { recordToolQueueDepthModule(mockConfig, 3); - expect(mockHistogramRecordFn).toHaveBeenCalledWith(3, { - 'session.id': 'test-session-id', - }); + expect(mockHistogramRecordFn).toHaveBeenCalledWith(3, {}); }); it('should record zero queue depth', () => { @@ -548,9 +523,7 @@ describe('Telemetry Metrics', () => { recordToolQueueDepthModule(mockConfig, 0); - expect(mockHistogramRecordFn).toHaveBeenCalledWith(0, { - 'session.id': 'test-session-id', - }); + expect(mockHistogramRecordFn).toHaveBeenCalledWith(0, {}); }); }); @@ -565,7 +538,6 @@ describe('Telemetry Metrics', () => { }); expect(mockHistogramRecordFn).toHaveBeenCalledWith(25, { - 'session.id': 'test-session-id', function_name: 'Read', phase: 'validation', }); @@ -590,17 +562,14 @@ describe('Telemetry Metrics', () => { expect(mockHistogramRecordFn).toHaveBeenCalledTimes(3); // One for each call expect(mockHistogramRecordFn).toHaveBeenNthCalledWith(1, 50, { - 'session.id': 'test-session-id', function_name: 'Bash', phase: 'preparation', }); expect(mockHistogramRecordFn).toHaveBeenNthCalledWith(2, 1500, { - 'session.id': 'test-session-id', function_name: 'Bash', phase: 'execution', }); expect(mockHistogramRecordFn).toHaveBeenNthCalledWith(3, 75, { - 'session.id': 'test-session-id', function_name: 'Bash', phase: 'result_processing', }); @@ -619,7 +588,6 @@ describe('Telemetry Metrics', () => { }); expect(mockHistogramRecordFn).toHaveBeenCalledWith(0.85, { - 'session.id': 'test-session-id', model: 'gemini-pro', metric: 'cache_hit_rate', context: 'api_request', @@ -636,7 +604,6 @@ describe('Telemetry Metrics', () => { }); expect(mockHistogramRecordFn).toHaveBeenCalledWith(125.5, { - 'session.id': 'test-session-id', model: 'gemini-pro', metric: 'tokens_per_operation', }); @@ -654,7 +621,6 @@ describe('Telemetry Metrics', () => { }); expect(mockHistogramRecordFn).toHaveBeenCalledWith(15, { - 'session.id': 'test-session-id', model: 'gemini-pro', phase: 'request_preparation', }); @@ -679,17 +645,14 @@ describe('Telemetry Metrics', () => { expect(mockHistogramRecordFn).toHaveBeenCalledTimes(3); // One for each call expect(mockHistogramRecordFn).toHaveBeenNthCalledWith(1, 250, { - 'session.id': 'test-session-id', model: 'gemini-pro', phase: 'network_latency', }); expect(mockHistogramRecordFn).toHaveBeenNthCalledWith(2, 100, { - 'session.id': 'test-session-id', model: 'gemini-pro', phase: 'response_processing', }); expect(mockHistogramRecordFn).toHaveBeenNthCalledWith(3, 50, { - 'session.id': 'test-session-id', model: 'gemini-pro', phase: 'token_processing', }); @@ -707,7 +670,6 @@ describe('Telemetry Metrics', () => { }); expect(mockHistogramRecordFn).toHaveBeenCalledWith(85.5, { - 'session.id': 'test-session-id', category: 'memory_efficiency', baseline: 80.0, }); @@ -722,7 +684,6 @@ describe('Telemetry Metrics', () => { }); expect(mockHistogramRecordFn).toHaveBeenCalledWith(92.3, { - 'session.id': 'test-session-id', category: 'overall_performance', }); }); @@ -743,7 +704,6 @@ describe('Telemetry Metrics', () => { // Verify regression counter expect(mockCounterAddFn).toHaveBeenCalledWith(1, { - 'session.id': 'test-session-id', metric: 'startup_time', severity: 'medium', current_value: 1200, @@ -752,7 +712,6 @@ describe('Telemetry Metrics', () => { // Verify baseline comparison histogram (20% increase) expect(mockHistogramRecordFn).toHaveBeenCalledWith(20, { - 'session.id': 'test-session-id', metric: 'startup_time', severity: 'medium', current_value: 1200, @@ -774,7 +733,6 @@ describe('Telemetry Metrics', () => { // Verify regression counter still recorded expect(mockCounterAddFn).toHaveBeenCalledWith(1, { - 'session.id': 'test-session-id', metric: 'memory_usage', severity: 'high', current_value: 100, @@ -803,14 +761,12 @@ describe('Telemetry Metrics', () => { }); expect(mockCounterAddFn).toHaveBeenNthCalledWith(1, 1, { - 'session.id': 'test-session-id', metric: 'api_latency', severity: 'low', current_value: 500, baseline_value: 400, }); expect(mockCounterAddFn).toHaveBeenNthCalledWith(2, 1, { - 'session.id': 'test-session-id', metric: 'cpu_usage', severity: 'high', current_value: 90, @@ -833,7 +789,6 @@ describe('Telemetry Metrics', () => { // 20% increase: (120 - 100) / 100 * 100 = 20% expect(mockHistogramRecordFn).toHaveBeenCalledWith(20, { - 'session.id': 'test-session-id', metric: 'memory_usage', category: 'performance_tracking', current_value: 120, @@ -854,7 +809,6 @@ describe('Telemetry Metrics', () => { // 20% decrease: (800 - 1000) / 1000 * 100 = -20% expect(mockHistogramRecordFn).toHaveBeenCalledWith(-20, { - 'session.id': 'test-session-id', metric: 'startup_time', category: 'optimization', current_value: 800, @@ -886,4 +840,37 @@ describe('Telemetry Metrics', () => { }); }); }); + + describe('metric attribute cardinality controls', () => { + it('omits session.id from metric attributes by default', () => { + const config = makeFakeConfig({ sessionId: 'cardinality-test' }); + initializeMetricsModule(config); + mockCounterAddFn.mockClear(); + + recordChatCompressionMetricsModule(config, { + tokens_after: 1, + tokens_before: 2, + }); + + const attrs = mockCounterAddFn.mock.calls[0]?.[1] ?? {}; + expect(attrs).not.toHaveProperty('session.id'); + }); + + it('includes session.id when telemetry.metrics.includeSessionId is true', () => { + const config = makeFakeConfig({ + sessionId: 'cardinality-test', + telemetry: { metrics: { includeSessionId: true } }, + }); + initializeMetricsModule(config); + mockCounterAddFn.mockClear(); + + recordChatCompressionMetricsModule(config, { + tokens_after: 1, + tokens_before: 2, + }); + + const attrs = mockCounterAddFn.mock.calls[0]?.[1] ?? {}; + expect(attrs['session.id']).toBe('cardinality-test'); + }); + }); }); diff --git a/packages/core/src/telemetry/metrics.ts b/packages/core/src/telemetry/metrics.ts index 72717804973..7d9de142ee5 100644 --- a/packages/core/src/telemetry/metrics.ts +++ b/packages/core/src/telemetry/metrics.ts @@ -53,9 +53,19 @@ const MEMORY_RECALL_COUNT = `${SERVICE_NAME}.memory.recall.count`; const MEMORY_RECALL_DURATION = `${SERVICE_NAME}.memory.recall.duration`; const baseMetricDefinition = { - getCommonAttributes: (config: Config): Attributes => ({ - 'session.id': config.getSessionId(), - }), + // session.id on metrics is opt-in: each session is a new value, so + // attaching it by default would create unbounded time-series fan-out on + // every metric backend. Operators who need session-level metric slicing + // can enable QWEN_TELEMETRY_METRICS_INCLUDE_SESSION_ID or + // telemetry.metrics.includeSessionId. Spans and logs always carry + // session.id for trace/log correlation. + getCommonAttributes: (config: Config): Attributes => { + const out: Attributes = {}; + if (config.getTelemetryMetricsIncludeSessionId()) { + out['session.id'] = config.getSessionId(); + } + return out; + }, }; const COUNTER_DEFINITIONS = { diff --git a/packages/core/src/telemetry/resource-attributes.test.ts b/packages/core/src/telemetry/resource-attributes.test.ts new file mode 100644 index 00000000000..f9a69fe7139 --- /dev/null +++ b/packages/core/src/telemetry/resource-attributes.test.ts @@ -0,0 +1,272 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { diag } from '@opentelemetry/api'; +import type { ResourceAttributeWarnings } from './resource-attributes.js'; +import { + RESERVED_RESOURCE_ATTRIBUTE_KEYS, + coerceStringResourceAttributes, + parseOtelResourceAttributes, + stripReservedResourceAttributes, +} from './resource-attributes.js'; + +describe('parseOtelResourceAttributes', () => { + let warnSpy: ReturnType; + + beforeEach(() => { + warnSpy = vi.spyOn(diag, 'warn').mockImplementation(() => {}); + }); + + afterEach(() => { + warnSpy.mockRestore(); + }); + + it.each<[string | undefined, Record]>([ + [undefined, {}], + ['', {}], + ['a=1', { a: '1' }], + ['a=1,b=2', { a: '1', b: '2' }], + ['team=platform,env=prod', { team: 'platform', env: 'prod' }], + ['a=hello%20world', { a: 'hello world' }], + ['a=val%25with%2Cspecial', { a: 'val%with,special' }], + ['a=,b=2', { a: '', b: '2' }], + ['a=1,a=2', { a: '2' }], + [' a = 1 , b = 2 ', { a: '1', b: '2' }], + ['a=1,,b=2', { a: '1', b: '2' }], + // First-`=` split contract: values may legitimately contain `=` (base64 + // padding, JWTs, connection strings). Regression-guard against a future + // refactor that switches indexOf('=') to split('='). + ['a=val=ue', { a: 'val=ue' }], + ['k=base64==,x=1', { k: 'base64==', x: '1' }], + // Key percent-decoding: prevents `service%2Eversion=99` from sneaking + // past the reserved-key filter as the literal key `service%2Eversion`. + ['service%2Eversion=99', { 'service.version': '99' }], + ['my%20key=val', { 'my key': 'val' }], + // Invalid percent-encoding in key falls back to raw key with a warn + // (mirrors the invalid-value behavior on `a=val%ZZbad`). + ['key%ZZ=val', { 'key%ZZ': 'val' }], + ])('parses %j → %j', (input, expected) => { + expect(parseOtelResourceAttributes(input)).toEqual(expected); + }); + + it('warns on a malformed pair missing =', () => { + expect(parseOtelResourceAttributes('a=1,bogus,c=3')).toEqual({ + a: '1', + c: '3', + }); + expect(warnSpy).toHaveBeenCalledTimes(1); + expect(warnSpy.mock.calls[0][0]).toContain('bogus'); + }); + + it('skips pairs with empty key without warning', () => { + expect(parseOtelResourceAttributes('=value,a=1')).toEqual({ a: '1' }); + // Empty-key paths skip silently — not a malformed-pair warning. + expect(warnSpy).not.toHaveBeenCalled(); + }); + + it('keeps raw value and warns on invalid percent-encoding', () => { + expect(parseOtelResourceAttributes('a=val%ZZbad')).toEqual({ + a: 'val%ZZbad', + }); + expect(warnSpy).toHaveBeenCalledTimes(1); + expect(warnSpy.mock.calls[0][0]).toContain('a'); + }); +}); + +describe('stripReservedResourceAttributes', () => { + let warnSpy: ReturnType; + + beforeEach(() => { + warnSpy = vi.spyOn(diag, 'warn').mockImplementation(() => {}); + }); + + afterEach(() => { + warnSpy.mockRestore(); + }); + + it('drops reserved keys and warns for env source', () => { + const attrs = { team: 'x', 'service.version': '99.0' }; + const out = stripReservedResourceAttributes( + attrs, + 'OTEL_RESOURCE_ATTRIBUTES', + ); + expect(out).toEqual({ team: 'x' }); + expect(warnSpy).toHaveBeenCalledTimes(1); + expect(warnSpy.mock.calls[0][0]).toContain('service.version'); + expect(warnSpy.mock.calls[0][0]).toContain('OTEL_RESOURCE_ATTRIBUTES'); + }); + + it('drops reserved keys and warns for settings source', () => { + const attrs = { 'service.version': 'x' }; + const out = stripReservedResourceAttributes( + attrs, + 'settings.telemetry.resourceAttributes', + ); + expect(out).toEqual({}); + expect(warnSpy).toHaveBeenCalledTimes(1); + expect(warnSpy.mock.calls[0][0]).toContain( + 'settings.telemetry.resourceAttributes', + ); + }); + + it('does not warn for service.name (not in reserved set)', () => { + const attrs = { 'service.name': 'foo' }; + const out = stripReservedResourceAttributes( + attrs, + 'OTEL_RESOURCE_ATTRIBUTES', + ); + expect(out).toEqual({ 'service.name': 'foo' }); + expect(warnSpy).not.toHaveBeenCalled(); + }); + + it('mutates the input object', () => { + const attrs: Record = { 'service.version': 'x', a: '1' }; + const out = stripReservedResourceAttributes( + attrs, + 'OTEL_RESOURCE_ATTRIBUTES', + ); + expect(out).toBe(attrs); + expect(attrs).toEqual({ a: '1' }); + }); +}); + +describe('RESERVED_RESOURCE_ATTRIBUTE_KEYS', () => { + it('contains service.version and session.id but not service.name', () => { + expect(RESERVED_RESOURCE_ATTRIBUTE_KEYS.has('service.version')).toBe(true); + expect(RESERVED_RESOURCE_ATTRIBUTE_KEYS.has('session.id')).toBe(true); + expect(RESERVED_RESOURCE_ATTRIBUTE_KEYS.has('service.name')).toBe(false); + }); +}); + +describe('stripReservedResourceAttributes — session.id', () => { + let warnSpy: ReturnType; + + beforeEach(() => { + warnSpy = vi.spyOn(diag, 'warn').mockImplementation(() => {}); + }); + + afterEach(() => { + warnSpy.mockRestore(); + }); + + it('drops user-provided session.id from env with warning', () => { + const attrs = { 'session.id': 'spoofed', team: 'x' }; + const out = stripReservedResourceAttributes( + attrs, + 'OTEL_RESOURCE_ATTRIBUTES', + ); + expect(out).toEqual({ team: 'x' }); + expect(warnSpy).toHaveBeenCalledTimes(1); + expect(warnSpy.mock.calls[0][0]).toContain('session.id'); + }); + + it('drops user-provided session.id from settings with warning', () => { + const attrs = { 'session.id': 'spoofed' }; + const out = stripReservedResourceAttributes( + attrs, + 'settings.telemetry.resourceAttributes', + ); + expect(out).toEqual({}); + expect(warnSpy).toHaveBeenCalledTimes(1); + }); +}); + +describe('coerceStringResourceAttributes', () => { + let warnSpy: ReturnType; + + beforeEach(() => { + warnSpy = vi.spyOn(diag, 'warn').mockImplementation(() => {}); + }); + + afterEach(() => { + warnSpy.mockRestore(); + }); + + it('returns empty object for undefined / null', () => { + expect(coerceStringResourceAttributes(undefined)).toEqual({}); + expect(coerceStringResourceAttributes(null)).toEqual({}); + expect(warnSpy).not.toHaveBeenCalled(); + }); + + it('passes through string-valued records unchanged', () => { + const input = { team: 'platform', env: 'prod' }; + expect(coerceStringResourceAttributes(input)).toEqual(input); + expect(warnSpy).not.toHaveBeenCalled(); + }); + + it('drops non-string values with warning, keeps strings', () => { + const input = { team: 'platform', count: 42, flag: true, list: ['a'] }; + expect(coerceStringResourceAttributes(input)).toEqual({ + team: 'platform', + }); + expect(warnSpy).toHaveBeenCalledTimes(3); + }); + + it('warns and returns {} for non-object input', () => { + expect(coerceStringResourceAttributes('not an object')).toEqual({}); + expect(coerceStringResourceAttributes(['a', 'b'])).toEqual({}); + expect(warnSpy).toHaveBeenCalledTimes(2); + }); + + it('trims keys and skips empty/whitespace-only keys', () => { + const input = { ' team ': 'platform', '': 'x', ' ': 'y', env: 'prod' }; + expect(coerceStringResourceAttributes(input)).toEqual({ + team: 'platform', + env: 'prod', + }); + // 2 warnings for the two empty/whitespace keys. + expect(warnSpy).toHaveBeenCalledTimes(2); + }); +}); + +describe('warnings accumulator', () => { + beforeEach(() => { + vi.spyOn(diag, 'warn').mockImplementation(() => {}); + }); + + it('parseOtelResourceAttributes pushes diagnostic strings into the accumulator', () => { + const warnings: ResourceAttributeWarnings = []; + parseOtelResourceAttributes('a=1,bogus,c=val%ZZ', warnings); + expect(warnings.length).toBeGreaterThanOrEqual(2); + expect(warnings.some((w) => w.includes('bogus'))).toBe(true); + expect(warnings.some((w) => w.includes('Invalid percent-encoding'))).toBe( + true, + ); + }); + + it('stripReservedResourceAttributes pushes diagnostic for each reserved drop', () => { + const warnings: ResourceAttributeWarnings = []; + stripReservedResourceAttributes( + { 'service.version': 'x', 'session.id': 'y', team: 'z' }, + 'OTEL_RESOURCE_ATTRIBUTES', + warnings, + ); + expect(warnings).toHaveLength(2); + }); + + it('coerceStringResourceAttributes pushes diagnostic for empty key + non-string value', () => { + const warnings: ResourceAttributeWarnings = []; + coerceStringResourceAttributes( + { '': 'empty', team: 'ok', count: 42 }, + warnings, + ); + expect(warnings).toHaveLength(2); + }); + + it('accumulator is opt-in (helpers work without one)', () => { + expect(() => parseOtelResourceAttributes('a=1,bogus')).not.toThrow(); + expect(() => + stripReservedResourceAttributes( + { 'service.version': 'x' }, + 'OTEL_RESOURCE_ATTRIBUTES', + ), + ).not.toThrow(); + expect(() => + coerceStringResourceAttributes({ team: 'ok', count: 1 }), + ).not.toThrow(); + }); +}); diff --git a/packages/core/src/telemetry/resource-attributes.ts b/packages/core/src/telemetry/resource-attributes.ts new file mode 100644 index 00000000000..f4371937805 --- /dev/null +++ b/packages/core/src/telemetry/resource-attributes.ts @@ -0,0 +1,175 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { diag } from '@opentelemetry/api'; + +/** + * Resource attribute keys that cannot be overridden from any user-controlled + * source (env var or settings.json). Attempts to set these are dropped with + * a warning, and the runtime-injected value is used instead. + * + * - `service.version` — telemetry integrity (no version spoofing). + * - `session.id` — runtime-injected; allowing user override would either bypass + * the metric cardinality toggle (Resource attrs auto-attach to every metric + * data point) or silently shadow the real session id. + * + * `service.name` is NOT in this set — it follows its own precedence chain + * (see design doc §4.2 for details). + */ +export const RESERVED_RESOURCE_ATTRIBUTE_KEYS: ReadonlySet = new Set([ + 'service.version', + 'session.id', +]); + +/** + * Optional accumulator the helpers in this module push human-readable + * diagnostic strings into when they drop or rewrite user input. Each helper + * also calls `diag.warn` for the debug log; the accumulator is what lets the + * SDK emit a one-time user-visible summary at telemetry startup (see + * `sdk.ts:initializeTelemetry`). + */ +export type ResourceAttributeWarnings = string[]; + +function warn(msg: string, warnings?: ResourceAttributeWarnings): void { + diag.warn(msg); + warnings?.push(msg); +} + +/** + * Parse the standard OpenTelemetry `OTEL_RESOURCE_ATTRIBUTES` env var format. + * + * Format: `key1=value1,key2=value2` with both keys and values URL-encoded per + * the OTel spec / W3C Baggage: + * https://opentelemetry.io/docs/specs/otel/configuration/sdk-environment-variables/ + * + * Behavior on malformed input is permissive — bad pairs are skipped with a + * `diag.warn` and parsing continues. The goal is to never block telemetry + * startup on a single malformed value. + * + * Duplicate keys: last-write-wins, matching the OTel SDK reference behavior. + * + * Note on warn visibility: `diag.warn` routes to the debug log file + * (`~/.qwen/log/otel-*.log`), not console — see PR #3986. The SDK emits a + * single console summary at startup when this list is non-empty so users + * notice silent drops without scanning the debug log. + */ +export function parseOtelResourceAttributes( + raw: string | undefined, + warnings?: ResourceAttributeWarnings, +): Record { + if (!raw) return {}; + const out: Record = {}; + for (const pair of raw.split(',')) { + const trimmed = pair.trim(); + if (!trimmed) continue; + const idx = trimmed.indexOf('='); + if (idx < 0) { + // Common cause: literal comma in value (split treats it as a separator). + // Per OTel spec, commas in values must be percent-encoded as %2C. + warn( + `Skipping malformed OTEL_RESOURCE_ATTRIBUTES entry: "${trimmed}" ` + + `(hint: percent-encode literal commas as %2C)`, + warnings, + ); + continue; + } + const rawKey = trimmed.slice(0, idx).trim(); + if (!rawKey) continue; // silent skip: "=value" or " =value" + const valueRaw = trimmed.slice(idx + 1).trim(); + // Keys are also percent-encoded per the OTel/W3C spec. If we did not + // decode them, a key like `service%2Eversion` would land verbatim and + // miss the RESERVED filter — collectors that decode keys downstream + // could then resurrect `service.version` and bypass reserved-key + // protection. Decode key + value identically. + let key: string; + try { + key = decodeURIComponent(rawKey); + } catch { + warn( + `Invalid percent-encoding in OTEL_RESOURCE_ATTRIBUTES key "${rawKey}", using raw key`, + warnings, + ); + key = rawKey; + } + let value: string; + try { + value = decodeURIComponent(valueRaw); + } catch { + warn( + `Invalid percent-encoding in OTEL_RESOURCE_ATTRIBUTES for key "${key}", using raw value`, + warnings, + ); + value = valueRaw; + } + out[key] = value; + } + return out; +} + +/** + * Strip RESERVED keys from a user-provided attribute map and warn the user. + * Mutates the input object and returns it. + */ +export function stripReservedResourceAttributes( + attrs: Record, + source: 'OTEL_RESOURCE_ATTRIBUTES' | 'settings.telemetry.resourceAttributes', + warnings?: ResourceAttributeWarnings, +): Record { + for (const k of RESERVED_RESOURCE_ATTRIBUTE_KEYS) { + if (k in attrs) { + warn(`${source} cannot override reserved key "${k}"; ignoring`, warnings); + delete attrs[k]; + } + } + return attrs; +} + +/** + * Defensive runtime coercion for settings-provided resource attributes. + * + * TypeScript types and the settings JSON schema both demand string values, + * but raw `settings.json` can be hand-edited and arrive with any value type. + * Drop non-string values with a warning rather than letting them flow into + * OTel (which would either reject the entire Resource at export or silently + * coerce them depending on SDK version). + * + * Also trims keys and drops empty/whitespace-only keys, matching + * `parseOtelResourceAttributes`. A settings.json with `{" ": "x"}` or + * `{"team ": "y"}` would otherwise produce malformed Resource attributes. + */ +export function coerceStringResourceAttributes( + raw: unknown, + warnings?: ResourceAttributeWarnings, +): Record { + if (raw === null || raw === undefined) return {}; + if (typeof raw !== 'object' || Array.isArray(raw)) { + warn( + 'settings.telemetry.resourceAttributes must be an object; ignoring', + warnings, + ); + return {}; + } + const out: Record = {}; + for (const [k, v] of Object.entries(raw as Record)) { + const key = k.trim(); + if (!key) { + warn( + 'settings.telemetry.resourceAttributes has an empty or whitespace-only key; ignoring', + warnings, + ); + continue; + } + if (typeof v === 'string') { + out[key] = v; + } else { + warn( + `settings.telemetry.resourceAttributes value for "${key}" must be a string (got ${typeof v}); ignoring`, + warnings, + ); + } + } + return out; +} diff --git a/packages/core/src/telemetry/sdk.test.ts b/packages/core/src/telemetry/sdk.test.ts index 970b198d000..e0730f9153b 100644 --- a/packages/core/src/telemetry/sdk.test.ts +++ b/packages/core/src/telemetry/sdk.test.ts @@ -137,6 +137,9 @@ describe('Telemetry SDK', () => { getTelemetryTarget: () => 'local', getTelemetryOutfile: () => undefined, getTelemetryIncludeSensitiveSpanAttributes: () => false, + getTelemetryResourceAttributes: () => ({}), + getTelemetryMetricsIncludeSessionId: () => false, + getTelemetryResourceAttributeWarnings: () => [], getDebugMode: () => false, getSessionId: () => 'test-session', getCliVersion: () => '1.0.0-test', @@ -512,6 +515,126 @@ describe('Telemetry SDK', () => { }; expect(resource.attributes['service.version']).toBe('unknown'); }); + + describe('Resource attributes', () => { + function getResourceAttributes(): Record { + const constructorCall = vi.mocked(NodeSDK).mock.calls[0]![0]!; + return ( + constructorCall.resource as { attributes: Record } + ).attributes; + } + + it('does not place session.id on the Resource', () => { + initializeTelemetry(mockConfig); + expect(getResourceAttributes()['session.id']).toBeUndefined(); + }); + + it('always sets service.name and service.version from runtime', () => { + initializeTelemetry(mockConfig); + const attrs = getResourceAttributes(); + expect(attrs['service.name']).toBe('qwen-code'); + expect(attrs['service.version']).toBe('1.0.0-test'); + }); + + it('attaches user-provided resource attributes', () => { + vi.spyOn(mockConfig, 'getTelemetryResourceAttributes').mockReturnValue({ + team: 'platform', + env: 'prod', + }); + initializeTelemetry(mockConfig); + const attrs = getResourceAttributes(); + expect(attrs['team']).toBe('platform'); + expect(attrs['env']).toBe('prod'); + }); + + it('user-provided service.name wins over default', () => { + vi.spyOn(mockConfig, 'getTelemetryResourceAttributes').mockReturnValue({ + 'service.name': 'qwen-code-ci', + }); + initializeTelemetry(mockConfig); + expect(getResourceAttributes()['service.name']).toBe('qwen-code-ci'); + }); + + it('user-provided service.version is ignored (runtime value wins)', () => { + vi.spyOn(mockConfig, 'getTelemetryResourceAttributes').mockReturnValue({ + 'service.version': '99.0.0-fake', + }); + initializeTelemetry(mockConfig); + expect(getResourceAttributes()['service.version']).toBe('1.0.0-test'); + }); + + it('empty-string service.name from settings falls back to default', () => { + // Reviewer caught: `??` would let "" pass; `||` correctly falls back + // so backends never see a blank service name. + vi.spyOn(mockConfig, 'getTelemetryResourceAttributes').mockReturnValue({ + 'service.name': '', + }); + initializeTelemetry(mockConfig); + expect(getResourceAttributes()['service.name']).toBe('qwen-code'); + }); + + it('whitespace-only service.name from settings falls back to default', () => { + // Reviewer caught: plain `||` lets `" "` through (truthy). The + // `.trim() || SERVICE_NAME` fallback covers both empty and + // whitespace-only values (env path can produce these via `%20`). + vi.spyOn(mockConfig, 'getTelemetryResourceAttributes').mockReturnValue({ + 'service.name': ' ', + }); + initializeTelemetry(mockConfig); + expect(getResourceAttributes()['service.name']).toBe('qwen-code'); + }); + + it('emits a console summary when resource-attribute warnings are present', () => { + const consoleWarnSpy = vi + .spyOn(console, 'warn') + .mockImplementation(() => {}); + try { + vi.spyOn( + mockConfig, + 'getTelemetryResourceAttributeWarnings', + ).mockReturnValue([ + 'OTEL_RESOURCE_ATTRIBUTES cannot override reserved key "service.version"; ignoring', + 'Skipping malformed OTEL_RESOURCE_ATTRIBUTES entry: "bogus"', + ]); + initializeTelemetry(mockConfig); + const header = consoleWarnSpy.mock.calls[0]?.[0] ?? ''; + expect(header).toContain('2 resource attribute issue'); + expect( + consoleWarnSpy.mock.calls.some((c) => + String(c[0]).includes('reserved key'), + ), + ).toBe(true); + } finally { + consoleWarnSpy.mockRestore(); + } + }); + + it('no console output when warnings list is empty', () => { + const consoleWarnSpy = vi + .spyOn(console, 'warn') + .mockImplementation(() => {}); + try { + initializeTelemetry(mockConfig); + expect(consoleWarnSpy).not.toHaveBeenCalled(); + } finally { + consoleWarnSpy.mockRestore(); + } + }); + + it('user-provided session.id is stripped (defense-in-depth)', () => { + // Simulates a caller that bypasses resolveTelemetrySettings() and feeds + // raw user input straight into Config. Resource must still not carry + // session.id, otherwise it would leak onto every metric data point. + vi.spyOn(mockConfig, 'getTelemetryResourceAttributes').mockReturnValue({ + 'session.id': 'spoofed', + team: 'x', + }); + initializeTelemetry(mockConfig); + const attrs = getResourceAttributes(); + expect(attrs['session.id']).toBeUndefined(); + expect(attrs['team']).toBe('x'); + }); + }); }); describe('refreshSessionContext', () => { @@ -528,6 +651,9 @@ describe('refreshSessionContext', () => { getTelemetryOtlpMetricsEndpoint: () => undefined, getTelemetryTarget: () => 'local', getTelemetryOutfile: () => undefined, + getTelemetryResourceAttributes: () => ({}), + getTelemetryMetricsIncludeSessionId: () => false, + getTelemetryResourceAttributeWarnings: () => [], getDebugMode: () => false, getSessionId: () => 'test-session', getCliVersion: () => '1.0.0-test', diff --git a/packages/core/src/telemetry/sdk.ts b/packages/core/src/telemetry/sdk.ts index a8c5a361e10..1412d06de82 100644 --- a/packages/core/src/telemetry/sdk.ts +++ b/packages/core/src/telemetry/sdk.ts @@ -153,13 +153,57 @@ export function initializeTelemetry(config: Config): void { } const debugLogger = createDebugLogger('OTEL'); + // User-provided resource attributes (env + settings, already merged with + // RESERVED stripping and OTEL_SERVICE_NAME precedence in the resolver). + // We strip service.name/service.version here too as defense-in-depth, then + // re-apply runtime-controlled values on top. + const userAttrs = config.getTelemetryResourceAttributes() ?? {}; + const userServiceName = userAttrs['service.name']; + // Strip keys we re-inject below (service.name, service.version) plus + // session.id, which never belongs on the Resource — Resource attributes + // auto-attach to every metric data point, which would bypass the metric + // cardinality toggle. The resolver normally drops session.id from user + // input already; this destructure is defense-in-depth for callers that + // bypass the resolver (e.g. direct Config construction in tests). + const { + 'service.name': _ignoredServiceName, + 'service.version': _ignoredServiceVersion, + 'session.id': _ignoredSessionId, + ...nonReservedUserAttrs + } = userAttrs; const resource = resourceFromAttributes({ - [SemanticResourceAttributes.SERVICE_NAME]: SERVICE_NAME, + ...nonReservedUserAttrs, + // `.trim() || SERVICE_NAME`: catches both empty string (`""`) and + // whitespace-only values (`" "`, `"\t"`) that would otherwise produce + // a blank service name on Resource (some backends reject these). Both + // settings (no value trimming there) and env (`%20` decodes to `" "`) + // can deliver whitespace-only values, so trim at the fallback point. + [SemanticResourceAttributes.SERVICE_NAME]: + userServiceName?.trim() || SERVICE_NAME, [SemanticResourceAttributes.SERVICE_VERSION]: config.getCliVersion() || 'unknown', - 'session.id': config.getSessionId(), }); + // One-time user-visible summary of resource-attribute diagnostics + // produced during config resolution. The per-warning `diag.warn` calls + // route to the OTel debug log; without this summary, an operator whose + // attributes are silently dropped has no console signal that anything + // happened. Telemetry init runs before Ink renders, so console output + // here does not interleave with the TUI. + // `?? []` defends against test mocks (`vi.mock('../config/config.js')`) + // that auto-stub Config methods to return undefined. + const attrWarnings = config.getTelemetryResourceAttributeWarnings() ?? []; + if (attrWarnings.length > 0) { + // eslint-disable-next-line no-console + console.warn( + `[qwen-code telemetry] ${attrWarnings.length} resource attribute issue(s):`, + ); + for (const w of attrWarnings) { + // eslint-disable-next-line no-console + console.warn(` - ${w}`); + } + } + const otlpEndpoint = config.getTelemetryOtlpEndpoint(); const otlpProtocol = config.getTelemetryOtlpProtocol(); const parsedEndpoint = parseOtlpEndpoint(otlpEndpoint, otlpProtocol); diff --git a/packages/vscode-ide-companion/schemas/settings.schema.json b/packages/vscode-ide-companion/schemas/settings.schema.json index 81fa1d53a2f..9ea83878b01 100644 --- a/packages/vscode-ide-companion/schemas/settings.schema.json +++ b/packages/vscode-ide-companion/schemas/settings.schema.json @@ -415,6 +415,26 @@ "description": "When enabled, user prompts, system prompts, tool inputs/outputs, and model responses are written to native OTel span attributes in addition to the log-to-span bridge. Warning: this may expose sensitive data (file contents, shell commands, conversation history) to your OTLP backend.", "type": "boolean", "default": false + }, + "resourceAttributes": { + "description": "Static resource attributes attached to every span/log/metric the SDK exports (OTLP or file outfile — they share the same Resource). Merged with the OTEL_RESOURCE_ATTRIBUTES env var; settings win on key conflict. Reserved keys (service.version, session.id) are dropped with a warning.", + "type": "object", + "additionalProperties": { + "type": "string" + }, + "default": {} + }, + "metrics": { + "description": "Per-signal cardinality controls for exported metrics.", + "type": "object", + "additionalProperties": false, + "properties": { + "includeSessionId": { + "description": "Include session.id on every metric data point. WARNING: each CLI session creates a new value, causing unbounded metric time-series fan-out at the backend. Only enable for short-term debugging — spans and logs still carry session.id.", + "type": "boolean", + "default": false + } + } } }, "additionalProperties": true,