diff --git a/.github/skills/add-policy/SKILL.md b/.github/skills/add-policy/SKILL.md index 32a34f41d6d85..1b9a126079402 100644 --- a/.github/skills/add-policy/SKILL.md +++ b/.github/skills/add-policy/SKILL.md @@ -328,4 +328,5 @@ Search the codebase for `policy:` to find all the examples of different policy c ## Learnings * Never hand-edit `build/lib/policies/policyData.jsonc` (its header explicitly forbids it). If `npm run export-policy-data` is failing, fix the script — don't patch the JSON. Common cause: running it in the wrong working directory (e.g. main repo instead of a worktree), which silently exports the wrong source tree. +* **Regenerate `policyData.jsonc` in a clean environment, or the `PolicyExport` integration test will fail in CI.** `referencedSettings` is only captured for references **loaded at export time**. A plain `npm run export-policy-data` loads your **dev-profile extensions** (e.g. the Copilot extension), which injects `referencedSettings` onto core policies that the test's **fixture-based** export (clean profile, no user extensions) won't produce — so the checked-in file ends up with extra `referencedSettings` and CI fails. This is **not reproducible locally** because the test reuses your default extensions dir. Regenerate the way the test exports: `DISTRO_PRODUCT_JSON= ./scripts/code.sh --export-policy-data="$PWD/build/lib/policies/policyData.jsonc" --user-data-dir="$(mktemp -d)" --extensions-dir="$(mktemp -d)"` (with `VSCODE_SKIP_PRELAUNCH=1`). * Document **behavior and business-logic expectations**, not copy-pasted implementation. Reproducing internal code (e.g. the `getPolicyData()` merge body) in the skill rots the moment the source changes and adds no information beyond the source itself. State the contract in prose (e.g. "server-delivered managed settings win over native MDM; the two layers are never merged") and point to the source for the implementation. Reserve code blocks for the **author-facing API contract** a contributor must follow — how to *declare* a `policy` / `managedSettings` / `value` callback — not for restating runtime plumbing. diff --git a/build/lib/policies/policyData.jsonc b/build/lib/policies/policyData.jsonc index b619ea557fe9b..c509540940371 100644 --- a/build/lib/policies/policyData.jsonc +++ b/build/lib/policies/policyData.jsonc @@ -38,6 +38,66 @@ } ], "policies": [ + { + "key": "chat.agentHost.otel.otlpProtocol", + "name": "CopilotOtelOtlpProtocol", + "category": "InteractiveSession", + "minimumVersion": "1.127", + "localization": { + "description": { + "key": "chat.agentHost.otel.otlpProtocol.policy", + "value": "Controls the enterprise-managed OTLP wire protocol (protobuf vs JSON) for Copilot OpenTelemetry export." + } + }, + "type": "string", + "default": "", + "included": false + }, + { + "key": "chat.agentHost.otel.serviceName", + "name": "CopilotOtelServiceName", + "category": "InteractiveSession", + "minimumVersion": "1.127", + "localization": { + "description": { + "key": "chat.agentHost.otel.serviceName.policy", + "value": "Controls the enterprise-managed OTel `service.name` resource attribute for Copilot OpenTelemetry export." + } + }, + "type": "string", + "default": "", + "included": false + }, + { + "key": "chat.agentHost.otel.resourceAttributes", + "name": "CopilotOtelResourceAttributes", + "category": "InteractiveSession", + "minimumVersion": "1.127", + "localization": { + "description": { + "key": "chat.agentHost.otel.resourceAttributes.policy", + "value": "Controls the enterprise-managed OTel resource attributes for Copilot OpenTelemetry export." + } + }, + "type": "object", + "default": {}, + "included": false + }, + { + "key": "chat.agentHost.otel.headers", + "name": "CopilotOtelHeaders", + "category": "InteractiveSession", + "minimumVersion": "1.127", + "localization": { + "description": { + "key": "chat.agentHost.otel.headers.policy", + "value": "Controls the enterprise-managed OTLP exporter headers for Copilot OpenTelemetry export." + } + }, + "type": "object", + "default": {}, + "included": false + }, { "key": "mcp.enterpriseManagedAuth.idp", "name": "McpEnterpriseManagedAuthIdp", @@ -143,6 +203,24 @@ "default": true, "included": true }, + { + "key": "chat.agentHost.claudeAgent.enabled", + "name": "Claude3PIntegration", + "category": "InteractiveSession", + "minimumVersion": "1.113", + "localization": { + "description": { + "key": "chat.agentHost.claudeAgent.enabled.policy", + "value": "Enable Claude Agent sessions in VS Code. Start and resume agentic coding sessions powered by Anthropic Claude Agent SDK directly in the editor. Uses your existing Copilot subscription." + } + }, + "type": "boolean", + "default": true, + "included": true, + "referencedSettings": [ + "github.copilot.chat.claudeAgent.enabled" + ] + }, { "key": "chat.agentHost.codexAgent.enabled", "name": "Codex3PIntegration", @@ -159,14 +237,68 @@ "included": true }, { - "key": "chat.defaultModel", - "name": "ChatDefaultModel", + "key": "chat.agentHost.otel.enabled", + "name": "CopilotOtelEnabled", "category": "InteractiveSession", "minimumVersion": "1.127", "localization": { "description": { - "key": "chat.defaultModel.policy", - "value": "Sets the default chat model for new conversations. Accepts \"auto\", a model family name (such as \"opus\" or \"gemini\"), or a full model id. Users can still switch the model within a conversation." + "key": "chat.agentHost.otel.enabled.policy", + "value": "Controls whether Copilot OpenTelemetry export is enabled. When managed, users cannot override the enterprise value." + } + }, + "type": "boolean", + "default": false, + "included": true + }, + { + "key": "chat.agentHost.otel.exporterType", + "name": "CopilotOtelProtocol", + "category": "InteractiveSession", + "minimumVersion": "1.127", + "localization": { + "description": { + "key": "chat.agentHost.otel.protocol.policy", + "value": "Controls the enterprise-managed OTLP protocol for Copilot OpenTelemetry export." + }, + "enumDescriptions": [ + { + "key": "chat.agentHost.otel.protocol.policy.otlpHttp", + "value": "Use OTLP over HTTP." + }, + { + "key": "chat.agentHost.otel.protocol.policy.otlpGrpc", + "value": "Use OTLP over gRPC." + }, + { + "key": "chat.agentHost.otel.protocol.policy.console", + "value": "Console exporter is not selected by enterprise managed settings." + }, + { + "key": "chat.agentHost.otel.protocol.policy.file", + "value": "File exporter is not selected by enterprise managed settings." + } + ] + }, + "type": "string", + "default": "otlp-http", + "enum": [ + "otlp-http", + "otlp-grpc", + "console", + "file" + ], + "included": true + }, + { + "key": "chat.agentHost.otel.otlpEndpoint", + "name": "CopilotOtelEndpoint", + "category": "InteractiveSession", + "minimumVersion": "1.127", + "localization": { + "description": { + "key": "chat.agentHost.otel.otlpEndpoint.policy", + "value": "Controls the enterprise-managed OTLP collector endpoint for Copilot OpenTelemetry export." } }, "type": "string", @@ -174,22 +306,49 @@ "included": true }, { - "key": "chat.agentHost.claudeAgent.enabled", - "name": "Claude3PIntegration", + "key": "chat.agentHost.otel.captureContent", + "name": "CopilotOtelCaptureContent", "category": "InteractiveSession", - "minimumVersion": "1.113", + "minimumVersion": "1.127", "localization": { "description": { - "key": "chat.agentHost.claudeAgent.enabled.policy", - "value": "Enable Claude Agent sessions in VS Code. Start and resume agentic coding sessions powered by Anthropic Claude Agent SDK directly in the editor. Uses your existing Copilot subscription." + "key": "chat.agentHost.otel.captureContent.policy", + "value": "Controls whether Copilot OpenTelemetry export captures prompt, response, and tool content." } }, "type": "boolean", - "default": true, - "included": true, - "referencedSettings": [ - "github.copilot.chat.claudeAgent.enabled" - ] + "default": false, + "included": true + }, + { + "key": "chat.agentHost.otel.outfile", + "name": "CopilotOtelOutfile", + "category": "InteractiveSession", + "minimumVersion": "1.127", + "localization": { + "description": { + "key": "chat.agentHost.otel.outfile.policy", + "value": "Prevents local file export when enterprise-managed Copilot OpenTelemetry export is configured." + } + }, + "type": "string", + "default": "", + "included": true + }, + { + "key": "chat.defaultModel", + "name": "ChatDefaultModel", + "category": "InteractiveSession", + "minimumVersion": "1.127", + "localization": { + "description": { + "key": "chat.defaultModel.policy", + "value": "Sets the default chat model for new conversations. Accepts \"auto\", a model family name (such as \"opus\" or \"gemini\"), or a full model id. Users can still switch the model within a conversation." + } + }, + "type": "string", + "default": "", + "included": true }, { "key": "chat.tools.global.autoApprove", diff --git a/extensions/copilot/docs/monitoring/agent_monitoring.md b/extensions/copilot/docs/monitoring/agent_monitoring.md index 60e0422bbb3ab..76de7393f8aa4 100644 --- a/extensions/copilot/docs/monitoring/agent_monitoring.md +++ b/extensions/copilot/docs/monitoring/agent_monitoring.md @@ -36,7 +36,7 @@ Open **Settings** (`Ctrl+,`) and add: } ``` -> **Note:** You can also use environment variables instead of VS Code settings (see [Configuration](#configuration)). Environment variables always take precedence. +> **Note:** You can also use environment variables instead of VS Code settings (see [Configuration](#configuration)). Precedence is **enterprise policy > environment variables > settings**. ### 3. Generate Telemetry @@ -70,13 +70,17 @@ Open **Settings** (`Ctrl+,`) and search for `copilot otel`: | `github.copilot.chat.otel.exporterType` | string | `"otlp-http"` | `otlp-http`, `otlp-grpc`, `console`, or `file` | | `github.copilot.chat.otel.otlpEndpoint` | string | `"http://localhost:4318"` | OTLP collector endpoint | | `github.copilot.chat.otel.captureContent` | boolean | `false` | Capture full prompt/response content | +| `github.copilot.chat.otel.protocol` | string | `""` | OTLP wire protocol: `http/json` (default), `http/protobuf`, or `grpc` | +| `github.copilot.chat.otel.serviceName` | string | `""` | Override the `service.name` resource attribute | +| `github.copilot.chat.otel.resourceAttributes` | object | `{}` | Extra resource attributes (`{ "key": "value" }`) | +| `github.copilot.chat.otel.headers` | object | `{}` | Extra OTLP exporter headers, applied directly to the exporter (`{ "key": "value" }`) | | `github.copilot.chat.otel.maxAttributeSizeChars` | integer | `0` | Max characters per OTel content attribute (prompts, tool args/results, hook input/output). `0` (the default) disables truncation so backends with no per-attribute limit get full payloads. Set to a positive value to match your backend's per-attribute size limit — consult your backend's documentation. The value counts JavaScript string characters (UTF-16 code units); for non-ASCII content one character can be multiple UTF-8 bytes on the wire. | | `github.copilot.chat.otel.outfile` | string | `""` | File path for JSON-lines output | | `github.copilot.chat.otel.dbSpanExporter.enabled` | boolean | `false` | Persist OTel spans to a local SQLite database for the **Chat: Export Agent Traces DB** command. Implicitly enables OTel. | ### Environment Variables -Environment variables **always take precedence** over VS Code settings. +Environment variables take precedence over VS Code settings, and **enterprise managed settings (policy) take precedence over both** — admins can centrally mandate any `github.copilot.chat.otel.*` value. | Variable | Default | Description | |---|---|---| @@ -98,6 +102,7 @@ Environment variables **always take precedence** over VS Code settings. OTel is **off by default** with zero overhead. It activates when: +- enterprise policy enables it (managed `telemetry.enabled` or a managed endpoint), or - `COPILOT_OTEL_ENABLED=true`, or - `OTEL_EXPORTER_OTLP_ENDPOINT` is set, or - `github.copilot.chat.otel.enabled` is `true`, or @@ -471,7 +476,7 @@ All signals carry: | Attribute | Value | |---|---| -| `service.name` | `copilot-chat` (configurable via `OTEL_SERVICE_NAME`) | +| `service.name` | `copilot-chat` (override via the `github.copilot.chat.otel.serviceName` setting, `OTEL_SERVICE_NAME`, or enterprise policy) | | `service.version` | Extension version | | `session.id` | Unique per VS Code window | @@ -541,7 +546,7 @@ Content is captured in full with no truncation. } ``` -> **Note:** Authentication headers are only configurable via the `OTEL_EXPORTER_OTLP_HEADERS` environment variable (e.g., `Authorization=Bearer your-token`). See [Environment Variables](#environment-variables). +> **Note:** Authentication headers can be set via the `github.copilot.chat.otel.headers` setting (a `{ "key": "value" }` map applied directly to the exporter) or the `OTEL_EXPORTER_OTLP_HEADERS` environment variable, and can be mandated by enterprise policy. See [Environment Variables](#environment-variables). **File-based output (offline / CI):** diff --git a/extensions/copilot/docs/monitoring/agent_monitoring_arch.md b/extensions/copilot/docs/monitoring/agent_monitoring_arch.md index 09c99b26de8ed..2eb3bc29fbac8 100644 --- a/extensions/copilot/docs/monitoring/agent_monitoring_arch.md +++ b/extensions/copilot/docs/monitoring/agent_monitoring_arch.md @@ -209,10 +209,13 @@ Both export to the same OTLP endpoint. Bridge processor sits on Provider B, forw `resolveOTelConfig()` implements layered precedence: -1. `COPILOT_OTEL_*` env vars (highest) -2. `OTEL_EXPORTER_OTLP_*` standard env vars -3. VS Code settings (`github.copilot.chat.otel.*`) -4. Defaults (lowest) +1. Enterprise managed settings (policy) — highest; overrides env vars +2. `COPILOT_OTEL_*` env vars +3. `OTEL_EXPORTER_OTLP_*` standard env vars +4. VS Code settings (`github.copilot.chat.otel.*`) +5. Defaults (lowest) + +The OTLP wire protocol distinguishes `http/json` (default) from `http/protobuf`; `grpc` is selected by the gRPC exporter type. Kill switch: `telemetry.telemetryLevel === 'off'` → all OTel disabled. @@ -222,6 +225,7 @@ The resolved config records *how* OTel was enabled in `OTelConfig.enabledVia` (u | `enabledVia` | Trigger | |---|---| +| `policy` | Enterprise managed settings enable OTel (`telemetry.enabled` or a managed endpoint) | | `envVar` | `COPILOT_OTEL_ENABLED=true` | | `setting` | `github.copilot.chat.otel.enabled` is `true` | | `otlpEndpointEnvVar` | `OTEL_EXPORTER_OTLP_ENDPOINT` is set without an explicit enable | diff --git a/extensions/copilot/package-lock.json b/extensions/copilot/package-lock.json index 218506a19a8de..53f42cb984ce1 100644 --- a/extensions/copilot/package-lock.json +++ b/extensions/copilot/package-lock.json @@ -22,10 +22,13 @@ "@opentelemetry/api-logs": "^0.212.0", "@opentelemetry/exporter-logs-otlp-grpc": "^0.214.0", "@opentelemetry/exporter-logs-otlp-http": "^0.214.0", + "@opentelemetry/exporter-logs-otlp-proto": "^0.214.0", "@opentelemetry/exporter-metrics-otlp-grpc": "^0.214.0", "@opentelemetry/exporter-metrics-otlp-http": "^0.214.0", + "@opentelemetry/exporter-metrics-otlp-proto": "^0.214.0", "@opentelemetry/exporter-trace-otlp-grpc": "^0.214.0", "@opentelemetry/exporter-trace-otlp-http": "^0.214.0", + "@opentelemetry/exporter-trace-otlp-proto": "^0.214.0", "@opentelemetry/resources": "^2.5.1", "@opentelemetry/sdk-logs": "^0.212.0", "@opentelemetry/sdk-metrics": "^2.5.1", @@ -4226,6 +4229,105 @@ "@opentelemetry/api": ">=1.4.0 <1.10.0" } }, + "node_modules/@opentelemetry/exporter-logs-otlp-proto": { + "version": "0.214.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/exporter-logs-otlp-proto/-/exporter-logs-otlp-proto-0.214.0.tgz", + "integrity": "sha512-IWAVvCO1TlpotRjFmhQFz9RSfQy5BsLtDRBtptSrXZRwfyRPpuql/RMe5zdmu0Gxl3ERDFwOzOqkf3bwy7Jzcw==", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/api-logs": "0.214.0", + "@opentelemetry/core": "2.6.1", + "@opentelemetry/otlp-exporter-base": "0.214.0", + "@opentelemetry/otlp-transformer": "0.214.0", + "@opentelemetry/resources": "2.6.1", + "@opentelemetry/sdk-logs": "0.214.0", + "@opentelemetry/sdk-trace-base": "2.6.1" + }, + "engines": { + "node": "^18.19.0 || >=20.6.0" + }, + "peerDependencies": { + "@opentelemetry/api": "^1.3.0" + } + }, + "node_modules/@opentelemetry/exporter-logs-otlp-proto/node_modules/@opentelemetry/api-logs": { + "version": "0.214.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/api-logs/-/api-logs-0.214.0.tgz", + "integrity": "sha512-40lSJeqYO8Uz2Yj7u94/SJWE/wONa7rmMKjI1ZcIjgf3MHNHv1OZUCrCETGuaRF62d5pQD1wKIW+L4lmSMTzZA==", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/api": "^1.3.0" + }, + "engines": { + "node": ">=8.0.0" + } + }, + "node_modules/@opentelemetry/exporter-logs-otlp-proto/node_modules/@opentelemetry/core": { + "version": "2.6.1", + "resolved": "https://registry.npmjs.org/@opentelemetry/core/-/core-2.6.1.tgz", + "integrity": "sha512-8xHSGWpJP9wBxgBpnqGL0R3PbdWQndL1Qp50qrg71+B28zK5OQmUgcDKLJgzyAAV38t4tOyLMGDD60LneR5W8g==", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/semantic-conventions": "^1.29.0" + }, + "engines": { + "node": "^18.19.0 || >=20.6.0" + }, + "peerDependencies": { + "@opentelemetry/api": ">=1.0.0 <1.10.0" + } + }, + "node_modules/@opentelemetry/exporter-logs-otlp-proto/node_modules/@opentelemetry/resources": { + "version": "2.6.1", + "resolved": "https://registry.npmjs.org/@opentelemetry/resources/-/resources-2.6.1.tgz", + "integrity": "sha512-lID/vxSuKWXM55XhAKNoYXu9Cutoq5hFdkbTdI/zDKQktXzcWBVhNsOkiZFTMU9UtEWuGRNe0HUgmsFldIdxVA==", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/core": "2.6.1", + "@opentelemetry/semantic-conventions": "^1.29.0" + }, + "engines": { + "node": "^18.19.0 || >=20.6.0" + }, + "peerDependencies": { + "@opentelemetry/api": ">=1.3.0 <1.10.0" + } + }, + "node_modules/@opentelemetry/exporter-logs-otlp-proto/node_modules/@opentelemetry/sdk-logs": { + "version": "0.214.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/sdk-logs/-/sdk-logs-0.214.0.tgz", + "integrity": "sha512-zf6acnScjhsaBUU22zXZ/sLWim1dfhUAbGXdMmHmNG3LfBnQ3DKsOCITb2IZwoUsNNMTogqFKBnlIPPftUgGwA==", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/api-logs": "0.214.0", + "@opentelemetry/core": "2.6.1", + "@opentelemetry/resources": "2.6.1", + "@opentelemetry/semantic-conventions": "^1.29.0" + }, + "engines": { + "node": "^18.19.0 || >=20.6.0" + }, + "peerDependencies": { + "@opentelemetry/api": ">=1.4.0 <1.10.0" + } + }, + "node_modules/@opentelemetry/exporter-logs-otlp-proto/node_modules/@opentelemetry/sdk-trace-base": { + "version": "2.6.1", + "resolved": "https://registry.npmjs.org/@opentelemetry/sdk-trace-base/-/sdk-trace-base-2.6.1.tgz", + "integrity": "sha512-r86ut4T1e8vNwB35CqCcKd45yzqH6/6Wzvpk2/cZB8PsPLlZFTvrh8yfOS3CYZYcUmAx4hHTZJ8AO8Dj8nrdhw==", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/core": "2.6.1", + "@opentelemetry/resources": "2.6.1", + "@opentelemetry/semantic-conventions": "^1.29.0" + }, + "engines": { + "node": "^18.19.0 || >=20.6.0" + }, + "peerDependencies": { + "@opentelemetry/api": ">=1.3.0 <1.10.0" + } + }, "node_modules/@opentelemetry/exporter-metrics-otlp-grpc": { "version": "0.214.0", "resolved": "https://registry.npmjs.org/@opentelemetry/exporter-metrics-otlp-grpc/-/exporter-metrics-otlp-grpc-0.214.0.tgz", @@ -4329,6 +4431,57 @@ "@opentelemetry/api": ">=1.3.0 <1.10.0" } }, + "node_modules/@opentelemetry/exporter-metrics-otlp-proto": { + "version": "0.214.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/exporter-metrics-otlp-proto/-/exporter-metrics-otlp-proto-0.214.0.tgz", + "integrity": "sha512-pJIcghFGhx3VSCgP5U+yZx+OMNj0t+ttnhC8IjL5Wza7vWIczctF6t3AGcVQffi2dEqX+ZHANoBwoPR8y6RMKA==", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/core": "2.6.1", + "@opentelemetry/exporter-metrics-otlp-http": "0.214.0", + "@opentelemetry/otlp-exporter-base": "0.214.0", + "@opentelemetry/otlp-transformer": "0.214.0", + "@opentelemetry/resources": "2.6.1", + "@opentelemetry/sdk-metrics": "2.6.1" + }, + "engines": { + "node": "^18.19.0 || >=20.6.0" + }, + "peerDependencies": { + "@opentelemetry/api": "^1.3.0" + } + }, + "node_modules/@opentelemetry/exporter-metrics-otlp-proto/node_modules/@opentelemetry/core": { + "version": "2.6.1", + "resolved": "https://registry.npmjs.org/@opentelemetry/core/-/core-2.6.1.tgz", + "integrity": "sha512-8xHSGWpJP9wBxgBpnqGL0R3PbdWQndL1Qp50qrg71+B28zK5OQmUgcDKLJgzyAAV38t4tOyLMGDD60LneR5W8g==", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/semantic-conventions": "^1.29.0" + }, + "engines": { + "node": "^18.19.0 || >=20.6.0" + }, + "peerDependencies": { + "@opentelemetry/api": ">=1.0.0 <1.10.0" + } + }, + "node_modules/@opentelemetry/exporter-metrics-otlp-proto/node_modules/@opentelemetry/resources": { + "version": "2.6.1", + "resolved": "https://registry.npmjs.org/@opentelemetry/resources/-/resources-2.6.1.tgz", + "integrity": "sha512-lID/vxSuKWXM55XhAKNoYXu9Cutoq5hFdkbTdI/zDKQktXzcWBVhNsOkiZFTMU9UtEWuGRNe0HUgmsFldIdxVA==", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/core": "2.6.1", + "@opentelemetry/semantic-conventions": "^1.29.0" + }, + "engines": { + "node": "^18.19.0 || >=20.6.0" + }, + "peerDependencies": { + "@opentelemetry/api": ">=1.3.0 <1.10.0" + } + }, "node_modules/@opentelemetry/exporter-trace-otlp-grpc": { "version": "0.214.0", "resolved": "https://registry.npmjs.org/@opentelemetry/exporter-trace-otlp-grpc/-/exporter-trace-otlp-grpc-0.214.0.tgz", @@ -4465,6 +4618,73 @@ "@opentelemetry/api": ">=1.3.0 <1.10.0" } }, + "node_modules/@opentelemetry/exporter-trace-otlp-proto": { + "version": "0.214.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/exporter-trace-otlp-proto/-/exporter-trace-otlp-proto-0.214.0.tgz", + "integrity": "sha512-ON0spYWb2yAdQ9b+ItNyK0c6qdtcs+0eVR4YFJkhJL7agfT8sHFg0e5EesauSRiTHPZHiDobI92k77q0lwAmqg==", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/core": "2.6.1", + "@opentelemetry/otlp-exporter-base": "0.214.0", + "@opentelemetry/otlp-transformer": "0.214.0", + "@opentelemetry/resources": "2.6.1", + "@opentelemetry/sdk-trace-base": "2.6.1" + }, + "engines": { + "node": "^18.19.0 || >=20.6.0" + }, + "peerDependencies": { + "@opentelemetry/api": "^1.3.0" + } + }, + "node_modules/@opentelemetry/exporter-trace-otlp-proto/node_modules/@opentelemetry/core": { + "version": "2.6.1", + "resolved": "https://registry.npmjs.org/@opentelemetry/core/-/core-2.6.1.tgz", + "integrity": "sha512-8xHSGWpJP9wBxgBpnqGL0R3PbdWQndL1Qp50qrg71+B28zK5OQmUgcDKLJgzyAAV38t4tOyLMGDD60LneR5W8g==", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/semantic-conventions": "^1.29.0" + }, + "engines": { + "node": "^18.19.0 || >=20.6.0" + }, + "peerDependencies": { + "@opentelemetry/api": ">=1.0.0 <1.10.0" + } + }, + "node_modules/@opentelemetry/exporter-trace-otlp-proto/node_modules/@opentelemetry/resources": { + "version": "2.6.1", + "resolved": "https://registry.npmjs.org/@opentelemetry/resources/-/resources-2.6.1.tgz", + "integrity": "sha512-lID/vxSuKWXM55XhAKNoYXu9Cutoq5hFdkbTdI/zDKQktXzcWBVhNsOkiZFTMU9UtEWuGRNe0HUgmsFldIdxVA==", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/core": "2.6.1", + "@opentelemetry/semantic-conventions": "^1.29.0" + }, + "engines": { + "node": "^18.19.0 || >=20.6.0" + }, + "peerDependencies": { + "@opentelemetry/api": ">=1.3.0 <1.10.0" + } + }, + "node_modules/@opentelemetry/exporter-trace-otlp-proto/node_modules/@opentelemetry/sdk-trace-base": { + "version": "2.6.1", + "resolved": "https://registry.npmjs.org/@opentelemetry/sdk-trace-base/-/sdk-trace-base-2.6.1.tgz", + "integrity": "sha512-r86ut4T1e8vNwB35CqCcKd45yzqH6/6Wzvpk2/cZB8PsPLlZFTvrh8yfOS3CYZYcUmAx4hHTZJ8AO8Dj8nrdhw==", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/core": "2.6.1", + "@opentelemetry/resources": "2.6.1", + "@opentelemetry/semantic-conventions": "^1.29.0" + }, + "engines": { + "node": "^18.19.0 || >=20.6.0" + }, + "peerDependencies": { + "@opentelemetry/api": ">=1.3.0 <1.10.0" + } + }, "node_modules/@opentelemetry/instrumentation": { "version": "0.41.2", "resolved": "https://registry.npmjs.org/@opentelemetry/instrumentation/-/instrumentation-0.41.2.tgz", diff --git a/extensions/copilot/package.json b/extensions/copilot/package.json index 3476540bf37b3..ff481106e8bac 100644 --- a/extensions/copilot/package.json +++ b/extensions/copilot/package.json @@ -4331,7 +4331,10 @@ "github.copilot.chat.tools.grepSearch.outputFormat": { "type": "string", "default": "tag", - "enum": ["grep", "tag"], + "enum": [ + "grep", + "tag" + ], "markdownDescription": "%github.copilot.chat.tools.grepSearch.outputFormat%", "tags": [ "experimental", @@ -5276,7 +5279,10 @@ "type": "boolean", "default": false, "scope": "application", - "markdownDescription": "Enable OpenTelemetry trace/metric/log emission for Copilot Chat operations. Configurable in user settings only. Env var `COPILOT_OTEL_ENABLED` takes precedence. Requires window reload.", + "policyReference": { + "name": "CopilotOtelEnabled" + }, + "markdownDescription": "Enable OpenTelemetry trace/metric/log emission for Copilot Chat operations. Precedence: enterprise policy > env var `COPILOT_OTEL_ENABLED` > user setting. Requires window reload.", "tags": [ "advanced" ] @@ -5291,7 +5297,28 @@ ], "default": "otlp-http", "scope": "application", - "markdownDescription": "OTel exporter type for Copilot Chat telemetry. Configurable in user settings only. Requires window reload.", + "policyReference": { + "name": "CopilotOtelProtocol" + }, + "markdownDescription": "OTel exporter type for Copilot Chat telemetry. Configurable in user settings or managed by enterprise policy (policy takes precedence). Requires window reload.", + "tags": [ + "advanced" + ] + }, + "github.copilot.chat.otel.protocol": { + "type": "string", + "enum": [ + "", + "http/json", + "http/protobuf", + "grpc" + ], + "default": "", + "scope": "application", + "policyReference": { + "name": "CopilotOtelOtlpProtocol" + }, + "markdownDescription": "OTLP wire protocol for Copilot Chat OTel data, mirroring `OTEL_EXPORTER_OTLP_PROTOCOL`. `http/protobuf` selects the protobuf-over-HTTP exporter; the default (empty) uses `http/json`. Precedence: enterprise policy > env var > user setting. Requires window reload.", "tags": [ "advanced" ] @@ -5300,7 +5327,10 @@ "type": "string", "default": "http://localhost:4318", "scope": "application", - "markdownDescription": "OTLP collector endpoint URL for Copilot Chat OTel data. Configurable in user settings only. Env var `OTEL_EXPORTER_OTLP_ENDPOINT` takes precedence. Requires window reload.", + "policyReference": { + "name": "CopilotOtelEndpoint" + }, + "markdownDescription": "OTLP collector endpoint URL for Copilot Chat OTel data. Precedence: enterprise policy > env var `OTEL_EXPORTER_OTLP_ENDPOINT` > user setting. Requires window reload.", "tags": [ "advanced" ] @@ -5309,7 +5339,52 @@ "type": "boolean", "default": false, "scope": "application", - "markdownDescription": "Capture input/output messages, system instructions, and tool definitions in OTel telemetry. **Contains potentially sensitive data.** Configurable in user settings only. Env var `COPILOT_OTEL_CAPTURE_CONTENT` takes precedence. Requires window reload.", + "policyReference": { + "name": "CopilotOtelCaptureContent" + }, + "markdownDescription": "Capture input/output messages, system instructions, and tool definitions in OTel telemetry. **Contains potentially sensitive data.** Precedence: enterprise policy > env var `COPILOT_OTEL_CAPTURE_CONTENT` > user setting. Requires window reload.", + "tags": [ + "advanced" + ] + }, + "github.copilot.chat.otel.serviceName": { + "type": "string", + "default": "", + "scope": "application", + "policyReference": { + "name": "CopilotOtelServiceName" + }, + "markdownDescription": "OTel `service.name` resource attribute for Copilot Chat OTel data. Configurable in user settings only. Env var `OTEL_SERVICE_NAME` takes precedence over the setting; enterprise policy takes precedence over both. Requires window reload.", + "tags": [ + "advanced" + ] + }, + "github.copilot.chat.otel.resourceAttributes": { + "type": "object", + "additionalProperties": { + "type": "string" + }, + "default": {}, + "scope": "application", + "policyReference": { + "name": "CopilotOtelResourceAttributes" + }, + "markdownDescription": "Additional OTel resource attributes for Copilot Chat OTel data, as a `{ \"key\": \"value\" }` map. Configurable in user settings only. Merged per-key with `OTEL_RESOURCE_ATTRIBUTES` env (env wins over the setting); enterprise policy wins over both. Requires window reload.", + "tags": [ + "advanced" + ] + }, + "github.copilot.chat.otel.headers": { + "type": "object", + "additionalProperties": { + "type": "string" + }, + "default": {}, + "scope": "application", + "policyReference": { + "name": "CopilotOtelHeaders" + }, + "markdownDescription": "Extra OTLP exporter headers (e.g. auth tokens) for Copilot Chat OTel data, as a `{ \"key\": \"value\" }` map. Applied directly to the OTLP exporter, not via environment variables. Configurable in user settings only. Merged per-key with `OTEL_EXPORTER_OTLP_HEADERS` env (env wins over the setting); enterprise policy wins over both. **Contains potentially sensitive credentials.** Requires window reload.", "tags": [ "advanced" ] @@ -5328,7 +5403,10 @@ "type": "string", "default": "", "scope": "application", - "markdownDescription": "File path for file-based OTel exporter output (JSON-lines). When set, overrides exporter type to `file`. Configurable in user settings only. Requires window reload.", + "policyReference": { + "name": "CopilotOtelOutfile" + }, + "markdownDescription": "File path for file-based OTel exporter output (JSON-lines). When set, overrides exporter type to `file`. Configurable in user settings or managed by enterprise policy (policy takes precedence). Requires window reload.", "tags": [ "advanced" ] @@ -7101,10 +7179,13 @@ "@opentelemetry/api-logs": "^0.212.0", "@opentelemetry/exporter-logs-otlp-grpc": "^0.214.0", "@opentelemetry/exporter-logs-otlp-http": "^0.214.0", + "@opentelemetry/exporter-logs-otlp-proto": "^0.214.0", "@opentelemetry/exporter-metrics-otlp-grpc": "^0.214.0", "@opentelemetry/exporter-metrics-otlp-http": "^0.214.0", + "@opentelemetry/exporter-metrics-otlp-proto": "^0.214.0", "@opentelemetry/exporter-trace-otlp-grpc": "^0.214.0", "@opentelemetry/exporter-trace-otlp-http": "^0.214.0", + "@opentelemetry/exporter-trace-otlp-proto": "^0.214.0", "@opentelemetry/resources": "^2.5.1", "@opentelemetry/sdk-logs": "^0.212.0", "@opentelemetry/sdk-metrics": "^2.5.1", diff --git a/extensions/copilot/src/extension/extension/vscode-node/services.ts b/extensions/copilot/src/extension/extension/vscode-node/services.ts index 6d304454c86d9..15ca799eec7c0 100644 --- a/extensions/copilot/src/extension/extension/vscode-node/services.ts +++ b/extensions/copilot/src/extension/extension/vscode-node/services.ts @@ -289,6 +289,7 @@ export function registerServices(builder: IInstantiationServiceBuilder, extensio // OTel service — resolve config from env + settings, create appropriate impl const otelSettings = workspace.getConfiguration('github.copilot.chat.otel'); + const policyValue = (key: string): T | undefined => (otelSettings.inspect(key) as { policyValue?: T } | undefined)?.policyValue; const otelConfig = resolveOTelConfig({ env: process.env, settingEnabled: otelSettings.get('enabled'), @@ -298,6 +299,19 @@ export function registerServices(builder: IInstantiationServiceBuilder, extensio settingMaxAttributeSizeChars: otelSettings.get('maxAttributeSizeChars'), settingOutfile: otelSettings.get('outfile') || undefined, settingDbSpanExporter: otelSettings.get('dbSpanExporter.enabled'), + settingProtocol: otelSettings.get('protocol') || undefined, + policyEnabled: policyValue('enabled'), + policyExporterType: policyValue<'otlp-grpc' | 'otlp-http' | 'console' | 'file'>('exporterType'), + policyOtlpEndpoint: policyValue('otlpEndpoint'), + policyCaptureContent: policyValue('captureContent'), + policyOutfile: policyValue('outfile'), + policyProtocol: policyValue('protocol'), + settingServiceName: otelSettings.get('serviceName') || undefined, + policyServiceName: policyValue('serviceName'), + settingResourceAttributes: otelSettings.get>('resourceAttributes'), + policyResourceAttributes: policyValue>('resourceAttributes'), + settingHeaders: otelSettings.get>('headers'), + policyHeaders: policyValue>('headers'), extensionVersion: extensionContext.extension.packageJSON.version ?? '0.0.0', sessionId: env.sessionId, }); diff --git a/extensions/copilot/src/platform/configuration/common/configurationService.ts b/extensions/copilot/src/platform/configuration/common/configurationService.ts index 01c2a48ee0883..e0d94d59300ae 100644 --- a/extensions/copilot/src/platform/configuration/common/configurationService.ts +++ b/extensions/copilot/src/platform/configuration/common/configurationService.ts @@ -728,8 +728,12 @@ export namespace ConfigKey { // OTel settings export const OTelEnabled = defineSetting('chat.otel.enabled', ConfigType.Simple, false); export const OTelExporterType = defineSetting('chat.otel.exporterType', ConfigType.Simple, 'otlp-http'); + export const OTelProtocol = defineSetting('chat.otel.protocol', ConfigType.Simple, ''); export const OTelOtlpEndpoint = defineSetting('chat.otel.otlpEndpoint', ConfigType.Simple, 'http://localhost:4318'); export const OTelCaptureContent = defineSetting('chat.otel.captureContent', ConfigType.Simple, false); + export const OTelServiceName = defineSetting('chat.otel.serviceName', ConfigType.Simple, ''); + export const OTelResourceAttributes = defineSetting>('chat.otel.resourceAttributes', ConfigType.Simple, {}); + export const OTelHeaders = defineSetting>('chat.otel.headers', ConfigType.Simple, {}); export const OTelMaxAttributeSizeChars = defineSetting('chat.otel.maxAttributeSizeChars', ConfigType.Simple, 0); export const OTelOutfile = defineSetting('chat.otel.outfile', ConfigType.Simple, ''); export const OTelDbSpanExporter = defineSetting('chat.otel.dbSpanExporter.enabled', ConfigType.Simple, false); diff --git a/extensions/copilot/src/platform/otel/common/agentOTelEnv.ts b/extensions/copilot/src/platform/otel/common/agentOTelEnv.ts index 1b66ca6108376..1a6676d38f1e8 100644 --- a/extensions/copilot/src/platform/otel/common/agentOTelEnv.ts +++ b/extensions/copilot/src/platform/otel/common/agentOTelEnv.ts @@ -75,7 +75,7 @@ export function deriveClaudeOTelEnv(config: OTelConfig, env: Record; + /** + * Extra OTLP request headers (e.g. auth tokens) applied directly to the exporter. Carried + * out-of-band from process env so secrets never leak into spawned tool subprocesses. + */ + readonly headers: Record; } /** @@ -87,6 +92,27 @@ export interface OTelConfigInput { settingMaxAttributeSizeChars?: number; settingOutfile?: string; settingDbSpanExporter?: boolean; + /** OTLP wire protocol mirroring `OTEL_EXPORTER_OTLP_PROTOCOL` (`http/json`, `http/protobuf`, `grpc`). */ + settingProtocol?: string; + policyEnabled?: boolean; + policyExporterType?: OTelExporterType; + policyOtlpEndpoint?: string; + policyCaptureContent?: boolean; + policyOutfile?: string; + /** Enterprise-managed OTLP wire protocol (raw `telemetry.protocol`). */ + policyProtocol?: string; + /** Service name from VS Code setting (`github.copilot.chat.otel.serviceName`). */ + settingServiceName?: string; + /** Enterprise-managed `service.name` (raw `telemetry.serviceName`). */ + policyServiceName?: string; + /** Resource attributes from VS Code setting (`github.copilot.chat.otel.resourceAttributes`). */ + settingResourceAttributes?: Record; + /** Enterprise-managed resource attributes (raw `telemetry.resourceAttributes`). */ + policyResourceAttributes?: Record; + /** OTLP headers from VS Code setting (`github.copilot.chat.otel.headers`). */ + settingHeaders?: Record; + /** Enterprise-managed OTLP headers (raw `telemetry.headers`). */ + policyHeaders?: Record; extensionVersion: string; sessionId: string; vscodeTelemetryLevel?: string; @@ -94,10 +120,11 @@ export interface OTelConfigInput { /** * Resolve OTel configuration with layered precedence: - * 1. COPILOT_OTEL_* env vars (highest) - * 2. OTEL_EXPORTER_OTLP_* standard env vars - * 3. VS Code settings - * 4. Defaults (lowest) + * 1. Enterprise policy values from managed settings (highest) + * 2. COPILOT_OTEL_* env vars + * 3. OTEL_EXPORTER_OTLP_* standard env vars + * 4. VS Code settings + * 5. Defaults (lowest) */ export function resolveOTelConfig(input: OTelConfigInput): OTelConfig { const { env } = input; @@ -110,17 +137,20 @@ export function resolveOTelConfig(input: OTelConfigInput): OTelConfig { // SQLite DB span exporter: setting > default(false) const dbSpanExporter = input.settingDbSpanExporter ?? false; - // Determine if enabled: env > setting > dbSpanExporter > default(false) + const policyMandatesOtlp = input.policyOtlpEndpoint !== undefined || input.policyExporterType !== undefined; + const policyEndpointEnables = input.policyOtlpEndpoint !== undefined ? true : undefined; + + // Determine if enabled: policy > env > setting > policy endpoint > dbSpanExporter > default(false) // When dbSpanExporter is on, OTel must be enabled for the SDK pipeline to work. - const enabled = (envBool(env['COPILOT_OTEL_ENABLED']) + const enabledSignal = input.policyEnabled + ?? envBool(env['COPILOT_OTEL_ENABLED']) ?? input.settingEnabled - ?? (!!env['OTEL_EXPORTER_OTLP_ENDPOINT'])) - || dbSpanExporter; + ?? policyEndpointEnables + ?? (!!env['OTEL_EXPORTER_OTLP_ENDPOINT']); + const enabled = input.policyEnabled === false ? false : enabledSignal || dbSpanExporter; - // OTel was explicitly enabled if the user/env turned it on, not just dbSpanExporter - const enabledExplicitly = (envBool(env['COPILOT_OTEL_ENABLED']) - ?? input.settingEnabled - ?? (!!env['OTEL_EXPORTER_OTLP_ENDPOINT'])) === true; + // OTel was explicitly enabled if policy/user/env turned it on, not just dbSpanExporter + const enabledExplicitly = enabled && enabledSignal === true; if (!enabled) { return createDisabledConfig(input); @@ -128,7 +158,9 @@ export function resolveOTelConfig(input: OTelConfigInput): OTelConfig { // Determine how OTel was enabled for telemetry tracking let enabledVia: OTelEnabledVia; - if (envBool(env['COPILOT_OTEL_ENABLED']) === true) { + if (input.policyEnabled === true || (input.policyEnabled === undefined && input.policyOtlpEndpoint !== undefined)) { + enabledVia = 'policy'; + } else if (envBool(env['COPILOT_OTEL_ENABLED']) === true) { enabledVia = 'envVar'; } else if (input.settingEnabled === true) { enabledVia = 'setting'; @@ -138,23 +170,49 @@ export function resolveOTelConfig(input: OTelConfigInput): OTelConfig { enabledVia = 'dbSpanExporterOnly'; } - // Protocol: env > inferred from exporter type > default - const rawProtocol = env['OTEL_EXPORTER_OTLP_PROTOCOL'] ?? env['COPILOT_OTEL_PROTOCOL']; + // Protocol (transport): policy > env > setting exporter type > default + const rawProtocol = input.policyExporterType === 'otlp-grpc' + ? 'grpc' + : input.policyExporterType === 'otlp-http' + ? 'http' + : (env['OTEL_EXPORTER_OTLP_PROTOCOL'] ?? env['COPILOT_OTEL_PROTOCOL'] + ?? (input.settingExporterType === 'otlp-grpc' ? 'grpc' : input.settingExporterType === 'otlp-http' ? 'http' : undefined)); const protocol: 'grpc' | 'http' = rawProtocol === 'grpc' ? 'grpc' : 'http'; - // Endpoint: COPILOT_OTEL env > OTEL env > setting > default - const rawEndpoint = env['COPILOT_OTEL_ENDPOINT'] + // Wire protocol (json vs protobuf within http): policy > env > setting > default(http/json). + // grpc transport always reports 'grpc'. + const rawWireProtocol = input.policyProtocol + ?? env['OTEL_EXPORTER_OTLP_PROTOCOL'] + ?? env['COPILOT_OTEL_PROTOCOL'] + ?? input.settingProtocol; + const otlpProtocol: OTelConfig['otlpProtocol'] = protocol === 'grpc' + ? 'grpc' + : rawWireProtocol === 'http/protobuf' + ? 'http/protobuf' + : 'http/json'; + + // Endpoint: policy > COPILOT_OTEL env > OTEL env > setting > default + const rawEndpoint = input.policyOtlpEndpoint + ?? env['COPILOT_OTEL_ENDPOINT'] ?? env['OTEL_EXPORTER_OTLP_ENDPOINT'] ?? input.settingOtlpEndpoint ?? DEFAULT_OTLP_ENDPOINT; const otlpEndpoint = parseOtlpEndpoint(rawEndpoint, protocol) ?? DEFAULT_OTLP_ENDPOINT; - // File exporter path - const fileExporterPath = env['COPILOT_OTEL_FILE_EXPORTER_PATH'] ?? input.settingOutfile; + // File exporter path. Enterprise OTLP policy suppresses file export diversion. + const fileExporterPath = input.policyOutfile !== undefined + ? input.policyOutfile || undefined + : policyMandatesOtlp + ? undefined + : env['COPILOT_OTEL_FILE_EXPORTER_PATH'] ?? input.settingOutfile; // Exporter type let exporterType: OTelExporterType; - if (fileExporterPath) { + if (input.policyExporterType) { + exporterType = input.policyExporterType; + } else if (policyMandatesOtlp) { + exporterType = 'otlp-http'; + } else if (fileExporterPath) { exporterType = 'file'; } else if (input.settingExporterType) { exporterType = input.settingExporterType; @@ -162,8 +220,9 @@ export function resolveOTelConfig(input: OTelConfigInput): OTelConfig { exporterType = protocol === 'grpc' ? 'otlp-grpc' : 'otlp-http'; } - // Content capture - const captureContent = envBool(env['COPILOT_OTEL_CAPTURE_CONTENT']) + // Content capture: policy > env > setting > default(false) + const captureContent = input.policyCaptureContent + ?? envBool(env['COPILOT_OTEL_CAPTURE_CONTENT']) ?? input.settingCaptureContent ?? false; @@ -182,11 +241,26 @@ export function resolveOTelConfig(input: OTelConfigInput): OTelConfig { // HTTP instrumentation const httpInstrumentation = envBool(env['COPILOT_OTEL_HTTP_INSTRUMENTATION']) ?? false; - // Service name - const serviceName = env['OTEL_SERVICE_NAME'] ?? 'copilot-chat'; + // Service name: policy > env > setting > default. Empty values fall through. + const serviceName = (input.policyServiceName || undefined) + ?? env['OTEL_SERVICE_NAME'] + ?? (input.settingServiceName || undefined) + ?? 'copilot-chat'; + + // Resource attributes: merged per-key with precedence policy > env > setting. + const resourceAttributes = { + ...(input.settingResourceAttributes ?? {}), + ...parseResourceAttributes(env['OTEL_RESOURCE_ATTRIBUTES']), + ...(input.policyResourceAttributes ?? {}), + }; - // Resource attributes - const resourceAttributes = parseResourceAttributes(env['OTEL_RESOURCE_ATTRIBUTES']); + // OTLP headers: merged per-key with precedence policy > env > setting. Same `k=v,k2=v2` format + // as resource attributes (`OTEL_EXPORTER_OTLP_HEADERS`). + const headers = { + ...(input.settingHeaders ?? {}), + ...parseResourceAttributes(env['OTEL_EXPORTER_OTLP_HEADERS']), + ...(input.policyHeaders ?? {}), + }; return Object.freeze({ enabled: true, @@ -194,7 +268,7 @@ export function resolveOTelConfig(input: OTelConfigInput): OTelConfig { enabledVia, exporterType, otlpEndpoint, - otlpProtocol: protocol, + otlpProtocol, captureContent, maxAttributeSizeChars: maxAttributeSizeChars < 0 ? 0 : maxAttributeSizeChars, fileExporterPath, @@ -205,6 +279,7 @@ export function resolveOTelConfig(input: OTelConfigInput): OTelConfig { serviceVersion: input.extensionVersion, sessionId: input.sessionId, resourceAttributes, + headers, }); } @@ -215,7 +290,7 @@ function createDisabledConfig(input: OTelConfigInput): OTelConfig { enabledVia: 'disabled' as const, exporterType: 'otlp-http' as const, otlpEndpoint: '', - otlpProtocol: 'http' as const, + otlpProtocol: 'http/json' as const, captureContent: false, maxAttributeSizeChars: 0, dbSpanExporter: false, @@ -225,6 +300,7 @@ function createDisabledConfig(input: OTelConfigInput): OTelConfig { serviceVersion: input.extensionVersion, sessionId: input.sessionId, resourceAttributes: {}, + headers: {}, }); } diff --git a/extensions/copilot/src/platform/otel/common/test/agentOTelEnv.spec.ts b/extensions/copilot/src/platform/otel/common/test/agentOTelEnv.spec.ts index d463cfa61596b..8aa8da6303f44 100644 --- a/extensions/copilot/src/platform/otel/common/test/agentOTelEnv.spec.ts +++ b/extensions/copilot/src/platform/otel/common/test/agentOTelEnv.spec.ts @@ -14,7 +14,7 @@ function makeConfig(overrides: Partial = {}): OTelConfig { enabledVia: 'setting', exporterType: 'otlp-http', otlpEndpoint: 'http://localhost:4318', - otlpProtocol: 'http', + otlpProtocol: 'http/json', captureContent: false, maxAttributeSizeChars: 0, dbSpanExporter: false, @@ -24,6 +24,7 @@ function makeConfig(overrides: Partial = {}): OTelConfig { serviceVersion: '1.0.0', sessionId: 'test-session', resourceAttributes: {}, + headers: {}, ...overrides, }; } diff --git a/extensions/copilot/src/platform/otel/common/test/otelConfig.spec.ts b/extensions/copilot/src/platform/otel/common/test/otelConfig.spec.ts index ff05f5c74b194..7d40811da9ae8 100644 --- a/extensions/copilot/src/platform/otel/common/test/otelConfig.spec.ts +++ b/extensions/copilot/src/platform/otel/common/test/otelConfig.spec.ts @@ -114,6 +114,40 @@ describe('resolveOTelConfig', () => { }); }); + it('merges resource attributes with precedence policy > env > setting', () => { + const config = resolveOTelConfig(makeInput({ + settingResourceAttributes: { fromSetting: 'setting', shared: 'setting' }, + policyResourceAttributes: { fromPolicy: 'policy', shared: 'policy' }, + env: { + 'COPILOT_OTEL_ENABLED': 'true', + 'OTEL_RESOURCE_ATTRIBUTES': 'fromEnv=env,shared=env', + }, + })); + expect(config.resourceAttributes).toEqual({ + fromSetting: 'setting', + fromEnv: 'env', + fromPolicy: 'policy', + shared: 'policy', + }); + }); + + it('merges OTLP headers with precedence policy > env > setting', () => { + const config = resolveOTelConfig(makeInput({ + settingHeaders: { fromSetting: 'setting', shared: 'setting' }, + policyHeaders: { fromPolicy: 'policy', shared: 'policy' }, + env: { + 'COPILOT_OTEL_ENABLED': 'true', + 'OTEL_EXPORTER_OTLP_HEADERS': 'fromEnv=env,shared=env', + }, + })); + expect(config.headers).toEqual({ + fromSetting: 'setting', + fromEnv: 'env', + fromPolicy: 'policy', + shared: 'policy', + }); + }); + it('uses grpc protocol when OTEL_EXPORTER_OTLP_PROTOCOL=grpc', () => { const config = resolveOTelConfig(makeInput({ env: { @@ -128,6 +162,18 @@ describe('resolveOTelConfig', () => { expect(config.otlpEndpoint).toBe('http://collector:4317'); }); + it('infers grpc transport from settingExporterType when no env/policy protocol is set', () => { + const config = resolveOTelConfig(makeInput({ + settingEnabled: true, + settingExporterType: 'otlp-grpc', + settingOtlpEndpoint: 'http://collector:4317/some/path', + })); + expect(config.otlpProtocol).toBe('grpc'); + expect(config.exporterType).toBe('otlp-grpc'); + // gRPC parsing keeps only the origin + expect(config.otlpEndpoint).toBe('http://collector:4317'); + }); + it('preserves service version and session id', () => { const config = resolveOTelConfig(makeInput({ settingEnabled: true, @@ -155,6 +201,37 @@ describe('resolveOTelConfig', () => { expect(config.serviceName).toBe('my-service'); }); + it('resolves service name from setting when env is absent', () => { + const config = resolveOTelConfig(makeInput({ + settingEnabled: true, + settingServiceName: 'setting-service', + })); + expect(config.serviceName).toBe('setting-service'); + }); + + it('prefers OTEL_SERVICE_NAME env over the setting', () => { + const config = resolveOTelConfig(makeInput({ + settingServiceName: 'setting-service', + env: { + 'COPILOT_OTEL_ENABLED': 'true', + 'OTEL_SERVICE_NAME': 'env-service', + }, + })); + expect(config.serviceName).toBe('env-service'); + }); + + it('enterprise policy service name wins over env and setting', () => { + const config = resolveOTelConfig(makeInput({ + policyServiceName: 'policy-service', + settingServiceName: 'setting-service', + env: { + 'COPILOT_OTEL_ENABLED': 'true', + 'OTEL_SERVICE_NAME': 'env-service', + }, + })); + expect(config.serviceName).toBe('policy-service'); + }); + it('returns frozen config objects', () => { const enabled = resolveOTelConfig(makeInput({ settingEnabled: true })); const disabled = resolveOTelConfig(makeInput()); @@ -318,4 +395,77 @@ describe('resolveOTelConfig', () => { expect(config.maxAttributeSizeChars).toBe(0); }); }); + + describe('enterprise policy precedence', () => { + it('policy enabled wins over a disabling user setting', () => { + const config = resolveOTelConfig(makeInput({ + settingEnabled: false, + policyEnabled: true, + })); + expect(config.enabled).toBe(true); + expect(config.enabledVia).toBe('policy'); + }); + + it('policy disabled forces OTel off even when env enables it', () => { + const config = resolveOTelConfig(makeInput({ + env: { 'COPILOT_OTEL_ENABLED': 'true' }, + policyEnabled: false, + })); + expect(config.enabled).toBe(false); + }); + + it('policy endpoint wins over env endpoint and enables OTel', () => { + const config = resolveOTelConfig(makeInput({ + env: { 'OTEL_EXPORTER_OTLP_ENDPOINT': 'http://user:4318' }, + policyOtlpEndpoint: 'http://enterprise:4318', + })); + expect(config.enabled).toBe(true); + expect(config.otlpEndpoint).toBe('http://enterprise:4318/'); + expect(config.enabledVia).toBe('policy'); + }); + + it('policy exporter type maps and suppresses file export', () => { + const config = resolveOTelConfig(makeInput({ + settingOutfile: '/tmp/spans.jsonl', + policyEnabled: true, + policyExporterType: 'otlp-http', + })); + expect(config.exporterType).toBe('otlp-http'); + expect(config.fileExporterPath).toBeUndefined(); + }); + + it('policy captureContent overrides the user setting', () => { + const config = resolveOTelConfig(makeInput({ + policyEnabled: true, + settingCaptureContent: true, + policyCaptureContent: false, + })); + expect(config.captureContent).toBe(false); + }); + + it('policy protocol http/protobuf selects the protobuf wire encoding', () => { + const config = resolveOTelConfig(makeInput({ + policyEnabled: true, + policyProtocol: 'http/protobuf', + })); + expect(config.otlpProtocol).toBe('http/protobuf'); + }); + + it('policy protocol http/json keeps the json wire encoding', () => { + const config = resolveOTelConfig(makeInput({ + policyEnabled: true, + policyProtocol: 'http/json', + })); + expect(config.otlpProtocol).toBe('http/json'); + }); + + it('grpc exporter reports grpc regardless of wire protocol', () => { + const config = resolveOTelConfig(makeInput({ + policyEnabled: true, + policyExporterType: 'otlp-grpc', + policyProtocol: 'http/protobuf', + })); + expect(config.otlpProtocol).toBe('grpc'); + }); + }); }); diff --git a/extensions/copilot/src/platform/otel/node/otelServiceImpl.ts b/extensions/copilot/src/platform/otel/node/otelServiceImpl.ts index 47036221a8b97..d6272b5d8bbb8 100644 --- a/extensions/copilot/src/platform/otel/node/otelServiceImpl.ts +++ b/extensions/copilot/src/platform/otel/node/otelServiceImpl.ts @@ -241,7 +241,7 @@ export class NodeOTelService implements IOTelService { import('@opentelemetry/exporter-logs-otlp-grpc'), import('@opentelemetry/exporter-metrics-otlp-grpc'), ]); - const opts = { url: config.otlpEndpoint }; + const opts = { url: config.otlpEndpoint, headers: config.headers }; return { spanExporter: new OTLPTraceExporter(opts), logExporter: new OTLPLogExporter(opts), @@ -249,6 +249,25 @@ export class NodeOTelService implements IOTelService { }; } + // otlp-http with protobuf wire encoding + if (config.otlpProtocol === 'http/protobuf' && !dbOnlyMode) { + const [ + { OTLPTraceExporter }, + { OTLPLogExporter }, + { OTLPMetricExporter }, + ] = await Promise.all([ + import('@opentelemetry/exporter-trace-otlp-proto'), + import('@opentelemetry/exporter-logs-otlp-proto'), + import('@opentelemetry/exporter-metrics-otlp-proto'), + ]); + const base = config.otlpEndpoint.replace(/\/$/, ''); + return { + spanExporter: new OTLPTraceExporter({ url: `${base}/v1/traces`, headers: config.headers }), + logExporter: new OTLPLogExporter({ url: `${base}/v1/logs`, headers: config.headers }), + metricExporter: new OTLPMetricExporter({ url: `${base}/v1/metrics`, headers: config.headers }), + }; + } + // Default: otlp-http (or noop when in db-only mode) if (dbOnlyMode) { const metricsSDK = await import('@opentelemetry/sdk-metrics'); @@ -270,9 +289,9 @@ export class NodeOTelService implements IOTelService { ]); const base = config.otlpEndpoint.replace(/\/$/, ''); return { - spanExporter: new OTLPTraceExporter({ url: `${base}/v1/traces` }), - logExporter: new OTLPLogExporter({ url: `${base}/v1/logs` }), - metricExporter: new OTLPMetricExporter({ url: `${base}/v1/metrics` }), + spanExporter: new OTLPTraceExporter({ url: `${base}/v1/traces`, headers: config.headers }), + logExporter: new OTLPLogExporter({ url: `${base}/v1/logs`, headers: config.headers }), + metricExporter: new OTLPMetricExporter({ url: `${base}/v1/metrics`, headers: config.headers }), }; } diff --git a/src/vs/platform/agentHost/OTEL.md b/src/vs/platform/agentHost/OTEL.md index 726f922a98608..60cd77310aefd 100644 --- a/src/vs/platform/agentHost/OTEL.md +++ b/src/vs/platform/agentHost/OTEL.md @@ -77,8 +77,10 @@ The workbench-side starter translates the settings above into the following env | `OTEL_INSTRUMENTATION_GENAI_CAPTURE_MESSAGE_CONTENT` | `chat.agentHost.otel.captureContent` | | | `COPILOT_OTEL_FILE_EXPORTER_PATH` | `chat.agentHost.otel.outfile` | | | `COPILOT_OTEL_DB_SPAN_EXPORTER_ENABLED` | `chat.agentHost.otel.dbSpanExporter.enabled` | | -| `OTEL_EXPORTER_OTLP_PROTOCOL` | (inherited) | `grpc` selects gRPC; any other value uses HTTP. | -| `OTEL_EXPORTER_OTLP_HEADERS` | (inherited) | Auth headers (e.g., `Authorization=Bearer …`). | +| `OTEL_EXPORTER_OTLP_PROTOCOL` | (inherited or enterprise policy) | `grpc` selects gRPC; any other value uses HTTP. Set from the managed `telemetry.protocol` when configured. | +| `OTEL_SERVICE_NAME` | (inherited or enterprise policy) | `service.name` resource attribute; set from the managed `telemetry.serviceName`. | +| `OTEL_RESOURCE_ATTRIBUTES` | (inherited or enterprise policy) | Extra resource attributes (`k=v,k2=v2`); set from the managed `telemetry.resourceAttributes`. | +| `OTEL_EXPORTER_OTLP_HEADERS` | (inherited) | Auth headers (e.g., `Authorization=Bearer …`). **Not** delivered from managed settings — env delivery would leak the secret to tool subprocesses; managed headers apply to the Copilot Chat extension only. | > **Activation timing.** Env vars are bound at agent host **spawn time**. Changing a setting while the agent host is already running has no effect until the host respawns — restart VS Code or reload the window if you change these settings mid-session. @@ -134,7 +136,7 @@ src/vs/platform/otel/ ## Settings → Env Var Translation -`buildAgentHostOTelEnv()` ([common/agentService.ts](common/agentService.ts)) is the single translation point. The starter (`electronAgentHostStarter.ts` / `nodeAgentHostStarter.ts`) reads settings, calls `buildAgentHostOTelEnv(settings, parentEnv)`, and merges the result into the spawned process's environment. Parent-env values always win. +`buildAgentHostOTelEnv()` ([common/agentService.ts](common/agentService.ts)) is the single translation point. The starter (`electronAgentHostStarter.ts` / `nodeAgentHostStarter.ts`) reads settings, calls `buildAgentHostOTelEnv(settings, parentEnv)`, and merges the result into the spawned process's environment. Parent-env values win over the local `chat.agentHost.otel.*` settings (developer override); **enterprise managed-policy values win over parent env**. | Setting | Env var | |---|---| @@ -145,7 +147,7 @@ src/vs/platform/otel/ | `chat.agentHost.otel.outfile` | `COPILOT_OTEL_FILE_EXPORTER_PATH` | | `chat.agentHost.otel.dbSpanExporter.enabled` | `COPILOT_OTEL_DB_SPAN_EXPORTER_ENABLED` | -`OTEL_EXPORTER_OTLP_PROTOCOL`, `OTEL_EXPORTER_OTLP_HEADERS`, and `OTEL_RESOURCE_ATTRIBUTES` flow via env inheritance — they are not translated from settings. +`OTEL_EXPORTER_OTLP_HEADERS` flows via env inheritance only. `OTEL_EXPORTER_OTLP_PROTOCOL`, `OTEL_SERVICE_NAME`, and `OTEL_RESOURCE_ATTRIBUTES` are not translated from the local `chat.agentHost.otel.*` settings, but **enterprise managed settings (policy)** can set them on the spawned host: the renderer forwards the resolved policy to the starter, and managed values win over inherited env. `readAgentHostOTelEnv()` ([node/otel/agentHostOTelService.ts](node/otel/agentHostOTelService.ts)) is the inverse: it reads `process.env` inside the agent host and produces the `ResolvedConfig` that drives mode selection and outbound forwarding. diff --git a/src/vs/platform/agentHost/common/agentHostStarter.config.contribution.ts b/src/vs/platform/agentHost/common/agentHostStarter.config.contribution.ts index 33700df0716b6..0beea5266a08e 100644 --- a/src/vs/platform/agentHost/common/agentHostStarter.config.contribution.ts +++ b/src/vs/platform/agentHost/common/agentHostStarter.config.contribution.ts @@ -4,8 +4,10 @@ *--------------------------------------------------------------------------------------------*/ import * as nls from '../../../nls.js'; +import { IPolicyData } from '../../../base/common/defaultAccount.js'; import { PolicyCategory } from '../../../base/common/policy.js'; import { ConfigurationScope, Extensions as ConfigurationExtensions, IConfigurationRegistry } from '../../configuration/common/configurationRegistry.js'; +import { COPILOT_OTEL_CAPTURE_CONTENT_KEY, COPILOT_OTEL_ENABLED_KEY, COPILOT_OTEL_ENDPOINT_KEY, COPILOT_OTEL_HEADERS_KEY, COPILOT_OTEL_LOCK_CAPTURE_CONTENT_KEY, COPILOT_OTEL_PROTOCOL_KEY, COPILOT_OTEL_RESOURCE_ATTRIBUTES_KEY, COPILOT_OTEL_SERVICE_NAME_KEY, managedSettingValue } from '../../policy/common/copilotManagedSettings.js'; import product from '../../product/common/product.js'; import { Registry } from '../../registry/common/platform.js'; import { @@ -19,7 +21,10 @@ import { AgentHostOTelEnabledSettingId, AgentHostOTelExporterTypeSettingId, AgentHostOTelOtlpEndpointSettingId, + AgentHostOTelOtlpProtocolSettingId, AgentHostOTelOutfileSettingId, + AgentHostOTelResourceAttributesSettingId, + AgentHostOTelServiceNameSettingId, } from './agentService.js'; // Settings consumed by the agent host starter (`electronAgentHostStarter.ts` @@ -39,6 +44,40 @@ import { // (renderer registration for the settings UI). const configurationRegistry = Registry.as(ConfigurationExtensions.Configuration); + +// Custom managed-settings resolvers for the enterprise OTel policies. The simple pass-through +// keys use `managedSettingValue(KEY)`; these three combine or transform the managed value: +// - protocol: the schema's OTLP protocol string maps onto the agent-host exporter type. +// - captureContent: explicit boolean wins; otherwise `lockCaptureContent` forces it off. +// - outfile: when the enterprise mandates an OTLP endpoint/protocol, local file export is +// suppressed so spans can't be diverted to disk. +function managedOTelProtocolValue(policyData: IPolicyData): string | undefined { + const protocol = policyData.managedSettings?.[COPILOT_OTEL_PROTOCOL_KEY]; + if (protocol === 'grpc') { + return 'otlp-grpc'; + } + if (protocol === 'http/protobuf' || protocol === 'http/json') { + return 'otlp-http'; + } + return undefined; +} + +function managedOTelCaptureContentValue(policyData: IPolicyData): boolean | undefined { + const captureContent = policyData.managedSettings?.[COPILOT_OTEL_CAPTURE_CONTENT_KEY]; + if (typeof captureContent === 'boolean') { + return captureContent; + } + return policyData.managedSettings?.[COPILOT_OTEL_LOCK_CAPTURE_CONTENT_KEY] === true ? false : undefined; +} + +function managedOTelOutfileValue(policyData: IPolicyData): string | undefined { + const managedSettings = policyData.managedSettings; + if (managedSettings?.[COPILOT_OTEL_ENDPOINT_KEY] !== undefined || managedSettings?.[COPILOT_OTEL_PROTOCOL_KEY] !== undefined) { + return ''; + } + return undefined; +} + configurationRegistry.registerConfiguration({ id: 'chatAgentHostStarter', title: nls.localize('chatAgentHostStarterConfigurationTitle', "Chat Agent Host Starter"), @@ -118,6 +157,23 @@ configurationRegistry.registerConfiguration({ default: false, scope: ConfigurationScope.APPLICATION, tags: ['experimental', 'advanced'], + // Owns `CopilotOtelEnabled`; the copilot-chat setting `github.copilot.chat.otel.enabled` + // attaches to it via a `policyReference` in the extension's package.json. + policy: { + name: 'CopilotOtelEnabled', + category: PolicyCategory.InteractiveSession, + minimumVersion: '1.127', + value: managedSettingValue(COPILOT_OTEL_ENABLED_KEY), + managedSettings: { + [COPILOT_OTEL_ENABLED_KEY]: { type: 'boolean' }, + }, + localization: { + description: { + key: 'chat.agentHost.otel.enabled.policy', + value: nls.localize('chat.agentHost.otel.enabled.policy', "Controls whether Copilot OpenTelemetry export is enabled. When managed, users cannot override the enterprise value."), + } + }, + }, }, [AgentHostOTelExporterTypeSettingId]: { type: 'string', @@ -126,6 +182,55 @@ configurationRegistry.registerConfiguration({ default: 'otlp-http', scope: ConfigurationScope.APPLICATION, tags: ['experimental', 'advanced'], + // Owns `CopilotOtelProtocol`; the managed `telemetry.protocol` string is mapped onto + // the exporter type (`grpc` -> `otlp-grpc`, `http/*` -> `otlp-http`). + policy: { + name: 'CopilotOtelProtocol', + category: PolicyCategory.InteractiveSession, + minimumVersion: '1.127', + value: managedOTelProtocolValue, + managedSettings: { + [COPILOT_OTEL_PROTOCOL_KEY]: { type: 'string' }, + }, + localization: { + description: { + key: 'chat.agentHost.otel.protocol.policy', + value: nls.localize('chat.agentHost.otel.protocol.policy', "Controls the enterprise-managed OTLP protocol for Copilot OpenTelemetry export."), + }, + enumDescriptions: [ + { key: 'chat.agentHost.otel.protocol.policy.otlpHttp', value: nls.localize('chat.agentHost.otel.protocol.policy.otlpHttp', "Use OTLP over HTTP."), }, + { key: 'chat.agentHost.otel.protocol.policy.otlpGrpc', value: nls.localize('chat.agentHost.otel.protocol.policy.otlpGrpc', "Use OTLP over gRPC."), }, + { key: 'chat.agentHost.otel.protocol.policy.console', value: nls.localize('chat.agentHost.otel.protocol.policy.console', "Console exporter is not selected by enterprise managed settings."), }, + { key: 'chat.agentHost.otel.protocol.policy.file', value: nls.localize('chat.agentHost.otel.protocol.policy.file', "File exporter is not selected by enterprise managed settings."), }, + ], + }, + }, + }, + [AgentHostOTelOtlpProtocolSettingId]: { + type: 'string', + markdownDescription: nls.localize('chat.agentHost.otel.otlpProtocol', "Enterprise-managed OTLP wire protocol (`http/json`, `http/protobuf`, or `grpc`) for Copilot OpenTelemetry export. Policy-only: there is no user-facing setting; it carries the managed `telemetry.protocol` so the agent host's `OTEL_EXPORTER_OTLP_PROTOCOL` distinguishes protobuf from json."), + default: '', + scope: ConfigurationScope.APPLICATION, + // Policy-only delivery slot — no user-writable surface (mirrors `chat.plugins.extraMarketplaces`). + included: false, + tags: ['experimental', 'advanced'], + // Owns `CopilotOtelOtlpProtocol`; passes the raw managed `telemetry.protocol` through so the + // starters can set `OTEL_EXPORTER_OTLP_PROTOCOL` (the `exporterType` policy only carries transport). + policy: { + name: 'CopilotOtelOtlpProtocol', + category: PolicyCategory.InteractiveSession, + minimumVersion: '1.127', + value: managedSettingValue(COPILOT_OTEL_PROTOCOL_KEY), + managedSettings: { + [COPILOT_OTEL_PROTOCOL_KEY]: { type: 'string' }, + }, + localization: { + description: { + key: 'chat.agentHost.otel.otlpProtocol.policy', + value: nls.localize('chat.agentHost.otel.otlpProtocol.policy', "Controls the enterprise-managed OTLP wire protocol (protobuf vs JSON) for Copilot OpenTelemetry export."), + } + }, + }, }, [AgentHostOTelOtlpEndpointSettingId]: { type: 'string', @@ -133,6 +238,22 @@ configurationRegistry.registerConfiguration({ default: '', scope: ConfigurationScope.APPLICATION, tags: ['experimental', 'advanced'], + // Owns `CopilotOtelEndpoint`. + policy: { + name: 'CopilotOtelEndpoint', + category: PolicyCategory.InteractiveSession, + minimumVersion: '1.127', + value: managedSettingValue(COPILOT_OTEL_ENDPOINT_KEY), + managedSettings: { + [COPILOT_OTEL_ENDPOINT_KEY]: { type: 'string' }, + }, + localization: { + description: { + key: 'chat.agentHost.otel.otlpEndpoint.policy', + value: nls.localize('chat.agentHost.otel.otlpEndpoint.policy', "Controls the enterprise-managed OTLP collector endpoint for Copilot OpenTelemetry export."), + } + }, + }, }, [AgentHostOTelCaptureContentSettingId]: { type: 'boolean', @@ -140,6 +261,24 @@ configurationRegistry.registerConfiguration({ default: false, scope: ConfigurationScope.APPLICATION, tags: ['experimental', 'advanced'], + // Owns `CopilotOtelCaptureContent`; explicit managed value wins, otherwise + // `telemetry.lockCaptureContent` forces capture off. + policy: { + name: 'CopilotOtelCaptureContent', + category: PolicyCategory.InteractiveSession, + minimumVersion: '1.127', + value: managedOTelCaptureContentValue, + managedSettings: { + [COPILOT_OTEL_CAPTURE_CONTENT_KEY]: { type: 'boolean' }, + [COPILOT_OTEL_LOCK_CAPTURE_CONTENT_KEY]: { type: 'boolean' }, + }, + localization: { + description: { + key: 'chat.agentHost.otel.captureContent.policy', + value: nls.localize('chat.agentHost.otel.captureContent.policy', "Controls whether Copilot OpenTelemetry export captures prompt, response, and tool content."), + } + }, + }, }, [AgentHostOTelOutfileSettingId]: { type: 'string', @@ -147,6 +286,23 @@ configurationRegistry.registerConfiguration({ default: '', scope: ConfigurationScope.APPLICATION, tags: ['experimental', 'advanced'], + // Owns `CopilotOtelOutfile`; suppresses local file export when the enterprise mandates an OTLP sink. + policy: { + name: 'CopilotOtelOutfile', + category: PolicyCategory.InteractiveSession, + minimumVersion: '1.127', + value: managedOTelOutfileValue, + managedSettings: { + [COPILOT_OTEL_ENDPOINT_KEY]: { type: 'string' }, + [COPILOT_OTEL_PROTOCOL_KEY]: { type: 'string' }, + }, + localization: { + description: { + key: 'chat.agentHost.otel.outfile.policy', + value: nls.localize('chat.agentHost.otel.outfile.policy', "Prevents local file export when enterprise-managed Copilot OpenTelemetry export is configured."), + } + }, + }, }, [AgentHostOTelDbSpanExporterEnabledSettingId]: { type: 'boolean', @@ -155,5 +311,85 @@ configurationRegistry.registerConfiguration({ scope: ConfigurationScope.APPLICATION, tags: ['experimental', 'advanced'], }, + [AgentHostOTelServiceNameSettingId]: { + type: 'string', + markdownDescription: nls.localize('chat.agentHost.otel.serviceName', "Enterprise-managed OTel `service.name` resource attribute for Copilot OpenTelemetry export. Policy-only: there is no user-facing setting; it carries the managed `telemetry.serviceName` so the agent host's `OTEL_SERVICE_NAME` identifies spans from this deployment."), + default: '', + scope: ConfigurationScope.APPLICATION, + // Policy-only delivery slot — no user-writable surface (mirrors `chat.agentHost.otel.otlpProtocol`). + included: false, + tags: ['experimental', 'advanced'], + // Owns `CopilotOtelServiceName`; passes the raw managed `telemetry.serviceName` through so the + // starters can set `OTEL_SERVICE_NAME` on the agent host process. + policy: { + name: 'CopilotOtelServiceName', + category: PolicyCategory.InteractiveSession, + minimumVersion: '1.127', + value: managedSettingValue(COPILOT_OTEL_SERVICE_NAME_KEY), + managedSettings: { + [COPILOT_OTEL_SERVICE_NAME_KEY]: { type: 'string' }, + }, + localization: { + description: { + key: 'chat.agentHost.otel.serviceName.policy', + value: nls.localize('chat.agentHost.otel.serviceName.policy', "Controls the enterprise-managed OTel `service.name` resource attribute for Copilot OpenTelemetry export."), + } + }, + }, + }, + [AgentHostOTelResourceAttributesSettingId]: { + // Policy-only delivery slot — no user-writable surface (mirrors `chat.plugins.extraMarketplaces`). + // Carried as a `{ [key]: string }` object; the starters serialize it into `OTEL_RESOURCE_ATTRIBUTES`. + type: 'object', + additionalProperties: { type: ['string'] as ['string'] }, + default: {}, + scope: ConfigurationScope.APPLICATION, + included: false, + tags: ['experimental', 'advanced'], + markdownDescription: nls.localize('chat.agentHost.otel.resourceAttributes', "Enterprise-managed OTel resource attributes for Copilot OpenTelemetry export. Policy-only: there is no user-facing setting; it carries the managed `telemetry.resourceAttributes` map so the agent host's `OTEL_RESOURCE_ATTRIBUTES` includes the deployment's attributes."), + policy: { + name: 'CopilotOtelResourceAttributes', + category: PolicyCategory.InteractiveSession, + minimumVersion: '1.127', + value: managedSettingValue(COPILOT_OTEL_RESOURCE_ATTRIBUTES_KEY), + managedSettings: { + [COPILOT_OTEL_RESOURCE_ATTRIBUTES_KEY]: { type: 'string' }, + }, + localization: { + description: { + key: 'chat.agentHost.otel.resourceAttributes.policy', + value: nls.localize('chat.agentHost.otel.resourceAttributes.policy', "Controls the enterprise-managed OTel resource attributes for Copilot OpenTelemetry export."), + } + }, + }, + }, + // Extension-only policy delivery slot for managed OTLP exporter headers (e.g. auth tokens). + // Deliberately NOT delivered to the agent host: headers would have to travel via env vars, + // which the agent host leaks into the tool subprocesses it spawns, exposing the secret. The + // Copilot Chat extension applies these headers directly to its OTLP exporter instead. + ['chat.agentHost.otel.headers']: { + type: 'object', + additionalProperties: { type: ['string'] as ['string'] }, + default: {}, + scope: ConfigurationScope.APPLICATION, + included: false, + tags: ['experimental', 'advanced'], + markdownDescription: nls.localize('chat.agentHost.otel.headers', "Enterprise-managed OTLP exporter headers (e.g. auth tokens) for Copilot OpenTelemetry export. Policy-only and extension-only: applied directly to the Copilot Chat extension's OTLP exporter, never delivered to the agent host process."), + policy: { + name: 'CopilotOtelHeaders', + category: PolicyCategory.InteractiveSession, + minimumVersion: '1.127', + value: managedSettingValue(COPILOT_OTEL_HEADERS_KEY), + managedSettings: { + [COPILOT_OTEL_HEADERS_KEY]: { type: 'string' }, + }, + localization: { + description: { + key: 'chat.agentHost.otel.headers.policy', + value: nls.localize('chat.agentHost.otel.headers.policy', "Controls the enterprise-managed OTLP exporter headers for Copilot OpenTelemetry export."), + } + }, + }, + }, } }); diff --git a/src/vs/platform/agentHost/common/agentService.ts b/src/vs/platform/agentHost/common/agentService.ts index e9654b37d4399..3cebbdccd4641 100644 --- a/src/vs/platform/agentHost/common/agentService.ts +++ b/src/vs/platform/agentHost/common/agentService.ts @@ -282,12 +282,23 @@ export const AgentHostCodexAgentBinaryArgsEnvVar = 'VSCODE_AGENT_HOST_CODEX_APP_ export const AgentHostOTelEnabledSettingId = 'chat.agentHost.otel.enabled'; /** Exporter type for the SDK's OTel pipeline. One of: `otlp-http`, `otlp-grpc`, `console`, `file`. */ export const AgentHostOTelExporterTypeSettingId = 'chat.agentHost.otel.exporterType'; +/** + * OTLP wire protocol (`http/json`, `http/protobuf`, `grpc`). Policy-only delivery slot (no user UI): + * carries the enterprise-managed `telemetry.protocol` so it can be threaded into the agent host's + * `OTEL_EXPORTER_OTLP_PROTOCOL` env, which the runtime needs to distinguish protobuf from json + * (the `exporterType` setting only models transport, not the HTTP wire encoding). + */ +export const AgentHostOTelOtlpProtocolSettingId = 'chat.agentHost.otel.otlpProtocol'; /** OTLP endpoint URL when `exporterType` is `otlp-http` or `otlp-grpc`. */ export const AgentHostOTelOtlpEndpointSettingId = 'chat.agentHost.otel.otlpEndpoint'; /** Whether to include prompt/response content in span attributes (privacy-sensitive). */ export const AgentHostOTelCaptureContentSettingId = 'chat.agentHost.otel.captureContent'; /** Output path when `exporterType` is `file`. */ export const AgentHostOTelOutfileSettingId = 'chat.agentHost.otel.outfile'; +/** Policy-only delivery slot for the enterprise-managed OTel `service.name` (no user UI). */ +export const AgentHostOTelServiceNameSettingId = 'chat.agentHost.otel.serviceName'; +/** Policy-only delivery slot for enterprise-managed OTel resource attributes (no user UI). */ +export const AgentHostOTelResourceAttributesSettingId = 'chat.agentHost.otel.resourceAttributes'; /** When true, ALL spans are persisted to a local SQLite store regardless of `exporterType`. */ export const AgentHostOTelDbSpanExporterEnabledSettingId = 'chat.agentHost.otel.dbSpanExporter.enabled'; @@ -314,10 +325,14 @@ export const AgentHostOTelEnvVars = Object.freeze({ OtlpEndpoint: 'OTEL_EXPORTER_OTLP_ENDPOINT', OtlpEndpointAlt: 'COPILOT_OTEL_ENDPOINT', OtlpProtocol: 'OTEL_EXPORTER_OTLP_PROTOCOL', + OtlpTracesProtocol: 'OTEL_EXPORTER_OTLP_TRACES_PROTOCOL', + OtlpMetricsProtocol: 'OTEL_EXPORTER_OTLP_METRICS_PROTOCOL', OtlpHeaders: 'OTEL_EXPORTER_OTLP_HEADERS', CaptureContent: 'OTEL_INSTRUMENTATION_GENAI_CAPTURE_MESSAGE_CONTENT', FilePath: 'COPILOT_OTEL_FILE_EXPORTER_PATH', SourceName: 'COPILOT_OTEL_SOURCE_NAME', + ServiceName: 'OTEL_SERVICE_NAME', + ResourceAttributes: 'OTEL_RESOURCE_ATTRIBUTES', DbSpanExporterEnabled: 'COPILOT_OTEL_DB_SPAN_EXPORTER_ENABLED', } as const); @@ -328,12 +343,102 @@ export const AgentHostOTelEnvVars = Object.freeze({ export interface IAgentHostOTelSettings { readonly enabled?: boolean; readonly exporterType?: string; + readonly otlpProtocol?: string; readonly otlpEndpoint?: string; readonly captureContent?: boolean; readonly outfile?: string; + readonly serviceName?: string; + readonly resourceAttributes?: Record; readonly dbSpanExporterEnabled?: boolean; } +/** + * IPC channel (renderer -> main) the desktop agent-host path uses to hand the + * enterprise-resolved `chat.agentHost.otel.*` policy to `ElectronAgentHostStarter`. + * + * The main-process configuration service does NOT include the renderer-only + * `AccountPolicyService` (managed settings: server / native-MDM / file channels), so a + * starter running in the main process sees `policyValue === undefined` for these keys. + * The renderer — whose policy layer does include managed settings — forwards the resolved + * values here just before requesting the agent-host connection, so the host is spawned with + * the managed OTel env. See {@link readAgentHostOTelPolicySettings}. + */ +export const AgentHostOTelPolicyIpcChannel = 'vscode:agentHostOTelPolicy'; + +/** + * Resolve the enterprise-policy values for the `chat.agentHost.otel.*` settings from a + * configuration service whose policy layer includes managed settings (i.e. the renderer's). + * Each field is `undefined` when no policy is set. Intended as the `policySettings` argument + * of {@link buildAgentHostOTelEnv}. + */ +export function readAgentHostOTelPolicySettings(configurationService: IConfigurationService): IAgentHostOTelSettings { + const policyValue = (key: string): T | undefined => configurationService.inspect(key).policyValue; + return { + enabled: policyValue(AgentHostOTelEnabledSettingId), + exporterType: policyValue(AgentHostOTelExporterTypeSettingId), + otlpProtocol: policyValue(AgentHostOTelOtlpProtocolSettingId), + otlpEndpoint: policyValue(AgentHostOTelOtlpEndpointSettingId), + captureContent: policyValue(AgentHostOTelCaptureContentSettingId), + outfile: policyValue(AgentHostOTelOutfileSettingId), + serviceName: policyValue(AgentHostOTelServiceNameSettingId), + resourceAttributes: policyValue>(AgentHostOTelResourceAttributesSettingId), + }; +} + +/** + * Validate/normalize an {@link IAgentHostOTelSettings} received over IPC, keeping only + * well-typed fields. Defends the main process against a malformed payload before the values + * are turned into agent-host process env vars. + */ +export function sanitizeAgentHostOTelPolicySettings(raw: unknown): IAgentHostOTelSettings { + if (!raw || typeof raw !== 'object') { + return {}; + } + const record = raw as Record; + const asString = (value: unknown): string | undefined => typeof value === 'string' ? value : undefined; + const asBoolean = (value: unknown): boolean | undefined => typeof value === 'boolean' ? value : undefined; + const asStringRecord = (value: unknown): Record | undefined => { + if (!value || typeof value !== 'object' || Array.isArray(value)) { + return undefined; + } + const out: Record = {}; + for (const [k, v] of Object.entries(value as Record)) { + if (k === '__proto__' || k === 'constructor' || k === 'prototype') { + continue; // defend the IPC boundary against prototype pollution + } + if (typeof v === 'string') { + out[k] = v; + } + } + return out; + }; + return { + enabled: asBoolean(record.enabled), + exporterType: asString(record.exporterType), + otlpProtocol: asString(record.otlpProtocol), + otlpEndpoint: asString(record.otlpEndpoint), + captureContent: asBoolean(record.captureContent), + outfile: asString(record.outfile), + serviceName: asString(record.serviceName), + resourceAttributes: asStringRecord(record.resourceAttributes), + }; +} + +/** + * Serialize an OTel resource-attribute map into the `OTEL_RESOURCE_ATTRIBUTES` env-var format + * (`key1=value1,key2=value2`, W3C Baggage style). Returns `undefined` for an empty/absent map so + * callers can skip emitting the env var. Empty keys and non-string values are dropped. + */ +function serializeResourceAttributes(attributes: Record | undefined): string | undefined { + if (!attributes) { + return undefined; + } + const parts = Object.entries(attributes) + .filter(([key, value]) => key !== '' && typeof value === 'string') + .map(([key, value]) => `${key}=${value}`); + return parts.length > 0 ? parts.join(',') : undefined; +} + /** * Build the env-var overlay for the agent host process from user settings and * inherited env. Settings are translated to env vars, but if the same env var is @@ -345,6 +450,7 @@ export interface IAgentHostOTelSettings { export function buildAgentHostOTelEnv( settings: IAgentHostOTelSettings, inheritedEnv: Readonly>, + policySettings: IAgentHostOTelSettings = {}, ): Record { const out: Record = {}; const setIfMissing = (key: string, value: string | undefined): void => { @@ -353,11 +459,20 @@ export function buildAgentHostOTelEnv( } out[key] = value; }; + // Enterprise policy wins over inherited env (managed settings cannot be overridden by a + // user-set env var), unlike user settings which yield to env via `setIfMissing`. + const setPolicy = (key: string, value: string | undefined): void => { + if (value !== undefined) { + out[key] = value; + } + }; if (settings.enabled) { setIfMissing(AgentHostOTelEnvVars.Enabled, 'true'); } setIfMissing(AgentHostOTelEnvVars.ExporterType, settings.exporterType); setIfMissing(AgentHostOTelEnvVars.OtlpEndpoint, settings.otlpEndpoint); + setIfMissing(AgentHostOTelEnvVars.ServiceName, settings.serviceName); + setIfMissing(AgentHostOTelEnvVars.ResourceAttributes, serializeResourceAttributes(settings.resourceAttributes)); setIfMissing(AgentHostOTelEnvVars.FilePath, settings.outfile); if (settings.captureContent !== undefined) { setIfMissing(AgentHostOTelEnvVars.CaptureContent, settings.captureContent ? 'true' : 'false'); @@ -365,6 +480,43 @@ export function buildAgentHostOTelEnv( if (settings.dbSpanExporterEnabled) { setIfMissing(AgentHostOTelEnvVars.DbSpanExporterEnabled, 'true'); } + + if (policySettings.enabled !== undefined) { + setPolicy(AgentHostOTelEnvVars.Enabled, policySettings.enabled ? 'true' : 'false'); + if (!policySettings.enabled) { + setPolicy(AgentHostOTelEnvVars.OtlpEndpoint, ''); + setPolicy(AgentHostOTelEnvVars.OtlpEndpointAlt, ''); + setPolicy(AgentHostOTelEnvVars.FilePath, ''); + } + } + if (policySettings.exporterType !== undefined) { + setPolicy(AgentHostOTelEnvVars.ExporterType, policySettings.exporterType); + setPolicy(AgentHostOTelEnvVars.FilePath, ''); + } + if (policySettings.otlpProtocol !== undefined && policySettings.otlpProtocol !== '') { + // Mirror the CLI: thread the managed protocol into the generic AND per-signal protocol + // env vars so it wins over any user-provided OTEL_EXPORTER_OTLP_{,TRACES_,METRICS_}PROTOCOL. + setPolicy(AgentHostOTelEnvVars.OtlpProtocol, policySettings.otlpProtocol); + setPolicy(AgentHostOTelEnvVars.OtlpTracesProtocol, policySettings.otlpProtocol); + setPolicy(AgentHostOTelEnvVars.OtlpMetricsProtocol, policySettings.otlpProtocol); + } + if (policySettings.otlpEndpoint !== undefined) { + setPolicy(AgentHostOTelEnvVars.OtlpEndpoint, policySettings.otlpEndpoint); + setPolicy(AgentHostOTelEnvVars.FilePath, ''); + } + if (policySettings.outfile !== undefined) { + setPolicy(AgentHostOTelEnvVars.FilePath, policySettings.outfile); + } + if (policySettings.captureContent !== undefined) { + setPolicy(AgentHostOTelEnvVars.CaptureContent, policySettings.captureContent ? 'true' : 'false'); + } + if (policySettings.serviceName !== undefined && policySettings.serviceName !== '') { + setPolicy(AgentHostOTelEnvVars.ServiceName, policySettings.serviceName); + } + const policyResourceAttributes = serializeResourceAttributes(policySettings.resourceAttributes); + if (policyResourceAttributes !== undefined) { + setPolicy(AgentHostOTelEnvVars.ResourceAttributes, policyResourceAttributes); + } return out; } diff --git a/src/vs/platform/agentHost/electron-browser/localAgentHostService.ts b/src/vs/platform/agentHost/electron-browser/localAgentHostService.ts index c3c8a58bf8cc8..3290ee83fa4c7 100644 --- a/src/vs/platform/agentHost/electron-browser/localAgentHostService.ts +++ b/src/vs/platform/agentHost/electron-browser/localAgentHostService.ts @@ -11,11 +11,12 @@ import { generateUuid } from '../../../base/common/uuid.js'; import { getDelayedChannel, ProxyChannel } from '../../../base/parts/ipc/common/ipc.js'; import { Client as MessagePortClient } from '../../../base/parts/ipc/common/ipc.mp.js'; import { acquirePort } from '../../../base/parts/ipc/electron-browser/ipc.mp.js'; +import { ipcRenderer } from '../../../base/parts/sandbox/electron-browser/globals.js'; import { IInstantiationService } from '../../instantiation/common/instantiation.js'; import { IConfigurationService } from '../../configuration/common/configuration.js'; import { IEnvironmentService } from '../../environment/common/environment.js'; import { ILogService } from '../../log/common/log.js'; -import { AgentHostAhpJsonlLoggingSettingId, AgentHostCodexAgentEnabledSettingId, AgentHostIpcChannels, IAgentCreateChatOptions, IAgentCreateSessionConfig, IAgentHostInspectInfo, IAgentHostService, IAgentResolveSessionConfigParams, IAgentService, IAgentSessionConfigCompletionsParams, IAgentSessionMetadata, AuthenticateParams, AuthenticateResult, IAgentHostSocketInfo, IConnectionTrackerService, isAgentHostEnabled, IMcpNotification } from '../common/agentService.js'; +import { AgentHostAhpJsonlLoggingSettingId, AgentHostCodexAgentEnabledSettingId, AgentHostIpcChannels, IAgentCreateChatOptions, IAgentCreateSessionConfig, IAgentHostInspectInfo, IAgentHostService, IAgentResolveSessionConfigParams, IAgentService, IAgentSessionConfigCompletionsParams, IAgentSessionMetadata, AuthenticateParams, AuthenticateResult, IAgentHostSocketInfo, IConnectionTrackerService, isAgentHostEnabled, IMcpNotification, AgentHostOTelPolicyIpcChannel, readAgentHostOTelPolicySettings } from '../common/agentService.js'; import { AhpJsonlLogger } from '../common/ahpJsonlLogger.js'; import { wrapAgentServiceWithAhpLogging } from './localAhpJsonlLogging.js'; import { AgentSubscriptionManager, isActionEnvelopeRelevantToSubscriptionUris, type IActiveSubscriptionInfo, type IAgentSubscription } from '../common/state/agentSubscription.js'; @@ -148,6 +149,13 @@ export class LocalAgentHostServiceClient extends Disposable implements IAgentHos private async _connect(): Promise { this._logService.info('[AgentHost:renderer] Acquiring MessagePort to agent host...'); + // Forward the enterprise-resolved OTel policy to the main-process starter BEFORE + // requesting the connection. The main config service does not include the renderer-only + // managed-settings policy (`AccountPolicyService`), so without this the agent host would + // be spawned missing managed OTel settings (endpoint/protocol/enabled). Sent first so it + // is processed (FIFO per sender) before the starter spawns the host on the connection + // request. See `AgentHostOTelPolicyIpcChannel`. + ipcRenderer.send(AgentHostOTelPolicyIpcChannel, readAgentHostOTelPolicySettings(this._configurationService)); const port = await acquirePort('vscode:createAgentHostMessageChannel', 'vscode:createAgentHostMessageChannelResult'); this._logService.info('[AgentHost:renderer] MessagePort acquired, creating client...'); diff --git a/src/vs/platform/agentHost/electron-main/electronAgentHostStarter.ts b/src/vs/platform/agentHost/electron-main/electronAgentHostStarter.ts index 054f96ca48969..d633d2d9041d0 100644 --- a/src/vs/platform/agentHost/electron-main/electronAgentHostStarter.ts +++ b/src/vs/platform/agentHost/electron-main/electronAgentHostStarter.ts @@ -19,7 +19,7 @@ import { getResolvedShellEnv } from '../../shell/node/shellEnv.js'; import { NullTelemetryService } from '../../telemetry/common/telemetryUtils.js'; import { UtilityProcess } from '../../utilityProcess/electron-main/utilityProcess.js'; import { IAgentHostConnection, IAgentHostStarter } from '../common/agent.js'; -import { AgentHostClaudeAgentEnabledSettingId, AgentHostCodexAgentBinaryArgsSettingId, AgentHostCodexAgentEnabledSettingId, AgentHostCodexAgentSdkRootSettingId, AgentHostCodexAgentCodexHomeSettingId, AgentHostOTelCaptureContentSettingId, AgentHostOTelDbSpanExporterEnabledSettingId, AgentHostOTelEnabledSettingId, AgentHostOTelExporterTypeSettingId, AgentHostOTelOtlpEndpointSettingId, AgentHostOTelOutfileSettingId, buildAgentHostOTelEnv, buildAgentSdkEnv } from '../common/agentService.js'; +import { AgentHostClaudeAgentEnabledSettingId, AgentHostCodexAgentBinaryArgsSettingId, AgentHostCodexAgentEnabledSettingId, AgentHostCodexAgentSdkRootSettingId, AgentHostCodexAgentCodexHomeSettingId, AgentHostOTelCaptureContentSettingId, AgentHostOTelDbSpanExporterEnabledSettingId, AgentHostOTelEnabledSettingId, AgentHostOTelExporterTypeSettingId, AgentHostOTelOtlpEndpointSettingId, AgentHostOTelOtlpProtocolSettingId, AgentHostOTelOutfileSettingId, AgentHostOTelResourceAttributesSettingId, AgentHostOTelServiceNameSettingId, AgentHostOTelPolicyIpcChannel, buildAgentHostOTelEnv, buildAgentSdkEnv, IAgentHostOTelSettings, sanitizeAgentHostOTelPolicySettings } from '../common/agentService.js'; import { deepClone } from '../../../base/common/objects.js'; import '../common/agentHost.config.contribution.js'; import '../common/agentHostStarter.config.contribution.js'; @@ -34,6 +34,15 @@ export class ElectronAgentHostStarter extends Disposable implements IAgentHostSt private readonly _onWillShutdown = this._register(new Emitter()); readonly onWillShutdown = this._onWillShutdown.event; + /** + * Enterprise OTel policy forwarded by the renderer (see `AgentHostOTelPolicyIpcChannel`). + * The main-process config service lacks the managed-settings (`AccountPolicyService`) policy + * layer, so the renderer — which has it — sends the resolved values here before requesting + * the connection that lazily spawns the host. Used as the `policySettings` of + * `buildAgentHostOTelEnv` in `start()`, falling back to main-process policy when absent. + */ + private _otelPolicyFromRenderer: IAgentHostOTelSettings | undefined = undefined; + constructor( @IConfigurationService private readonly _configurationService: IConfigurationService, @IEnvironmentMainService private readonly _environmentMainService: IEnvironmentMainService, @@ -44,6 +53,16 @@ export class ElectronAgentHostStarter extends Disposable implements IAgentHostSt this._register(this._lifecycleMainService.onWillShutdown(() => this._onWillShutdown.fire())); + // Capture the enterprise OTel policy the renderer forwards before it requests a + // connection (FIFO per sender ensures this lands before the spawn in `start()`). + const onOTelPolicy = (_e: IpcMainEvent, policy: unknown) => { + this._otelPolicyFromRenderer = sanitizeAgentHostOTelPolicySettings(policy); + }; + validatedIpcMain.on(AgentHostOTelPolicyIpcChannel, onOTelPolicy); + this._register(toDisposable(() => { + validatedIpcMain.removeListener(AgentHostOTelPolicyIpcChannel, onOTelPolicy); + })); + // Listen for new windows to establish a direct MessagePort connection to the agent host const onWindowConnection = (e: IpcMainEvent, nonce: string) => this._onWindowConnection(e, nonce); validatedIpcMain.on('vscode:createAgentHostMessageChannel', onWindowConnection); @@ -79,7 +98,24 @@ export class ElectronAgentHostStarter extends Disposable implements IAgentHostSt // Translate `chat.agentHost.otel.*` settings into the env vars consumed by // the agent host process. Any value already present on `process.env` wins - // (developer override) — see `buildAgentHostOTelEnv` for the precedence. + // for user settings, while enterprise policy values win over inherited env — + // see `buildAgentHostOTelEnv` for the precedence. + // + // Policy source: prefer the renderer-forwarded policy (its config service + // includes the managed-settings `AccountPolicyService` layer that the main + // process cannot see); fall back to the main-process policy for the keys it + // can resolve (e.g. native MDM via the policy channel). + const policyValue = (key: string): T | undefined => this._configurationService.inspect(key).policyValue; + const policySettings: IAgentHostOTelSettings = this._otelPolicyFromRenderer ?? { + enabled: policyValue(AgentHostOTelEnabledSettingId), + exporterType: policyValue(AgentHostOTelExporterTypeSettingId), + otlpProtocol: policyValue(AgentHostOTelOtlpProtocolSettingId), + otlpEndpoint: policyValue(AgentHostOTelOtlpEndpointSettingId), + captureContent: policyValue(AgentHostOTelCaptureContentSettingId), + outfile: policyValue(AgentHostOTelOutfileSettingId), + serviceName: policyValue(AgentHostOTelServiceNameSettingId), + resourceAttributes: policyValue>(AgentHostOTelResourceAttributesSettingId), + }; const otelEnv = buildAgentHostOTelEnv({ enabled: this._configurationService.getValue(AgentHostOTelEnabledSettingId), exporterType: this._configurationService.getValue(AgentHostOTelExporterTypeSettingId), @@ -87,7 +123,7 @@ export class ElectronAgentHostStarter extends Disposable implements IAgentHostSt captureContent: this._configurationService.getValue(AgentHostOTelCaptureContentSettingId), outfile: this._configurationService.getValue(AgentHostOTelOutfileSettingId), dbSpanExporterEnabled: this._configurationService.getValue(AgentHostOTelDbSpanExporterEnabledSettingId), - }, process.env); + }, process.env, policySettings); const args = [ '--logsPath', this._environmentMainService.logsHome.with({ scheme: Schemas.file }).fsPath, diff --git a/src/vs/platform/agentHost/node/nodeAgentHostStarter.ts b/src/vs/platform/agentHost/node/nodeAgentHostStarter.ts index 8016d0f293cbc..a145f36451b5a 100644 --- a/src/vs/platform/agentHost/node/nodeAgentHostStarter.ts +++ b/src/vs/platform/agentHost/node/nodeAgentHostStarter.ts @@ -13,7 +13,7 @@ import { parseAgentHostDebugPort } from '../../environment/node/environmentServi import { ILogService } from '../../log/common/log.js'; import { getResolvedShellEnv } from '../../shell/node/shellEnv.js'; import { IAgentHostConnection, IAgentHostStarter } from '../common/agent.js'; -import { AgentHostClaudeAgentEnabledSettingId, AgentHostCodexAgentBinaryArgsSettingId, AgentHostCodexAgentEnabledSettingId, AgentHostCodexAgentSdkRootSettingId, AgentHostCodexAgentCodexHomeSettingId, AgentHostOTelCaptureContentSettingId, AgentHostOTelDbSpanExporterEnabledSettingId, AgentHostOTelEnabledSettingId, AgentHostOTelExporterTypeSettingId, AgentHostOTelOtlpEndpointSettingId, AgentHostOTelOutfileSettingId, buildAgentHostOTelEnv, buildAgentSdkEnv } from '../common/agentService.js'; +import { AgentHostClaudeAgentEnabledSettingId, AgentHostCodexAgentBinaryArgsSettingId, AgentHostCodexAgentEnabledSettingId, AgentHostCodexAgentSdkRootSettingId, AgentHostCodexAgentCodexHomeSettingId, AgentHostOTelCaptureContentSettingId, AgentHostOTelDbSpanExporterEnabledSettingId, AgentHostOTelEnabledSettingId, AgentHostOTelExporterTypeSettingId, AgentHostOTelOtlpEndpointSettingId, AgentHostOTelOtlpProtocolSettingId, AgentHostOTelOutfileSettingId, AgentHostOTelResourceAttributesSettingId, AgentHostOTelServiceNameSettingId, buildAgentHostOTelEnv, buildAgentSdkEnv } from '../common/agentService.js'; import '../common/agentHostStarter.config.contribution.js'; /** @@ -88,7 +88,9 @@ export class NodeAgentHostStarter extends Disposable implements IAgentHostStarte // Translate `chat.agentHost.otel.*` settings into the env vars consumed by // the agent host process. Any value already present on `process.env` wins - // (developer override) — see `buildAgentHostOTelEnv`. + // for user settings, while enterprise policy values win over inherited env — + // see `buildAgentHostOTelEnv`. + const policyValue = (key: string): T | undefined => this._configurationService.inspect(key).policyValue; const otelEnv = buildAgentHostOTelEnv({ enabled: this._configurationService.getValue(AgentHostOTelEnabledSettingId), exporterType: this._configurationService.getValue(AgentHostOTelExporterTypeSettingId), @@ -96,7 +98,16 @@ export class NodeAgentHostStarter extends Disposable implements IAgentHostStarte captureContent: this._configurationService.getValue(AgentHostOTelCaptureContentSettingId), outfile: this._configurationService.getValue(AgentHostOTelOutfileSettingId), dbSpanExporterEnabled: this._configurationService.getValue(AgentHostOTelDbSpanExporterEnabledSettingId), - }, process.env); + }, process.env, { + enabled: policyValue(AgentHostOTelEnabledSettingId), + exporterType: policyValue(AgentHostOTelExporterTypeSettingId), + otlpProtocol: policyValue(AgentHostOTelOtlpProtocolSettingId), + otlpEndpoint: policyValue(AgentHostOTelOtlpEndpointSettingId), + captureContent: policyValue(AgentHostOTelCaptureContentSettingId), + outfile: policyValue(AgentHostOTelOutfileSettingId), + serviceName: policyValue(AgentHostOTelServiceNameSettingId), + resourceAttributes: policyValue>(AgentHostOTelResourceAttributesSettingId), + }); Object.assign(env, otelEnv); // Forward WebSocket server configuration to the child process via env vars diff --git a/src/vs/platform/agentHost/test/common/agentService.test.ts b/src/vs/platform/agentHost/test/common/agentService.test.ts index cd29d5fdfc9f9..8545e9f22084b 100644 --- a/src/vs/platform/agentHost/test/common/agentService.test.ts +++ b/src/vs/platform/agentHost/test/common/agentService.test.ts @@ -6,7 +6,8 @@ import assert from 'assert'; import { URI } from '../../../../base/common/uri.js'; import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../base/test/common/utils.js'; -import { AgentSession, isAgentEnabled } from '../../common/agentService.js'; +import { IConfigurationService } from '../../../configuration/common/configuration.js'; +import { AgentSession, AgentHostOTelEnvVars, buildAgentHostOTelEnv, isAgentEnabled, readAgentHostOTelPolicySettings, sanitizeAgentHostOTelPolicySettings } from '../../common/agentService.js'; suite('AgentSession namespace', () => { @@ -66,3 +67,176 @@ suite('isAgentEnabled', () => { }); } }); + +suite('buildAgentHostOTelEnv', () => { + + ensureNoDisposablesAreLeakedInTestSuite(); + + test('enterprise policy wins over inherited env', () => { + const env = buildAgentHostOTelEnv( + { enabled: false }, + { [AgentHostOTelEnvVars.OtlpEndpoint]: 'http://user:4318' }, + { enabled: true, otlpEndpoint: 'http://enterprise:4318' }, + ); + assert.strictEqual(env[AgentHostOTelEnvVars.Enabled], 'true'); + assert.strictEqual(env[AgentHostOTelEnvVars.OtlpEndpoint], 'http://enterprise:4318'); + }); + + test('managed protocol sets the generic and per-signal protocol env vars', () => { + const env = buildAgentHostOTelEnv( + {}, + { [AgentHostOTelEnvVars.OtlpProtocol]: 'http/json' }, + { otlpProtocol: 'http/protobuf' }, + ); + assert.strictEqual(env[AgentHostOTelEnvVars.OtlpProtocol], 'http/protobuf'); + assert.strictEqual(env[AgentHostOTelEnvVars.OtlpTracesProtocol], 'http/protobuf'); + assert.strictEqual(env[AgentHostOTelEnvVars.OtlpMetricsProtocol], 'http/protobuf'); + }); + + test('policy-disabled blanks endpoint and file export', () => { + const env = buildAgentHostOTelEnv( + { enabled: true, otlpEndpoint: 'http://user:4318' }, + {}, + { enabled: false }, + ); + assert.strictEqual(env[AgentHostOTelEnvVars.Enabled], 'false'); + assert.strictEqual(env[AgentHostOTelEnvVars.OtlpEndpoint], ''); + assert.strictEqual(env[AgentHostOTelEnvVars.FilePath], ''); + }); + + test('managed service name wins over inherited env', () => { + const env = buildAgentHostOTelEnv( + { serviceName: 'user-service' }, + { [AgentHostOTelEnvVars.ServiceName]: 'env-service' }, + { serviceName: 'enterprise-service' }, + ); + assert.strictEqual(env[AgentHostOTelEnvVars.ServiceName], 'enterprise-service'); + }); + + test('empty managed service name emits no override', () => { + const env = buildAgentHostOTelEnv( + {}, + { [AgentHostOTelEnvVars.ServiceName]: 'env-service' }, + { serviceName: '' }, + ); + // The builder returns only overrides; leaving the key out preserves the inherited env value. + assert.strictEqual(env[AgentHostOTelEnvVars.ServiceName], undefined); + }); + + test('managed resource attributes serialize into OTEL_RESOURCE_ATTRIBUTES', () => { + const env = buildAgentHostOTelEnv( + {}, + { [AgentHostOTelEnvVars.ResourceAttributes]: 'service.namespace=env' }, + { resourceAttributes: { 'deployment.environment': 'prod', 'service.namespace': 'acme' } }, + ); + assert.strictEqual(env[AgentHostOTelEnvVars.ResourceAttributes], 'deployment.environment=prod,service.namespace=acme'); + }); + + test('empty managed resource attributes emit no override', () => { + const env = buildAgentHostOTelEnv( + {}, + { [AgentHostOTelEnvVars.ResourceAttributes]: 'service.namespace=env' }, + { resourceAttributes: {} }, + ); + assert.strictEqual(env[AgentHostOTelEnvVars.ResourceAttributes], undefined); + }); +}); + +suite('readAgentHostOTelPolicySettings', () => { + + ensureNoDisposablesAreLeakedInTestSuite(); + + function fakeConfig(policy: Record): IConfigurationService { + return { + inspect: (key: string) => ({ policyValue: policy[key] as T | undefined }), + } as unknown as IConfigurationService; + } + + test('maps the policy value of every otel key', () => { + const cfg = fakeConfig({ + 'chat.agentHost.otel.enabled': true, + 'chat.agentHost.otel.exporterType': 'otlp-http', + 'chat.agentHost.otel.otlpProtocol': 'http/protobuf', + 'chat.agentHost.otel.otlpEndpoint': 'http://localhost:4318', + 'chat.agentHost.otel.captureContent': false, + 'chat.agentHost.otel.outfile': '/tmp/o.jsonl', + 'chat.agentHost.otel.serviceName': 'my-service', + 'chat.agentHost.otel.resourceAttributes': { 'service.namespace': 'acme' }, + }); + assert.deepStrictEqual(readAgentHostOTelPolicySettings(cfg), { + enabled: true, + exporterType: 'otlp-http', + otlpProtocol: 'http/protobuf', + otlpEndpoint: 'http://localhost:4318', + captureContent: false, + outfile: '/tmp/o.jsonl', + serviceName: 'my-service', + resourceAttributes: { 'service.namespace': 'acme' }, + }); + }); + + test('absent policy yields an all-undefined snapshot', () => { + assert.deepStrictEqual(readAgentHostOTelPolicySettings(fakeConfig({})), { + enabled: undefined, + exporterType: undefined, + otlpProtocol: undefined, + otlpEndpoint: undefined, + captureContent: undefined, + outfile: undefined, + serviceName: undefined, + resourceAttributes: undefined, + }); + }); +}); + +suite('sanitizeAgentHostOTelPolicySettings', () => { + + ensureNoDisposablesAreLeakedInTestSuite(); + + test('keeps well-typed fields and drops unknown/mistyped ones', () => { + assert.deepStrictEqual( + sanitizeAgentHostOTelPolicySettings({ + enabled: true, + exporterType: 'otlp-http', + otlpProtocol: 'http/protobuf', + otlpEndpoint: 'http://localhost:4318', + captureContent: false, + outfile: '/tmp/o.jsonl', + serviceName: 'my-service', + resourceAttributes: { 'service.namespace': 'acme', dropped: 7 }, + bogus: 123, + }), + { + enabled: true, + exporterType: 'otlp-http', + otlpProtocol: 'http/protobuf', + otlpEndpoint: 'http://localhost:4318', + captureContent: false, + outfile: '/tmp/o.jsonl', + serviceName: 'my-service', + resourceAttributes: { 'service.namespace': 'acme' }, + }, + ); + }); + + test('mistyped fields are dropped to undefined', () => { + assert.deepStrictEqual( + sanitizeAgentHostOTelPolicySettings({ enabled: 'yes', otlpEndpoint: 42, captureContent: 1 }), + { enabled: undefined, exporterType: undefined, otlpProtocol: undefined, otlpEndpoint: undefined, captureContent: undefined, outfile: undefined, serviceName: undefined, resourceAttributes: undefined }, + ); + }); + + test('non-object input yields an empty policy', () => { + assert.deepStrictEqual(sanitizeAgentHostOTelPolicySettings(null), {}); + assert.deepStrictEqual(sanitizeAgentHostOTelPolicySettings('x'), {}); + }); + + test('resourceAttributes drop prototype-pollution keys', () => { + // JSON.parse yields an OWN enumerable `__proto__` data property; the sanitizer must not + // copy it onto the result (which would trigger the prototype setter). + const raw = JSON.parse('{"resourceAttributes":{"__proto__":"polluted","constructor":"x","service.namespace":"acme"}}'); + const result = sanitizeAgentHostOTelPolicySettings(raw); + assert.deepStrictEqual(result.resourceAttributes, { 'service.namespace': 'acme' }); + assert.strictEqual(({} as Record).polluted, undefined); + }); +}); diff --git a/src/vs/platform/policy/common/copilotManagedSettings.ts b/src/vs/platform/policy/common/copilotManagedSettings.ts index a63c6c10d1639..a0b91f1c5c7d6 100644 --- a/src/vs/platform/policy/common/copilotManagedSettings.ts +++ b/src/vs/platform/policy/common/copilotManagedSettings.ts @@ -38,6 +38,40 @@ export const COPILOT_STRICT_MARKETPLACES_KEY = 'strictKnownMarketplaces'; /** Managed-settings key for the default chat model (carried as a plain string: `auto`, a model family name, or a full model id). */ export const COPILOT_MODEL_KEY = 'model'; +/** + * Enterprise OTel managed-settings keys. These are the scalar leaves of the canonical + * `telemetry` block from the cross-client managed-settings schema (see the CLI + * `ManagedTelemetrySettings`); they flatten to dot-path bag keys via + * {@link normalizeManagedSettings}, so no {@link STRUCTURED_MANAGED_SETTINGS} entry is needed. + * The `telemetry.resourceAttributes` and `telemetry.headers` map fields are structured + * ({@link STRUCTURED_MANAGED_SETTINGS} rows carry them as JSON-encoded objects under their nested + * keys); `telemetry.serviceName` is a scalar. + */ + +/** Managed-settings key for enterprise OTel enablement. */ +export const COPILOT_OTEL_ENABLED_KEY = 'telemetry.enabled'; + +/** Managed-settings key for the enterprise OTLP collector endpoint. */ +export const COPILOT_OTEL_ENDPOINT_KEY = 'telemetry.endpoint'; + +/** Managed-settings key for the enterprise OTLP protocol (`http/json`, `http/protobuf`, or `grpc`). */ +export const COPILOT_OTEL_PROTOCOL_KEY = 'telemetry.protocol'; + +/** Managed-settings key for enterprise OTel content capture. */ +export const COPILOT_OTEL_CAPTURE_CONTENT_KEY = 'telemetry.captureContent'; + +/** Managed-settings key that prevents users from enabling OTel content capture themselves. */ +export const COPILOT_OTEL_LOCK_CAPTURE_CONTENT_KEY = 'telemetry.lockCaptureContent'; + +/** Managed-settings key for the OTel `service.name` resource attribute. */ +export const COPILOT_OTEL_SERVICE_NAME_KEY = 'telemetry.serviceName'; + +/** Managed-settings key for additional OTel resource attributes (a `{ [k]: string }` map). */ +export const COPILOT_OTEL_RESOURCE_ATTRIBUTES_KEY = 'telemetry.resourceAttributes'; + +/** Managed-settings key for extra OTLP exporter headers (a `{ [k]: string }` map). */ +export const COPILOT_OTEL_HEADERS_KEY = 'telemetry.headers'; + const managedSettingValueCallbacks = new Map ManagedSettingValue | undefined>(); /** @@ -244,6 +278,29 @@ interface IStructuredManagedSetting { readonly encode: (value: unknown, onWarn?: (msg: string) => void) => unknown; } +/** + * Encode a managed-settings value into a canonical `{ [k]: string }` map: keeps string values + * as-is and coerces number/boolean values to strings; drops keys with non-primitive values. + * Returns `undefined` for a non-object input so the structured key is omitted. + */ +function encodeStringMap(value: unknown): Record | undefined { + if (!isObject(value)) { + return undefined; + } + const out: Record = {}; + for (const [k, v] of Object.entries(value)) { + if (k === '__proto__' || k === 'constructor' || k === 'prototype') { + continue; // defend the shared normalizer against prototype pollution + } + if (isString(v)) { + out[k] = v; + } else if (typeof v === 'number' || typeof v === 'boolean') { + out[k] = String(v); + } + } + return out; +} + const STRUCTURED_MANAGED_SETTINGS: readonly IStructuredManagedSetting[] = [ { key: COPILOT_ENABLED_PLUGINS_KEY, @@ -257,8 +314,56 @@ const STRUCTURED_MANAGED_SETTINGS: readonly IStructuredManagedSetting[] = [ key: COPILOT_EXTRA_MARKETPLACES_KEY, encode: (value, onWarn) => extraKnownMarketplacesToConfigDict(normalizeExtraKnownMarketplaces(value, onWarn)), }, + { + // Nested under `telemetry`; carried as a JSON-encoded `{ [k]: string }` map. Non-string + // primitive values are coerced to strings; non-primitive values are dropped. + key: COPILOT_OTEL_RESOURCE_ATTRIBUTES_KEY, + encode: encodeStringMap, + }, + { + // Nested under `telemetry`; carried as a JSON-encoded `{ [k]: string }` map of OTLP headers. + key: COPILOT_OTEL_HEADERS_KEY, + encode: encodeStringMap, + }, ]; +/** + * Read a (possibly nested) dot-separated key from a parsed managed-settings object, e.g. + * `telemetry.resourceAttributes`. Returns `undefined` if any path segment is missing or not an + * object. Single-segment keys behave like a plain property read. + */ +function readNestedManagedKey(obj: Record, dottedKey: string): unknown { + let current: unknown = obj; + for (const segment of dottedKey.split('.')) { + if (!isObject(current)) { + return undefined; + } + current = (current as Record)[segment]; + } + return current; +} + +/** + * Return a copy of `obj` with the (possibly nested) dot-separated key removed, cloning only the + * objects along the touched path so the original (and any shared sub-objects) stay untouched. The + * spread-then-`delete` shape matches a destructuring rest: it copies own enumerable keys (including + * an own `__proto__`) without triggering the inherited `__proto__` setter. + */ +function withNestedManagedKeyDeleted(obj: Record, dottedKey: string): Record { + const dot = dottedKey.indexOf('.'); + if (dot === -1) { + const clone = { ...obj }; + delete clone[dottedKey]; + return clone; + } + const head = dottedKey.slice(0, dot); + const child = obj[head]; + if (!isObject(child)) { + return obj; + } + return { ...obj, [head]: withNestedManagedKeyDeleted(child as Record, dottedKey.slice(dot + 1)) }; +} + /** * Normalize a parsed managed-settings object (from the server `managed_settings` API, a file on * disk, or any other source using the managed-settings schema) into the canonical @@ -282,16 +387,17 @@ const STRUCTURED_MANAGED_SETTINGS: readonly IStructuredManagedSetting[] = [ export function normalizeManagedSettings(parsed: Record, onWarn?: (msg: string) => void): ManagedSettingsData { // Spread + delete (not for..in + assignment) so the scalar remainder keeps exact `{ ...rest }` // semantics: it never triggers the inherited `__proto__` setter for a source-sent own - // `__proto__` key, matching a destructuring rest. - const scalarRest: Record = { ...parsed }; + // `__proto__` key, matching a destructuring rest. Structured keys may be nested (e.g. + // `telemetry.resourceAttributes`), so removal clones only the touched path. + let scalarRest: Record = { ...parsed }; for (const setting of STRUCTURED_MANAGED_SETTINGS) { - delete scalarRest[setting.key]; + scalarRest = withNestedManagedKeyDeleted(scalarRest, setting.key); } const result: Record = { ...flattenManagedSettings(scalarRest) }; for (const setting of STRUCTURED_MANAGED_SETTINGS) { - const encoded = setting.encode(parsed[setting.key], onWarn); + const encoded = setting.encode(readNestedManagedKey(parsed, setting.key), onWarn); if (encoded !== undefined) { result[setting.key] = JSON.stringify(encoded); } diff --git a/src/vs/workbench/services/accounts/browser/managedSettings.ts b/src/vs/workbench/services/accounts/browser/managedSettings.ts index babea8116cdc5..5a9d276e0c69d 100644 --- a/src/vs/workbench/services/accounts/browser/managedSettings.ts +++ b/src/vs/workbench/services/accounts/browser/managedSettings.ts @@ -28,6 +28,16 @@ export interface IManagedSettingsResponse { | { readonly source: 'git'; readonly url: string; readonly ref?: string }; }>; readonly strictKnownMarketplaces?: readonly unknown[]; + readonly telemetry?: { + readonly enabled?: boolean; + readonly endpoint?: string; + readonly protocol?: 'grpc' | 'http/protobuf' | 'http/json'; + readonly captureContent?: boolean; + readonly lockCaptureContent?: boolean; + readonly serviceName?: string; + readonly resourceAttributes?: Record; + readonly headers?: Record; + }; /** Any unknown keys in the response are accepted for forward compatibility. */ readonly [key: string]: unknown; } diff --git a/src/vs/workbench/services/accounts/test/browser/managedSettings.test.ts b/src/vs/workbench/services/accounts/test/browser/managedSettings.test.ts index e3f70c93498ec..771e945684e09 100644 --- a/src/vs/workbench/services/accounts/test/browser/managedSettings.test.ts +++ b/src/vs/workbench/services/accounts/test/browser/managedSettings.test.ts @@ -57,6 +57,24 @@ suite('adaptManagedSettings', () => { }); }); + test('flattens scalar telemetry leaves and carries resourceAttributes and headers as single JSON keys', () => { + assert.deepStrictEqual(adaptManagedSettings({ + telemetry: { + enabled: true, + serviceName: 'acme-copilot', + resourceAttributes: { 'deployment.environment': 'prod', 'service.namespace': 'acme' }, + headers: { 'x-api-key': 'secret' }, + }, + }), { + managedSettings: { + 'telemetry.enabled': true, + 'telemetry.serviceName': 'acme-copilot', + 'telemetry.resourceAttributes': '{"deployment.environment":"prod","service.namespace":"acme"}', + 'telemetry.headers': '{"x-api-key":"secret"}', + }, + }); + }); + test('encodes github marketplaces as a { name: shorthand } JSON dict', () => { assert.deepStrictEqual(adaptManagedSettings({ extraKnownMarketplaces: { @@ -189,4 +207,15 @@ suite('adaptManagedSettings', () => { managedSettings: {}, }); }); + + test('resilience: telemetry map keys that could pollute the prototype are dropped', () => { + // JSON.parse yields an OWN enumerable `__proto__` data property on the nested map. + const response = JSON.parse('{"telemetry":{"resourceAttributes":{"__proto__":"polluted","constructor":"x","service.namespace":"acme"}}}') as IManagedSettingsResponse; + assert.deepStrictEqual(adaptManagedSettings(response), { + managedSettings: { + 'telemetry.resourceAttributes': '{"service.namespace":"acme"}', + }, + }); + assert.strictEqual(({} as Record).polluted, undefined); + }); });