Book a Demo!
CoCalc Logo Icon
StoreFeaturesDocsShareSupportNewsAboutPoliciesSign UpSign In
microsoft
GitHub Repository: microsoft/vscode
Path: blob/main/src/vs/workbench/contrib/editTelemetry/browser/telemetry/editSourceTrackingImpl.ts
5240 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 { reverseOrder, compareBy, numberComparator, sumBy } from '../../../../../base/common/arrays.js';
7
import { IntervalTimer, TimeoutTimer } from '../../../../../base/common/async.js';
8
import { toDisposable, Disposable } from '../../../../../base/common/lifecycle.js';
9
import { mapObservableArrayCached, derived, IObservable, observableSignal, runOnChange, autorun } from '../../../../../base/common/observable.js';
10
import { IInstantiationService } from '../../../../../platform/instantiation/common/instantiation.js';
11
import { ITelemetryService } from '../../../../../platform/telemetry/common/telemetry.js';
12
import { IUserAttentionService } from '../../../../services/userAttention/common/userAttentionService.js';
13
import { AnnotatedDocument, IAnnotatedDocuments } from '../helpers/annotatedDocuments.js';
14
import { CreateSuggestionIdForChatOrInlineChatCaller, EditTelemetryReportEditArcForChatOrInlineChatSender, EditTelemetryReportInlineEditArcSender } from './arcTelemetrySender.js';
15
import { createDocWithJustReason, EditSource } from '../helpers/documentWithAnnotatedEdits.js';
16
import { DocumentEditSourceTracker, TrackedEdit } from './editTracker.js';
17
import { sumByCategory } from '../helpers/utils.js';
18
import { ScmAdapter, ScmRepoAdapter } from './scmAdapter.js';
19
import { IRandomService } from '../randomService.js';
20
21
type EditTelemetryMode = 'longterm' | '5minWindow' | '20minFocusWindow';
22
type EditTelemetryTrigger = '10hours' | 'hashChange' | 'branchChange' | 'closed' | 'time';
23
24
export class EditSourceTrackingImpl extends Disposable {
25
public readonly docsState;
26
private readonly _states;
27
28
constructor(
29
private readonly _statsEnabled: IObservable<boolean>,
30
private readonly _annotatedDocuments: IAnnotatedDocuments,
31
@IInstantiationService private readonly _instantiationService: IInstantiationService,
32
) {
33
super();
34
35
const scmBridge = this._instantiationService.createInstance(ScmAdapter);
36
this._states = mapObservableArrayCached(this, this._annotatedDocuments.documents, (doc, store) => {
37
return [doc.document, store.add(this._instantiationService.createInstance(TrackedDocumentInfo, doc, scmBridge, this._statsEnabled))] as const;
38
});
39
this.docsState = this._states.map((entries) => new Map(entries));
40
41
this.docsState.recomputeInitiallyAndOnChange(this._store);
42
}
43
}
44
45
class TrackedDocumentInfo extends Disposable {
46
public readonly longtermTracker: IObservable<DocumentEditSourceTracker<undefined> | undefined>;
47
public readonly windowedTracker: IObservable<DocumentEditSourceTracker<undefined> | undefined>;
48
public readonly windowedFocusTracker: IObservable<DocumentEditSourceTracker<undefined> | undefined>;
49
50
private readonly _repo: IObservable<ScmRepoAdapter | undefined>;
51
52
constructor(
53
private readonly _doc: AnnotatedDocument,
54
private readonly _scm: ScmAdapter,
55
private readonly _statsEnabled: IObservable<boolean>,
56
@IInstantiationService private readonly _instantiationService: IInstantiationService,
57
@ITelemetryService private readonly _telemetryService: ITelemetryService,
58
@IRandomService private readonly _randomService: IRandomService,
59
@IUserAttentionService private readonly _userAttentionService: IUserAttentionService,
60
) {
61
super();
62
63
this._repo = derived(this, reader => this._scm.getRepo(_doc.document.uri, reader));
64
65
const docWithJustReason = createDocWithJustReason(_doc.documentWithAnnotations, this._store);
66
67
const longtermResetSignal = observableSignal('resetSignal');
68
69
let longtermReason: EditTelemetryTrigger = 'closed';
70
this.longtermTracker = derived((reader) => {
71
if (!this._statsEnabled.read(reader)) { return undefined; }
72
longtermResetSignal.read(reader);
73
74
const t = reader.store.add(new DocumentEditSourceTracker(docWithJustReason, undefined));
75
const startFocusTime = this._userAttentionService.totalFocusTimeMs;
76
const startTime = Date.now();
77
reader.store.add(toDisposable(() => {
78
// send long term document telemetry
79
if (!t.isEmpty()) {
80
this.sendTelemetry('longterm', longtermReason, t, this._userAttentionService.totalFocusTimeMs - startFocusTime, Date.now() - startTime);
81
}
82
t.dispose();
83
}));
84
return t;
85
}).recomputeInitiallyAndOnChange(this._store);
86
87
this._store.add(new IntervalTimer()).cancelAndSet(() => {
88
// Reset after 10 hours
89
longtermReason = '10hours';
90
longtermResetSignal.trigger(undefined);
91
longtermReason = 'closed';
92
}, 10 * 60 * 60 * 1000);
93
94
// Reset on branch change or commit
95
this._store.add(autorun(reader => {
96
const repo = this._repo.read(reader);
97
if (repo) {
98
reader.store.add(runOnChange(repo.headCommitHashObs, () => {
99
longtermReason = 'hashChange';
100
longtermResetSignal.trigger(undefined);
101
longtermReason = 'closed';
102
}));
103
reader.store.add(runOnChange(repo.headBranchNameObs, () => {
104
longtermReason = 'branchChange';
105
longtermResetSignal.trigger(undefined);
106
longtermReason = 'closed';
107
}));
108
}
109
}));
110
111
this._store.add(this._instantiationService.createInstance(EditTelemetryReportInlineEditArcSender, _doc.documentWithAnnotations, this._repo));
112
this._store.add(this._instantiationService.createInstance(EditTelemetryReportEditArcForChatOrInlineChatSender, _doc.documentWithAnnotations, this._repo));
113
this._store.add(this._instantiationService.createInstance(CreateSuggestionIdForChatOrInlineChatCaller, _doc.documentWithAnnotations));
114
115
// Wall-clock time based 5-minute window tracker
116
const resetSignal = observableSignal('resetSignal');
117
118
this.windowedTracker = derived((reader) => {
119
if (!this._statsEnabled.read(reader)) { return undefined; }
120
121
if (!this._doc.isVisible.read(reader)) {
122
return undefined;
123
}
124
resetSignal.read(reader);
125
126
// Reset after 5 minutes of wall-clock time
127
reader.store.add(new TimeoutTimer(() => {
128
resetSignal.trigger(undefined);
129
}, 5 * 60 * 1000));
130
131
const t = reader.store.add(new DocumentEditSourceTracker(docWithJustReason, undefined));
132
const startFocusTime = this._userAttentionService.totalFocusTimeMs;
133
const startTime = Date.now();
134
reader.store.add(toDisposable(async () => {
135
// send windowed document telemetry
136
this.sendTelemetry('5minWindow', 'time', t, this._userAttentionService.totalFocusTimeMs - startFocusTime, Date.now() - startTime);
137
t.dispose();
138
}));
139
140
return t;
141
}).recomputeInitiallyAndOnChange(this._store);
142
143
// Focus time based 20-minute window tracker
144
const focusResetSignal = observableSignal('focusResetSignal');
145
146
this.windowedFocusTracker = derived((reader) => {
147
if (!this._statsEnabled.read(reader)) { return undefined; }
148
149
if (!this._doc.isVisible.read(reader)) {
150
return undefined;
151
}
152
focusResetSignal.read(reader);
153
154
// Reset after 20 minutes of accumulated focus time
155
reader.store.add(this._userAttentionService.fireAfterGivenFocusTimePassed(20 * 60 * 1000, () => {
156
focusResetSignal.trigger(undefined);
157
}));
158
159
const t = reader.store.add(new DocumentEditSourceTracker(docWithJustReason, undefined));
160
const startFocusTime = this._userAttentionService.totalFocusTimeMs;
161
const startTime = Date.now();
162
reader.store.add(toDisposable(async () => {
163
// send focus-windowed document telemetry
164
this.sendTelemetry('20minFocusWindow', 'time', t, this._userAttentionService.totalFocusTimeMs - startFocusTime, Date.now() - startTime);
165
t.dispose();
166
}));
167
168
return t;
169
}).recomputeInitiallyAndOnChange(this._store);
170
171
}
172
173
async sendTelemetry(mode: EditTelemetryMode, trigger: EditTelemetryTrigger, t: DocumentEditSourceTracker, focusTime: number, actualTime: number) {
174
const ranges = t.getTrackedRanges();
175
const keys = t.getAllKeys();
176
if (keys.length === 0) {
177
return;
178
}
179
180
const data = this.getTelemetryData(ranges);
181
182
const statsUuid = this._randomService.generateUuid();
183
184
const sums = sumByCategory(ranges, r => r.range.length, r => r.sourceKey);
185
const entries = Object.entries(sums).filter(([key, value]) => value !== undefined);
186
entries.sort(reverseOrder(compareBy(([key, value]) => value!, numberComparator)));
187
entries.length = mode === 'longterm' ? 30 : 10;
188
189
for (const key of keys) {
190
if (!sums[key]) {
191
sums[key] = 0;
192
}
193
}
194
195
for (const [key, value] of Object.entries(sums)) {
196
if (value === undefined) {
197
continue;
198
}
199
200
const repr = t.getRepresentative(key)!;
201
const deltaModifiedCount = t.getTotalInsertedCharactersCount(key);
202
203
this._telemetryService.publicLog2<{
204
mode: EditTelemetryMode;
205
sourceKey: string;
206
207
sourceKeyCleaned: string;
208
extensionId: string | undefined;
209
extensionVersion: string | undefined;
210
modelId: string | undefined;
211
212
trigger: EditTelemetryTrigger;
213
languageId: string;
214
statsUuid: string;
215
modifiedCount: number;
216
deltaModifiedCount: number;
217
totalModifiedCount: number;
218
}, {
219
owner: 'hediet';
220
comment: 'Provides detailed character count breakdown for individual edit sources (typing, paste, inline completions, NES, etc.) within a session. Reports the top 10-30 sources per session with granular metadata including extension IDs and model IDs for AI edits. Sessions are scoped to either 5-minute wall-clock time windows, 20-minute focus time windows for visible documents, or longer periods ending on branch changes, commits, or 10-hour intervals. Focus time is computed as the accumulated time where VS Code has focus and there was recent user activity (within the last minute). This event complements editSources.stats by providing source-specific details. @sentToGitHub';
221
222
mode: { classification: 'SystemMetaData'; purpose: 'PerformanceAndHealth'; comment: 'Describes the session mode. Is either \'longterm\', \'5minWindow\', or \'20minFocusWindow\'.' };
223
sourceKey: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'A description of the source of the edit.' };
224
225
sourceKeyCleaned: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'The source of the edit with some properties (such as extensionId, extensionVersion and modelId) removed.' };
226
extensionId: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'The extension id.' };
227
extensionVersion: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'The version of the extension.' };
228
modelId: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'The LLM id.' };
229
230
languageId: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'The language id of the document.' };
231
statsUuid: { classification: 'SystemMetaData'; purpose: 'PerformanceAndHealth'; comment: 'The unique identifier of the session for which stats are reported. The sourceKey is unique in this session.' };
232
233
trigger: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'Indicates why the session ended.' };
234
235
modifiedCount: { classification: 'SystemMetaData'; purpose: 'PerformanceAndHealth'; comment: 'The number of characters inserted by the given edit source during the session that are still in the text document at the end of the session.'; isMeasurement: true };
236
deltaModifiedCount: { classification: 'SystemMetaData'; purpose: 'PerformanceAndHealth'; comment: 'The number of characters inserted by the given edit source during the session.'; isMeasurement: true };
237
totalModifiedCount: { classification: 'SystemMetaData'; purpose: 'PerformanceAndHealth'; comment: 'The number of characters inserted by any edit source during the session that are still in the text document at the end of the session.'; isMeasurement: true };
238
239
}>('editTelemetry.editSources.details', {
240
mode,
241
sourceKey: key,
242
243
sourceKeyCleaned: repr.toKey(1, { $extensionId: false, $extensionVersion: false, $modelId: false }),
244
extensionId: repr.props.$extensionId,
245
extensionVersion: repr.props.$extensionVersion,
246
modelId: repr.props.$modelId,
247
248
trigger,
249
languageId: this._doc.document.languageId.get(),
250
statsUuid: statsUuid,
251
modifiedCount: value,
252
deltaModifiedCount: deltaModifiedCount,
253
totalModifiedCount: data.totalModifiedCharactersInFinalState,
254
});
255
}
256
257
258
const isTrackedByGit = await data.isTrackedByGit;
259
this._telemetryService.publicLog2<{
260
mode: EditTelemetryMode;
261
languageId: string;
262
statsUuid: string;
263
nesModifiedCount: number;
264
inlineCompletionsCopilotModifiedCount: number;
265
inlineCompletionsNESModifiedCount: number;
266
otherAIModifiedCount: number;
267
unknownModifiedCount: number;
268
userModifiedCount: number;
269
ideModifiedCount: number;
270
totalModifiedCharacters: number;
271
externalModifiedCount: number;
272
isTrackedByGit: number;
273
focusTime: number;
274
actualTime: number;
275
trigger: EditTelemetryTrigger;
276
}, {
277
owner: 'hediet';
278
comment: 'Aggregates character counts by edit source category (user typing, AI completions, NES, IDE actions, external changes) for each editing session. Sessions represent units of work and end when documents close, branches change, commits occur, or time limits are reached (5 minutes of wall-clock time, 20 minutes of focus time for visible documents, or 10 hours otherwise). Focus time is computed as accumulated 1-minute blocks where VS Code has focus and there was recent user activity. Tracks both total characters inserted and characters remaining at session end to measure retention. This high-level summary complements editSources.details which provides granular per-source breakdowns. @sentToGitHub';
279
280
mode: { classification: 'SystemMetaData'; purpose: 'PerformanceAndHealth'; comment: 'longterm, 5minWindow, or 20minFocusWindow' };
281
languageId: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'The language id of the document.' };
282
statsUuid: { classification: 'SystemMetaData'; purpose: 'PerformanceAndHealth'; comment: 'The unique identifier for the telemetry event.' };
283
284
nesModifiedCount: { classification: 'SystemMetaData'; purpose: 'PerformanceAndHealth'; comment: 'Fraction of nes modified characters'; isMeasurement: true };
285
inlineCompletionsCopilotModifiedCount: { classification: 'SystemMetaData'; purpose: 'PerformanceAndHealth'; comment: 'Fraction of inline completions copilot modified characters'; isMeasurement: true };
286
inlineCompletionsNESModifiedCount: { classification: 'SystemMetaData'; purpose: 'PerformanceAndHealth'; comment: 'Fraction of inline completions nes modified characters'; isMeasurement: true };
287
otherAIModifiedCount: { classification: 'SystemMetaData'; purpose: 'PerformanceAndHealth'; comment: 'Fraction of other AI modified characters'; isMeasurement: true };
288
unknownModifiedCount: { classification: 'SystemMetaData'; purpose: 'PerformanceAndHealth'; comment: 'Fraction of unknown modified characters'; isMeasurement: true };
289
userModifiedCount: { classification: 'SystemMetaData'; purpose: 'PerformanceAndHealth'; comment: 'Fraction of user modified characters'; isMeasurement: true };
290
ideModifiedCount: { classification: 'SystemMetaData'; purpose: 'PerformanceAndHealth'; comment: 'Fraction of IDE modified characters'; isMeasurement: true };
291
totalModifiedCharacters: { classification: 'SystemMetaData'; purpose: 'PerformanceAndHealth'; comment: 'Total modified characters'; isMeasurement: true };
292
externalModifiedCount: { classification: 'SystemMetaData'; purpose: 'PerformanceAndHealth'; comment: 'Fraction of external modified characters'; isMeasurement: true };
293
isTrackedByGit: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'Indicates if the document is tracked by git.' };
294
focusTime: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'The focus time in ms during the session.'; isMeasurement: true };
295
actualTime: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'The actual time in ms during the session.'; isMeasurement: true };
296
trigger: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'Indicates why the session ended.' };
297
}>('editTelemetry.editSources.stats', {
298
mode,
299
languageId: this._doc.document.languageId.get(),
300
statsUuid: statsUuid,
301
nesModifiedCount: data.nesModifiedCount,
302
inlineCompletionsCopilotModifiedCount: data.inlineCompletionsCopilotModifiedCount,
303
inlineCompletionsNESModifiedCount: data.inlineCompletionsNESModifiedCount,
304
otherAIModifiedCount: data.otherAIModifiedCount,
305
unknownModifiedCount: data.unknownModifiedCount,
306
userModifiedCount: data.userModifiedCount,
307
ideModifiedCount: data.ideModifiedCount,
308
totalModifiedCharacters: data.totalModifiedCharactersInFinalState,
309
externalModifiedCount: data.externalModifiedCount,
310
isTrackedByGit: isTrackedByGit ? 1 : 0,
311
focusTime,
312
actualTime,
313
trigger,
314
});
315
}
316
317
getTelemetryData(ranges: readonly TrackedEdit[]) {
318
const getEditCategory = (source: EditSource) => {
319
if (source.category === 'ai' && source.kind === 'nes') { return 'nes'; }
320
321
if (source.category === 'ai' && source.kind === 'completion' && source.extensionId === 'github.copilot') { return 'inlineCompletionsCopilot'; }
322
if (source.category === 'ai' && source.kind === 'completion' && source.extensionId === 'github.copilot-chat' && source.providerId === 'completions') { return 'inlineCompletionsCopilot'; }
323
if (source.category === 'ai' && source.kind === 'completion' && source.extensionId === 'github.copilot-chat' && source.providerId === 'nes') { return 'inlineCompletionsNES'; }
324
if (source.category === 'ai' && source.kind === 'completion') { return 'inlineCompletionsOther'; }
325
326
if (source.category === 'ai') { return 'otherAI'; }
327
if (source.category === 'user') { return 'user'; }
328
if (source.category === 'ide') { return 'ide'; }
329
if (source.category === 'external') { return 'external'; }
330
if (source.category === 'unknown') { return 'unknown'; }
331
332
return 'unknown';
333
};
334
335
const sums = sumByCategory(ranges, r => r.range.length, r => getEditCategory(r.source));
336
const totalModifiedCharactersInFinalState = sumBy(ranges, r => r.range.length);
337
338
return {
339
nesModifiedCount: sums.nes ?? 0,
340
inlineCompletionsCopilotModifiedCount: sums.inlineCompletionsCopilot ?? 0,
341
inlineCompletionsNESModifiedCount: sums.inlineCompletionsNES ?? 0,
342
otherAIModifiedCount: sums.otherAI ?? 0,
343
userModifiedCount: sums.user ?? 0,
344
ideModifiedCount: sums.ide ?? 0,
345
unknownModifiedCount: sums.unknown ?? 0,
346
externalModifiedCount: sums.external ?? 0,
347
totalModifiedCharactersInFinalState,
348
languageId: this._doc.document.languageId.get(),
349
isTrackedByGit: this._repo.get()?.isIgnored(this._doc.document.uri),
350
};
351
}
352
}
353
354