Book a Demo!
CoCalc Logo Icon
StoreFeaturesDocsShareSupportNewsAboutPoliciesSign UpSign In
microsoft
GitHub Repository: microsoft/vscode
Path: blob/main/extensions/copilot/src/extension/inlineEdits/test/node/nextEditProviderTelemetry.spec.ts
13405 views
1
/*---------------------------------------------------------------------------------------------
2
* Copyright (c) Microsoft Corporation. All rights reserved.
3
* Licensed under the MIT License. See License.txt in the project root for license information.
4
*--------------------------------------------------------------------------------------------*/
5
6
import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest';
7
import { DocumentId } from '../../../../platform/inlineEdits/common/dataTypes/documentId';
8
import { MutableObservableDocument, MutableObservableWorkspace } from '../../../../platform/inlineEdits/common/observableWorkspace';
9
import { NullTelemetryService } from '../../../../platform/telemetry/common/nullTelemetryService';
10
import { TelemetryEventProperties } from '../../../../platform/telemetry/common/telemetry';
11
import { URI } from '../../../../util/vs/base/common/uri';
12
import { OffsetRange } from '../../../../util/vs/editor/common/core/ranges/offsetRange';
13
import { StringText } from '../../../../util/vs/editor/common/core/text/abstractText';
14
import { IEnhancedTelemetrySendingReason, NextEditProviderTelemetryBuilder, TelemetrySender } from '../../node/nextEditProviderTelemetry';
15
import { INextEditResult } from '../../node/nextEditResult';
16
17
class RecordingTelemetryService extends NullTelemetryService {
18
readonly enhancedEvents: { eventName: string; properties?: TelemetryEventProperties }[] = [];
19
20
override sendEnhancedGHTelemetryEvent(eventName: string, properties?: TelemetryEventProperties): void {
21
this.enhancedEvents.push({ eventName, properties });
22
}
23
}
24
25
function createMockNextEditResult(): INextEditResult {
26
return { requestId: 1, result: undefined };
27
}
28
29
function createMockBuilder(doc?: MutableObservableDocument): NextEditProviderTelemetryBuilder {
30
return new NextEditProviderTelemetryBuilder(
31
undefined, // gitExtensionService
32
undefined, // notebookService
33
undefined, // workspaceService
34
'test-provider',
35
doc,
36
);
37
}
38
39
const workspaceRoot = URI.parse('file:///workspace');
40
41
describe('TelemetrySender', () => {
42
let telemetryService: RecordingTelemetryService;
43
let sender: TelemetrySender;
44
let workspace: MutableObservableWorkspace;
45
46
beforeEach(() => {
47
vi.useFakeTimers();
48
telemetryService = new RecordingTelemetryService();
49
workspace = new MutableObservableWorkspace();
50
sender = new TelemetrySender(workspace, telemetryService);
51
});
52
53
afterEach(() => {
54
sender.dispose();
55
workspace.clear();
56
vi.useRealTimers();
57
});
58
59
describe('scheduleSendingEnhancedTelemetry', () => {
60
const initialTimeoutMs = 2 * 60 * 1000; // matches production value
61
62
test('sends after initial timeout when no workspace', async () => {
63
const senderNoWorkspace = new TelemetrySender(undefined, telemetryService);
64
const result = createMockNextEditResult();
65
const builder = createMockBuilder(undefined);
66
67
senderNoWorkspace.scheduleSendingEnhancedTelemetry(result, builder);
68
expect(telemetryService.enhancedEvents).toHaveLength(0);
69
70
await vi.advanceTimersByTimeAsync(initialTimeoutMs);
71
72
expect(telemetryService.enhancedEvents).toHaveLength(1);
73
expect(telemetryService.enhancedEvents[0].eventName).toBe('copilot-nes/provideInlineEdit');
74
senderNoWorkspace.dispose();
75
});
76
77
test('sends after initial timeout + 5s idle when user is not typing', async () => {
78
const doc = workspace.addDocument({ id: DocumentId.create('file:///test.ts'), workspaceRoot });
79
const result = createMockNextEditResult();
80
const builder = createMockBuilder(doc);
81
82
sender.scheduleSendingEnhancedTelemetry(result, builder);
83
expect(telemetryService.enhancedEvents).toHaveLength(0);
84
85
await vi.advanceTimersByTimeAsync(initialTimeoutMs);
86
expect(telemetryService.enhancedEvents).toHaveLength(0);
87
88
// Advance 5s — idle timer fires
89
await vi.advanceTimersByTimeAsync(5_000);
90
expect(telemetryService.enhancedEvents).toHaveLength(1);
91
});
92
93
test('resets idle timer when user types in any document during idle phase', async () => {
94
const doc = workspace.addDocument({ id: DocumentId.create('file:///test.ts'), workspaceRoot });
95
const otherDoc = workspace.addDocument({ id: DocumentId.create('file:///other.ts'), workspaceRoot });
96
const result = createMockNextEditResult();
97
const builder = createMockBuilder(doc);
98
99
sender.scheduleSendingEnhancedTelemetry(result, builder);
100
101
await vi.advanceTimersByTimeAsync(initialTimeoutMs);
102
expect(telemetryService.enhancedEvents).toHaveLength(0);
103
104
// Wait 3s, then type in a DIFFERENT document
105
await vi.advanceTimersByTimeAsync(3_000);
106
otherDoc.setValue(new StringText('typing in other file'));
107
108
// 3s after typing → still not sent
109
await vi.advanceTimersByTimeAsync(3_000);
110
expect(telemetryService.enhancedEvents).toHaveLength(0);
111
112
// 2 more seconds → 5s since last activity → sends
113
await vi.advanceTimersByTimeAsync(2_000);
114
expect(telemetryService.enhancedEvents).toHaveLength(1);
115
});
116
117
test('hard cap sends after 30s even if user keeps typing', async () => {
118
const doc = workspace.addDocument({ id: DocumentId.create('file:///test.ts'), workspaceRoot });
119
const result = createMockNextEditResult();
120
const builder = createMockBuilder(doc);
121
122
sender.scheduleSendingEnhancedTelemetry(result, builder);
123
124
await vi.advanceTimersByTimeAsync(initialTimeoutMs);
125
126
// Simulate continuous typing every 2s for 30s
127
for (let i = 0; i < 15; i++) {
128
await vi.advanceTimersByTimeAsync(2_000);
129
doc.setValue(new StringText(`edit ${i}`));
130
}
131
132
expect(telemetryService.enhancedEvents).toHaveLength(1);
133
});
134
135
test('does not send twice', async () => {
136
const doc = workspace.addDocument({ id: DocumentId.create('file:///test.ts'), workspaceRoot });
137
const result = createMockNextEditResult();
138
const builder = createMockBuilder(doc);
139
140
sender.scheduleSendingEnhancedTelemetry(result, builder);
141
142
await vi.advanceTimersByTimeAsync(initialTimeoutMs + 5_000);
143
expect(telemetryService.enhancedEvents).toHaveLength(1);
144
145
await vi.advanceTimersByTimeAsync(30_000);
146
expect(telemetryService.enhancedEvents).toHaveLength(1);
147
});
148
149
test('dispose cancels pending initial timeout', async () => {
150
const result = createMockNextEditResult();
151
const builder = createMockBuilder(undefined);
152
153
sender.scheduleSendingEnhancedTelemetry(result, builder);
154
sender.dispose();
155
156
await vi.advanceTimersByTimeAsync(initialTimeoutMs + 5_000 + 30_000);
157
expect(telemetryService.enhancedEvents).toHaveLength(0);
158
});
159
160
test('dispose during idle-wait phase cancels idle timers and subscription', async () => {
161
const doc = workspace.addDocument({ id: DocumentId.create('file:///test.ts'), workspaceRoot });
162
const result = createMockNextEditResult();
163
const builder = createMockBuilder(doc);
164
165
sender.scheduleSendingEnhancedTelemetry(result, builder);
166
167
// Advance past the 2-minute timeout to enter idle-wait phase
168
await vi.advanceTimersByTimeAsync(initialTimeoutMs);
169
expect(telemetryService.enhancedEvents).toHaveLength(0);
170
171
// Dispose during idle-wait phase (before 5s idle timer fires)
172
sender.dispose();
173
174
// Advance past both idle timer and hard cap — nothing should be sent
175
await vi.advanceTimersByTimeAsync(5_000 + 30_000);
176
expect(telemetryService.enhancedEvents).toHaveLength(0);
177
});
178
});
179
180
describe('enhancedTelemetrySendingReason', () => {
181
const initialTimeoutMs = 2 * 60 * 1000;
182
183
function getSendingReason(event: { properties?: TelemetryEventProperties }): IEnhancedTelemetrySendingReason | undefined {
184
// Sending reason may be at top level (when alternativeAction is absent) or embedded in alternativeAction
185
const topLevel = event.properties?.['enhancedTelemetrySendingReason'];
186
if (topLevel) { return JSON.parse(String(topLevel)); }
187
const altAction = event.properties?.['alternativeAction'];
188
if (altAction) {
189
const parsed = JSON.parse(String(altAction));
190
return parsed.enhancedTelemetrySendingReason;
191
}
192
return undefined;
193
}
194
195
test('sends reason "idle" with idleTimeoutMs=0 when no workspace', async () => {
196
const senderNoWorkspace = new TelemetrySender(undefined, telemetryService);
197
const result = createMockNextEditResult();
198
const builder = createMockBuilder(undefined);
199
200
senderNoWorkspace.scheduleSendingEnhancedTelemetry(result, builder);
201
await vi.advanceTimersByTimeAsync(initialTimeoutMs);
202
203
expect(telemetryService.enhancedEvents).toHaveLength(1);
204
const reason = getSendingReason(telemetryService.enhancedEvents[0]);
205
expect(reason).toEqual({ reason: 'idle', details: { idleTimeoutMs: 0 } });
206
senderNoWorkspace.dispose();
207
});
208
209
test('sends reason "idle" with idleTimeoutMs after 5s of inactivity', async () => {
210
workspace.addDocument({ id: DocumentId.create('file:///test.ts'), workspaceRoot });
211
const result = createMockNextEditResult();
212
const builder = createMockBuilder(undefined);
213
214
sender.scheduleSendingEnhancedTelemetry(result, builder);
215
await vi.advanceTimersByTimeAsync(initialTimeoutMs + 5_000);
216
217
expect(telemetryService.enhancedEvents).toHaveLength(1);
218
const reason = getSendingReason(telemetryService.enhancedEvents[0]);
219
expect(reason).toEqual({ reason: 'idle', details: { idleTimeoutMs: 5_000 } });
220
});
221
222
test('sends reason "hard_cap" when user keeps typing past 30s', async () => {
223
const doc = workspace.addDocument({ id: DocumentId.create('file:///test.ts'), workspaceRoot });
224
const result = createMockNextEditResult();
225
const builder = createMockBuilder(doc);
226
227
sender.scheduleSendingEnhancedTelemetry(result, builder);
228
await vi.advanceTimersByTimeAsync(initialTimeoutMs);
229
230
// Simulate continuous typing every 2s for 30s
231
for (let i = 0; i < 15; i++) {
232
await vi.advanceTimersByTimeAsync(2_000);
233
doc.setValue(new StringText(`edit ${i}`));
234
}
235
236
expect(telemetryService.enhancedEvents).toHaveLength(1);
237
const reason = getSendingReason(telemetryService.enhancedEvents[0]);
238
expect(reason).toEqual({ reason: 'hard_cap', details: { hardCapTimeoutMs: 30_000 } });
239
});
240
241
test('sends reason "user_jump" with from/to when selection moves to different line in same file', async () => {
242
const doc = workspace.addDocument({ id: DocumentId.create('file:///test.ts'), workspaceRoot, initialValue: 'line0\nline1\nline2' });
243
const result = createMockNextEditResult();
244
245
// Set initial selection on line 0 BEFORE creating builder (so originalSelectionLine is captured)
246
doc.setSelection([OffsetRange.fromTo(0, 0)], undefined, 0);
247
const builder = createMockBuilder(doc);
248
249
sender.scheduleSendingEnhancedTelemetry(result, builder);
250
await vi.advanceTimersByTimeAsync(initialTimeoutMs);
251
expect(telemetryService.enhancedEvents).toHaveLength(0);
252
253
// Wait 1s (no recent typing), then jump selection to line 2 (offset 12 = start of "line2")
254
await vi.advanceTimersByTimeAsync(1_000);
255
doc.setSelection([OffsetRange.fromTo(12, 12)], undefined, 2);
256
257
await vi.advanceTimersByTimeAsync(0); // flush
258
expect(telemetryService.enhancedEvents).toHaveLength(1);
259
const reason = getSendingReason(telemetryService.enhancedEvents[0]);
260
expect(reason).toEqual({
261
reason: 'user_jump',
262
details: {
263
from: { file: 'file:///test.ts', line: 0 },
264
to: { file: 'file:///test.ts', line: 2 },
265
},
266
});
267
});
268
269
test('sends reason "user_jump" when user jumps to a different file', async () => {
270
const doc = workspace.addDocument({ id: DocumentId.create('file:///test.ts'), workspaceRoot, initialValue: 'line0\nline1' });
271
const otherDoc = workspace.addDocument({ id: DocumentId.create('file:///other.ts'), workspaceRoot, initialValue: 'other0\nother1\nother2' });
272
const result = createMockNextEditResult();
273
274
// Set initial selection BEFORE creating builder
275
doc.setSelection([OffsetRange.fromTo(0, 0)], undefined, 0);
276
const builder = createMockBuilder(doc);
277
278
sender.scheduleSendingEnhancedTelemetry(result, builder);
279
await vi.advanceTimersByTimeAsync(initialTimeoutMs);
280
expect(telemetryService.enhancedEvents).toHaveLength(0);
281
282
// Wait 1s, then "jump" to other file by changing its selection
283
await vi.advanceTimersByTimeAsync(1_000);
284
otherDoc.setSelection([OffsetRange.fromTo(7, 7)], undefined, 1); // line 1 of other.ts ("other1")
285
286
await vi.advanceTimersByTimeAsync(0); // flush
287
expect(telemetryService.enhancedEvents).toHaveLength(1);
288
const reason = getSendingReason(telemetryService.enhancedEvents[0]);
289
expect(reason).toEqual({
290
reason: 'user_jump',
291
details: {
292
from: { file: 'file:///test.ts', line: 0 },
293
to: { file: 'file:///other.ts', line: 1 },
294
},
295
});
296
});
297
298
test('does not trigger user_jump for selection change on same line', async () => {
299
const doc = workspace.addDocument({ id: DocumentId.create('file:///test.ts'), workspaceRoot, initialValue: 'hello world' });
300
const result = createMockNextEditResult();
301
302
doc.setSelection([OffsetRange.fromTo(0, 0)], undefined, 0);
303
const builder = createMockBuilder(doc);
304
305
sender.scheduleSendingEnhancedTelemetry(result, builder);
306
await vi.advanceTimersByTimeAsync(initialTimeoutMs);
307
308
// Move selection within the same line (offset 5 is still line 0)
309
await vi.advanceTimersByTimeAsync(1_000);
310
doc.setSelection([OffsetRange.fromTo(5, 5)], undefined, 0);
311
312
await vi.advanceTimersByTimeAsync(0);
313
expect(telemetryService.enhancedEvents).toHaveLength(0);
314
315
// Eventually sends via idle timer
316
await vi.advanceTimersByTimeAsync(5_000);
317
expect(telemetryService.enhancedEvents).toHaveLength(1);
318
expect(getSendingReason(telemetryService.enhancedEvents[0])?.reason).toBe('idle');
319
});
320
321
test('does not trigger user_jump for selection change during typing', async () => {
322
const doc = workspace.addDocument({ id: DocumentId.create('file:///test.ts'), workspaceRoot, initialValue: 'line0\nline1\nline2' });
323
const result = createMockNextEditResult();
324
325
doc.setSelection([OffsetRange.fromTo(0, 0)], undefined, 0);
326
const builder = createMockBuilder(doc);
327
328
sender.scheduleSendingEnhancedTelemetry(result, builder);
329
await vi.advanceTimersByTimeAsync(initialTimeoutMs);
330
331
// Type first (triggers lastTypingTime update), then immediately move selection
332
doc.setValue(new StringText('line0\nline1\nline2!'));
333
doc.setSelection([OffsetRange.fromTo(12, 12)], undefined, 2);
334
335
await vi.advanceTimersByTimeAsync(0);
336
// Should NOT have sent — selection change within 200ms of typing is ignored
337
expect(telemetryService.enhancedEvents).toHaveLength(0);
338
339
// Sends via idle timer instead
340
await vi.advanceTimersByTimeAsync(5_000);
341
expect(telemetryService.enhancedEvents).toHaveLength(1);
342
expect(getSendingReason(telemetryService.enhancedEvents[0])?.reason).toBe('idle');
343
});
344
345
test('pre-existing selection on another file does not trigger false jump', async () => {
346
const doc = workspace.addDocument({ id: DocumentId.create('file:///test.ts'), workspaceRoot, initialValue: 'line0\nline1' });
347
const otherDoc = workspace.addDocument({ id: DocumentId.create('file:///other.ts'), workspaceRoot, initialValue: 'other0\nother1' });
348
349
// Both docs have pre-existing selections before idle-wait starts
350
doc.setSelection([OffsetRange.fromTo(0, 0)], undefined, 0);
351
otherDoc.setSelection([OffsetRange.fromTo(7, 7)], undefined, 1);
352
353
const result = createMockNextEditResult();
354
const builder = createMockBuilder(doc);
355
356
sender.scheduleSendingEnhancedTelemetry(result, builder);
357
await vi.advanceTimersByTimeAsync(initialTimeoutMs);
358
359
// No selection changes — should NOT trigger user_jump
360
await vi.advanceTimersByTimeAsync(1_000);
361
expect(telemetryService.enhancedEvents).toHaveLength(0);
362
363
// Eventually sends via idle timer
364
await vi.advanceTimersByTimeAsync(5_000);
365
expect(telemetryService.enhancedEvents).toHaveLength(1);
366
expect(getSendingReason(telemetryService.enhancedEvents[0])?.reason).toBe('idle');
367
});
368
369
test('sendTelemetry during idle-wait cancels pending idle timers', async () => {
370
const doc = workspace.addDocument({ id: DocumentId.create('file:///test.ts'), workspaceRoot });
371
const result = createMockNextEditResult();
372
const builder = createMockBuilder(doc);
373
374
sender.scheduleSendingEnhancedTelemetry(result, builder);
375
376
// Enter idle-wait phase
377
await vi.advanceTimersByTimeAsync(initialTimeoutMs);
378
expect(telemetryService.enhancedEvents).toHaveLength(0);
379
380
// Send via the direct path (error/cancel scenario)
381
const directBuilder = createMockBuilder(undefined);
382
sender.sendTelemetry(result, directBuilder);
383
384
// Flush async _doSendEnhancedTelemetry
385
await vi.advanceTimersByTimeAsync(0);
386
expect(telemetryService.enhancedEvents).toHaveLength(1);
387
388
// Advance past idle and hard cap — should NOT send again
389
await vi.advanceTimersByTimeAsync(5_000 + 30_000);
390
expect(telemetryService.enhancedEvents).toHaveLength(1);
391
});
392
393
test('sends undefined "from" when builder has no doc', async () => {
394
workspace.addDocument({ id: DocumentId.create('file:///test.ts'), workspaceRoot, initialValue: 'line0\nline1' });
395
const result = createMockNextEditResult();
396
// No doc on builder — nesDocId and nesDocLine will be undefined
397
const builder = createMockBuilder(undefined);
398
399
sender.scheduleSendingEnhancedTelemetry(result, builder);
400
await vi.advanceTimersByTimeAsync(initialTimeoutMs);
401
402
// Idle sends — reason should have no nesDocId so from is undefined
403
await vi.advanceTimersByTimeAsync(5_000);
404
expect(telemetryService.enhancedEvents).toHaveLength(1);
405
const reason = getSendingReason(telemetryService.enhancedEvents[0]);
406
expect(reason).toEqual({ reason: 'idle', details: { idleTimeoutMs: 5_000 } });
407
});
408
409
test('rescheduling for same result cancels previous schedule', async () => {
410
const doc = workspace.addDocument({ id: DocumentId.create('file:///test.ts'), workspaceRoot });
411
const result = createMockNextEditResult();
412
const builder1 = createMockBuilder(doc);
413
const builder2 = createMockBuilder(doc);
414
415
sender.scheduleSendingEnhancedTelemetry(result, builder1);
416
417
// After 1 minute, reschedule with a new builder
418
await vi.advanceTimersByTimeAsync(60_000);
419
sender.scheduleSendingEnhancedTelemetry(result, builder2);
420
421
// Original 2-min timeout would fire at 120s, but it was cancelled
422
// New 2-min timeout fires at 60s + 120s = 180s
423
await vi.advanceTimersByTimeAsync(60_000); // at 120s total
424
expect(telemetryService.enhancedEvents).toHaveLength(0); // old one cancelled
425
426
await vi.advanceTimersByTimeAsync(60_000 + 5_000); // at 185s total — new timeout + idle
427
expect(telemetryService.enhancedEvents).toHaveLength(1);
428
});
429
});
430
});
431
432