Path: blob/main/src/vs/workbench/contrib/editTelemetry/browser/telemetry/editSourceTrackingImpl.ts
5240 views
/*---------------------------------------------------------------------------------------------1* Copyright (c) Microsoft Corporation. All rights reserved.2* Licensed under the MIT License. See License.txt in the project root for license information.3*--------------------------------------------------------------------------------------------*/45import { reverseOrder, compareBy, numberComparator, sumBy } from '../../../../../base/common/arrays.js';6import { IntervalTimer, TimeoutTimer } from '../../../../../base/common/async.js';7import { toDisposable, Disposable } from '../../../../../base/common/lifecycle.js';8import { mapObservableArrayCached, derived, IObservable, observableSignal, runOnChange, autorun } from '../../../../../base/common/observable.js';9import { IInstantiationService } from '../../../../../platform/instantiation/common/instantiation.js';10import { ITelemetryService } from '../../../../../platform/telemetry/common/telemetry.js';11import { IUserAttentionService } from '../../../../services/userAttention/common/userAttentionService.js';12import { AnnotatedDocument, IAnnotatedDocuments } from '../helpers/annotatedDocuments.js';13import { CreateSuggestionIdForChatOrInlineChatCaller, EditTelemetryReportEditArcForChatOrInlineChatSender, EditTelemetryReportInlineEditArcSender } from './arcTelemetrySender.js';14import { createDocWithJustReason, EditSource } from '../helpers/documentWithAnnotatedEdits.js';15import { DocumentEditSourceTracker, TrackedEdit } from './editTracker.js';16import { sumByCategory } from '../helpers/utils.js';17import { ScmAdapter, ScmRepoAdapter } from './scmAdapter.js';18import { IRandomService } from '../randomService.js';1920type EditTelemetryMode = 'longterm' | '5minWindow' | '20minFocusWindow';21type EditTelemetryTrigger = '10hours' | 'hashChange' | 'branchChange' | 'closed' | 'time';2223export class EditSourceTrackingImpl extends Disposable {24public readonly docsState;25private readonly _states;2627constructor(28private readonly _statsEnabled: IObservable<boolean>,29private readonly _annotatedDocuments: IAnnotatedDocuments,30@IInstantiationService private readonly _instantiationService: IInstantiationService,31) {32super();3334const scmBridge = this._instantiationService.createInstance(ScmAdapter);35this._states = mapObservableArrayCached(this, this._annotatedDocuments.documents, (doc, store) => {36return [doc.document, store.add(this._instantiationService.createInstance(TrackedDocumentInfo, doc, scmBridge, this._statsEnabled))] as const;37});38this.docsState = this._states.map((entries) => new Map(entries));3940this.docsState.recomputeInitiallyAndOnChange(this._store);41}42}4344class TrackedDocumentInfo extends Disposable {45public readonly longtermTracker: IObservable<DocumentEditSourceTracker<undefined> | undefined>;46public readonly windowedTracker: IObservable<DocumentEditSourceTracker<undefined> | undefined>;47public readonly windowedFocusTracker: IObservable<DocumentEditSourceTracker<undefined> | undefined>;4849private readonly _repo: IObservable<ScmRepoAdapter | undefined>;5051constructor(52private readonly _doc: AnnotatedDocument,53private readonly _scm: ScmAdapter,54private readonly _statsEnabled: IObservable<boolean>,55@IInstantiationService private readonly _instantiationService: IInstantiationService,56@ITelemetryService private readonly _telemetryService: ITelemetryService,57@IRandomService private readonly _randomService: IRandomService,58@IUserAttentionService private readonly _userAttentionService: IUserAttentionService,59) {60super();6162this._repo = derived(this, reader => this._scm.getRepo(_doc.document.uri, reader));6364const docWithJustReason = createDocWithJustReason(_doc.documentWithAnnotations, this._store);6566const longtermResetSignal = observableSignal('resetSignal');6768let longtermReason: EditTelemetryTrigger = 'closed';69this.longtermTracker = derived((reader) => {70if (!this._statsEnabled.read(reader)) { return undefined; }71longtermResetSignal.read(reader);7273const t = reader.store.add(new DocumentEditSourceTracker(docWithJustReason, undefined));74const startFocusTime = this._userAttentionService.totalFocusTimeMs;75const startTime = Date.now();76reader.store.add(toDisposable(() => {77// send long term document telemetry78if (!t.isEmpty()) {79this.sendTelemetry('longterm', longtermReason, t, this._userAttentionService.totalFocusTimeMs - startFocusTime, Date.now() - startTime);80}81t.dispose();82}));83return t;84}).recomputeInitiallyAndOnChange(this._store);8586this._store.add(new IntervalTimer()).cancelAndSet(() => {87// Reset after 10 hours88longtermReason = '10hours';89longtermResetSignal.trigger(undefined);90longtermReason = 'closed';91}, 10 * 60 * 60 * 1000);9293// Reset on branch change or commit94this._store.add(autorun(reader => {95const repo = this._repo.read(reader);96if (repo) {97reader.store.add(runOnChange(repo.headCommitHashObs, () => {98longtermReason = 'hashChange';99longtermResetSignal.trigger(undefined);100longtermReason = 'closed';101}));102reader.store.add(runOnChange(repo.headBranchNameObs, () => {103longtermReason = 'branchChange';104longtermResetSignal.trigger(undefined);105longtermReason = 'closed';106}));107}108}));109110this._store.add(this._instantiationService.createInstance(EditTelemetryReportInlineEditArcSender, _doc.documentWithAnnotations, this._repo));111this._store.add(this._instantiationService.createInstance(EditTelemetryReportEditArcForChatOrInlineChatSender, _doc.documentWithAnnotations, this._repo));112this._store.add(this._instantiationService.createInstance(CreateSuggestionIdForChatOrInlineChatCaller, _doc.documentWithAnnotations));113114// Wall-clock time based 5-minute window tracker115const resetSignal = observableSignal('resetSignal');116117this.windowedTracker = derived((reader) => {118if (!this._statsEnabled.read(reader)) { return undefined; }119120if (!this._doc.isVisible.read(reader)) {121return undefined;122}123resetSignal.read(reader);124125// Reset after 5 minutes of wall-clock time126reader.store.add(new TimeoutTimer(() => {127resetSignal.trigger(undefined);128}, 5 * 60 * 1000));129130const t = reader.store.add(new DocumentEditSourceTracker(docWithJustReason, undefined));131const startFocusTime = this._userAttentionService.totalFocusTimeMs;132const startTime = Date.now();133reader.store.add(toDisposable(async () => {134// send windowed document telemetry135this.sendTelemetry('5minWindow', 'time', t, this._userAttentionService.totalFocusTimeMs - startFocusTime, Date.now() - startTime);136t.dispose();137}));138139return t;140}).recomputeInitiallyAndOnChange(this._store);141142// Focus time based 20-minute window tracker143const focusResetSignal = observableSignal('focusResetSignal');144145this.windowedFocusTracker = derived((reader) => {146if (!this._statsEnabled.read(reader)) { return undefined; }147148if (!this._doc.isVisible.read(reader)) {149return undefined;150}151focusResetSignal.read(reader);152153// Reset after 20 minutes of accumulated focus time154reader.store.add(this._userAttentionService.fireAfterGivenFocusTimePassed(20 * 60 * 1000, () => {155focusResetSignal.trigger(undefined);156}));157158const t = reader.store.add(new DocumentEditSourceTracker(docWithJustReason, undefined));159const startFocusTime = this._userAttentionService.totalFocusTimeMs;160const startTime = Date.now();161reader.store.add(toDisposable(async () => {162// send focus-windowed document telemetry163this.sendTelemetry('20minFocusWindow', 'time', t, this._userAttentionService.totalFocusTimeMs - startFocusTime, Date.now() - startTime);164t.dispose();165}));166167return t;168}).recomputeInitiallyAndOnChange(this._store);169170}171172async sendTelemetry(mode: EditTelemetryMode, trigger: EditTelemetryTrigger, t: DocumentEditSourceTracker, focusTime: number, actualTime: number) {173const ranges = t.getTrackedRanges();174const keys = t.getAllKeys();175if (keys.length === 0) {176return;177}178179const data = this.getTelemetryData(ranges);180181const statsUuid = this._randomService.generateUuid();182183const sums = sumByCategory(ranges, r => r.range.length, r => r.sourceKey);184const entries = Object.entries(sums).filter(([key, value]) => value !== undefined);185entries.sort(reverseOrder(compareBy(([key, value]) => value!, numberComparator)));186entries.length = mode === 'longterm' ? 30 : 10;187188for (const key of keys) {189if (!sums[key]) {190sums[key] = 0;191}192}193194for (const [key, value] of Object.entries(sums)) {195if (value === undefined) {196continue;197}198199const repr = t.getRepresentative(key)!;200const deltaModifiedCount = t.getTotalInsertedCharactersCount(key);201202this._telemetryService.publicLog2<{203mode: EditTelemetryMode;204sourceKey: string;205206sourceKeyCleaned: string;207extensionId: string | undefined;208extensionVersion: string | undefined;209modelId: string | undefined;210211trigger: EditTelemetryTrigger;212languageId: string;213statsUuid: string;214modifiedCount: number;215deltaModifiedCount: number;216totalModifiedCount: number;217}, {218owner: 'hediet';219comment: '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';220221mode: { classification: 'SystemMetaData'; purpose: 'PerformanceAndHealth'; comment: 'Describes the session mode. Is either \'longterm\', \'5minWindow\', or \'20minFocusWindow\'.' };222sourceKey: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'A description of the source of the edit.' };223224sourceKeyCleaned: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'The source of the edit with some properties (such as extensionId, extensionVersion and modelId) removed.' };225extensionId: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'The extension id.' };226extensionVersion: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'The version of the extension.' };227modelId: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'The LLM id.' };228229languageId: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'The language id of the document.' };230statsUuid: { classification: 'SystemMetaData'; purpose: 'PerformanceAndHealth'; comment: 'The unique identifier of the session for which stats are reported. The sourceKey is unique in this session.' };231232trigger: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'Indicates why the session ended.' };233234modifiedCount: { 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 };235deltaModifiedCount: { classification: 'SystemMetaData'; purpose: 'PerformanceAndHealth'; comment: 'The number of characters inserted by the given edit source during the session.'; isMeasurement: true };236totalModifiedCount: { 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 };237238}>('editTelemetry.editSources.details', {239mode,240sourceKey: key,241242sourceKeyCleaned: repr.toKey(1, { $extensionId: false, $extensionVersion: false, $modelId: false }),243extensionId: repr.props.$extensionId,244extensionVersion: repr.props.$extensionVersion,245modelId: repr.props.$modelId,246247trigger,248languageId: this._doc.document.languageId.get(),249statsUuid: statsUuid,250modifiedCount: value,251deltaModifiedCount: deltaModifiedCount,252totalModifiedCount: data.totalModifiedCharactersInFinalState,253});254}255256257const isTrackedByGit = await data.isTrackedByGit;258this._telemetryService.publicLog2<{259mode: EditTelemetryMode;260languageId: string;261statsUuid: string;262nesModifiedCount: number;263inlineCompletionsCopilotModifiedCount: number;264inlineCompletionsNESModifiedCount: number;265otherAIModifiedCount: number;266unknownModifiedCount: number;267userModifiedCount: number;268ideModifiedCount: number;269totalModifiedCharacters: number;270externalModifiedCount: number;271isTrackedByGit: number;272focusTime: number;273actualTime: number;274trigger: EditTelemetryTrigger;275}, {276owner: 'hediet';277comment: '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';278279mode: { classification: 'SystemMetaData'; purpose: 'PerformanceAndHealth'; comment: 'longterm, 5minWindow, or 20minFocusWindow' };280languageId: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'The language id of the document.' };281statsUuid: { classification: 'SystemMetaData'; purpose: 'PerformanceAndHealth'; comment: 'The unique identifier for the telemetry event.' };282283nesModifiedCount: { classification: 'SystemMetaData'; purpose: 'PerformanceAndHealth'; comment: 'Fraction of nes modified characters'; isMeasurement: true };284inlineCompletionsCopilotModifiedCount: { classification: 'SystemMetaData'; purpose: 'PerformanceAndHealth'; comment: 'Fraction of inline completions copilot modified characters'; isMeasurement: true };285inlineCompletionsNESModifiedCount: { classification: 'SystemMetaData'; purpose: 'PerformanceAndHealth'; comment: 'Fraction of inline completions nes modified characters'; isMeasurement: true };286otherAIModifiedCount: { classification: 'SystemMetaData'; purpose: 'PerformanceAndHealth'; comment: 'Fraction of other AI modified characters'; isMeasurement: true };287unknownModifiedCount: { classification: 'SystemMetaData'; purpose: 'PerformanceAndHealth'; comment: 'Fraction of unknown modified characters'; isMeasurement: true };288userModifiedCount: { classification: 'SystemMetaData'; purpose: 'PerformanceAndHealth'; comment: 'Fraction of user modified characters'; isMeasurement: true };289ideModifiedCount: { classification: 'SystemMetaData'; purpose: 'PerformanceAndHealth'; comment: 'Fraction of IDE modified characters'; isMeasurement: true };290totalModifiedCharacters: { classification: 'SystemMetaData'; purpose: 'PerformanceAndHealth'; comment: 'Total modified characters'; isMeasurement: true };291externalModifiedCount: { classification: 'SystemMetaData'; purpose: 'PerformanceAndHealth'; comment: 'Fraction of external modified characters'; isMeasurement: true };292isTrackedByGit: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'Indicates if the document is tracked by git.' };293focusTime: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'The focus time in ms during the session.'; isMeasurement: true };294actualTime: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'The actual time in ms during the session.'; isMeasurement: true };295trigger: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'Indicates why the session ended.' };296}>('editTelemetry.editSources.stats', {297mode,298languageId: this._doc.document.languageId.get(),299statsUuid: statsUuid,300nesModifiedCount: data.nesModifiedCount,301inlineCompletionsCopilotModifiedCount: data.inlineCompletionsCopilotModifiedCount,302inlineCompletionsNESModifiedCount: data.inlineCompletionsNESModifiedCount,303otherAIModifiedCount: data.otherAIModifiedCount,304unknownModifiedCount: data.unknownModifiedCount,305userModifiedCount: data.userModifiedCount,306ideModifiedCount: data.ideModifiedCount,307totalModifiedCharacters: data.totalModifiedCharactersInFinalState,308externalModifiedCount: data.externalModifiedCount,309isTrackedByGit: isTrackedByGit ? 1 : 0,310focusTime,311actualTime,312trigger,313});314}315316getTelemetryData(ranges: readonly TrackedEdit[]) {317const getEditCategory = (source: EditSource) => {318if (source.category === 'ai' && source.kind === 'nes') { return 'nes'; }319320if (source.category === 'ai' && source.kind === 'completion' && source.extensionId === 'github.copilot') { return 'inlineCompletionsCopilot'; }321if (source.category === 'ai' && source.kind === 'completion' && source.extensionId === 'github.copilot-chat' && source.providerId === 'completions') { return 'inlineCompletionsCopilot'; }322if (source.category === 'ai' && source.kind === 'completion' && source.extensionId === 'github.copilot-chat' && source.providerId === 'nes') { return 'inlineCompletionsNES'; }323if (source.category === 'ai' && source.kind === 'completion') { return 'inlineCompletionsOther'; }324325if (source.category === 'ai') { return 'otherAI'; }326if (source.category === 'user') { return 'user'; }327if (source.category === 'ide') { return 'ide'; }328if (source.category === 'external') { return 'external'; }329if (source.category === 'unknown') { return 'unknown'; }330331return 'unknown';332};333334const sums = sumByCategory(ranges, r => r.range.length, r => getEditCategory(r.source));335const totalModifiedCharactersInFinalState = sumBy(ranges, r => r.range.length);336337return {338nesModifiedCount: sums.nes ?? 0,339inlineCompletionsCopilotModifiedCount: sums.inlineCompletionsCopilot ?? 0,340inlineCompletionsNESModifiedCount: sums.inlineCompletionsNES ?? 0,341otherAIModifiedCount: sums.otherAI ?? 0,342userModifiedCount: sums.user ?? 0,343ideModifiedCount: sums.ide ?? 0,344unknownModifiedCount: sums.unknown ?? 0,345externalModifiedCount: sums.external ?? 0,346totalModifiedCharactersInFinalState,347languageId: this._doc.document.languageId.get(),348isTrackedByGit: this._repo.get()?.isIgnored(this._doc.document.uri),349};350}351}352353354