From 7d6473ca6915f055437cdb60af0b01a2f4aaa49f Mon Sep 17 00:00:00 2001 From: Vijay Upadya <41652029+vijayupadya@users.noreply.github.com> Date: Mon, 27 Jul 2026 12:41:29 -0700 Subject: [PATCH] Compress large telemetry properties with gzip chunked columns (#327490) * Compress large telemetry properties with gzip chunked columns * feedback updates * Fix continuous telemetry sender test for async best-effort send --- .../node/continuousEnhancedTelemetrySender.ts | 20 ++-- .../node/nextEditProviderTelemetry.ts | 50 +++++----- .../continuousEnhancedTelemetrySender.spec.ts | 5 +- .../extension/prompt/node/chatMLFetcher.ts | 8 +- .../prompt/node/repoInfoTelemetry.ts | 4 +- .../prompts/node/codeMapper/codeMapper.ts | 2 +- .../tools/node/abstractReplaceStringTool.tsx | 4 +- .../extension/tools/node/applyPatchTool.tsx | 4 +- .../common/multiFileEditQualityTelemetry.ts | 5 +- .../platform/networking/node/chatStream.ts | 8 +- .../platform/telemetry/common/telemetry.ts | 96 +++++++++++++++---- .../telemetry/test/node/telemetry.spec.ts | 93 +++++++++++++++++- .../vscode-node/telemetryServiceImpl.ts | 11 ++- .../node/agentHostGitHubTelemetryRouter.ts | 4 +- .../node/agentHostRepoInfoTelemetry.ts | 6 +- .../node/agentHostRestrictedTelemetry.ts | 69 ++++++++----- .../node/agentHostTelemetryReporter.ts | 26 ++--- .../agentHost/node/copilot/copilotAgent.ts | 8 +- .../node/copilot/copilotAgentSession.ts | 10 +- .../agentHostGitHubTelemetryRouter.test.ts | 45 +++++---- .../node/agentHostRepoInfoTelemetry.test.ts | 14 +-- .../node/agentHostTelemetryReporter.test.ts | 38 ++++---- 22 files changed, 362 insertions(+), 168 deletions(-) diff --git a/extensions/copilot/src/extension/inlineEdits/node/continuousEnhancedTelemetrySender.ts b/extensions/copilot/src/extension/inlineEdits/node/continuousEnhancedTelemetrySender.ts index 8bdadf36475..2ee7a737994 100644 --- a/extensions/copilot/src/extension/inlineEdits/node/continuousEnhancedTelemetrySender.ts +++ b/extensions/copilot/src/extension/inlineEdits/node/continuousEnhancedTelemetrySender.ts @@ -216,15 +216,15 @@ export class ContinuousEnhancedTelemetrySender extends Disposable { const repositoryUrls = this._collectWorkspaceRepositories(); - this._telemetryService.sendEnhancedGHTelemetryEvent(NES_GH_TELEMETRY_EVENT_NAME, - multiplexProperties({ - continuous: 'true', - recording: JSON.stringify(recording), - // `activeDocumentRepository` is intentionally omitted: a continuous slice spans many - // documents across `WINDOW_MS`, so there's no single "active" doc that meaningfully - // applies. The full workspace repo set is reported via `repositories` instead. - repositories: repositoryUrls === undefined ? undefined : JSON.stringify(repositoryUrls), - }), + void multiplexProperties({ + continuous: 'true', + recording: JSON.stringify(recording), + // `activeDocumentRepository` is intentionally omitted: a continuous slice spans many + // documents across `WINDOW_MS`, so there's no single "active" doc that meaningfully + // applies. The full workspace repo set is reported via `repositories` instead. + repositories: repositoryUrls === undefined ? undefined : JSON.stringify(repositoryUrls), + }).then(properties => this._telemetryService.sendEnhancedGHTelemetryEvent(NES_GH_TELEMETRY_EVENT_NAME, + properties, { continuousWindowDurationMs: ContinuousEnhancedTelemetrySender.WINDOW_MS, continuousOverlapMs: ContinuousEnhancedTelemetrySender.OVERLAP_MS, @@ -232,7 +232,7 @@ export class ContinuousEnhancedTelemetrySender extends Disposable { continuousEntriesSize: entriesSize, continuousSequenceNumber: sequenceNumber, } - ); + )).catch(() => { /* best-effort telemetry */ }); } private _collectWorkspaceRepositories(): string[] | undefined { diff --git a/extensions/copilot/src/extension/inlineEdits/node/nextEditProviderTelemetry.ts b/extensions/copilot/src/extension/inlineEdits/node/nextEditProviderTelemetry.ts index 1842808820c..676c73a2bf3 100644 --- a/extensions/copilot/src/extension/inlineEdits/node/nextEditProviderTelemetry.ts +++ b/extensions/copilot/src/extension/inlineEdits/node/nextEditProviderTelemetry.ts @@ -1371,34 +1371,34 @@ export class TelemetrySender implements IDisposable { // (model responded with nothing) apart from other reasons `modelResponse` is empty. const fetchResult: ChatFetchResponseType | undefined = modelResponse?.fetchResult; - this._telemetryService.sendEnhancedGHTelemetryEvent(NES_GH_TELEMETRY_EVENT_NAME, - multiplexProperties({ - opportunityId, - headerRequestId, - providerId, - activeDocumentLanguageId, - suggestionStatus, - modelName, - prompt, - modelResponse: modelResponse === undefined || modelResponse.response.type !== ChatFetchResponseType.Success ? undefined : modelResponse.response.value, - fetchResult, - alternativeAction: alternativeAction ? JSON.stringify({ ...alternativeAction, enhancedTelemetrySendingReason: sendingReason }) : undefined, - enhancedTelemetrySendingReason: !alternativeAction && sendingReason ? JSON.stringify(sendingReason) : undefined, - postProcessingOutcome, - activeDocumentRepository, - repositories: JSON.stringify(repositoryUrls), - cursorJumpModelName, - cursorJumpPrompt, - cursorJumpResponse, - lintErrors, - terminalOutput, - similarFilesContext: resolvedSimilarFilesContext, - modelConfig, - }), + void multiplexProperties({ + opportunityId, + headerRequestId, + providerId, + activeDocumentLanguageId, + suggestionStatus, + modelName, + prompt, + modelResponse: modelResponse === undefined || modelResponse.response.type !== ChatFetchResponseType.Success ? undefined : modelResponse.response.value, + fetchResult, + alternativeAction: alternativeAction ? JSON.stringify({ ...alternativeAction, enhancedTelemetrySendingReason: sendingReason }) : undefined, + enhancedTelemetrySendingReason: !alternativeAction && sendingReason ? JSON.stringify(sendingReason) : undefined, + postProcessingOutcome, + activeDocumentRepository, + repositories: JSON.stringify(repositoryUrls), + cursorJumpModelName, + cursorJumpPrompt, + cursorJumpResponse, + lintErrors, + terminalOutput, + similarFilesContext: resolvedSimilarFilesContext, + modelConfig, + }).then(properties => this._telemetryService.sendEnhancedGHTelemetryEvent(NES_GH_TELEMETRY_EVENT_NAME, + properties, { isFromCache: this._boolToNum(isFromCache), } - ); + )).catch(() => { /* best-effort telemetry */ }); } /** diff --git a/extensions/copilot/src/extension/inlineEdits/test/node/continuousEnhancedTelemetrySender.spec.ts b/extensions/copilot/src/extension/inlineEdits/test/node/continuousEnhancedTelemetrySender.spec.ts index cd2bbaf2f59..986bcf67095 100644 --- a/extensions/copilot/src/extension/inlineEdits/test/node/continuousEnhancedTelemetrySender.spec.ts +++ b/extensions/copilot/src/extension/inlineEdits/test/node/continuousEnhancedTelemetrySender.spec.ts @@ -211,8 +211,9 @@ describe('ContinuousEnhancedTelemetrySender', () => { addDocAndEdit('a'); - // First tick — _sendNow throws, but reschedule() must still fire. - await expect(vi.advanceTimersByTimeAsync(INTERVAL_MS + IDLE_MS)).rejects.toThrow('boom'); + // First tick — the telemetry send throws, but the error is swallowed as best-effort + // telemetry and reschedule() still fires, so the loop keeps running. + await vi.advanceTimersByTimeAsync(INTERVAL_MS + IDLE_MS); expect(calls).toBe(1); expect(throwingTelemetry.enhancedEvents).toHaveLength(0); diff --git a/extensions/copilot/src/extension/prompt/node/chatMLFetcher.ts b/extensions/copilot/src/extension/prompt/node/chatMLFetcher.ts index 170d23ef45c..fe61da79064 100644 --- a/extensions/copilot/src/extension/prompt/node/chatMLFetcher.ts +++ b/extensions/copilot/src/extension/prompt/node/chatMLFetcher.ts @@ -1223,11 +1223,11 @@ export class ChatMLFetcherImpl extends AbstractChatMLFetcher { this._telemetryService.sendGHTelemetryEvent('request.sent', telemetryData.properties, telemetryData.measurements); if (request.tools) { - this._telemetryService.sendEnhancedGHTelemetryEvent('request.options.tools', multiplexProperties({ + void multiplexProperties({ headerRequestId: ourRequestId, conversationId, messagesJson: stringifyToolsRawForTelemetry(request.tools)!, - }), telemetryData.measurements); + }).then(properties => this._telemetryService.sendEnhancedGHTelemetryEvent('request.options.tools', properties, telemetryData.measurements)).catch(() => { /* best-effort telemetry */ }); } const requestStart = Date.now(); @@ -1507,11 +1507,11 @@ export class ChatMLFetcherImpl extends AbstractChatMLFetcher { this._telemetryService.sendGHTelemetryEvent('request.sent', telemetryData.properties, telemetryData.measurements); if (request.tools) { - this._telemetryService.sendEnhancedGHTelemetryEvent('request.options.tools', multiplexProperties({ + void multiplexProperties({ headerRequestId: ourRequestId, conversationId: telemetryProperties?.conversationId, messagesJson: stringifyToolsRawForTelemetry(request.tools)!, - }), telemetryData.measurements); + }).then(properties => this._telemetryService.sendEnhancedGHTelemetryEvent('request.options.tools', properties, telemetryData.measurements)).catch(() => { /* best-effort telemetry */ }); } const requestStart = Date.now(); diff --git a/extensions/copilot/src/extension/prompt/node/repoInfoTelemetry.ts b/extensions/copilot/src/extension/prompt/node/repoInfoTelemetry.ts index 825c640cce2..c34f9950a0c 100644 --- a/extensions/copilot/src/extension/prompt/node/repoInfoTelemetry.ts +++ b/extensions/copilot/src/extension/prompt/node/repoInfoTelemetry.ts @@ -242,9 +242,9 @@ export class RepoInfoTelemetry { if (isInternal) { const { headBranchName: _, fileRelativePaths: _2, ...msftProperties } = internalProperties; - this._telemetryService.sendInternalMSFTTelemetryEvent('request.repoInfo', multiplexProperties(msftProperties), data.measurements); + void multiplexProperties(msftProperties).then(properties => this._telemetryService.sendInternalMSFTTelemetryEvent('request.repoInfo', properties, data.measurements)).catch(() => { /* best-effort telemetry */ }); } - this._telemetryService.sendEnhancedGHTelemetryEvent('request.repoInfo', multiplexProperties(internalProperties), data.measurements); + void multiplexProperties(internalProperties).then(properties => this._telemetryService.sendEnhancedGHTelemetryEvent('request.repoInfo', properties, data.measurements)).catch(() => { /* best-effort telemetry */ }); } return results; diff --git a/extensions/copilot/src/extension/prompts/node/codeMapper/codeMapper.ts b/extensions/copilot/src/extension/prompts/node/codeMapper/codeMapper.ts index 24cc725416d..2ea69e36fff 100644 --- a/extensions/copilot/src/extension/prompts/node/codeMapper/codeMapper.ts +++ b/extensions/copilot/src/extension/prompts/node/codeMapper/codeMapper.ts @@ -635,7 +635,7 @@ export class CodeMapper { messageText: useGPT4oProxy ? JSON.stringify(builtPrompt.messages) : builtPrompt.prompt, completionTextJson: result.allResponseText.join(''), }; - this.telemetryService.sendEnhancedGHTelemetryEvent('fastApply/successfulEdit', multiplexProperties(payload)); + void multiplexProperties(payload).then(properties => this.telemetryService.sendEnhancedGHTelemetryEvent('fastApply/successfulEdit', properties)).catch(() => { /* best-effort telemetry */ }); this.telemetryService.sendInternalMSFTTelemetryEvent('fastApply/successfulEdit', payload); } diff --git a/extensions/copilot/src/extension/tools/node/abstractReplaceStringTool.tsx b/extensions/copilot/src/extension/tools/node/abstractReplaceStringTool.tsx index 055dd581b48..f43fae58cd9 100644 --- a/extensions/copilot/src/extension/tools/node/abstractReplaceStringTool.tsx +++ b/extensions/copilot/src/extension/tools/node/abstractReplaceStringTool.tsx @@ -519,13 +519,13 @@ export abstract class AbstractReplaceStringTool this.telemetryService.sendEnhancedGHTelemetryEvent('replaceStringTool', properties, { isNotebook })).catch(() => { /* best-effort telemetry */ }); } private async sendHealingTelemetry(options: vscode.LanguageModelToolInvocationOptions | vscode.LanguageModelToolInvocationPrepareOptions, healError: string | undefined, applicationError: string | undefined) { diff --git a/extensions/copilot/src/extension/tools/node/applyPatchTool.tsx b/extensions/copilot/src/extension/tools/node/applyPatchTool.tsx index b127f184e40..393fdfbd2c5 100644 --- a/extensions/copilot/src/extension/tools/node/applyPatchTool.tsx +++ b/extensions/copilot/src/extension/tools/node/applyPatchTool.tsx @@ -656,14 +656,14 @@ export class ApplyPatchTool implements ICopilotTool { }, ); - this.telemetryService.sendEnhancedGHTelemetryEvent('applyPatchTool', multiplexProperties({ + void multiplexProperties({ headerRequestId: options.chatRequestId, baseModel: model, messageText: file, completionTextJson: options.input.input, postProcessingOutcome: outcome, healed: String(healed), - })); + }).then(properties => this.telemetryService.sendEnhancedGHTelemetryEvent('applyPatchTool', properties)).catch(() => { /* best-effort telemetry */ }); } async resolveInput(input: IApplyPatchToolParams, promptContext: IBuildPromptContext): Promise { diff --git a/extensions/copilot/src/platform/multiFileEdit/common/multiFileEditQualityTelemetry.ts b/extensions/copilot/src/platform/multiFileEdit/common/multiFileEditQualityTelemetry.ts index 87e2788bfc2..298dd0882f0 100644 --- a/extensions/copilot/src/platform/multiFileEdit/common/multiFileEditQualityTelemetry.ts +++ b/extensions/copilot/src/platform/multiFileEdit/common/multiFileEditQualityTelemetry.ts @@ -168,7 +168,7 @@ export class MultiFileEditInternalTelemetryService extends Disposable implements ); const workspace = resolveWorkspaceOTelMetadata(this.gitService, uri); - const gitHubEnhancedTelemetryProperties = multiplexProperties({ + void multiplexProperties({ headerRequestId: edit.speculationRequestId, providerId: edit.mapper, languageId: languageId, @@ -181,8 +181,7 @@ export class MultiFileEditInternalTelemetryService extends Disposable implements headCommitHash: workspace.headCommitHash, remoteUrl: workspace.remoteUrl, fileRelativePath: workspace.fileRelativePath, - }); - this.telemetryService.sendEnhancedGHTelemetryEvent('fastApply/editOutcome', gitHubEnhancedTelemetryProperties); + }).then(gitHubEnhancedTelemetryProperties => this.telemetryService.sendEnhancedGHTelemetryEvent('fastApply/editOutcome', gitHubEnhancedTelemetryProperties)).catch(() => { /* best-effort telemetry */ }); this.logService.debug(`Sent telemetry for ${uri.toString()} with request ID ${edit.chatRequestId}, SD request ID ${edit.speculationRequestId}, and outcome ${outcome}`); } catch (e) { this.logService.error('Error sending multi-file edit telemetry', JSON.stringify(e)); diff --git a/extensions/copilot/src/platform/networking/node/chatStream.ts b/extensions/copilot/src/platform/networking/node/chatStream.ts index fd99ab39d36..060d53c795f 100644 --- a/extensions/copilot/src/platform/networking/node/chatStream.ts +++ b/extensions/copilot/src/platform/networking/node/chatStream.ts @@ -96,8 +96,10 @@ export function sendEngineMessagesLengthTelemetry(telemetryService: ITelemetrySe modelCallId: modelCallId, // Include at telemetry event level too }, telemetryData.measurements); - telemetryService.sendEnhancedGHTelemetryEvent('engine.messages.length', multiplexProperties(telemetryDataWithPrompt.properties), telemetryDataWithPrompt.measurements); - telemetryService.sendInternalMSFTTelemetryEvent('engine.messages.length', multiplexProperties(telemetryDataWithPrompt.properties), telemetryDataWithPrompt.measurements); + void multiplexProperties(telemetryDataWithPrompt.properties).then(properties => { + telemetryService.sendEnhancedGHTelemetryEvent('engine.messages.length', properties, telemetryDataWithPrompt.measurements); + telemetryService.sendInternalMSFTTelemetryEvent('engine.messages.length', properties, telemetryDataWithPrompt.measurements); + }).catch(() => { /* best-effort telemetry */ }); } // LRU cache from message hash to UUID to ensure same content gets same UUID (limit: 1000 entries) @@ -454,7 +456,7 @@ export function sendEngineMessagesTelemetry(telemetryService: ITelemetryService, messagesJson: JSON.stringify(messages), }); - telemetryService.sendEnhancedGHTelemetryEvent('engine.messages', multiplexProperties(telemetryDataWithPrompt.properties), telemetryDataWithPrompt.measurements); + void multiplexProperties(telemetryDataWithPrompt.properties).then(properties => telemetryService.sendEnhancedGHTelemetryEvent('engine.messages', properties, telemetryDataWithPrompt.measurements)).catch(() => { /* best-effort telemetry */ }); // Commenting this out to test a new deduplicated way to collect the same information using sendModelTelemetryEvents() // TO DO remove this line completely if the new way allows for complete reconstruction of entire message arrays with much lower drop rate //telemetryService.sendInternalMSFTTelemetryEvent('engine.messages', multiplexProperties(telemetryDataWithPrompt.properties), telemetryDataWithPrompt.measurements); diff --git a/extensions/copilot/src/platform/telemetry/common/telemetry.ts b/extensions/copilot/src/platform/telemetry/common/telemetry.ts index 2225d300ecb..07e43a833ff 100644 --- a/extensions/copilot/src/platform/telemetry/common/telemetry.ts +++ b/extensions/copilot/src/platform/telemetry/common/telemetry.ts @@ -210,30 +210,88 @@ export class TelemetryTrustedValue { const MAX_PROPERTY_LENGTH = 8192; const MAX_CONCATENATED_PROPERTIES = 50; // 50 properties of 8192 characters each is 409600 characters. -export function multiplexProperties(properties: { [key: string]: string | undefined }): { [key: string]: string | undefined } { +// Suffix appended to the base property name for the compressed (gzip + base64) chunk family. +const COMPRESSED_CHUNK_SUFFIX = 'Chunk'; + +// Fields that are always emitted as a compressed chunk family (when a compressor is available), +// regardless of their length. These are known to frequently exceed the per-property limit, so +// always producing the `Chunk` family gives the backend a single, uniform place to read the +// value from instead of having to branch on whether the value happened to be chunked. +const ALWAYS_COMPRESSED_CHUNK_KEYS = new Set(['messagesJson', 'diffsJSON']); + +// Compressor used by multiplexProperties to gzip + base64 encode oversized property values. It is +// registered once by the Node layer (via setTelemetryPropertyCompressor) because Node's `zlib` is +// unavailable in the common layer; until then multiplexProperties falls back to plain chunking. It +// is async so the gzip work runs off the main thread (libuv threadpool) and never blocks the host. +let defaultCompressor: ((value: string) => Promise) | undefined; + +/** + * Registers the process-wide compressor used by {@link multiplexProperties}. Called once from the + * Node layer with a function that resolves to the base64-encoded gzip of its input. + */ +export function setTelemetryPropertyCompressor(compress: (value: string) => Promise): void { + defaultCompressor = compress; +} + +/** + * Ensures every string property survives the Application Insights per-property truncation at + * {@link MAX_PROPERTY_LENGTH}. Values that already fit pass through untouched. + * + * When a value is too long it is chunked. If a compressor is available (the normal case, registered + * via {@link setTelemetryPropertyCompressor}), the value is chunked in compressed form only: the + * full value is gzip + base64 compressed and emitted as `Chunk`, `Chunk_2`, + * `Chunk_3`, ... (first column has no numeric suffix, the rest are NOT zero-padded, each + * capped at {@link MAX_PROPERTY_LENGTH}), and the original `` column simply carries the first + * uncompressed chunk of the value. No redundant plain continuation family (`_02`, ...) is + * produced in this case. + * + * Fields in {@link ALWAYS_COMPRESSED_CHUNK_KEYS} always get the compressed chunk family (when a + * compressor is available) even if they fit within {@link MAX_PROPERTY_LENGTH}, so the backend can + * always read them from the `Chunk` family without branching on size. + * + * If no compressor is available, the value falls back to the plain continuation family (``, + * `_02`, `_03`, ...). `compress` can be passed explicitly to override the registered + * compressor (used by tests). + */ +export async function multiplexProperties( + properties: { [key: string]: string | undefined }, + compress: ((value: string) => Promise) | undefined = defaultCompressor +): Promise<{ [key: string]: string | undefined }> { const newProperties = { ...properties }; for (const key in properties) { const value = properties[key]; - // Test the length of value - let remainingValueCharactersLength = value?.length ?? 0; - if (remainingValueCharactersLength > MAX_PROPERTY_LENGTH) { - let lastStartIndex = 0; - let newPropertiesCount = 0; - while (remainingValueCharactersLength > 0 && newPropertiesCount < MAX_CONCATENATED_PROPERTIES) { - newPropertiesCount += 1; - let propertyName = key; - if (newPropertiesCount > 1) { - propertyName = key + '_' + (newPropertiesCount < 10 ? '0' : '') + newPropertiesCount; - } - let offsetIndex = lastStartIndex + MAX_PROPERTY_LENGTH; - if (remainingValueCharactersLength < MAX_PROPERTY_LENGTH) { - offsetIndex = lastStartIndex + remainingValueCharactersLength; - } - newProperties[propertyName] = value!.slice(lastStartIndex, offsetIndex); - remainingValueCharactersLength -= MAX_PROPERTY_LENGTH; - lastStartIndex += MAX_PROPERTY_LENGTH; + const valueLength = value?.length ?? 0; + // Known-large fields are always emitted as a compressed chunk family (when a compressor is + // available) so the backend can read them uniformly, even when they happen to be short. + const forceCompress = !!compress && value !== undefined && ALWAYS_COMPRESSED_CHUNK_KEYS.has(key); + if (valueLength <= MAX_PROPERTY_LENGTH && !forceCompress) { + continue; + } + if (compress) { + // Compressed chunking: keep the original column as just the first uncompressed chunk and + // emit the full value gzip + base64 compressed as Chunk, Chunk_2, ... (no zero + // padding). No redundant plain continuation family is produced. + newProperties[key] = value!.slice(0, MAX_PROPERTY_LENGTH); + const compressed = await compress(value!); + for (let offset = 0, index = 1; offset < compressed.length && index <= MAX_CONCATENATED_PROPERTIES; offset += MAX_PROPERTY_LENGTH, index++) { + const columnName = index === 1 ? `${key}${COMPRESSED_CHUNK_SUFFIX}` : `${key}${COMPRESSED_CHUNK_SUFFIX}_${index}`; + newProperties[columnName] = compressed.slice(offset, offset + MAX_PROPERTY_LENGTH); } + continue; + } + // No compressor available: fall back to the plain continuation family , _02, ... + let remaining = valueLength; + let start = 0; + let count = 0; + while (remaining > 0 && count < MAX_CONCATENATED_PROPERTIES) { + count += 1; + const columnName = count > 1 ? key + '_' + (count < 10 ? '0' : '') + count : key; + const end = remaining < MAX_PROPERTY_LENGTH ? start + remaining : start + MAX_PROPERTY_LENGTH; + newProperties[columnName] = value!.slice(start, end); + remaining -= MAX_PROPERTY_LENGTH; + start += MAX_PROPERTY_LENGTH; } } + return newProperties; } diff --git a/extensions/copilot/src/platform/telemetry/test/node/telemetry.spec.ts b/extensions/copilot/src/platform/telemetry/test/node/telemetry.spec.ts index 21ee3eee166..1c3edbda270 100644 --- a/extensions/copilot/src/platform/telemetry/test/node/telemetry.spec.ts +++ b/extensions/copilot/src/platform/telemetry/test/node/telemetry.spec.ts @@ -5,6 +5,7 @@ import { afterEach, beforeEach, expect, Mock, suite, test, vi } from 'vitest'; import type { TelemetryLogger } from 'vscode'; +import * as zlib from 'zlib'; import { CopilotToken, createTestExtendedTokenInfo } from '../../../authentication/common/copilotToken'; import { ICopilotTokenStore } from '../../../authentication/common/copilotTokenStore'; import { IConfigurationService } from '../../../configuration/common/configurationService'; @@ -13,7 +14,32 @@ import { IEnvService } from '../../../env/common/envService'; import { createPlatformServices, ITestingServicesAccessor } from '../../../test/node/services'; import { BaseGHTelemetrySender } from '../../common/ghTelemetrySender'; import { BaseMsftTelemetrySender, ITelemetryReporter } from '../../common/msftTelemetrySender'; -import { ITelemetryUserConfig, TelemetryTrustedValue } from '../../common/telemetry'; +import { ITelemetryUserConfig, multiplexProperties, TelemetryTrustedValue } from '../../common/telemetry'; + +const gzipBase64 = async (value: string): Promise => zlib.gzipSync(Buffer.from(value, 'utf8')).toString('base64'); +const gunzipFromBase64 = (value: string): string => zlib.gunzipSync(Buffer.from(value, 'base64')).toString('utf8'); + +function joinCompressedChunks(chunks: { [key: string]: string }, base: string): string { + let out = chunks[base] ?? ''; + for (let index = 2; chunks[`${base}_${index}`] !== undefined; index++) { + out += chunks[`${base}_${index}`]; + } + return out; +} + +function pseudoRandomString(length: number): string { + let seed = 0x2545f491; + const chars = 'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789'; + let out = ''; + for (let i = 0; i < length; i++) { + seed ^= seed << 13; + seed ^= seed >>> 17; + seed ^= seed << 5; + seed >>>= 0; + out += chars[seed % chars.length]; + } + return out; +} suite('Microsoft Telemetry Sender', function () { let mockExternalReporter: ITelemetryReporter; @@ -327,3 +353,68 @@ suite('GitHub Telemetry Sender', function () { expect(mockEnhancedLogger.dispose).toHaveBeenCalledOnce(); }); }); + +suite('multiplexProperties compression', function () { + test('chunks a long value in compressed form only and round-trips', async () => { + const original = 'x'.repeat(20000); // > 8192 and highly compressible. + const result = await multiplexProperties({ diffsJSON: original, short: 'hi' }, gzipBase64); + + // The original column carries just the first uncompressed chunk. + expect(result.diffsJSON).toBe(original.slice(0, 8192)); + // No redundant plain continuation family is produced. + expect(result.diffsJSON_02).toBeUndefined(); + // First compressed column has no numeric suffix. + expect(result.diffsJSONChunk).toBeDefined(); + // Round-trips back to the original value. + expect(gunzipFromBase64(joinCompressedChunks(result as { [key: string]: string }, 'diffsJSONChunk'))).toBe(original); + // No zero-padded suffixes on the compressed family. + expect(Object.keys(result).every(key => !/Chunk_0\d$/.test(key))).toBe(true); + // Short (non-chunked) properties pass through untouched with no compressed family. + expect(result.short).toBe('hi'); + expect(result.shortChunk).toBeUndefined(); + }); + + test('produces no compressed columns for size-triggered fields that were not chunked', async () => { + const result = await multiplexProperties({ someField: 'small', other: 'x' }, gzipBase64); + expect(result).toEqual({ someField: 'small', other: 'x' }); + }); + + test('always emits a compressed chunk family for known-large fields even when they fit', async () => { + const result = await multiplexProperties({ diffsJSON: 'small', messagesJson: 'tiny', other: 'x' }, gzipBase64) as { [key: string]: string }; + + // Known-large fields are always chunked in compressed form for backend uniformity. + expect(result.diffsJSONChunk).toBeDefined(); + expect(result.messagesJsonChunk).toBeDefined(); + expect(gunzipFromBase64(joinCompressedChunks(result, 'diffsJSONChunk'))).toBe('small'); + expect(gunzipFromBase64(joinCompressedChunks(result, 'messagesJsonChunk'))).toBe('tiny'); + // The original columns still carry the (short) uncompressed value. + expect(result.diffsJSON).toBe('small'); + expect(result.messagesJson).toBe('tiny'); + // Other short fields are left untouched. + expect(result.other).toBe('x'); + expect(result.otherChunk).toBeUndefined(); + }); + + test('falls back to the plain continuation family when no compressor is provided', async () => { + const original = 'x'.repeat(20000); + const result = await multiplexProperties({ diffsJSON: original }); + expect(result.diffsJSON).toBeDefined(); + expect(result.diffsJSON_02).toBeDefined(); + expect(result.diffsJSONChunk).toBeUndefined(); + }); + + test('splits large compressed payloads across multiple non-zero-padded columns', async () => { + const original = pseudoRandomString(60000); // Poorly compressible -> compressed base64 > 8192. + const result = await multiplexProperties({ diffsJSON: original }, gzipBase64) as { [key: string]: string }; + + // The original column carries just the first uncompressed chunk; no plain continuation family. + expect(result.diffsJSON).toBe(original.slice(0, 8192)); + expect(result.diffsJSON_02).toBeUndefined(); + expect(result.diffsJSONChunk).toBeDefined(); + expect(result.diffsJSONChunk_2).toBeDefined(); + // Every compressed column stays within the Application Insights per-property limit. + const chunkValues = Object.keys(result).filter(key => key.startsWith('diffsJSONChunk')).map(key => result[key]); + expect(chunkValues.every(value => value.length <= 8192)).toBe(true); + expect(gunzipFromBase64(joinCompressedChunks(result, 'diffsJSONChunk'))).toBe(original); + }); +}); diff --git a/extensions/copilot/src/platform/telemetry/vscode-node/telemetryServiceImpl.ts b/extensions/copilot/src/platform/telemetry/vscode-node/telemetryServiceImpl.ts index 8a957c88326..487ac242dff 100644 --- a/extensions/copilot/src/platform/telemetry/vscode-node/telemetryServiceImpl.ts +++ b/extensions/copilot/src/platform/telemetry/vscode-node/telemetryServiceImpl.ts @@ -4,6 +4,8 @@ *--------------------------------------------------------------------------------------------*/ import { CustomFetcher } from '@vscode/extension-telemetry'; +import * as zlib from 'zlib'; +import { promisify } from 'util'; import { IInstantiationService } from '../../../util/vs/platform/instantiation/common/instantiation'; import { ICopilotTokenStore } from '../../authentication/common/copilotTokenStore'; import { ConfigKey, IConfigurationService } from '../../configuration/common/configurationService'; @@ -14,10 +16,17 @@ import { IFetcherService, NO_FETCH_TELEMETRY } from '../../networking/common/fet import { FetcherService } from '../../networking/vscode-node/fetcherServiceImpl'; import { BaseTelemetryService } from '../common/baseTelemetryService'; import { IExperimentationService } from '../common/nullExperimentationService'; -import { ITelemetryUserConfig, TelemetryTrustedValue } from '../common/telemetry'; +import { ITelemetryUserConfig, setTelemetryPropertyCompressor, TelemetryTrustedValue } from '../common/telemetry'; import { GitHubTelemetrySender } from './githubTelemetrySender'; import { MicrosoftTelemetrySender } from './microsoftTelemetrySender'; +// Register the Node-only property compressor with the common-layer multiplexProperties helper once, +// so oversized telemetry properties are chunked in gzip + base64 form without every call site +// having to thread the compressor through. gzip runs on the libuv threadpool so it never blocks the +// host process event loop. +const gzip = promisify(zlib.gzip); +setTelemetryPropertyCompressor(async value => (await gzip(Buffer.from(value, 'utf8'))).toString('base64')); + export class TelemetryService extends BaseTelemetryService { declare readonly _serviceBrand: undefined; constructor( diff --git a/src/vs/platform/agentHost/node/agentHostGitHubTelemetryRouter.ts b/src/vs/platform/agentHost/node/agentHostGitHubTelemetryRouter.ts index 42d9144bcb2..ab773491897 100644 --- a/src/vs/platform/agentHost/node/agentHostGitHubTelemetryRouter.ts +++ b/src/vs/platform/agentHost/node/agentHostGitHubTelemetryRouter.ts @@ -29,7 +29,7 @@ export class AgentHostGitHubTelemetryRouter { return targetDestinations.has(notification.event.kind); } - route(notification: GitHubTelemetryNotification, context?: IAgentHostRestrictedTelemetryContext, additionalProperties?: TelemetryProps): boolean { + async route(notification: GitHubTelemetryNotification, context?: IAgentHostRestrictedTelemetryContext, additionalProperties?: TelemetryProps): Promise { const { event } = notification; const eventName = event.kind; const destinations = targetDestinations.get(eventName); @@ -48,7 +48,7 @@ export class AgentHostGitHubTelemetryRouter { ...(event.model_call_id && event.properties.modelCallId === undefined ? { modelCallId: event.model_call_id } : {}), ...additionalProperties, }; - const multiplexedProperties = multiplexProperties(properties); + const multiplexedProperties = await multiplexProperties(properties); if ((destinations & TelemetryDestination.EnhancedGH) && context.restrictedTelemetryEnabled) { this._telemetryService.sendEnhancedGHTelemetryEventForContext(context, eventName, multiplexedProperties, event.metrics); } diff --git a/src/vs/platform/agentHost/node/agentHostRepoInfoTelemetry.ts b/src/vs/platform/agentHost/node/agentHostRepoInfoTelemetry.ts index 876ad5f034a..5ddd66b2754 100644 --- a/src/vs/platform/agentHost/node/agentHostRepoInfoTelemetry.ts +++ b/src/vs/platform/agentHost/node/agentHostRepoInfoTelemetry.ts @@ -314,7 +314,7 @@ export class AgentHostRepoInfoTelemetry extends Disposable { if (this._isDisposed || !isContextCurrent()) { return result; } - this._reporter.reportRepoInfo(telemetryContext, { + void this._reporter.reportRepoInfo(telemetryContext, { telemetryMessageId, location, remoteUrl: repoInfo.remoteUrl, @@ -329,7 +329,7 @@ export class AgentHostRepoInfoTelemetry extends Disposable { workspaceFileCount, changedFileCount, diffSizeBytes, - }); + }).catch(err => this._logService.trace(`[AgentHostRepoInfoTelemetry] Failed to report repo info: ${err instanceof Error ? err.message : String(err)}`)); return result; } } @@ -339,4 +339,4 @@ function truncateRepoInfoDiff(diff: string, uri: string): string { return diff; } return `${diff.substring(0, MAX_DIFF_SIZE)}\n... Diff truncated (exceeded ${MAX_DIFF_SIZE} characters) for ${uri}`; -} \ No newline at end of file +} diff --git a/src/vs/platform/agentHost/node/agentHostRestrictedTelemetry.ts b/src/vs/platform/agentHost/node/agentHostRestrictedTelemetry.ts index 2516047b758..0fb5382e7ac 100644 --- a/src/vs/platform/agentHost/node/agentHostRestrictedTelemetry.ts +++ b/src/vs/platform/agentHost/node/agentHostRestrictedTelemetry.ts @@ -3,6 +3,8 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ +import * as zlib from 'zlib'; +import { promisify } from 'util'; import { generateUuid } from '../../../base/common/uuid.js'; import { ILogService } from '../../log/common/log.js'; import { ICommonProperties } from '../../telemetry/common/telemetry.js'; @@ -54,36 +56,57 @@ export interface IAgentHostInternalTelemetrySink { export type FetchFn = typeof globalThis.fetch; /** - * App Insights caps a single property value at ~8192 chars. Long values are split across - * numbered keys (`key`, `key_02`, `key_03`, …) so the Copilot Telemetry Service reassembles - * them, mirroring the Copilot extension's `multiplexProperties` so events look identical on the - * wire and downstream. + * App Insights caps a single property value at ~8192 chars. Long values are chunked so the Copilot + * Telemetry Service can reassemble them, mirroring the Copilot extension's `multiplexProperties` so + * events look identical on the wire and downstream. + * + * When a value is too long it is gzip + base64 compressed and emitted as `Chunk`, + * `Chunk_2`, `Chunk_3`, … (first column has no numeric suffix, the rest are NOT + * zero-padded, each capped at {@link MAX_PROPERTY_LENGTH}), while the original `` column + * carries just the first uncompressed chunk of the value. + * + * Fields in {@link ALWAYS_COMPRESSED_CHUNK_KEYS} always get the compressed chunk family even when + * they fit within {@link MAX_PROPERTY_LENGTH}, so the backend can always read them from the + * `Chunk` family without branching on size. */ const MAX_PROPERTY_LENGTH = 8192; const MAX_CONCATENATED_PROPERTIES = 50; -export function multiplexProperties(properties: TelemetryProps): TelemetryProps { +// Suffix appended to the base property name for the compressed (gzip + base64) chunk family. +const COMPRESSED_CHUNK_SUFFIX = 'Chunk'; + +// Fields that are always emitted as a compressed chunk family, regardless of their length. These +// are known to frequently exceed the per-property limit, so always producing the `Chunk` +// family gives the backend a single, uniform place to read the value from. +const ALWAYS_COMPRESSED_CHUNK_KEYS = new Set(['messagesJson', 'diffsJSON']); + +const gzip = promisify(zlib.gzip); + +// Compress off the main thread (libuv threadpool) so large telemetry values never block the agent +// host event loop. +async function compressTelemetryValue(value: string): Promise { + const compressed = await gzip(Buffer.from(value, 'utf8')); + return compressed.toString('base64'); +} + +export async function multiplexProperties(properties: TelemetryProps): Promise { const newProperties: TelemetryProps = { ...properties }; for (const key in properties) { const value = properties[key]; - let remaining = value?.length ?? 0; - if (remaining > MAX_PROPERTY_LENGTH) { - let lastStartIndex = 0; - let count = 0; - while (remaining > 0 && count < MAX_CONCATENATED_PROPERTIES) { - count += 1; - let propertyName = key; - if (count > 1) { - propertyName = key + '_' + (count < 10 ? '0' : '') + count; - } - let offsetIndex = lastStartIndex + MAX_PROPERTY_LENGTH; - if (remaining < MAX_PROPERTY_LENGTH) { - offsetIndex = lastStartIndex + remaining; - } - newProperties[propertyName] = value!.slice(lastStartIndex, offsetIndex); - remaining -= MAX_PROPERTY_LENGTH; - lastStartIndex += MAX_PROPERTY_LENGTH; - } + const valueLength = value?.length ?? 0; + // Known-large fields are always emitted as a compressed chunk family so the backend can read + // them uniformly, even when they happen to be short. + const forceCompress = value !== undefined && ALWAYS_COMPRESSED_CHUNK_KEYS.has(key); + if (valueLength <= MAX_PROPERTY_LENGTH && !forceCompress) { + continue; + } + // Compressed chunking: keep the original column as just the first uncompressed chunk and emit + // the full value gzip + base64 compressed as Chunk, Chunk_2, … (no zero padding). + newProperties[key] = value!.slice(0, MAX_PROPERTY_LENGTH); + const compressed = await compressTelemetryValue(value!); + for (let offset = 0, index = 1; offset < compressed.length && index <= MAX_CONCATENATED_PROPERTIES; offset += MAX_PROPERTY_LENGTH, index++) { + const columnName = index === 1 ? `${key}${COMPRESSED_CHUNK_SUFFIX}` : `${key}${COMPRESSED_CHUNK_SUFFIX}_${index}`; + newProperties[columnName] = compressed.slice(offset, offset + MAX_PROPERTY_LENGTH); } } return newProperties; diff --git a/src/vs/platform/agentHost/node/agentHostTelemetryReporter.ts b/src/vs/platform/agentHost/node/agentHostTelemetryReporter.ts index e217380d1c5..bd770448d24 100644 --- a/src/vs/platform/agentHost/node/agentHostTelemetryReporter.ts +++ b/src/vs/platform/agentHost/node/agentHostTelemetryReporter.ts @@ -296,12 +296,12 @@ export class AgentHostTelemetryReporter { * @param clientRequestId The model call's client-minted `x-request-id`, mapped to the extension's `headerRequestId`. No-ops when absent (e.g. providers that don't surface it). * @param tools The tool definitions offered to the model for this call. */ - assistantMessageReceived(session: string, clientType: AgentHostClientType, clientRequestId: string | undefined, tools: readonly ToolDefinition[]): void { + async assistantMessageReceived(session: string, clientType: AgentHostClientType, clientRequestId: string | undefined, tools: readonly ToolDefinition[]): Promise { const restricted = this._restricted; if (!restricted || !clientRequestId || tools.length === 0) { return; } - restricted.sendEnhancedGHTelemetryEvent('request.options.tools', multiplexProperties({ + restricted.sendEnhancedGHTelemetryEvent('request.options.tools', await multiplexProperties({ headerRequestId: clientRequestId, conversationId: AgentSession.id(session), initiatorClientType: clientType, @@ -322,12 +322,12 @@ export class AgentHostTelemetryReporter { * @param content The user's prompt text. No-ops when empty. * @param turnIndex The 0-based ordinal of the turn this message belongs to, matching the extension's numeric `turnIndex` (`conversation.turns.length`). CTS parses `turn_index` as an integer, so a numeric ordinal is required here (a non-numeric id lands empty). */ - userMessageText(session: string, clientType: AgentHostClientType, content: string, turnIndex: number): void { + async userMessageText(session: string, clientType: AgentHostClientType, content: string, turnIndex: number): Promise { const restricted = this._restricted; if (!restricted || !content) { return; } - const properties = multiplexProperties({ + const properties = await multiplexProperties({ source: 'user', conversationId: AgentSession.id(session), initiatorClientType: clientType, @@ -351,12 +351,12 @@ export class AgentHostTelemetryReporter { * @param turnIndex The 0-based ordinal of the turn this message belongs to, matching the extension's numeric `turnIndex` (`conversation.turns.length`). CTS parses `turn_index` as an integer, so a numeric ordinal is required here. * @param serviceRequestId The model call's `x-copilot-service-request-id`, mapped to `headerRequestId`. */ - modelMessageText(session: string, clientType: AgentHostClientType, content: string, turnIndex: number, serviceRequestId: string | undefined): void { + async modelMessageText(session: string, clientType: AgentHostClientType, content: string, turnIndex: number, serviceRequestId: string | undefined): Promise { const restricted = this._restricted; if (!restricted || !content) { return; } - const properties = multiplexProperties({ + const properties = await multiplexProperties({ source: 'model', conversationId: AgentSession.id(session), initiatorClientType: clientType, @@ -382,13 +382,13 @@ export class AgentHostTelemetryReporter { * * @param report The per-turn tool-call aggregate. */ - toolCallDetails(report: IAgentHostToolCallDetailsReport): void { + async toolCallDetails(report: IAgentHostToolCallDetailsReport): Promise { const restricted = this._restricted; if (!restricted || report.availableTools.length === 0) { return; } const session = isAhpChatChannel(report.session) ? parseRequiredSessionUriFromChatUri(report.session) : report.session; - const properties = multiplexProperties({ + const properties = await multiplexProperties({ conversationId: AgentSession.id(session), requestId: report.turnId, messageId: report.turnId, @@ -450,7 +450,7 @@ export class AgentHostTelemetryReporter { restricted.sendInternalMSFTTelemetryEvent('skillContentRead', plaintextProps); } - reportRepoInfo(context: IAgentHostRestrictedTelemetryContext, report: IAgentHostRepoInfoReport): void { + async reportRepoInfo(context: IAgentHostRestrictedTelemetryContext, report: IAgentHostRepoInfoReport): Promise { const restricted = this._restricted; if (!restricted) { return; @@ -476,8 +476,12 @@ export class AgentHostTelemetryReporter { repoCount: 1, }; const { headBranchName: _, fileRelativePaths: _2, ...internalProperties } = properties; - restricted.sendEnhancedGHTelemetryEventForContext(context, 'request.repoInfo', multiplexProperties(properties), measurements); - restricted.sendInternalMSFTTelemetryEventForContext(context, 'request.repoInfo', multiplexProperties(internalProperties), measurements); + const [enhancedProperties, internalMultiplexedProperties] = await Promise.all([ + multiplexProperties(properties), + multiplexProperties(internalProperties), + ]); + restricted.sendEnhancedGHTelemetryEventForContext(context, 'request.repoInfo', enhancedProperties, measurements); + restricted.sendInternalMSFTTelemetryEventForContext(context, 'request.repoInfo', internalMultiplexedProperties, measurements); } turnCompleted(report: IAgentHostTurnCompletedReport): void { diff --git a/src/vs/platform/agentHost/node/copilot/copilotAgent.ts b/src/vs/platform/agentHost/node/copilot/copilotAgent.ts index 1febb790e77..2f2bb35c345 100644 --- a/src/vs/platform/agentHost/node/copilot/copilotAgent.ts +++ b/src/vs/platform/agentHost/node/copilot/copilotAgent.ts @@ -859,14 +859,14 @@ export class CopilotAgent extends Disposable implements IAgent { return; } if (!notification.restricted) { - router.route(notification, undefined, additionalProperties); + await router.route(notification, undefined, additionalProperties); return; } const sessionId = notification.sessionId; const githubToken = this._githubToken; if (!githubToken) { - router.route(notification, undefined, additionalProperties); + await router.route(notification, undefined, additionalProperties); return; } @@ -875,7 +875,7 @@ export class CopilotAgent extends Disposable implements IAgent { if (this._githubToken !== githubToken) { return; } - router.route(notification, { + await router.route(notification, { restrictedTelemetryEnabled: context.restrictedTelemetryEnabled, trackingId: context.trackingId, telemetryEndpoint: toRestrictedTelemetryEndpoint(context.telemetryEndpoint), @@ -1267,7 +1267,7 @@ export class CopilotAgent extends Disposable implements IAgent { telemetry, logLevel: copilotSdkLogLevelAtStartup, enableRemoteSessions: sessionSyncAtStartup, - onGitHubTelemetry: notification => this._routeGitHubTelemetry(notification), + onGitHubTelemetry: notification => { void this._routeGitHubTelemetry(notification).catch(err => this._logService.trace(`[Copilot] GitHub telemetry routing failed: ${err instanceof Error ? err.message : String(err)}`)); }, }; const client = this._createCopilotClient(clientOptions); await client.start(); diff --git a/src/vs/platform/agentHost/node/copilot/copilotAgentSession.ts b/src/vs/platform/agentHost/node/copilot/copilotAgentSession.ts index fdb7eab9491..f3d81c40c21 100644 --- a/src/vs/platform/agentHost/node/copilot/copilotAgentSession.ts +++ b/src/vs/platform/agentHost/node/copilot/copilotAgentSession.ts @@ -1012,7 +1012,7 @@ export class CopilotAgentSession extends Disposable { // Emit the restricted per-turn tool-call aggregate before the turn is cleared. Main agent // only: `_appliedSnapshot.tools` and this turn's accumulator describe the main session's turn. // No-ops (in the reporter) when the turn made no tool calls. - this._telemetryReporter.toolCallDetails({ + void this._telemetryReporter.toolCallDetails({ session: this.sessionUri.toString(), turnId: turn.id, clientType: turn.clientType, @@ -1024,7 +1024,7 @@ export class CopilotAgentSession extends Disposable { totalToolCalls: turn.totalToolCalls, parallelToolCallRounds: turn.parallelToolCallRounds, parallelToolCallsTotal: turn.parallelToolCallsTotal, - }); + }).catch(err => this._logService.trace(`[Copilot:${this.sessionId}] Telemetry emission failed: ${getErrorMessage(err)}`)); this._emitAction({ type: ActionType.ChatTurnComplete, turnId: turn.id, @@ -3259,9 +3259,9 @@ export class CopilotAgentSession extends Disposable { // describe a subagent's model call, so subagent messages (mapped or dropped) are skipped. if (!e.agentId) { const clientType = this._currentTurn?.clientType ?? AgentHostClientType.Unknown; - this._telemetryReporter.assistantMessageReceived(this.sessionUri.toString(), clientType, e.data.clientRequestId, this._appliedSnapshot.tools); + void this._telemetryReporter.assistantMessageReceived(this.sessionUri.toString(), clientType, e.data.clientRequestId, this._appliedSnapshot.tools).catch(err => this._logService.trace(`[Copilot:${this.sessionId}] Telemetry emission failed: ${getErrorMessage(err)}`)); // Restricted `conversation.messageText` (source=model): the model's raw response text. - this._telemetryReporter.modelMessageText(this.sessionUri.toString(), clientType, e.data.content, this._turnOrdinal, e.data.serviceRequestId); + void this._telemetryReporter.modelMessageText(this.sessionUri.toString(), clientType, e.data.content, this._turnOrdinal, e.data.serviceRequestId).catch(err => this._logService.trace(`[Copilot:${this.sessionId}] Telemetry emission failed: ${getErrorMessage(err)}`)); // Accumulate the per-turn tool-call aggregate for the restricted `toolCallDetails` event. // Every main-agent `assistant.message` is one model-call round (matches the extension's // `numRequests = toolCallRounds.length`, which counts the final tool-free response round @@ -4467,7 +4467,7 @@ export class CopilotAgentSession extends Disposable { // and SDK-injected synthetic messages (skill/harness injections carry a non-`user` source, // matching `isSyntheticUserMessage`) so injected content is not reported as the user's prompt. if (!e.agentId && (!e.data.source || e.data.source.toLowerCase() === 'user')) { - this._telemetryReporter.userMessageText(this.sessionUri.toString(), this._currentTurn?.clientType ?? AgentHostClientType.Unknown, e.data.content, this._turnOrdinal); + void this._telemetryReporter.userMessageText(this.sessionUri.toString(), this._currentTurn?.clientType ?? AgentHostClientType.Unknown, e.data.content, this._turnOrdinal).catch(err => this._logService.trace(`[Copilot:${this.sessionId}] Telemetry emission failed: ${getErrorMessage(err)}`)); } })); diff --git a/src/vs/platform/agentHost/test/node/agentHostGitHubTelemetryRouter.test.ts b/src/vs/platform/agentHost/test/node/agentHostGitHubTelemetryRouter.test.ts index 3ab2ac47677..4ade37199ab 100644 --- a/src/vs/platform/agentHost/test/node/agentHostGitHubTelemetryRouter.test.ts +++ b/src/vs/platform/agentHost/test/node/agentHostGitHubTelemetryRouter.test.ts @@ -5,6 +5,7 @@ import type { GitHubTelemetryNotification } from '@github/copilot-sdk'; import assert from 'assert'; +import * as zlib from 'zlib'; import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../base/test/common/utils.js'; import { AgentHostGitHubTelemetryRouter } from '../../node/agentHostGitHubTelemetryRouter.js'; import type { IAgentHostInternalTelemetryContext, IAgentHostRestrictedTelemetry, IAgentHostRestrictedTelemetryContext, TelemetryMeasurements, TelemetryProps } from '../../node/agentHostRestrictedTelemetry.js'; @@ -63,11 +64,11 @@ function notification(kind: string, restricted = true): GitHubTelemetryNotificat suite('AgentHostGitHubTelemetryRouter', () => { ensureNoDisposablesAreLeakedInTestSuite(); - test('routes the explicit restricted target allowlist to the exact sinks', () => { + test('routes the explicit restricted target allowlist to the exact sinks', async () => { const telemetry = new TestRestrictedTelemetry(); const router = new AgentHostGitHubTelemetryRouter(telemetry); - const handled = [ + const handled = await Promise.all([ 'engine.messages', 'engine.messages.length', 'model.message.added', @@ -75,7 +76,7 @@ suite('AgentHostGitHubTelemetryRouter', () => { 'model.modelCall.output', 'model.request.added', 'model.request.options.added', - ].map(kind => router.route(notification(kind), internalContext)); + ].map(kind => router.route(notification(kind), internalContext))); assert.deepStrictEqual({ handled, @@ -95,13 +96,13 @@ suite('AgentHostGitHubTelemetryRouter', () => { }); }); - test('falls back for unknown events and consumes misclassified target events', () => { + test('falls back for unknown events and consumes misclassified target events', async () => { const telemetry = new TestRestrictedTelemetry(); const router = new AgentHostGitHubTelemetryRouter(telemetry); - const unknownHandled = router.route(notification('unknown', false)); - const misclassifiedTargetHandled = router.route(notification('engine.messages', false)); - const missingContextHandled = router.route(notification('engine.messages')); + const unknownHandled = await router.route(notification('unknown', false)); + const misclassifiedTargetHandled = await router.route(notification('engine.messages', false)); + const missingContextHandled = await router.route(notification('engine.messages')); assert.deepStrictEqual({ unknownHandled, misclassifiedTargetHandled, missingContextHandled, events: telemetry.events }, { unknownHandled: false, @@ -111,14 +112,14 @@ suite('AgentHostGitHubTelemetryRouter', () => { }); }); - test('forwards properties and metrics and maps model_call_id without overwriting modelCallId', () => { + test('forwards properties and metrics and maps model_call_id without overwriting modelCallId', async () => { const telemetry = new TestRestrictedTelemetry(); const router = new AgentHostGitHubTelemetryRouter(telemetry); - router.route(notification('engine.messages'), internalContext, { initiatorClientType: 'agents_window' }); + await router.route(notification('engine.messages'), internalContext, { initiatorClientType: 'agents_window' }); const existingModelCallId = notification('engine.messages'); existingModelCallId.event.properties.modelCallId = 'existing-model-call'; - router.route(existingModelCallId, internalContext); + await router.route(existingModelCallId, internalContext); assert.deepStrictEqual(telemetry.events, [ { @@ -136,24 +137,28 @@ suite('AgentHostGitHubTelemetryRouter', () => { ]); }); - test('multiplexes long properties before routing to either sink', () => { + test('multiplexes long properties before routing to either sink', async () => { const telemetry = new TestRestrictedTelemetry(); const router = new AgentHostGitHubTelemetryRouter(telemetry); const longNotification = notification('engine.messages.length'); - longNotification.event.properties.messagesJson = 'x'.repeat(16_385); + const original = 'x'.repeat(16_385); + longNotification.event.properties.messagesJson = original; - router.route(longNotification, internalContext); + await router.route(longNotification, internalContext); + const gunzip = (chunks: (string | undefined)[]): string => + zlib.gunzipSync(Buffer.from(chunks.join(''), 'base64')).toString('utf8'); assert.deepStrictEqual(telemetry.events.map(event => ({ destination: event.destination, - chunkLengths: [ - event.properties?.messagesJson?.length, - event.properties?.messagesJson_02?.length, - event.properties?.messagesJson_03?.length, - ], + // The original column carries just the first uncompressed chunk. + original: event.properties?.messagesJson, + // No plain continuation family is produced. + plainContinuation: event.properties?.messagesJson_02, + // The full value round-trips from the compressed chunk family. + roundTrip: gunzip([event.properties?.messagesJsonChunk, event.properties?.messagesJsonChunk_2]), })), [ - { destination: 'enhancedGH', chunkLengths: [8192, 8192, 1] }, - { destination: 'internalMSFT', chunkLengths: [8192, 8192, 1] }, + { destination: 'enhancedGH', original: original.slice(0, 8192), plainContinuation: undefined, roundTrip: original }, + { destination: 'internalMSFT', original: original.slice(0, 8192), plainContinuation: undefined, roundTrip: original }, ]); }); diff --git a/src/vs/platform/agentHost/test/node/agentHostRepoInfoTelemetry.test.ts b/src/vs/platform/agentHost/test/node/agentHostRepoInfoTelemetry.test.ts index c94da38f4d0..0696e7bd314 100644 --- a/src/vs/platform/agentHost/test/node/agentHostRepoInfoTelemetry.test.ts +++ b/src/vs/platform/agentHost/test/node/agentHostRepoInfoTelemetry.test.ts @@ -84,7 +84,7 @@ suite('AgentHostRepoInfoTelemetry', () => { }; const reports: IAgentHostRepoInfoReport[] = []; const collector = disposables.add(new AgentHostRepoInfoTelemetry({ - reportRepoInfo: (_context, report) => reports.push(report), + reportRepoInfo: async (_context, report) => { reports.push(report); }, }, gitService, createTestGitHubEndpointService(), new NullLogService())); await collector.reportBegin(restrictedContext, 'agent-session://copilot/s1', 'turn-1', root, undefined, () => true); @@ -151,7 +151,7 @@ suite('AgentHostRepoInfoTelemetry', () => { ...createNoopGitService(), getSessionGitState: async () => { gitCalls++; return undefined; }, }; - const collector = disposables.add(new AgentHostRepoInfoTelemetry({ reportRepoInfo: () => { } }, gitService, createTestGitHubEndpointService(), new NullLogService())); + const collector = disposables.add(new AgentHostRepoInfoTelemetry({ reportRepoInfo: async () => { } }, gitService, createTestGitHubEndpointService(), new NullLogService())); await collector.reportBegin({ ...restrictedContext, restrictedTelemetryEnabled: false, isInternal: false }, 'agent-session://copilot/s1', 'turn-1', URI.file('/repo'), undefined, () => true); @@ -176,7 +176,7 @@ suite('AgentHostRepoInfoTelemetry', () => { computeFileDiffsBetweenRefs: async () => fileDiffs, }; const reports: IAgentHostRepoInfoReport[] = []; - const collector = disposables.add(new AgentHostRepoInfoTelemetry({ reportRepoInfo: (_context, report) => reports.push(report) }, gitService, createTestGitHubEndpointService(), new NullLogService())); + const collector = disposables.add(new AgentHostRepoInfoTelemetry({ reportRepoInfo: async (_context, report) => { reports.push(report); } }, gitService, createTestGitHubEndpointService(), new NullLogService())); await collector.reportBegin(restrictedContext, 'agent-session://copilot/s1', 'turn-1', root, undefined, () => true); await collector.reportEnd(restrictedContext, 'agent-session://copilot/s1', 'turn-1', root, undefined, () => true); @@ -203,7 +203,7 @@ suite('AgentHostRepoInfoTelemetry', () => { getDiffPatchBetweenRefs: async () => { patchCalls++; return { patch: 'secret', tooLarge: false }; }, }; const reports: IAgentHostRepoInfoReport[] = []; - const collector = disposables.add(new AgentHostRepoInfoTelemetry({ reportRepoInfo: (_context, report) => reports.push(report) }, gitService, createTestGitHubEndpointService(), new NullLogService())); + const collector = disposables.add(new AgentHostRepoInfoTelemetry({ reportRepoInfo: async (_context, report) => { reports.push(report); } }, gitService, createTestGitHubEndpointService(), new NullLogService())); for (const [index, copilotIgnoreEnabled] of [true, undefined].entries()) { await collector.reportBegin({ ...restrictedContext, copilotIgnoreEnabled }, 'agent-session://copilot/s1', `turn-${index}`, root, undefined, () => true); @@ -252,7 +252,7 @@ suite('AgentHostRepoInfoTelemetry', () => { }], getDiffPatchBetweenRefs: async () => ({ patch: '-before\n+after', tooLarge: false }), }; - const collector = disposables.add(new AgentHostRepoInfoTelemetry({ reportRepoInfo: (_context, report) => reports.push(report) }, gitService, createTestGitHubEndpointService(), new NullLogService())); + const collector = disposables.add(new AgentHostRepoInfoTelemetry({ reportRepoInfo: async (_context, report) => { reports.push(report); } }, gitService, createTestGitHubEndpointService(), new NullLogService())); await collector.reportBegin(restrictedContext, 'agent-session://copilot/s1', 'turn-1', root, undefined, () => true); @@ -281,7 +281,7 @@ suite('AgentHostRepoInfoTelemetry', () => { }], getDiffPatchBetweenRefs: async () => ({ patch: 'x'.repeat(100_001), tooLarge: false }), }; - const collector = disposables.add(new AgentHostRepoInfoTelemetry({ reportRepoInfo: (_context, report) => reports.push(report) }, gitService, createTestGitHubEndpointService(), new NullLogService())); + const collector = disposables.add(new AgentHostRepoInfoTelemetry({ reportRepoInfo: async (_context, report) => { reports.push(report); } }, gitService, createTestGitHubEndpointService(), new NullLogService())); await collector.reportBegin(restrictedContext, 'agent-session://copilot/s1', 'turn-1', root, undefined, () => true); @@ -296,4 +296,4 @@ suite('AgentHostRepoInfoTelemetry', () => { truncated: true, }); }); -}); \ No newline at end of file +}); diff --git a/src/vs/platform/agentHost/test/node/agentHostTelemetryReporter.test.ts b/src/vs/platform/agentHost/test/node/agentHostTelemetryReporter.test.ts index 10479a0ad2e..8aa8bd0a63b 100644 --- a/src/vs/platform/agentHost/test/node/agentHostTelemetryReporter.test.ts +++ b/src/vs/platform/agentHost/test/node/agentHostTelemetryReporter.test.ts @@ -4,6 +4,7 @@ *--------------------------------------------------------------------------------------------*/ import assert from 'assert'; +import * as zlib from 'zlib'; import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../base/test/common/utils.js'; import { hash } from '../../../../base/common/hash.js'; import { ITelemetryService, TelemetryLevel } from '../../../telemetry/common/telemetry.js'; @@ -64,13 +65,13 @@ suite('AgentHostTelemetryReporter', () => { const session = 'agent-session://copilot/abc'; const tools: ToolDefinition[] = [{ name: 'grep' }, { name: 'edit' }]; - test('assistantMessageReceived emits request.options.tools keyed on the client request id, and no-ops without one or without tools', () => { + test('assistantMessageReceived emits request.options.tools keyed on the client request id, and no-ops without one or without tools', async () => { const service = new TestRestrictedTelemetryService(); const reporter = new AgentHostTelemetryReporter(service); - reporter.assistantMessageReceived(session, AgentHostClientType.AgentsWindow, undefined, tools); // dropped: no client request id - reporter.assistantMessageReceived(session, AgentHostClientType.AgentsWindow, 'client-1', []); // dropped: no tools - reporter.assistantMessageReceived(session, AgentHostClientType.AgentsWindow, 'client-1', tools); // emitted + await reporter.assistantMessageReceived(session, AgentHostClientType.AgentsWindow, undefined, tools); // dropped: no client request id + await reporter.assistantMessageReceived(session, AgentHostClientType.AgentsWindow, 'client-1', []); // dropped: no tools + await reporter.assistantMessageReceived(session, AgentHostClientType.AgentsWindow, 'client-1', tools); // emitted assert.deepStrictEqual(service.enhancedEvents, [{ eventName: 'request.options.tools', @@ -79,16 +80,17 @@ suite('AgentHostTelemetryReporter', () => { conversationId: AgentSession.id(session), initiatorClientType: 'agents_window', messagesJson: JSON.stringify(tools), + messagesJsonChunk: zlib.gzipSync(Buffer.from(JSON.stringify(tools), 'utf8')).toString('base64'), }, }]); }); - test('userMessageText emits conversation.messageText (source=user) to enhanced + internal, and no-ops on empty content', () => { + test('userMessageText emits conversation.messageText (source=user) to enhanced + internal, and no-ops on empty content', async () => { const service = new TestRestrictedTelemetryService(); const reporter = new AgentHostTelemetryReporter(service); - reporter.userMessageText(session, AgentHostClientType.EditorWindow, '', 3); // dropped: no content - reporter.userMessageText(session, AgentHostClientType.EditorWindow, 'hello agent', 3); // emitted + await reporter.userMessageText(session, AgentHostClientType.EditorWindow, '', 3); // dropped: no content + await reporter.userMessageText(session, AgentHostClientType.EditorWindow, 'hello agent', 3); // emitted const expected: IRestrictedCall = { eventName: 'conversation.messageText', @@ -104,12 +106,12 @@ suite('AgentHostTelemetryReporter', () => { assert.deepStrictEqual(service.internalEvents, [expected]); }); - test('modelMessageText emits conversation.messageText (source=model) with headerRequestId, and no-ops on empty content', () => { + test('modelMessageText emits conversation.messageText (source=model) with headerRequestId, and no-ops on empty content', async () => { const service = new TestRestrictedTelemetryService(); const reporter = new AgentHostTelemetryReporter(service); - reporter.modelMessageText(session, AgentHostClientType.AgentsWindow, '', 3, 'svc-1'); // dropped: no content - reporter.modelMessageText(session, AgentHostClientType.AgentsWindow, 'sure, here you go', 3, 'svc-1'); // emitted + await reporter.modelMessageText(session, AgentHostClientType.AgentsWindow, '', 3, 'svc-1'); // dropped: no content + await reporter.modelMessageText(session, AgentHostClientType.AgentsWindow, 'sure, here you go', 3, 'svc-1'); // emitted const expected: IRestrictedCall = { eventName: 'conversation.messageText', @@ -126,21 +128,21 @@ suite('AgentHostTelemetryReporter', () => { assert.deepStrictEqual(service.internalEvents, [expected]); }); - test('toolCallDetails emits toolCallDetailsExternal + toolCallDetailsInternal aggregate whenever tools were available, and no-ops when none were', () => { + test('toolCallDetails emits toolCallDetailsExternal + toolCallDetailsInternal aggregate whenever tools were available, and no-ops when none were', async () => { const service = new TestRestrictedTelemetryService(); const reporter = new AgentHostTelemetryReporter(service); - reporter.toolCallDetails({ + await reporter.toolCallDetails({ session, turnId: 'a1b2c3d4-0000-4000-8000-000000000000', clientType: AgentHostClientType.Unknown, model: 'gpt-x', responseType: 'success', toolCounts: {}, availableTools: [], numRequests: 1, totalToolCalls: 0, parallelToolCallRounds: 0, parallelToolCallsTotal: 0, }); // dropped: no tools were available - reporter.toolCallDetails({ + await reporter.toolCallDetails({ session, turnId: 'a1b2c3d4-0000-4000-8000-000000000000', clientType: AgentHostClientType.EditorWindow, model: 'gpt-x', responseType: 'success', toolCounts: {}, availableTools: ['grep', 'edit'], numRequests: 1, totalToolCalls: 0, parallelToolCallRounds: 0, parallelToolCallsTotal: 0, }); // emitted: tools available, even though no tool calls were made - reporter.toolCallDetails({ + await reporter.toolCallDetails({ session, turnId: 'a1b2c3d4-0000-4000-8000-000000000000', clientType: AgentHostClientType.AgentsWindow, model: 'gpt-x', responseType: 'success', toolCounts: { grep: 2, edit: 1 }, availableTools: ['grep', 'edit'], numRequests: 2, totalToolCalls: 3, parallelToolCallRounds: 1, parallelToolCallsTotal: 2, @@ -201,11 +203,11 @@ suite('AgentHostTelemetryReporter', () => { assert.deepStrictEqual(service.internalEvents, [expected]); }); - test('repoInfo gates collection and multiplexes sink-specific properties', () => { + test('repoInfo gates collection and multiplexes sink-specific properties', async () => { const service = new TestRestrictedTelemetryService(); const reporter = new AgentHostTelemetryReporter(service); - reporter.reportRepoInfo({ + await reporter.reportRepoInfo({ restrictedTelemetryEnabled: true, trackingId: 'tracking-id', telemetryEndpoint: 'https://telemetry.example/telemetry', @@ -243,7 +245,7 @@ suite('AgentHostTelemetryReporter', () => { headBranchName: 'feature', fileRelativePaths: JSON.stringify(['src/a.ts']), diffsJSON: 'x'.repeat(8192), - diffsJSON_02: 'x', + diffsJSONChunk: zlib.gzipSync(Buffer.from('x'.repeat(8193), 'utf8')).toString('base64'), result: 'success', isActiveRepository: 'true', location: 'begin', @@ -258,7 +260,7 @@ suite('AgentHostTelemetryReporter', () => { repoType: 'github', headCommitHash: 'abc', diffsJSON: 'x'.repeat(8192), - diffsJSON_02: 'x', + diffsJSONChunk: zlib.gzipSync(Buffer.from('x'.repeat(8193), 'utf8')).toString('base64'), result: 'success', isActiveRepository: 'true', location: 'begin',