From 0eda5d545402629d2962f2a64dc49ddad1cf26fd Mon Sep 17 00:00:00 2001 From: Julie Stevenson Date: Thu, 23 Jul 2026 17:28:13 -0400 Subject: [PATCH 1/3] Forward CAPI assignment context to core telemetry via internal command Register a private '_telemetry.setExperimentProperty' command in the workbench that calls ITelemetryService.setExperimentProperty(). The Copilot extension calls this command when it receives CAPI flight assignments, so they appear on all core telemetry events from the current window (same scope as TAS abexp.assignmentcontext). Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- extensions/copilot/src/lib/node/chatLibMain.ts | 8 ++++++++ .../telemetry/vscode-node/telemetryServiceImpl.ts | 12 ++++++++++++ src/vs/workbench/api/browser/mainThreadTelemetry.ts | 5 +++++ 3 files changed, 25 insertions(+) diff --git a/extensions/copilot/src/lib/node/chatLibMain.ts b/extensions/copilot/src/lib/node/chatLibMain.ts index d845f450a1f2e..9d9b830c81000 100644 --- a/extensions/copilot/src/lib/node/chatLibMain.ts +++ b/extensions/copilot/src/lib/node/chatLibMain.ts @@ -175,6 +175,7 @@ export interface ILogTarget { export interface ITelemetrySender { sendTelemetryEvent(eventName: string, properties?: Record, measurements?: Record): void; sendEnhancedTelemetryEvent?(eventName: string, properties?: Record, measurements?: Record): void; + setExperimentProperty?(name: string, value: string): void; } export interface INESProviderOptions { @@ -679,6 +680,9 @@ class SimpleTelemetryService implements ITelemetryService { return; } setSharedProperty(name: string, value: string): void { + if (name === 'capi.assignmentcontext') { + this._telemetrySender.setExperimentProperty?.(name, value); + } return; } setAdditionalExpAssignments(expAssignments: string[]): void { @@ -879,6 +883,10 @@ class UnwrappingTelemetrySender implements ITelemetrySender { } } + setExperimentProperty(name: string, value: string): void { + this.sender.setExperimentProperty?.(name, value); + } + private normalizeEventName(eventName: string): string { const unwrapped = unwrapEventNameFromPrefix(eventName); const withoutPrefix = unwrapped.match(/^[^/]+\/(.*)/); diff --git a/extensions/copilot/src/platform/telemetry/vscode-node/telemetryServiceImpl.ts b/extensions/copilot/src/platform/telemetry/vscode-node/telemetryServiceImpl.ts index 8a957c8832630..6168e59e2bc58 100644 --- a/extensions/copilot/src/platform/telemetry/vscode-node/telemetryServiceImpl.ts +++ b/extensions/copilot/src/platform/telemetry/vscode-node/telemetryServiceImpl.ts @@ -4,6 +4,7 @@ *--------------------------------------------------------------------------------------------*/ import { CustomFetcher } from '@vscode/extension-telemetry'; +import * as vscode from 'vscode'; import { IInstantiationService } from '../../../util/vs/platform/instantiation/common/instantiation'; import { ICopilotTokenStore } from '../../authentication/common/copilotTokenStore'; import { ConfigKey, IConfigurationService } from '../../configuration/common/configurationService'; @@ -120,4 +121,15 @@ export class TelemetryService extends BaseTelemetryService { }); } } + + // __GDPR__COMMON__ "capi.assignmentcontext" : { "classification": "SystemMetaData", "purpose": "FeatureInsight" } + override setSharedProperty(name: string, value: string): void { + super.setSharedProperty(name, value); + + // Forward CAPI assignment context to the core telemetry pipeline + // so it appears on all telemetry events from the current window. + if (name === 'capi.assignmentcontext') { + vscode.commands.executeCommand('_telemetry.setExperimentProperty', name, value); + } + } } diff --git a/src/vs/workbench/api/browser/mainThreadTelemetry.ts b/src/vs/workbench/api/browser/mainThreadTelemetry.ts index e4d5e7e5b6c5d..14675a09f64c6 100644 --- a/src/vs/workbench/api/browser/mainThreadTelemetry.ts +++ b/src/vs/workbench/api/browser/mainThreadTelemetry.ts @@ -5,6 +5,7 @@ import { Disposable } from '../../../base/common/lifecycle.js'; import { IConfigurationService } from '../../../platform/configuration/common/configuration.js'; +import { CommandsRegistry } from '../../../platform/commands/common/commands.js'; import { IEnvironmentService } from '../../../platform/environment/common/environment.js'; import { IProductService } from '../../../platform/product/common/productService.js'; import { ClassifiedEvent, IGDPRProperty, OmitMetadata, StrictPropertyCheck } from '../../../platform/telemetry/common/gdprTypings.js'; @@ -59,4 +60,8 @@ export class MainThreadTelemetry extends Disposable implements MainThreadTelemet } } +CommandsRegistry.registerCommand('_telemetry.setExperimentProperty', function (accessor, name: string, value: string) { + accessor.get(ITelemetryService).setExperimentProperty(name, value); +}); + From bdfa0cf12e560e0b8fa0dbe0f1a4e7f336b15ee7 Mon Sep 17 00:00:00 2001 From: Julie Stevenson Date: Fri, 24 Jul 2026 17:09:49 -0400 Subject: [PATCH 2/3] Harden CAPI telemetry command surface - Replace generic _telemetry.setExperimentProperty bridge with _telemetry.setCapiAssignmentContext - Hardcode capi.assignmentcontext as the only settable property - Validate assignment-context format and size before trusting input - Update Copilot forwarding call to use the narrowed command Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .../vscode-node/telemetryServiceImpl.ts | 2 +- .../api/browser/mainThreadTelemetry.ts | 21 ++++++++++++++++--- 2 files changed, 19 insertions(+), 4 deletions(-) diff --git a/extensions/copilot/src/platform/telemetry/vscode-node/telemetryServiceImpl.ts b/extensions/copilot/src/platform/telemetry/vscode-node/telemetryServiceImpl.ts index 6168e59e2bc58..e17bcf2f08ef7 100644 --- a/extensions/copilot/src/platform/telemetry/vscode-node/telemetryServiceImpl.ts +++ b/extensions/copilot/src/platform/telemetry/vscode-node/telemetryServiceImpl.ts @@ -129,7 +129,7 @@ export class TelemetryService extends BaseTelemetryService { // Forward CAPI assignment context to the core telemetry pipeline // so it appears on all telemetry events from the current window. if (name === 'capi.assignmentcontext') { - vscode.commands.executeCommand('_telemetry.setExperimentProperty', name, value); + vscode.commands.executeCommand('_telemetry.setCapiAssignmentContext', value); } } } diff --git a/src/vs/workbench/api/browser/mainThreadTelemetry.ts b/src/vs/workbench/api/browser/mainThreadTelemetry.ts index 14675a09f64c6..4e410116e4a54 100644 --- a/src/vs/workbench/api/browser/mainThreadTelemetry.ts +++ b/src/vs/workbench/api/browser/mainThreadTelemetry.ts @@ -60,8 +60,23 @@ export class MainThreadTelemetry extends Disposable implements MainThreadTelemet } } -CommandsRegistry.registerCommand('_telemetry.setExperimentProperty', function (accessor, name: string, value: string) { - accessor.get(ITelemetryService).setExperimentProperty(name, value); -}); +const capiAssignmentContextPropertyName = 'capi.assignmentcontext'; +const maxCapiAssignmentContextLength = 8 * 1024; +const capiAssignmentContextEntryPattern = /^[^:;\s\x00-\x1F\x7F]+:[^;\x00-\x1F\x7F]+$/; + +function isValidCapiAssignmentContext(value: string): boolean { + if (value.length === 0 || value.length > maxCapiAssignmentContextLength) { + return false; + } + const entries = value.split(';'); + return entries.length > 0 && entries.every(entry => capiAssignmentContextEntryPattern.test(entry)); +} +CommandsRegistry.registerCommand('_telemetry.setCapiAssignmentContext', function (accessor, value: string) { + if (!isValidCapiAssignmentContext(value)) { + return; + } + + accessor.get(ITelemetryService).setExperimentProperty(capiAssignmentContextPropertyName, value); +}); From 179a7044b1dc72852b95e43e21c020f41cdcc634 Mon Sep 17 00:00:00 2001 From: Vijay Upadya <41652029+vijayupadya@users.noreply.github.com> Date: Mon, 27 Jul 2026 15:16:25 -0700 Subject: [PATCH 3/3] telemetry bridge for clarity and testability --- .../vscode-node/telemetryServiceImpl.ts | 24 +++++++++-- .../api/browser/mainThreadTelemetry.ts | 40 ++++++++++++++----- .../test/browser/mainThreadTelemetry.test.ts | 29 ++++++++++++++ 3 files changed, 79 insertions(+), 14 deletions(-) create mode 100644 src/vs/workbench/api/test/browser/mainThreadTelemetry.test.ts diff --git a/extensions/copilot/src/platform/telemetry/vscode-node/telemetryServiceImpl.ts b/extensions/copilot/src/platform/telemetry/vscode-node/telemetryServiceImpl.ts index e17bcf2f08ef7..7d4edffea73aa 100644 --- a/extensions/copilot/src/platform/telemetry/vscode-node/telemetryServiceImpl.ts +++ b/extensions/copilot/src/platform/telemetry/vscode-node/telemetryServiceImpl.ts @@ -19,6 +19,20 @@ import { ITelemetryUserConfig, TelemetryTrustedValue } from '../common/telemetry import { GitHubTelemetrySender } from './githubTelemetrySender'; import { MicrosoftTelemetrySender } from './microsoftTelemetrySender'; +/** + * The shared telemetry property carrying the CAPI flight assignment context. + * Kept in sync with `CAPI_ASSIGNMENT_CONTEXT_PROPERTY` in the VS Code core + * (`src/vs/workbench/api/browser/mainThreadTelemetry.ts`). + */ +const CAPI_ASSIGNMENT_CONTEXT_PROPERTY = 'capi.assignmentcontext'; + +/** + * Private core command that forwards the CAPI assignment context onto core + * telemetry events. Kept in sync with `SET_CAPI_ASSIGNMENT_CONTEXT_COMMAND` in + * `src/vs/workbench/api/browser/mainThreadTelemetry.ts`. + */ +const SET_CAPI_ASSIGNMENT_CONTEXT_COMMAND = '_telemetry.setCapiAssignmentContext'; + export class TelemetryService extends BaseTelemetryService { declare readonly _serviceBrand: undefined; constructor( @@ -126,10 +140,12 @@ export class TelemetryService extends BaseTelemetryService { override setSharedProperty(name: string, value: string): void { super.setSharedProperty(name, value); - // Forward CAPI assignment context to the core telemetry pipeline - // so it appears on all telemetry events from the current window. - if (name === 'capi.assignmentcontext') { - vscode.commands.executeCommand('_telemetry.setCapiAssignmentContext', value); + // Forward the CAPI assignment context to the core telemetry pipeline so it + // appears on all telemetry events from the current window (same scope as + // `abexp.assignmentcontext`). The core side (`mainThreadTelemetry.ts`) + // validates the value before trusting it onto telemetry events. + if (name === CAPI_ASSIGNMENT_CONTEXT_PROPERTY) { + vscode.commands.executeCommand(SET_CAPI_ASSIGNMENT_CONTEXT_COMMAND, value); } } } diff --git a/src/vs/workbench/api/browser/mainThreadTelemetry.ts b/src/vs/workbench/api/browser/mainThreadTelemetry.ts index 4e410116e4a54..ee1daa3a59f1c 100644 --- a/src/vs/workbench/api/browser/mainThreadTelemetry.ts +++ b/src/vs/workbench/api/browser/mainThreadTelemetry.ts @@ -60,23 +60,43 @@ export class MainThreadTelemetry extends Disposable implements MainThreadTelemet } } -const capiAssignmentContextPropertyName = 'capi.assignmentcontext'; -const maxCapiAssignmentContextLength = 8 * 1024; -const capiAssignmentContextEntryPattern = /^[^:;\s\x00-\x1F\x7F]+:[^;\x00-\x1F\x7F]+$/; +/** + * The core telemetry property under which the Copilot CAPI flight assignment + * context is surfaced. It mirrors the scope of `abexp.assignmentcontext`. + */ +export const CAPI_ASSIGNMENT_CONTEXT_PROPERTY = 'capi.assignmentcontext'; -function isValidCapiAssignmentContext(value: string): boolean { - if (value.length === 0 || value.length > maxCapiAssignmentContextLength) { +/** + * The private command Copilot invokes to forward its CAPI flight assignments + * into core telemetry. Not part of the public API. + */ +export const SET_CAPI_ASSIGNMENT_CONTEXT_COMMAND = '_telemetry.setCapiAssignmentContext'; + +const MAX_CAPI_ASSIGNMENT_CONTEXT_LENGTH = 8 * 1024; +const CAPI_ASSIGNMENT_CONTEXT_ENTRY_PATTERN = /^[^:;\s\x00-\x1F\x7F]+:[^;\x00-\x1F\x7F]+$/; + +/** + * Validates a CAPI assignment-context string before it is trusted onto every + * core telemetry event. Because {@link ITelemetryService.setExperimentProperty} + * wraps the value in a `TelemetryTrustedValue` (bypassing PII cleaning), the + * value must be strictly shaped: a non-empty, size-capped list of `key:value` + * entries separated by `;`, with no whitespace or control characters. Any + * malformed input is rejected outright. + */ +export function isValidCapiAssignmentContext(value: string): boolean { + if (value.length === 0 || value.length > MAX_CAPI_ASSIGNMENT_CONTEXT_LENGTH) { return false; } - const entries = value.split(';'); - return entries.length > 0 && entries.every(entry => capiAssignmentContextEntryPattern.test(entry)); + // Tolerate a single trailing separator (`a:b;`) but nothing else empty. + const entries = value.endsWith(';') ? value.slice(0, -1).split(';') : value.split(';'); + return entries.length > 0 && entries.every(entry => CAPI_ASSIGNMENT_CONTEXT_ENTRY_PATTERN.test(entry)); } -CommandsRegistry.registerCommand('_telemetry.setCapiAssignmentContext', function (accessor, value: string) { - if (!isValidCapiAssignmentContext(value)) { +CommandsRegistry.registerCommand(SET_CAPI_ASSIGNMENT_CONTEXT_COMMAND, function (accessor, value: string) { + if (typeof value !== 'string' || !isValidCapiAssignmentContext(value)) { return; } - accessor.get(ITelemetryService).setExperimentProperty(capiAssignmentContextPropertyName, value); + accessor.get(ITelemetryService).setExperimentProperty(CAPI_ASSIGNMENT_CONTEXT_PROPERTY, value); }); diff --git a/src/vs/workbench/api/test/browser/mainThreadTelemetry.test.ts b/src/vs/workbench/api/test/browser/mainThreadTelemetry.test.ts new file mode 100644 index 0000000000000..36f45492c6241 --- /dev/null +++ b/src/vs/workbench/api/test/browser/mainThreadTelemetry.test.ts @@ -0,0 +1,29 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import assert from 'assert'; +import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../base/test/common/utils.js'; +import { isValidCapiAssignmentContext } from '../../browser/mainThreadTelemetry.js'; + +suite('MainThreadTelemetry - CAPI assignment context', () => { + + ensureNoDisposablesAreLeakedInTestSuite(); + + test('accepts well-formed assignment contexts', () => { + assert.strictEqual(isValidCapiAssignmentContext('e4hcf520:1109203'), true); + assert.strictEqual(isValidCapiAssignmentContext('e4hcf520:1109203;61623843:1255491'), true); + // a single trailing separator is tolerated (CAPI emits `a:b;c:d;`) + assert.strictEqual(isValidCapiAssignmentContext('e4hcf520:1109203;61623843:1255491;'), true); + }); + + test('rejects malformed, empty or oversized input', () => { + assert.strictEqual(isValidCapiAssignmentContext(''), false); + assert.strictEqual(isValidCapiAssignmentContext('noColonHere'), false); + assert.strictEqual(isValidCapiAssignmentContext('a:b;;c:d'), false); // empty inner entry + assert.strictEqual(isValidCapiAssignmentContext('a b:c'), false); // whitespace + assert.strictEqual(isValidCapiAssignmentContext('a:b\nc:d'), false); // control character + assert.strictEqual(isValidCapiAssignmentContext(`a:${'b'.repeat(9 * 1024)}`), false); // over 8KB + }); +});