diff --git a/extensions/copilot/src/lib/node/chatLibMain.ts b/extensions/copilot/src/lib/node/chatLibMain.ts index d845f450a1f2eb..9d9b830c810004 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 487ac242dffe0d..d70c0a8b5da094 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 * as zlib from 'zlib'; import { promisify } from 'util'; import { IInstantiationService } from '../../../util/vs/platform/instantiation/common/instantiation'; @@ -27,6 +28,20 @@ import { MicrosoftTelemetrySender } from './microsoftTelemetrySender'; const gzip = promisify(zlib.gzip); setTelemetryPropertyCompressor(async value => (await gzip(Buffer.from(value, 'utf8'))).toString('base64')); +/** + * 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( @@ -129,4 +144,17 @@ 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 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 e4d5e7e5b6c5d8..ee1daa3a59f1ca 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,43 @@ export class MainThreadTelemetry extends Disposable implements MainThreadTelemet } } +/** + * 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'; +/** + * 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; + } + + // 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(SET_CAPI_ASSIGNMENT_CONTEXT_COMMAND, function (accessor, value: string) { + if (typeof value !== 'string' || !isValidCapiAssignmentContext(value)) { + return; + } + + 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 00000000000000..36f45492c62413 --- /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 + }); +});