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
This commit is contained in:
Vijay Upadya
2026-07-27 19:41:29 +00:00
committed by GitHub
parent 7f590c39d9
commit 7d6473ca69
22 changed files with 362 additions and 168 deletions
@@ -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 {
@@ -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 */ });
}
/**
@@ -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);
@@ -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();
@@ -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;
@@ -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);
}
@@ -519,13 +519,13 @@ export abstract class AbstractReplaceStringTool<T extends { explanation: string
}, { isNotebook, didHeal: didHeal === undefined ? -1 : (didHeal ? 1 : 0), isMulti }
);
this.telemetryService.sendEnhancedGHTelemetryEvent('replaceStringTool', multiplexProperties({
void multiplexProperties({
headerRequestId: options.chatRequestId,
baseModel: model,
messageText: file,
completionTextJson: JSON.stringify(input),
postProcessingOutcome: outcome,
}), { isNotebook });
}).then(properties => this.telemetryService.sendEnhancedGHTelemetryEvent('replaceStringTool', properties, { isNotebook })).catch(() => { /* best-effort telemetry */ });
}
private async sendHealingTelemetry(options: vscode.LanguageModelToolInvocationOptions<T> | vscode.LanguageModelToolInvocationPrepareOptions<T>, healError: string | undefined, applicationError: string | undefined) {
@@ -656,14 +656,14 @@ export class ApplyPatchTool implements ICopilotTool<IApplyPatchToolParams> {
},
);
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<IApplyPatchToolParams> {
@@ -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));
@@ -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);
@@ -210,30 +210,88 @@ export class TelemetryTrustedValue<T> {
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 `<key>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<string>(['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<string>) | 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<string>): 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 `<key>Chunk`, `<key>Chunk_2`,
* `<key>Chunk_3`, ... (first column has no numeric suffix, the rest are NOT zero-padded, each
* capped at {@link MAX_PROPERTY_LENGTH}), and the original `<key>` column simply carries the first
* uncompressed chunk of the value. No redundant plain continuation family (`<key>_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 `<key>Chunk` family without branching on size.
*
* If no compressor is available, the value falls back to the plain continuation family (`<key>`,
* `<key>_02`, `<key>_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<string>) | 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 <key>Chunk, <key>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 <key>, <key>_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;
}
@@ -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<string> => 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);
});
});
@@ -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(
@@ -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<boolean> {
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);
}
@@ -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}`;
}
}
@@ -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 `<key>Chunk`,
* `<key>Chunk_2`, `<key>Chunk_3`, (first column has no numeric suffix, the rest are NOT
* zero-padded, each capped at {@link MAX_PROPERTY_LENGTH}), while the original `<key>` 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
* `<key>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 `<key>Chunk`
// family gives the backend a single, uniform place to read the value from.
const ALWAYS_COMPRESSED_CHUNK_KEYS = new Set<string>(['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<string> {
const compressed = await gzip(Buffer.from(value, 'utf8'));
return compressed.toString('base64');
}
export async function multiplexProperties(properties: TelemetryProps): Promise<TelemetryProps> {
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 <key>Chunk, <key>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;
@@ -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<void> {
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<void> {
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<void> {
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<void> {
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<void> {
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 {
@@ -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();
@@ -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)}`));
}
}));
@@ -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 },
]);
});
@@ -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,
});
});
});
});
@@ -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',