Skip to content

Commit a39698f

Browse files
authored
agentHost: add Claude and Codex session telemetry (#333274)
* agentHost: add Claude and Codex session telemetry Generalize Agent Host subscription telemetry across providers while preserving Copilot-specific SDK resume dimensions. Add coverage for Claude, Codex, and future providers.\n\n(Written by Copilot)\n\nCo-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * agentHost: filter session telemetry by registered provider Resolve subscription providers through the provider service so future registered providers are included while terminal and unknown resource schemes are excluded.\n\n(Written by Copilot)\n\nCo-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * agentHost: use canonical provider service interface Keep the production dependency typed as IAgentHostProviderService and move partial service stubbing into test code.\n\n(Written by Copilot)\n\nCo-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * docs: require canonical injected service interfaces Document that production service dependencies use their canonical interfaces and keep partial test conveniences in test code.\n\n(Written by Copilot)\n\nCo-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
1 parent 3ae3ff0 commit a39698f

4 files changed

Lines changed: 115 additions & 34 deletions

File tree

.github/instructions/source-code-organization.instructions.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -64,4 +64,6 @@ class MyComponent {
6464
}
6565
```
6666

67+
Decorated service parameters must use their canonical service interface. Do not invent local subset interfaces or `Pick<...>` aliases in production to simplify tests; keep partial stubs, adapters, and other test-only conveniences in test code.
68+
6769
Services are provided via `registerSingleton(IMyService, MyServiceImpl, InstantiationType.Delayed)`.

src/vs/platform/agentHost/node/agentHostSessionOpenTelemetry.ts

Lines changed: 28 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -9,13 +9,15 @@ import { StopWatch } from '../../../base/common/stopwatch.js';
99
import { URI } from '../../../base/common/uri.js';
1010
import { createDecorator } from '../../instantiation/common/instantiation.js';
1111
import { ITelemetryService } from '../../telemetry/common/telemetry.js';
12-
import { AgentSession } from '../common/agent.js';
12+
import type { AgentProvider } from '../common/agent.js';
1313
import { isAhpChatChannel, isDefaultChatUri, parseRequiredSessionUriFromChatUri } from '../common/state/sessionState.js';
14+
import { IAgentHostProviderService } from './agentHostProviderService.js';
1415

15-
export const AgentHostCopilotSessionSubscribeTimeoutMs = 60_000;
16+
export const AgentHostSessionSubscribeTimeoutMs = 60_000;
1617

17-
export type AgentHostCopilotSessionSubscribeChannel = 'session' | 'defaultChat' | 'chat';
18-
export type AgentHostCopilotSessionSubscribeOutcome = 'success' | 'failure' | 'timeout';
18+
export type AgentHostSessionSubscribeProvider = AgentProvider;
19+
export type AgentHostSessionSubscribeChannel = 'session' | 'defaultChat' | 'chat';
20+
export type AgentHostSessionSubscribeOutcome = 'success' | 'failure' | 'timeout';
1921
export type AgentHostCopilotSdkResumeOutcome = 'success' | 'failure' | 'fallbackCreate' | 'incomplete' | 'notStarted';
2022

2123
export interface IAgentHostSessionOpenTelemetryScope {
@@ -35,13 +37,14 @@ export interface IAgentHostSessionOpenTelemetry {
3537

3638
export const IAgentHostSessionOpenTelemetry = createDecorator<IAgentHostSessionOpenTelemetry>('agentHostSessionOpenTelemetry');
3739

38-
type AgentHostCopilotSessionSubscribeEvent = {
40+
type AgentHostSessionSubscribeEvent = {
41+
provider: string;
3942
channel: string;
4043
outcome: string;
4144
servedFromMemory: boolean | undefined;
4245
joinedRestore: boolean | undefined;
43-
sdkResumeOutcome: string;
44-
sdkResumeAttemptCount: number;
46+
sdkResumeOutcome: string | undefined;
47+
sdkResumeAttemptCount: number | undefined;
4548
timeToRestoreStartMs: number | undefined;
4649
timeToSdkResumeStartMs: number | undefined;
4750
sdkResumeDurationMs: number | undefined;
@@ -50,9 +53,10 @@ type AgentHostCopilotSessionSubscribeEvent = {
5053
totalDurationMs: number;
5154
};
5255

53-
type AgentHostCopilotSessionSubscribeClassification = {
56+
type AgentHostSessionSubscribeClassification = {
5457
owner: 'roblourens';
55-
comment: 'Measures Copilot Agent Host subscription latency from the subscribe request through session restoration and SDK resume to the returned snapshot.';
58+
comment: 'Measures Agent Host subscription latency from the subscribe request through session restoration and provider-specific resume work to the returned snapshot.';
59+
provider: { classification: 'SystemMetaData'; purpose: 'PerformanceAndHealth'; comment: 'Agent provider identifier.' };
5660
channel: { classification: 'SystemMetaData'; purpose: 'PerformanceAndHealth'; comment: 'Bounded subscribed channel kind: session, defaultChat, or chat.' };
5761
outcome: { classification: 'SystemMetaData'; purpose: 'PerformanceAndHealth'; comment: 'Terminal subscription outcome: success, failure, or timeout.' };
5862
servedFromMemory: { classification: 'SystemMetaData'; purpose: 'PerformanceAndHealth'; isMeasurement: true; comment: 'Whether the subscribed snapshot was already materialized when the request was received.' };
@@ -72,7 +76,7 @@ class AgentHostSessionOpenTelemetryAttempt extends Disposable {
7276
readonly resources = this._register(new DisposableStore());
7377
servedFromMemory: boolean | undefined;
7478
joinedRestore: boolean | undefined;
75-
sdkResumeOutcome: AgentHostCopilotSdkResumeOutcome = 'notStarted';
79+
sdkResumeOutcome: AgentHostCopilotSdkResumeOutcome | undefined;
7680
sdkResumeAttemptCount = 0;
7781
timeToRestoreStartMs: number | undefined;
7882
timeToSdkResumeStartMs: number | undefined;
@@ -84,9 +88,11 @@ class AgentHostSessionOpenTelemetryAttempt extends Disposable {
8488
constructor(
8589
readonly id: number,
8690
readonly session: URI,
87-
readonly channel: AgentHostCopilotSessionSubscribeChannel,
91+
readonly provider: AgentHostSessionSubscribeProvider,
92+
readonly channel: AgentHostSessionSubscribeChannel,
8893
) {
8994
super();
95+
this.sdkResumeOutcome = provider === 'copilotcli' ? 'notStarted' : undefined;
9096
}
9197
}
9298

@@ -99,6 +105,7 @@ export class AgentHostSessionOpenTelemetry extends Disposable implements IAgentH
99105

100106
constructor(
101107
@ITelemetryService private readonly _telemetryService: ITelemetryService,
108+
@IAgentHostProviderService private readonly _providerService: IAgentHostProviderService,
102109
) {
103110
super();
104111
this._register(toDisposable(() => {
@@ -170,15 +177,15 @@ export class AgentHostSessionOpenTelemetry extends Disposable implements IAgentH
170177
return undefined;
171178
}
172179

173-
const attempt = new AgentHostSessionOpenTelemetryAttempt(this._nextAttemptId++, info.session, info.channel);
180+
const attempt = new AgentHostSessionOpenTelemetryAttempt(this._nextAttemptId++, info.session, info.provider, info.channel);
174181
this._attempts.set(attempt.id, attempt);
175182
let sessionAttempts = this._attemptsBySession.get(info.session.toString());
176183
if (!sessionAttempts) {
177184
sessionAttempts = new Set();
178185
this._attemptsBySession.set(info.session.toString(), sessionAttempts);
179186
}
180187
sessionAttempts.add(attempt);
181-
attempt.resources.add(disposableTimeout(() => this._finish(attempt, 'timeout', attempt.servedFromMemory), AgentHostCopilotSessionSubscribeTimeoutMs));
188+
attempt.resources.add(disposableTimeout(() => this._finish(attempt, 'timeout', attempt.servedFromMemory), AgentHostSessionSubscribeTimeoutMs));
182189
return attempt;
183190
}
184191

@@ -221,7 +228,7 @@ export class AgentHostSessionOpenTelemetry extends Disposable implements IAgentH
221228
}
222229
}
223230

224-
private _finish(attempt: AgentHostSessionOpenTelemetryAttempt, outcome: AgentHostCopilotSessionSubscribeOutcome, servedFromMemory: boolean | undefined): void {
231+
private _finish(attempt: AgentHostSessionOpenTelemetryAttempt, outcome: AgentHostSessionSubscribeOutcome, servedFromMemory: boolean | undefined): void {
225232
if (!this._attempts.delete(attempt.id)) {
226233
return;
227234
}
@@ -252,13 +259,14 @@ export class AgentHostSessionOpenTelemetry extends Disposable implements IAgentH
252259
const totalDurationMs = Math.max(timeToRestoreCompleteMs ?? timeToSdkResumeCompleteMs ?? timeToRestoreStartMs ?? 0, elapsed);
253260
attempt.dispose();
254261

255-
this._telemetryService.publicLog2<AgentHostCopilotSessionSubscribeEvent, AgentHostCopilotSessionSubscribeClassification>('agentHost.copilotSessionSubscribe', {
262+
this._telemetryService.publicLog2<AgentHostSessionSubscribeEvent, AgentHostSessionSubscribeClassification>('agentHost.sessionSubscribe', {
263+
provider: attempt.provider,
256264
channel: attempt.channel,
257265
outcome,
258266
servedFromMemory,
259267
joinedRestore: attempt.joinedRestore,
260268
sdkResumeOutcome: attempt.sdkResumeOutcome,
261-
sdkResumeAttemptCount: attempt.sdkResumeAttemptCount,
269+
sdkResumeAttemptCount: attempt.provider === 'copilotcli' ? attempt.sdkResumeAttemptCount : undefined,
262270
timeToRestoreStartMs,
263271
timeToSdkResumeStartMs,
264272
sdkResumeDurationMs: attempt.sdkResumeAttemptCount > 0 ? attempt.sdkResumeDurationMs : undefined,
@@ -280,16 +288,18 @@ export class AgentHostSessionOpenTelemetry extends Disposable implements IAgentH
280288
return Math.max(0, Math.round(attempt.stopwatch.elapsed()));
281289
}
282290

283-
private _classify(resource: URI): { readonly session: URI; readonly channel: AgentHostCopilotSessionSubscribeChannel } | undefined {
291+
private _classify(resource: URI): { readonly session: URI; readonly provider: AgentHostSessionSubscribeProvider; readonly channel: AgentHostSessionSubscribeChannel } | undefined {
284292
const resourceString = resource.toString();
285293
const session = isAhpChatChannel(resourceString)
286294
? URI.parse(parseRequiredSessionUriFromChatUri(resourceString))
287295
: resource;
288-
if (AgentSession.provider(session) !== 'copilotcli') {
296+
const provider = this._providerService.getProviderForSession(session)?.id;
297+
if (!provider) {
289298
return undefined;
290299
}
291300
return {
292301
session,
302+
provider,
293303
channel: !isAhpChatChannel(resourceString) ? 'session' : isDefaultChatUri(resource) ? 'defaultChat' : 'chat',
294304
};
295305
}

src/vs/platform/agentHost/test/node/agentHostSessionOpenTelemetry.test.ts

Lines changed: 73 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -6,12 +6,16 @@
66
import assert from 'assert';
77
import { DeferredPromise, timeout } from '../../../../base/common/async.js';
88
import { URI } from '../../../../base/common/uri.js';
9+
import { mock } from '../../../../base/test/common/mock.js';
910
import { runWithFakedTimers } from '../../../../base/test/common/timeTravelScheduler.js';
1011
import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../base/test/common/utils.js';
12+
import { TestInstantiationService } from '../../../instantiation/test/common/instantiationServiceMock.js';
13+
import { ITelemetryService } from '../../../telemetry/common/telemetry.js';
1114
import { NullTelemetryServiceShape } from '../../../telemetry/common/telemetryUtils.js';
12-
import { AgentSession } from '../../common/agent.js';
15+
import { AgentSession, CLAUDE_AGENT_PROVIDER_ID, CODEX_AGENT_PROVIDER_ID, type IAgent } from '../../common/agent.js';
1316
import { buildDefaultChatUri } from '../../common/state/sessionState.js';
14-
import { AgentHostCopilotSessionSubscribeTimeoutMs, AgentHostSessionOpenTelemetry } from '../../node/agentHostSessionOpenTelemetry.js';
17+
import { IAgentHostProviderService } from '../../node/agentHostProviderService.js';
18+
import { AgentHostSessionOpenTelemetry, AgentHostSessionSubscribeTimeoutMs } from '../../node/agentHostSessionOpenTelemetry.js';
1519

1620
function isTelemetryData(data: unknown): data is Record<string, unknown> {
1721
return typeof data === 'object' && data !== null;
@@ -31,11 +35,28 @@ suite('AgentHostSessionOpenTelemetry', () => {
3135
const disposables = ensureNoDisposablesAreLeakedInTestSuite();
3236
const session = AgentSession.uri('copilotcli', 'session');
3337
const defaultChat = URI.parse(buildDefaultChatUri(session));
38+
const createService = (telemetryService: TestTelemetryService, providers: readonly string[] = ['copilotcli']) => {
39+
const instantiationService = disposables.add(new TestInstantiationService());
40+
instantiationService.stub(ITelemetryService, telemetryService);
41+
instantiationService.stub(IAgentHostProviderService, {
42+
getProviderForSession: session => {
43+
const provider = AgentSession.provider(session);
44+
if (!provider || !providers.includes(provider)) {
45+
return undefined;
46+
}
47+
const providerId = provider;
48+
return new class extends mock<IAgent>() {
49+
override readonly id = providerId;
50+
};
51+
},
52+
});
53+
return disposables.add(instantiationService.createInstance(AgentHostSessionOpenTelemetry));
54+
};
3455

3556
test('emits ordered subscribe, restore, and SDK resume milestones', async () => {
3657
await runWithFakedTimers({ useFakeTimers: true, startTime: 1_000 }, async () => {
3758
const telemetryService = new TestTelemetryService();
38-
const service = disposables.add(new AgentHostSessionOpenTelemetry(telemetryService));
59+
const service = createService(telemetryService);
3960
await service.withSubscription(defaultChat, async telemetry => {
4061
telemetry.setServedFromMemory(false);
4162
await timeout(5);
@@ -48,8 +69,9 @@ suite('AgentHostSessionOpenTelemetry', () => {
4869
});
4970

5071
assert.deepStrictEqual(telemetryService.events, [{
51-
name: 'agentHost.copilotSessionSubscribe',
72+
name: 'agentHost.sessionSubscribe',
5273
data: {
74+
provider: 'copilotcli',
5375
channel: 'defaultChat',
5476
outcome: 'success',
5577
servedFromMemory: false,
@@ -70,10 +92,11 @@ suite('AgentHostSessionOpenTelemetry', () => {
7092
test('records warm subscriptions without restore or SDK resume milestones', async () => {
7193
await runWithFakedTimers({ useFakeTimers: true }, async () => {
7294
const telemetryService = new TestTelemetryService();
73-
const service = disposables.add(new AgentHostSessionOpenTelemetry(telemetryService));
95+
const service = createService(telemetryService);
7496
await service.withSubscription(session, async telemetry => telemetry.setServedFromMemory(true));
7597

7698
assert.deepStrictEqual(telemetryService.events.map(event => event.data), [{
99+
provider: 'copilotcli',
77100
channel: 'session',
78101
outcome: 'success',
79102
servedFromMemory: true,
@@ -93,7 +116,7 @@ suite('AgentHostSessionOpenTelemetry', () => {
93116
test('accumulates retries and reports fallback creation without duplicate emission', async () => {
94117
await runWithFakedTimers({ useFakeTimers: true }, async () => {
95118
const telemetryService = new TestTelemetryService();
96-
const service = disposables.add(new AgentHostSessionOpenTelemetry(telemetryService));
119+
const service = createService(telemetryService);
97120
await service.withSubscription(session, async telemetry => {
98121
telemetry.setServedFromMemory(false);
99122
telemetry.restoreStarted(true);
@@ -127,7 +150,7 @@ suite('AgentHostSessionOpenTelemetry', () => {
127150
test('does not attribute an in-flight SDK resume to a late subscriber', async () => {
128151
await runWithFakedTimers({ useFakeTimers: true }, async () => {
129152
const telemetryService = new TestTelemetryService();
130-
const service = disposables.add(new AgentHostSessionOpenTelemetry(telemetryService));
153+
const service = createService(telemetryService);
131154
const first = service.withSubscription(session, async telemetry => {
132155
telemetry.setServedFromMemory(false);
133156
await service.withSdkResume(session, () => timeout(20));
@@ -153,7 +176,7 @@ suite('AgentHostSessionOpenTelemetry', () => {
153176

154177
test('emits one failure outcome and rethrows the subscription error', async () => {
155178
const telemetryService = new TestTelemetryService();
156-
const service = disposables.add(new AgentHostSessionOpenTelemetry(telemetryService));
179+
const service = createService(telemetryService);
157180
const expectedError = new Error('Restore failed');
158181

159182
await assert.rejects(service.withSubscription(defaultChat, async telemetry => {
@@ -173,11 +196,46 @@ suite('AgentHostSessionOpenTelemetry', () => {
173196
}]);
174197
});
175198

176-
test('emits a bounded timeout and ignores non-Copilot subscriptions', async () => {
199+
test('emits subscription telemetry for current and future providers', async () => {
200+
const telemetryService = new TestTelemetryService();
201+
const providers = [CLAUDE_AGENT_PROVIDER_ID, CODEX_AGENT_PROVIDER_ID, 'future'];
202+
const service = createService(telemetryService, providers);
203+
for (const provider of [CLAUDE_AGENT_PROVIDER_ID, CODEX_AGENT_PROVIDER_ID, 'future']) {
204+
await service.withSubscription(AgentSession.uri(provider, 'session'), async telemetry => {
205+
telemetry.setServedFromMemory(false);
206+
telemetry.restoreStarted(false);
207+
telemetry.restoreCompleted();
208+
});
209+
}
210+
const terminalResult = await service.withSubscription(URI.parse('agenthost-terminal:/terminal'), async () => 'terminal');
211+
const unknownResult = await service.withSubscription(AgentSession.uri('unknown', 'session'), async () => 'unknown');
212+
213+
assert.deepStrictEqual({
214+
terminalResult,
215+
unknownResult,
216+
events: telemetryService.events.map(event => ({
217+
name: event.name,
218+
provider: event.data.provider,
219+
channel: event.data.channel,
220+
outcome: event.data.outcome,
221+
sdkResumeOutcome: event.data.sdkResumeOutcome,
222+
sdkResumeAttemptCount: event.data.sdkResumeAttemptCount,
223+
})),
224+
}, {
225+
terminalResult: 'terminal',
226+
unknownResult: 'unknown',
227+
events: [
228+
{ name: 'agentHost.sessionSubscribe', provider: 'claude', channel: 'session', outcome: 'success', sdkResumeOutcome: undefined, sdkResumeAttemptCount: undefined },
229+
{ name: 'agentHost.sessionSubscribe', provider: 'codex', channel: 'session', outcome: 'success', sdkResumeOutcome: undefined, sdkResumeAttemptCount: undefined },
230+
{ name: 'agentHost.sessionSubscribe', provider: 'future', channel: 'session', outcome: 'success', sdkResumeOutcome: undefined, sdkResumeAttemptCount: undefined },
231+
],
232+
});
233+
});
234+
235+
test('emits a bounded timeout', async () => {
177236
await runWithFakedTimers({ useFakeTimers: true }, async () => {
178237
const telemetryService = new TestTelemetryService();
179-
const service = disposables.add(new AgentHostSessionOpenTelemetry(telemetryService));
180-
assert.strictEqual(await service.withSubscription(AgentSession.uri('claude', 'session'), async () => 'not measured'), 'not measured');
238+
const service = createService(telemetryService);
181239
const resume = new DeferredPromise<void>();
182240
const subscription = service.withSubscription(session, async telemetry => {
183241
telemetry.setServedFromMemory(false);
@@ -187,11 +245,12 @@ suite('AgentHostSessionOpenTelemetry', () => {
187245
});
188246

189247
await timeout(10_000);
190-
await timeout(AgentHostCopilotSessionSubscribeTimeoutMs - 10_000);
248+
await timeout(AgentHostSessionSubscribeTimeoutMs - 10_000);
191249

192250
assert.deepStrictEqual(telemetryService.events, [{
193-
name: 'agentHost.copilotSessionSubscribe',
251+
name: 'agentHost.sessionSubscribe',
194252
data: {
253+
provider: 'copilotcli',
195254
channel: 'session',
196255
outcome: 'timeout',
197256
servedFromMemory: false,
@@ -203,7 +262,7 @@ suite('AgentHostSessionOpenTelemetry', () => {
203262
sdkResumeDurationMs: 50_000,
204263
timeToSdkResumeCompleteMs: undefined,
205264
timeToRestoreCompleteMs: undefined,
206-
totalDurationMs: AgentHostCopilotSessionSubscribeTimeoutMs,
265+
totalDurationMs: AgentHostSessionSubscribeTimeoutMs,
207266
},
208267
}]);
209268
resume.complete();

src/vs/platform/agentHost/test/node/copilotAgent.test.ts

Lines changed: 12 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -34,7 +34,7 @@ import type { IByokLmBridgeConnection, IByokLmModelInfo } from '../../common/age
3434
import { ITelemetryService } from '../../../telemetry/common/telemetry.js';
3535
import { NullTelemetryService, NullTelemetryServiceShape } from '../../../telemetry/common/telemetryUtils.js';
3636
import { AgentHostTelemetryService } from '../../node/agentHostTelemetryService.js';
37-
import { AgentHostSessionOpenTelemetry, IAgentHostSessionOpenTelemetry } from '../../node/agentHostSessionOpenTelemetry.js';
37+
import { IAgentHostSessionOpenTelemetry } from '../../node/agentHostSessionOpenTelemetry.js';
3838
import { CopilotCliConfigKey, CopilotCliVSCodeAssignmentContextKey } from '../../common/copilotCliConfig.js';
3939
import { AgentHostConfigKey } from '../../common/agentHostCustomizationConfig.js';
4040
import { AgentHostAutoApprovePolicyRestrictedConfigKey, AgentHostByokModelsEnabledConfigKey, AgentHostGitHubMcpServerEnabledConfigKey, AgentHostCopilotMultiRootEnabledConfigKey, AgentHostMigrateLegacyCopilotCliEnabledConfigKey, AgentHostProxyConfigKey, AgentHostSystemProxyEnabledConfigKey } from '../../common/agentHostSchema.js';
@@ -917,7 +917,17 @@ function createTestAgentContext(disposables: Pick<DisposableStore, 'add'>, optio
917917
const copilotApiService = options?.copilotApiService ?? new TestCopilotApiService();
918918
services.set(ICopilotApiService, copilotApiService);
919919
services.set(ITelemetryService, telemetryService);
920-
services.set(IAgentHostSessionOpenTelemetry, disposables.add(new AgentHostSessionOpenTelemetry(telemetryService)));
920+
services.set(IAgentHostSessionOpenTelemetry, {
921+
_serviceBrand: undefined,
922+
withSubscription: async (_resource, operation) => operation({
923+
servedFromMemory: undefined,
924+
setServedFromMemory: () => { },
925+
restoreStarted: () => { },
926+
restoreCompleted: () => { },
927+
}),
928+
withSdkResume: async (_session, operation) => operation(),
929+
sdkResumeFallbackCreated: () => { },
930+
});
921931
if (options?.environmentServiceRegistration !== 'none') {
922932
const environmentService = {
923933
_serviceBrand: undefined,

0 commit comments

Comments
 (0)