diff --git a/docs/design/gen-ai-arms-field-alignment.md b/docs/design/gen-ai-arms-field-alignment.md index ac4933ba96a..4b9389ebcca 100644 --- a/docs/design/gen-ai-arms-field-alignment.md +++ b/docs/design/gen-ai-arms-field-alignment.md @@ -6,6 +6,7 @@ This design aligns the first set of Qwen Code span attributes whose names, types, and meanings agree between OpenTelemetry GenAI semantic conventions and Alibaba Cloud ARMS LLM Trace. It does not change span names, span kinds, parenting, or retry topology. +It also documents the opt-in ARMS-only end-user identity extension. The OpenTelemetry GenAI convention is still Development status. This change is pinned to commit @@ -184,6 +185,25 @@ Qwen Code does not inject that vendor-specific resource attribute or `gen_ai.span.kind`. ARMS can infer LLM, Tool, and Agent roles from `gen_ai.operation.name`. +### ARMS end-user identity extension + +`gen_ai.user.id` is an ARMS Span common attribute, not part of the pinned +OpenTelemetry GenAI baseline above. Qwen Code emits it only when the operator +explicitly configures `telemetry.userId` or `QWEN_TELEMETRY_USER_ID`. The value +is placed on the interaction Span at creation and propagated through the +existing in-process context to LLM, Tool, and Agent spans, including linked-root +fork/background agents. Tool-result continuations resolve the same logical +interaction by prompt ID without changing Span parenting; that minimal identity +entry expires with the existing 30-minute Span safety-net TTL. + +The value is never inferred, generated, written to Resource/logs/metrics, or +placed in outbound Baggage. Qwen Code does not dual-write `enduser.id` or +`user.id`. A previous `telemetry.resourceAttributes.user.id` remains a generic +Resource dimension and must be removed explicitly when migrating. Because the +setting is process-wide, it is supported only when one process represents one +end user; request-scoped identity for shared daemon and channel deployments is +deferred until their trusted caller identity can be wired end to end. + ## Deferred work - `seed` and `top_k` have incompatible ARMS and GenAI types in the baselines. diff --git a/docs/developers/daemon/17-configuration.md b/docs/developers/daemon/17-configuration.md index 9917dc85cb1..3e2a3f8fd16 100644 --- a/docs/developers/daemon/17-configuration.md +++ b/docs/developers/daemon/17-configuration.md @@ -85,7 +85,7 @@ The daemon constructs each workspace runtime from that workspace's merged settin | `context.fileName` | string | Overrides `getCurrentGeminiMdFilename()` through `BridgeOptions.contextFilename`. | | `tools.disabled` | string[] | Tools disabled for the next ACP child spawn. Normalized through `normalizeDisabledToolList()` (`packages/cli/src/config/normalizeDisabledTools.ts`): non-array becomes `[]`, non-string entries are skipped, whitespace is trimmed, empty entries are dropped, and duplicates are removed while preserving first occurrence. Boot and `restartMcpServer` settings refresh both run through this function. `ToolRegistry.has(name)` is exact and case-sensitive. `POST /workspace/tools/:name/enable` and `tool_toggled` update this key. | | `tools.approvalMode` | `'default' \| 'auto' \| ...` | Default session approval mode; `POST /session/:id/approval-mode` writes here when `persist: true`. | -| `telemetry` | object | OTel config. Keys include `enabled`, `otlpEndpoint`, `otlpProtocol`, `otlpTracesEndpoint`, `otlpLogsEndpoint`, `otlpMetricsEndpoint`, `target`, `outfile`, `includeSensitiveSpanAttributes`, `sensitiveSpanAttributeMaxLength`, `resourceAttributes`, and `metrics.includeSessionId`. `resolveTelemetrySettings()` reads it at boot and initializes `initializeTelemetry()`. | +| `telemetry` | object | OTel config. Keys include `enabled`, `otlpEndpoint`, `otlpProtocol`, `otlpTracesEndpoint`, `otlpLogsEndpoint`, `otlpMetricsEndpoint`, `target`, `outfile`, `userId`, `includeSensitiveSpanAttributes`, `sensitiveSpanAttributeMaxLength`, `resourceAttributes`, and `metrics.includeSessionId`. `resolveTelemetrySettings()` reads it at boot and initializes `initializeTelemetry()`. `userId` is process-wide and must not be configured as end-user identity when the daemon serves multiple users. | ## `ServeOptions` (programmatic embedding) diff --git a/docs/developers/development/telemetry.md b/docs/developers/development/telemetry.md index c6c69297e37..f1441e112a2 100644 --- a/docs/developers/development/telemetry.md +++ b/docs/developers/development/telemetry.md @@ -77,6 +77,7 @@ These settings can be overridden by environment variables or CLI flags. | `otlpMetricsEndpoint` | `QWEN_TELEMETRY_OTLP_METRICS_ENDPOINT` | - | Per-signal endpoint override for metrics (HTTP only) | URL string | - | | `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` | +| `userId` | `QWEN_TELEMETRY_USER_ID` | - | Stable end-user identifier written to GenAI spans as the ARMS extension `gen_ai.user.id`; prefer a pseudonymous value | string | - | | `includeSensitiveSpanAttributes` | `QWEN_TELEMETRY_INCLUDE_SENSITIVE_SPAN_ATTRIBUTES` | - | Include standard GenAI messages, instructions, tool definitions, tool arguments, and successful tool results as native span attributes | `true`/`false` | `false` | | `sensitiveSpanAttributeMaxLength` | `QWEN_TELEMETRY_SENSITIVE_SPAN_ATTRIBUTE_MAX_LENGTH` | - | Maximum compact JSON string length for each sensitive native span attribute. Set lower if your backend rejects large attributes. | `1..104857600` | `1048576` | | `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,…` | `{}` | @@ -159,6 +160,17 @@ OpenTelemetry names: `OTEL_EXPORTER_OTLP_TRACES_ENDPOINT`, `OTEL_EXPORTER_OTLP_LOGS_ENDPOINT`, `OTEL_EXPORTER_OTLP_METRICS_ENDPOINT`. The `QWEN_TELEMETRY_OTLP_*` variants take precedence over the `OTEL_*` variants. +**End-user identity:** `telemetry.userId` and +`QWEN_TELEMETRY_USER_ID` are explicit opt-ins for the ARMS span attribute +`gen_ai.user.id`. The environment variable takes precedence after both values +are trimmed; a blank environment value falls back to settings. The identifier +is written only to interaction, LLM, Tool, and Agent spans. It is not a Resource +attribute, log or metric attribute, outbound Baggage value, or current +OpenTelemetry GenAI standard field. Prefer a stable pseudonymous identifier. +The value is resolved at startup, so configuration changes require a restart. +Do not configure a process-wide value on a daemon or channel instance serving +multiple end users. + For detailed information about all configuration options, see the [Configuration Guide](../../users/configuration/settings.md). @@ -446,6 +458,26 @@ sent to Alibaba Cloud. > backend uses different paths, use per-signal endpoint overrides as > shown in Option B. + To populate ARMS Session Analysis `User ID`, add a stable pseudonymous + identity as a span-level setting: + + ```json + { + "telemetry": { + "userId": "user-079458", + "resourceAttributes": { + "acs.arms.service.feature": "genai_app" + } + } + } + ``` + + For container deployments, set + `QWEN_TELEMETRY_USER_ID=user-079458` instead. A custom + `telemetry.resourceAttributes.user.id` remains an unrelated Resource + dimension and does not populate ARMS Session Analysis; remove it when + migrating to the span-level setting. + 2. If your Alibaba Cloud endpoint requires authentication, provide OTLP headers through standard OpenTelemetry environment variables such as `OTEL_EXPORTER_OTLP_HEADERS` (or the signal-specific variants). Qwen @@ -824,16 +856,16 @@ The daemon process (long-running HTTP server mode) exposes its own metrics. Distributed tracing spans form a tree rooted at `qwen-code.interaction`. Each interaction is a trace root with its own `traceId`; cross-prompt correlation uses the `session.id` attribute. - `qwen-code.interaction`: Root span for each user prompt turn. - - **Attributes**: `session.id`, `qwen-code.prompt_id`, `qwen-code.message_type`, `qwen-code.model`, `qwen-code.approval_mode`, `interaction.sequence`, `interaction.duration_ms`, `qwen-code.turn_status` ("ok"/"error"/"cancelled") + - **Attributes**: `session.id`, optional ARMS extension `gen_ai.user.id`, `qwen-code.prompt_id`, `qwen-code.message_type`, `qwen-code.model`, `qwen-code.approval_mode`, `interaction.sequence`, `interaction.duration_ms`, `qwen-code.turn_status` ("ok"/"error"/"cancelled") - `qwen-code.llm_request`: Wraps a single LLM API call. - - **GenAI attributes**: `gen_ai.operation.name`, `gen_ai.provider.name`, `gen_ai.conversation.id`, `gen_ai.request.model`, `gen_ai.request.choice.count`, `gen_ai.request.max_tokens`, `gen_ai.request.temperature`, `gen_ai.request.top_p`, `gen_ai.request.frequency_penalty`, `gen_ai.request.presence_penalty`, `gen_ai.request.stop_sequences`, optional `gen_ai.output.type`, `gen_ai.response.id`, `gen_ai.response.model`, `gen_ai.response.finish_reasons`, `gen_ai.usage.input_tokens`, `gen_ai.usage.output_tokens`, `gen_ai.usage.cache_read.input_tokens`, `gen_ai.usage.cache_creation.input_tokens` + - **GenAI attributes**: `gen_ai.operation.name`, `gen_ai.provider.name`, `gen_ai.conversation.id`, optional ARMS extension `gen_ai.user.id`, `gen_ai.request.model`, `gen_ai.request.choice.count`, `gen_ai.request.max_tokens`, `gen_ai.request.temperature`, `gen_ai.request.top_p`, `gen_ai.request.frequency_penalty`, `gen_ai.request.presence_penalty`, `gen_ai.request.stop_sequences`, optional `gen_ai.output.type`, `gen_ai.response.id`, `gen_ai.response.model`, `gen_ai.response.finish_reasons`, `gen_ai.usage.input_tokens`, `gen_ai.usage.output_tokens`, `gen_ai.usage.cache_read.input_tokens`, `gen_ai.usage.cache_creation.input_tokens` - **Compatibility attributes**: `session.id`, `qwen-code.prompt_id`, `llm_request.context` ("subagent"/"interaction"/"standalone"), `duration_ms`, `ttft_ms`, `request_setup_ms`, `attempt`, `retry_total_delay_ms`, `sampling_ms`, `output_tokens_per_second`, `success`, `error`, `finish_reason`, `thoughts_token_count`, `subagent_name`, `error_type`, `error_status_code` - Standard response fields come from the provider response. Standard token fields are emitted only for provider-reported non-negative safe integers. If the provider reports only a total token count, input/output usage is omitted rather than estimated. - Standard request-parameter fields come from the first provider-final SDK request object after adapter defaults, overrides, unsupported-field removal, and output-window clamps. Qwen Code does not infer SDK or server defaults. - `qwen-code.tool`: Wraps the full tool lifecycle (approval wait + execution). - - **Attributes**: `session.id`, `gen_ai.operation.name` (`execute_tool`), `gen_ai.tool.name`, `gen_ai.tool.type` (`function`), `gen_ai.tool.call.id`, `tool.call_id`, `duration_ms`, `success`, `error` + - **Attributes**: `session.id`, optional ARMS extension `gen_ai.user.id`, `gen_ai.operation.name` (`execute_tool`), `gen_ai.tool.name`, `gen_ai.tool.type` (`function`), `gen_ai.tool.call.id`, `tool.call_id`, `duration_ms`, `success`, `error` - `qwen-code.tool.execution`: Wraps the tool execution phase (after approval). - **Attributes**: `session.id`, `duration_ms`, `success`, `error` @@ -845,7 +877,7 @@ Distributed tracing spans form a tree rooted at `qwen-code.interaction`. Each in - **Attributes**: `session.id`, `hook_event` ("PreToolUse"/"PostToolUse"/"PostToolUseFailure"/"PostToolBatch"), `tool.name`, `tool.use_id` (optional), `is_interrupt` (boolean, optional), `duration_ms`, `success`, `should_proceed` (optional), `should_stop` (optional), `block_type` (optional), `error` (optional) - `qwen-code.subagent`: Wraps a single subagent invocation. - - **Attributes**: `gen_ai.operation.name` (`invoke_agent`), `gen_ai.agent.name`, `gen_ai.agent.description`, `gen_ai.conversation.id`, optional `gen_ai.request.model`, `qwen-code.subagent.id`, `qwen-code.subagent.name`, `qwen-code.subagent.invocation_kind` ("foreground"/"fork"/"background"), `qwen-code.subagent.is_built_in`, `qwen-code.subagent.depth`, `qwen-code.subagent.status`, `qwen-code.subagent.terminate_reason`, `qwen-code.subagent.duration_ms` + - **Attributes**: `gen_ai.operation.name` (`invoke_agent`), `gen_ai.agent.name`, `gen_ai.agent.description`, `gen_ai.conversation.id`, optional ARMS extension `gen_ai.user.id`, optional `gen_ai.request.model`, `qwen-code.subagent.id`, `qwen-code.subagent.name`, `qwen-code.subagent.invocation_kind` ("foreground"/"fork"/"background"), `qwen-code.subagent.is_built_in`, `qwen-code.subagent.depth`, `qwen-code.subagent.status`, `qwen-code.subagent.terminate_reason`, `qwen-code.subagent.duration_ms` #### GenAI field migration and ARMS recognition diff --git a/docs/users/configuration/settings.md b/docs/users/configuration/settings.md index 5d95e268724..58f3d294da8 100644 --- a/docs/users/configuration/settings.md +++ b/docs/users/configuration/settings.md @@ -593,6 +593,7 @@ Configures logging and metrics collection for Qwen Code. For more information, s | `telemetry.otlpEndpoint` | string | The endpoint for the OTLP Exporter. | | | `telemetry.otlpProtocol` | string | The protocol for the OTLP Exporter (`grpc` or `http`). | | | `telemetry.logPrompts` | boolean | Whether or not to include the content of user prompts in the logs. | | +| `telemetry.userId` | string | Stable end-user identifier written to GenAI spans as the ARMS extension `gen_ai.user.id`. Prefer a pseudonymous value. Do not set a process-wide value for a shared multi-user daemon or channel instance. | | | `telemetry.includeSensitiveSpanAttributes` | boolean | When enabled, attaches verbatim user prompts, system prompts, tool inputs/outputs, and model responses to native OTel span attributes (in addition to log-to-span bridge spans). ⚠️ Streams sensitive data — file contents, shell commands, conversation history — to your OTLP backend. | `false` | | `telemetry.sensitiveSpanAttributeMaxLength` | number | Maximum JavaScript string length for each sensitive native OTel span attribute content payload. Must be between `1` and `104857600` (100 MiB). Set lower if your collector or backend rejects large attributes. | `1048576` | | `telemetry.outfile` | string | Path to write telemetry to a file. When set, overrides OTLP export. | | @@ -639,6 +640,7 @@ Here is an example of a `settings.json` file with the nested structure, new as o "target": "local", "otlpEndpoint": "http://localhost:4317", "logPrompts": true, + "userId": "user-079458", "includeSensitiveSpanAttributes": false, "sensitiveSpanAttributeMaxLength": 1048576 }, @@ -696,6 +698,7 @@ For authentication-related variables (like `OPENAI_*`) and the recommended `.qwe | `QWEN_TELEMETRY_OTLP_ENDPOINT` | Sets the OTLP endpoint for telemetry. | Overrides the `telemetry.otlpEndpoint` setting. | | `QWEN_TELEMETRY_OTLP_PROTOCOL` | Sets the OTLP protocol (`grpc` or `http`). | Overrides the `telemetry.otlpProtocol` setting. | | `QWEN_TELEMETRY_LOG_PROMPTS` | Set to `true` or `1` to enable or disable logging of user prompts. Any other value is treated as disabling it. | Overrides the `telemetry.logPrompts` setting. | +| `QWEN_TELEMETRY_USER_ID` | Sets a stable end-user identifier on interaction, LLM, Tool, and Agent spans as `gen_ai.user.id`. Prefer a pseudonymous value. | Overrides `telemetry.userId` after trimming. A blank value falls back to settings. This is process-wide and must not be used as per-request identity in a shared multi-user process. | | `QWEN_TELEMETRY_INCLUDE_SENSITIVE_SPAN_ATTRIBUTES` | Set to `true` or `1` to attach verbatim user prompts, system prompts, tool I/O, and model responses to native OTel span attributes (and keep `prompt` / `function_args` / `response_text` on log-to-span bridge spans). Any other value disables it. | Overrides the `telemetry.includeSensitiveSpanAttributes` setting. ⚠️ Streams sensitive data to your OTLP backend. | | `QWEN_TELEMETRY_SENSITIVE_SPAN_ATTRIBUTE_MAX_LENGTH` | Sets the maximum JavaScript string length for each sensitive native OTel span attribute content payload. Must be a positive integer no greater than `104857600` (100 MiB). | Overrides the `telemetry.sensitiveSpanAttributeMaxLength` setting. Default is `1048576` (1 MiB); lower it if your collector or backend rejects large span attributes. | | `QWEN_TELEMETRY_OUTFILE` | Sets the file path to write telemetry to. When set, overrides OTLP export. | Overrides the `telemetry.outfile` setting. | diff --git a/integration-tests/cli/gen-ai-telemetry.test.ts b/integration-tests/cli/gen-ai-telemetry.test.ts index 8cff3487dd5..170a0fbfb09 100644 --- a/integration-tests/cli/gen-ai-telemetry.test.ts +++ b/integration-tests/cli/gen-ai-telemetry.test.ts @@ -17,6 +17,9 @@ type TelemetryRecord = { name?: string; attributes?: Record; events?: Array<{ name?: string }>; + resource?: { + _rawAttributes?: Array<[string, unknown]>; + }; }; const SKIP = @@ -133,6 +136,7 @@ describeLocal('GenAI telemetry fields', () => { }, }, ui: { enableFollowupSuggestions: false }, + outboundCorrelation: { propagateTraceContext: true }, }, }); @@ -153,6 +157,7 @@ describeLocal('GenAI telemetry fields', () => { all_proxy: undefined, DASHSCOPE_PROXY_BASE_URL: undefined, QWEN_TELEMETRY_INCLUDE_SENSITIVE_SPAN_ATTRIBUTES: 'true', + QWEN_TELEMETRY_USER_ID: 'integration-user-079458', }); try { @@ -173,7 +178,14 @@ describeLocal('GenAI telemetry fields', () => { const firstLlm = llmSpans[0]!.attributes!; const secondLlm = llmSpans[1]!.attributes!; + const interactionSpan = records.find( + (record) => record.name === 'qwen-code.interaction', + ); + expect(interactionSpan?.attributes?.['gen_ai.user.id']).toBe( + 'integration-user-079458', + ); expect(firstLlm).toMatchObject({ + 'gen_ai.user.id': 'integration-user-079458', 'gen_ai.operation.name': 'chat', 'gen_ai.provider.name': 'openai', 'gen_ai.request.model': 'request-model', @@ -191,6 +203,7 @@ describeLocal('GenAI telemetry fields', () => { 'gen_ai.usage.cache_read.input_tokens': 3, }); expect(secondLlm).toMatchObject({ + 'gen_ai.user.id': 'integration-user-079458', 'gen_ai.operation.name': 'chat', 'gen_ai.provider.name': 'openai', 'gen_ai.request.choice.count': 2, @@ -335,6 +348,7 @@ describeLocal('GenAI telemetry fields', () => { record.attributes?.['gen_ai.tool.name'] === 'run_shell_command', ); expect(toolSpan?.attributes).toMatchObject({ + 'gen_ai.user.id': 'integration-user-079458', 'gen_ai.operation.name': 'execute_tool', 'gen_ai.tool.name': 'run_shell_command', 'gen_ai.tool.type': 'function', @@ -355,6 +369,88 @@ describeLocal('GenAI telemetry fields', () => { expect(toolSpan?.attributes).not.toHaveProperty('tool.name'); expect(toolSpan?.attributes).not.toHaveProperty('tool_input'); expect(toolSpan?.attributes).not.toHaveProperty('tool_result'); + + const canonicalSpanNames = new Set([ + 'qwen-code.interaction', + 'qwen-code.llm_request', + 'qwen-code.tool', + ]); + for (const record of records) { + const attributes = record.attributes ?? {}; + const resourceAttributes = new Map(record.resource?._rawAttributes ?? []); + expect(resourceAttributes.has('gen_ai.user.id')).toBe(false); + expect(attributes).not.toHaveProperty('enduser.id'); + expect(attributes).not.toHaveProperty('user.id'); + if (!record.name || !canonicalSpanNames.has(record.name)) { + expect(attributes).not.toHaveProperty('gen_ai.user.id'); + } + } + + expect(server.requests).toHaveLength(2); + for (const request of server.requests) { + expect(request.headers['traceparent']).toEqual(expect.any(String)); + expect(String(request.headers['baggage'] ?? '')).not.toContain( + 'integration-user-079458', + ); + } + }); + + it('omits the ARMS user ID when it is not configured', async () => { + server = await startFakeOpenAIServer(() => ({ + model: 'provider-model', + content: 'Done.', + usage: { + prompt_tokens: 10, + completion_tokens: 2, + total_tokens: 12, + }, + })); + + rig = new TestRig(); + rig.setup('gen-ai-telemetry-no-user', { + settings: { + security: { auth: { selectedType: 'openai' } }, + model: { name: 'request-model' }, + ui: { enableFollowupSuggestions: false }, + }, + }); + + const restoreEnvironment = setEnvironment({ + HOME: rig.testDir!, + QWEN_HOME: join(rig.testDir!, '.qwen'), + OPENAI_API_KEY: 'fake-key', + OPENAI_BASE_URL: server.baseUrl, + OPENAI_MODEL: 'request-model', + QWEN_MODEL: 'request-model', + QWEN_TELEMETRY_USER_ID: undefined, + NO_PROXY: '127.0.0.1,localhost', + no_proxy: '127.0.0.1,localhost', + HTTP_PROXY: undefined, + HTTPS_PROXY: undefined, + ALL_PROXY: undefined, + http_proxy: undefined, + https_proxy: undefined, + all_proxy: undefined, + DASHSCOPE_PROXY_BASE_URL: undefined, + }); + + try { + await rig.run('Reply with done.', '--output-format', 'json'); + } finally { + restoreEnvironment(); + } + + const records = parseTelemetry(rig.readFile('telemetry.log')); + const interactionSpan = records.find( + (record) => record.name === 'qwen-code.interaction', + ); + const llmSpan = records.find( + (record) => record.name === 'qwen-code.llm_request', + ); + expect(interactionSpan?.attributes ?? {}).not.toHaveProperty( + 'gen_ai.user.id', + ); + expect(llmSpan?.attributes ?? {}).not.toHaveProperty('gen_ai.user.id'); }); it('omits the default choice count and sensitive tool payloads', async () => { diff --git a/integration-tests/fake-openai-server.ts b/integration-tests/fake-openai-server.ts index a2bb08b4b2d..7ba74a20930 100644 --- a/integration-tests/fake-openai-server.ts +++ b/integration-tests/fake-openai-server.ts @@ -7,6 +7,7 @@ import { createServer, type IncomingMessage, + type IncomingHttpHeaders, type Server, type ServerResponse, } from 'node:http'; @@ -52,6 +53,7 @@ export type FakeOpenAIChoice = { export type FakeOpenAIRequest = { body: JsonObject; + headers: IncomingHttpHeaders; }; export type FakeOpenAIServer = { @@ -117,7 +119,7 @@ export async function startFakeOpenAIServer( } const requestIndex = requests.length; - requests.push({ body }); + requests.push({ body, headers: req.headers }); const response = await handler({ body, requestIndex }); if (body['stream'] === true) { diff --git a/packages/cli/src/acp-integration/session/Session.test.ts b/packages/cli/src/acp-integration/session/Session.test.ts index d2f8e46d704..aaaaf083b0f 100644 --- a/packages/cli/src/acp-integration/session/Session.test.ts +++ b/packages/cli/src/acp-integration/session/Session.test.ts @@ -15476,6 +15476,7 @@ describe('Session', () => { 'gen_ai.tool.call.id': 'provider-call', }), 'read_file', + 'prompt-tool-span', ); expect(addToolArgumentsAttributesSpy).toHaveBeenCalledWith( mockConfig, @@ -15509,6 +15510,7 @@ describe('Session', () => { 'gen_ai.tool.call.id': 'internal-call', }), 'read_file', + 'prompt-tool-span-fallback', ); }); diff --git a/packages/cli/src/acp-integration/session/Session.ts b/packages/cli/src/acp-integration/session/Session.ts index 5df37a9f6f9..585e2483ef3 100644 --- a/packages/cli/src/acp-integration/session/Session.ts +++ b/packages/cli/src/acp-integration/session/Session.ts @@ -7125,6 +7125,7 @@ export class Session implements SessionContext { tool_name: policyToolName, }, tool.description, + promptId, ); let spanSuccess = false; diff --git a/packages/cli/src/config/settingsSchema.test.ts b/packages/cli/src/config/settingsSchema.test.ts index 3418c51e0db..c8c2cfee1f7 100644 --- a/packages/cli/src/config/settingsSchema.test.ts +++ b/packages/cli/src/config/settingsSchema.test.ts @@ -269,6 +269,15 @@ describe('SettingsSchema', () => { }); }); + it('should define telemetry userId as a privacy-sensitive string', () => { + const telemetrySchema = getSettingsSchema().telemetry.jsonSchemaOverride; + expect(telemetrySchema.properties?.userId).toEqual({ + description: + 'Stable end-user identifier written to GenAI spans as gen_ai.user.id for ARMS session analysis. This value is linkable personal data: prefer a pseudonymous ID, and configure it only when one process represents one user.', + type: 'string', + }); + }); + it('should have voice dictation settings under general', () => { const voice = getSettingsSchema().general.properties.voice.properties ?? {}; diff --git a/packages/cli/src/config/settingsSchema.ts b/packages/cli/src/config/settingsSchema.ts index 3c8996f1b9e..53b0137a400 100644 --- a/packages/cli/src/config/settingsSchema.ts +++ b/packages/cli/src/config/settingsSchema.ts @@ -1225,6 +1225,11 @@ const SETTINGS_SCHEMA = { jsonSchemaOverride: { type: 'object', properties: { + userId: { + description: + 'Stable end-user identifier written to GenAI spans as gen_ai.user.id for ARMS session analysis. This value is linkable personal data: prefer a pseudonymous ID, and configure it only when one process represents one user.', + type: 'string', + }, includeSensitiveSpanAttributes: { 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.', diff --git a/packages/core/src/config/config.test.ts b/packages/core/src/config/config.test.ts index c81616f41c6..05df9dae258 100644 --- a/packages/core/src/config/config.test.ts +++ b/packages/core/src/config/config.test.ts @@ -5248,6 +5248,24 @@ describe('Server Config (config.ts)', () => { expect(config.getTelemetryEnabled()).toBe(TELEMETRY_SETTINGS.enabled); }); + it('Config exposes the telemetry user ID', () => { + const config = new Config({ + ...baseParams, + telemetry: { enabled: true, userId: ' user-079458 ' }, + }); + + expect(config.getTelemetryUserId()).toBe('user-079458'); + }); + + it('Config omits the telemetry user ID by default', () => { + const config = new Config({ + ...baseParams, + telemetry: { enabled: true }, + }); + + expect(config.getTelemetryUserId()).toBeUndefined(); + }); + it('should have a getFileService method that returns FileDiscoveryService', () => { const config = new Config(baseParams); const fileService = config.getFileService(); diff --git a/packages/core/src/config/config.ts b/packages/core/src/config/config.ts index b9d614a29f0..cc009053cc5 100644 --- a/packages/core/src/config/config.ts +++ b/packages/core/src/config/config.ts @@ -501,6 +501,11 @@ export interface TelemetrySettings { /** Per-signal endpoint override for metrics (HTTP only). Used as-is without path appending. */ otlpMetricsEndpoint?: string; logPrompts?: boolean; + /** + * Stable end-user identifier written to GenAI spans as `gen_ai.user.id`. + * This is an ARMS extension and may contain linkable personal data. + */ + userId?: string; includeSensitiveSpanAttributes?: boolean; sensitiveSpanAttributeMaxLength?: number; outfile?: string; @@ -2047,6 +2052,7 @@ export class Config { otlpLogsEndpoint: params.telemetry?.otlpLogsEndpoint, otlpMetricsEndpoint: params.telemetry?.otlpMetricsEndpoint, logPrompts: params.telemetry?.logPrompts ?? true, + userId: params.telemetry?.userId?.trim() || undefined, includeSensitiveSpanAttributes: params.telemetry?.includeSensitiveSpanAttributes ?? false, sensitiveSpanAttributeMaxLength: resolveSensitiveSpanAttributeMaxLength( @@ -5782,6 +5788,10 @@ export class Config { return this.telemetrySettings.logPrompts ?? true; } + getTelemetryUserId(): string | undefined { + return this.telemetrySettings.userId; + } + getTelemetryIncludeSensitiveSpanAttributes(): boolean { return this.telemetrySettings.includeSensitiveSpanAttributes ?? false; } diff --git a/packages/core/src/core/coreToolScheduler.ts b/packages/core/src/core/coreToolScheduler.ts index fc4e736cc58..5036a3a3bee 100644 --- a/packages/core/src/core/coreToolScheduler.ts +++ b/packages/core/src/core/coreToolScheduler.ts @@ -2374,6 +2374,7 @@ export class CoreToolScheduler { tool_name: canonicalName, }, toolCall.tool.description, + reqInfo.prompt_id, ); this.toolSpans.set(reqInfo.callId, toolSpan); batchState.callIds.add(reqInfo.callId); @@ -3755,6 +3756,7 @@ export class CoreToolScheduler { tool_name: canonical, // legacy alias — see _schedule for context }, scheduledCall.tool.description, + scheduledCall.request.prompt_id, ); this.toolSpans.set(callId, toolSpan); } diff --git a/packages/core/src/telemetry/config.test.ts b/packages/core/src/telemetry/config.test.ts index 42c22e0a5b2..a003005d00b 100644 --- a/packages/core/src/telemetry/config.test.ts +++ b/packages/core/src/telemetry/config.test.ts @@ -64,6 +64,7 @@ describe('telemetry/config helpers', () => { otlpEndpoint: 'http://localhost:4317', otlpProtocol: 'grpc' as const, logPrompts: false, + userId: 'settings-user', includeSensitiveSpanAttributes: true, sensitiveSpanAttributeMaxLength: 1234, outfile: 'settings.log', @@ -87,6 +88,7 @@ describe('telemetry/config helpers', () => { otlpEndpoint: 'http://settings:4317', otlpProtocol: 'grpc' as const, logPrompts: false, + userId: 'settings-user', includeSensitiveSpanAttributes: false, sensitiveSpanAttributeMaxLength: 1234, outfile: 'settings.log', @@ -97,6 +99,7 @@ describe('telemetry/config helpers', () => { QWEN_TELEMETRY_OTLP_ENDPOINT: 'http://env:4317', QWEN_TELEMETRY_OTLP_PROTOCOL: 'http', QWEN_TELEMETRY_LOG_PROMPTS: 'true', + QWEN_TELEMETRY_USER_ID: 'env-user', QWEN_TELEMETRY_INCLUDE_SENSITIVE_SPAN_ATTRIBUTES: 'true', QWEN_TELEMETRY_SENSITIVE_SPAN_ATTRIBUTE_MAX_LENGTH: '2048', QWEN_TELEMETRY_OUTFILE: 'env.log', @@ -120,6 +123,7 @@ describe('telemetry/config helpers', () => { otlpLogsEndpoint: undefined, otlpMetricsEndpoint: undefined, logPrompts: true, + userId: 'env-user', includeSensitiveSpanAttributes: true, sensitiveSpanAttributeMaxLength: 2048, outfile: 'env.log', @@ -142,6 +146,7 @@ describe('telemetry/config helpers', () => { otlpLogsEndpoint: undefined, otlpMetricsEndpoint: undefined, logPrompts: false, + userId: 'env-user', includeSensitiveSpanAttributes: true, sensitiveSpanAttributeMaxLength: 2048, outfile: 'argv.log', @@ -157,6 +162,48 @@ describe('telemetry/config helpers', () => { expect(resolved.includeSensitiveSpanAttributes).toBe(false); }); + it('resolves and normalizes the telemetry user ID', async () => { + const resolvedFromSettings = await resolveTelemetrySettings({ + settings: { userId: ' user α beta ' }, + }); + expect(resolvedFromSettings.userId).toBe('user α beta'); + + const resolvedFromEnv = await resolveTelemetrySettings({ + env: { QWEN_TELEMETRY_USER_ID: ' 0 ' }, + settings: { userId: 'settings-user' }, + }); + expect(resolvedFromEnv.userId).toBe('0'); + }); + + it('falls back to settings when the telemetry user ID env var is blank', async () => { + const resolved = await resolveTelemetrySettings({ + env: { QWEN_TELEMETRY_USER_ID: ' ' }, + settings: { userId: ' settings-user ' }, + }); + + expect(resolved.userId).toBe('settings-user'); + }); + + it('omits the telemetry user ID when both sources are blank', async () => { + const resolved = await resolveTelemetrySettings({ + env: { QWEN_TELEMETRY_USER_ID: '\t' }, + settings: { userId: ' ' }, + }); + + expect(resolved.userId).toBeUndefined(); + }); + + it('rejects a non-string telemetry user ID setting', async () => { + await expect( + resolveTelemetrySettings({ + settings: { + // @ts-expect-error — runtime defensive path against bad JSON. + userId: 42, + }, + }), + ).rejects.toThrow(/telemetry\.userId.*must be a string.*number/); + }); + it('defaults sensitiveSpanAttributeMaxLength to 1MiB', async () => { const resolved = await resolveTelemetrySettings({}); const resolvedMaxLength: number = diff --git a/packages/core/src/telemetry/config.ts b/packages/core/src/telemetry/config.ts index 275c1cf32ec..400b263e2fc 100644 --- a/packages/core/src/telemetry/config.ts +++ b/packages/core/src/telemetry/config.ts @@ -90,6 +90,19 @@ function parseSensitiveSpanAttributeMaxLengthSetting( return value; } +function parseTelemetryUserId( + source: string, + value: unknown, +): string | undefined { + if (value === undefined) return undefined; + if (typeof value !== 'string') { + throw new FatalConfigError( + `Invalid ${source}: must be a string, got ${typeof value}`, + ); + } + return value.trim() || undefined; +} + export interface TelemetryArgOverrides { telemetry?: boolean; telemetryTarget?: string | TelemetryTarget; @@ -155,6 +168,12 @@ export async function resolveTelemetrySettings(options: { parseBooleanEnvFlag(env['QWEN_TELEMETRY_LOG_PROMPTS']) ?? settings.logPrompts; + const userId = + parseTelemetryUserId( + 'QWEN_TELEMETRY_USER_ID', + env['QWEN_TELEMETRY_USER_ID'], + ) ?? parseTelemetryUserId('telemetry.userId', settings.userId); + const includeSensitiveSpanAttributes = parseBooleanEnvFlag( env['QWEN_TELEMETRY_INCLUDE_SENSITIVE_SPAN_ATTRIBUTES'], @@ -245,6 +264,7 @@ export async function resolveTelemetrySettings(options: { otlpLogsEndpoint, otlpMetricsEndpoint, logPrompts, + userId, includeSensitiveSpanAttributes, sensitiveSpanAttributeMaxLength, outfile, diff --git a/packages/core/src/telemetry/session-tracing.test.ts b/packages/core/src/telemetry/session-tracing.test.ts index ffc851efbd5..6d541f237cc 100644 --- a/packages/core/src/telemetry/session-tracing.test.ts +++ b/packages/core/src/telemetry/session-tracing.test.ts @@ -182,11 +182,13 @@ function createMockConfig( overrides: Partial<{ sessionId: string; approvalMode: string; + userId: string; }> = {}, ): Config { return { getSessionId: () => overrides.sessionId ?? 'test-session-id', getApprovalMode: () => overrides.approvalMode ?? 'suggest', + getTelemetryUserId: () => overrides.userId, } as unknown as Config; } @@ -207,7 +209,7 @@ describe('session-tracing', () => { describe('interaction spans', () => { it('starts and ends an interaction span with ok status', () => { - const config = createMockConfig(); + const config = createMockConfig({ userId: 'user-1' }); startInteractionSpan(config, { promptId: 'prompt-1', model: 'test-model', @@ -217,6 +219,7 @@ describe('session-tracing', () => { expect(mockSpans).toHaveLength(1); expect(mockSpans[0]!.name).toBe('qwen-code.interaction'); expect(mockSpans[0]!.attributes['session.id']).toBe('test-session-id'); + expect(mockSpans[0]!.attributes['gen_ai.user.id']).toBe('user-1'); expect(mockSpans[0]!.attributes['qwen-code.prompt_id']).toBe('prompt-1'); expect(mockSpans[0]!.attributes['qwen-code.model']).toBe('test-model'); @@ -239,7 +242,10 @@ describe('session-tracing', () => { }); it('runs scoped interaction spans without mutating the global interaction context', async () => { - const config = createMockConfig({ sessionId: 'scoped-session' }); + const config = createMockConfig({ + sessionId: 'scoped-session', + userId: 'scoped-user', + }); const result = await withInteractionSpan( config, { @@ -256,6 +262,7 @@ describe('session-tracing', () => { expect(mockSpans[0]!.name).toBe('qwen-code.interaction'); expect(mockSpans[0]!.parentContext).toEqual({ parent: 'daemon' }); expect(mockSpans[0]!.attributes['session.id']).toBe('scoped-session'); + expect(mockSpans[0]!.attributes['gen_ai.user.id']).toBe('scoped-user'); expect(mockSpans[0]!.attributes['qwen-code.message_type']).toBe( 'acp_prompt', ); @@ -1314,6 +1321,166 @@ describe('session-tracing', () => { }); }); + describe('gen_ai.user.id propagation', () => { + it('propagates from an interaction to LLM and tool spans', () => { + startInteractionSpan( + createMockConfig({ sessionId: 'session-A', userId: 'user-A' }), + { + promptId: 'p-a', + model: 'm', + messageType: 'userQuery', + }, + ); + + const llmSpan = startLLMRequestSpan('m', 'p-a'); + const toolSpan = startToolSpan('Read', { 'tool.call_id': 'call-1' }); + + expect( + mockSpans.find((span) => span.name === 'qwen-code.llm_request') + ?.attributes['gen_ai.user.id'], + ).toBe('user-A'); + expect( + mockSpans.find((span) => span.name === 'qwen-code.tool')?.attributes[ + 'gen_ai.user.id' + ], + ).toBe('user-A'); + + endLLMRequestSpan(llmSpan, { success: true }); + endToolSpan(toolSpan, { success: true }); + endInteractionSpan('ok'); + }); + + it('does not let tool attributes override the interaction user ID', () => { + startInteractionSpan(createMockConfig({ userId: 'canonical-user' }), { + promptId: 'p', + model: 'm', + messageType: 'userQuery', + }); + + const toolSpan = startToolSpan('Read', { + 'gen_ai.user.id': 'spoofed-user', + }); + const record = mockSpans.find((span) => span.name === 'qwen-code.tool'); + + expect(record?.attributes['gen_ai.user.id']).toBe('canonical-user'); + endToolSpan(toolSpan, { success: true }); + endInteractionSpan('ok'); + }); + + it('omits the user ID from standalone LLM and tool spans', () => { + const llmSpan = startLLMRequestSpan('m', 'p'); + const toolSpan = startToolSpan('Read'); + + for (const record of mockSpans) { + expect(record.attributes).not.toHaveProperty('gen_ai.user.id'); + } + + endLLMRequestSpan(llmSpan, { success: true }); + endToolSpan(toolSpan, { success: true }); + }); + + it('propagates across tool-result turns by prompt ID without changing span parenting', () => { + startInteractionSpan(createMockConfig({ userId: 'continuation-user' }), { + promptId: 'continuation-prompt', + model: 'm', + messageType: 'userQuery', + }); + endInteractionSpan('ok'); + + const llmSpan = startLLMRequestSpan('m', 'continuation-prompt'); + const toolSpan = startToolSpan( + 'Read', + { 'tool.call_id': 'call-2' }, + undefined, + 'continuation-prompt', + ); + + const llmRecord = mockSpans.find( + (span) => span.name === 'qwen-code.llm_request', + ); + const toolRecord = mockSpans.find( + (span) => span.name === 'qwen-code.tool', + ); + expect(llmRecord?.attributes['gen_ai.user.id']).toBe('continuation-user'); + expect(toolRecord?.attributes['gen_ai.user.id']).toBe( + 'continuation-user', + ); + expect(llmRecord?.parentContext).not.toHaveProperty('__parentSpan'); + expect(toolRecord?.parentContext).not.toHaveProperty('__parentSpan'); + + endLLMRequestSpan(llmSpan, { success: true }); + endToolSpan(toolSpan, { success: true }); + }); + + it('expires prompt identity with the existing span TTL', () => { + startInteractionSpan(createMockConfig({ userId: 'expired-user' }), { + promptId: 'expired-prompt', + model: 'm', + messageType: 'userQuery', + }); + endInteractionSpan('ok'); + runTTLSweepForTesting(Date.now() + 31 * 60 * 1000); + + const llmSpan = startLLMRequestSpan('m', 'expired-prompt'); + const llmRecord = mockSpans.find( + (span) => span.name === 'qwen-code.llm_request', + ); + expect(llmRecord?.attributes).not.toHaveProperty('gen_ai.user.id'); + endLLMRequestSpan(llmSpan, { success: true }); + }); + + it('keeps the creation-time user ID across failures and repeated endings', () => { + startInteractionSpan(createMockConfig({ userId: 'stable-user' }), { + promptId: 'failure-prompt', + model: 'm', + messageType: 'userQuery', + }); + const llmSpan = startLLMRequestSpan('m', 'failure-prompt'); + const toolSpan = startToolSpan('Read'); + + endLLMRequestSpan(llmSpan, { success: false, error: 'failed' }); + endLLMRequestSpan(llmSpan, { success: true }); + endToolSpan(toolSpan, { success: false, error: 'failed' }); + endToolSpan(toolSpan, { success: true }); + endInteractionSpan('cancelled'); + endInteractionSpan('ok'); + + for (const record of mockSpans) { + expect(record.attributes['gen_ai.user.id']).toBe('stable-user'); + } + }); + + it('isolates user IDs across concurrent scoped interactions', async () => { + await Promise.all([ + withInteractionSpan( + createMockConfig({ sessionId: 'session-A', userId: 'user-A' }), + { promptId: 'pa', model: 'm', messageType: 'acp_prompt' }, + async () => { + await Promise.resolve(); + endToolSpan(startToolSpan('Read'), { success: true }); + }, + ), + withInteractionSpan( + createMockConfig({ sessionId: 'session-B', userId: 'user-B' }), + { promptId: 'pb', model: 'm', messageType: 'acp_prompt' }, + async () => { + await Promise.resolve(); + endToolSpan(startToolSpan('Write'), { success: true }); + }, + ), + ]); + + const readSpan = mockSpans.find( + (span) => span.attributes['gen_ai.tool.name'] === 'Read', + ); + const writeSpan = mockSpans.find( + (span) => span.attributes['gen_ai.tool.name'] === 'Write', + ); + expect(readSpan?.attributes['gen_ai.user.id']).toBe('user-A'); + expect(writeSpan?.attributes['gen_ai.user.id']).toBe('user-B'); + }); + }); + describe('tool execution sub-spans', () => { it('creates a tool execution span as child of tool span via runInToolSpanContext', () => { const toolSpan = startToolSpan('Bash'); @@ -2062,6 +2229,73 @@ describe('session-tracing', () => { endSubagentSpan(span, { status: 'completed' }); }); + it.each(['foreground', 'fork', 'background'] as const)( + '%s invocation and its children inherit the interaction user ID', + async (invocationKind) => { + startInteractionSpan(createMockConfig({ userId: 'agent-user' }), { + promptId: 'p', + model: 'm', + messageType: 'userQuery', + }); + const span = startSubagentSpan({ + ...baseOpts, + invocationKind, + }); + const record = mockSpans.find( + (candidate) => candidate.name === 'qwen-code.subagent', + ); + expect(record?.attributes['gen_ai.user.id']).toBe('agent-user'); + + await runInSubagentSpanContext(span, async () => { + const llmSpan = startLLMRequestSpan('m', 'subagent-p'); + const toolSpan = startToolSpan('Read'); + const childSpans = mockSpans.filter( + (candidate) => + candidate.name === 'qwen-code.llm_request' || + candidate.name === 'qwen-code.tool', + ); + expect(childSpans).toHaveLength(2); + for (const child of childSpans) { + expect(child.attributes['gen_ai.user.id']).toBe('agent-user'); + } + endLLMRequestSpan(llmSpan, { success: true }); + endToolSpan(toolSpan, { success: true }); + }); + + endSubagentSpan(span, { status: 'completed' }); + endInteractionSpan('ok'); + }, + ); + + it('inherits the user ID from an Agent tool on a continuation turn', async () => { + startInteractionSpan(createMockConfig({ userId: 'agent-tool-user' }), { + promptId: 'agent-tool-prompt', + model: 'm', + messageType: 'userQuery', + }); + endInteractionSpan('ok'); + const toolSpan = startToolSpan( + 'agent', + undefined, + undefined, + 'agent-tool-prompt', + ); + + await runInToolSpanContext(toolSpan, async () => { + const agentSpan = startSubagentSpan({ + ...baseOpts, + invocationKind: 'background', + }); + const record = mockSpans.find( + (candidate) => candidate.name === 'qwen-code.subagent', + ); + expect(record?.attributes['gen_ai.user.id']).toBe('agent-tool-user'); + endSubagentSpan(agentSpan, { status: 'completed' }); + }); + + endToolSpan(toolSpan, { success: true }); + }); + it('captures optional attrs: parentAgentId, invokingRequestId, modelOverride', () => { const span = startSubagentSpan({ ...baseOpts, diff --git a/packages/core/src/telemetry/session-tracing.ts b/packages/core/src/telemetry/session-tracing.ts index d87ff4cba7f..d09b487d4c9 100644 --- a/packages/core/src/telemetry/session-tracing.ts +++ b/packages/core/src/telemetry/session-tracing.ts @@ -192,6 +192,13 @@ const toolContext = new AsyncLocalStorage(); * Review wenshao @ #4410. */ const subagentContext = new AsyncLocalStorage(); +// The interaction span ends before a ToolResult continuation starts. Retain +// only its identity attributes so later spans can inherit the user without +// re-parenting to an ended span or keeping the Span object alive. +const interactionIdentityByPromptId = new Map< + string, + Pick +>(); export function isInNativeSubagentSpan(): boolean { const ctx = subagentContext.getStore(); @@ -221,6 +228,17 @@ function resolveSessionId( : getCurrentSessionId(); } +function resolveGenAiUserId( + parentCtx: Pick | undefined, + promptId?: string, +): string | undefined { + const logicalParent = + parentCtx ?? + (promptId ? interactionIdentityByPromptId.get(promptId) : undefined); + const value = logicalParent?.attributes['gen_ai.user.id']; + return typeof value === 'string' && value ? value : undefined; +} + const activeSpans = new Map>(); const strongSpans = new Map(); @@ -271,6 +289,12 @@ function ttlFor(ctx: SpanContext): number { } function sweepStaleSpans(now: number): void { + for (const [promptId, ctx] of interactionIdentityByPromptId) { + if (now - ctx.startTime >= SPAN_TTL_MS_DEFAULT) { + interactionIdentityByPromptId.delete(promptId); + } + } + for (const [spanId, weakRef] of activeSpans) { const ctx = weakRef.deref(); if (ctx === undefined) { @@ -412,8 +436,10 @@ export function startInteractionSpan( ensureCleanupInterval(); interactionSequence++; + const userId = config.getTelemetryUserId(); const attributes: Attributes = { 'session.id': config.getSessionId(), + ...(userId ? { 'gen_ai.user.id': userId } : {}), 'qwen-code.prompt_id': options.promptId, 'qwen-code.message_type': options.messageType, 'qwen-code.model': options.model, @@ -439,6 +465,14 @@ export function startInteractionSpan( }; activeSpans.set(spanId, new WeakRef(spanContextObj)); strongSpans.set(spanId, spanContextObj); + if (userId) { + interactionIdentityByPromptId.set(options.promptId, { + startTime: spanContextObj.startTime, + attributes: { 'gen_ai.user.id': userId }, + }); + } else { + interactionIdentityByPromptId.delete(options.promptId); + } lastInteractionCtx = spanContextObj; interactionContext.enterWith(spanContextObj); } @@ -492,8 +526,10 @@ export async function withInteractionSpan( ensureCleanupInterval(); interactionSequence++; + const userId = config.getTelemetryUserId(); const attributes: Attributes = { 'session.id': config.getSessionId(), + ...(userId ? { 'gen_ai.user.id': userId } : {}), 'qwen-code.prompt_id': options.promptId, 'qwen-code.message_type': options.messageType, 'qwen-code.model': options.model, @@ -519,6 +555,14 @@ export async function withInteractionSpan( }; activeSpans.set(spanId, new WeakRef(spanContextObj)); strongSpans.set(spanId, spanContextObj); + if (userId) { + interactionIdentityByPromptId.set(options.promptId, { + startTime: spanContextObj.startTime, + attributes: { 'gen_ai.user.id': userId }, + }); + } else { + interactionIdentityByPromptId.delete(options.promptId); + } const activeContext = trace.setSpan(parentContext, span); return await otelContext.with(activeContext, async () => @@ -589,9 +633,11 @@ export function startLLMRequestSpan( const ctx = resolveParentContext(parentCtx); const sessionId = resolveSessionId(parentCtx); + const userId = resolveGenAiUserId(parentCtx, promptId); const attributes: Attributes = { ...(sessionId ? { 'session.id': sessionId } : {}), ...(sessionId ? { 'gen_ai.conversation.id': sessionId } : {}), + ...(userId ? { 'gen_ai.user.id': userId } : {}), 'qwen-code.prompt_id': promptId, 'llm_request.context': subagentContext.getStore() ? 'subagent' @@ -827,6 +873,7 @@ export function startToolSpan( toolName: string, attrs?: Record, description?: string, + promptId?: string, ): Span { if (!isTelemetrySdkInitialized()) { return NOOP_SPAN; @@ -840,9 +887,11 @@ export function startToolSpan( const ctx = resolveParentContext(parentCtx); const sessionId = resolveSessionId(parentCtx); + const userId = resolveGenAiUserId(parentCtx, promptId); const attributes: Attributes = { ...(sessionId ? { 'session.id': sessionId } : {}), ...attrs, + ...(userId ? { 'gen_ai.user.id': userId } : {}), 'gen_ai.operation.name': 'execute_tool', 'gen_ai.tool.name': toolName, 'gen_ai.tool.type': 'function', @@ -1426,11 +1475,17 @@ export function startSubagentSpan(opts: StartSubagentSpanOptions): Span { ensureCleanupInterval(); + const parentCtx = + subagentContext.getStore() ?? + toolContext.getStore() ?? + interactionContext.getStore(); + const userId = resolveGenAiUserId(parentCtx); const attributes: Attributes = { // Spec-aligned (OTel GenAI Agent Spans, Development status). 'gen_ai.operation.name': 'invoke_agent', 'gen_ai.agent.name': opts.subagentName, 'gen_ai.conversation.id': opts.sessionId, + ...(userId ? { 'gen_ai.user.id': userId } : {}), // Vendor identity and lifecycle. The per-invocation ID stays private; // gen_ai.agent.id is reserved for a stable agent definition identity. @@ -1661,6 +1716,7 @@ export function getActiveInteractionSpan(): Span | undefined { export function clearSessionTracingForTesting(): void { activeSpans.clear(); strongSpans.clear(); + interactionIdentityByPromptId.clear(); interactionContext.enterWith(undefined); toolContext.enterWith(undefined); // subagentContext is checked BEFORE interactionContext in startXSpan, so diff --git a/packages/vscode-ide-companion/schemas/settings.schema.json b/packages/vscode-ide-companion/schemas/settings.schema.json index 598839709a5..1926c0f1782 100644 --- a/packages/vscode-ide-companion/schemas/settings.schema.json +++ b/packages/vscode-ide-companion/schemas/settings.schema.json @@ -505,6 +505,10 @@ "telemetry": { "type": "object", "properties": { + "userId": { + "description": "Stable end-user identifier written to GenAI spans as gen_ai.user.id for ARMS session analysis. This value is linkable personal data: prefer a pseudonymous ID, and configure it only when one process represents one user.", + "type": "string" + }, "includeSensitiveSpanAttributes": { "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",