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

342 LOC · 293 covered · 49 uncovered · 79 ranges · 893 concepts · 25 introducers · 417 tests

File neighbourhood

The centred file is linked to every concept that introduces one of its ranges, every test that runs code from the file, and the gray connector concepts standing between those tests and the file's own introducer concepts. Undirected links join concepts to every file where they introduce source and concepts to the tests they introduce; arrows show specialization between the displayed concepts and bridge only concepts omitted from this view. Concept colors match the source ranges below; connector concepts have no source color and are shown in gray.

Focused file, its introducer and connector concepts, their introduced files, and tests that run code from the file

In the embedded map, ordinary wheel input scrolls the page; use the visible controls to zoom and drag to pan. Open the full-screen map for canvas navigation: wheel pans, Ctrl/Command plus wheel zooms, and arrow keys pan when this region is focused. On touch screens, open the full-screen map to pan or pinch. If JavaScript or WebGL is unavailable, use the related-file, concept, and source links on this page.

Graph controls are ready.

Interactive rendering requires JavaScript and WebGL. Use the related-file, concept, and source links on this page while the interactive map is unavailable.

1 > /*--------------------------------------------------------------------------------------------- agentHostRepoInfoTelemetry.ts ×13
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 { Limiter } from '../../../base/common/async.js';
7 > import { Disposable } from '../../../base/common/lifecycle.js';
8 > import { relativePath } from '../../../base/common/resources.js';
9 > import { URI } from '../../../base/common/uri.js';
10 > import { ILogService } from '../../log/common/log.js';
11 > import { IAgentHostGitService } from '../common/agentHostGitService.js';
12 > import type { ISessionFileDiff } from '../common/state/sessionState.js';
13 > import { IAgentHostGitHubEndpointService } from './agentHostGitHubEndpointService.js';
14 > import type { AgentHostRepoInfoResult, AgentHostTelemetryReporter } from './agentHostTelemetryReporter.js';
15 > import type { IAgentHostRestrictedTelemetryContext } from './agentHostRestrictedTelemetry.js';
16 >
17 > const MAX_DIFFS_JSON_BYTES = 900 * 1024;
18 > const MAX_DIFFS_JSON_CHARS = 50 * 8192;
19 > const MAX_CHANGES = 100;
20 > const MAX_MERGE_BASE_AGE_MS = 30 * 24 * 60 * 60 * 1000;
21 > const MAX_DIFF_COMMITS = 30;
22 > const DIFF_PATCH_CONCURRENCY = 4;
23 > const MAX_DIFF_SIZE = 100_000;
24 >
25 > interface IRepoInfoContext extends IResolvedRepoInfoRemote {
26 > readonly headCommitHash: string;
27 > readonly headBranchName: string | undefined;
28 > }
29 >
30 > interface IRepoInfoFileDescriptor {
31 > readonly uri: string;
32 > readonly originalUri: string;
33 > readonly renameUri: string | undefined;
34 > readonly status: 'INDEX_ADDED' | 'MODIFIED' | 'DELETED' | 'INDEX_RENAMED' | 'UNTRACKED';
35 > readonly oldPath: string | undefined;
36 > readonly newPath: string | undefined;
37 > }
38 >
39 > type RepoInfoTelemetryReporter = Pick<AgentHostTelemetryReporter, 'reportRepoInfo'>;
40 >
41 > export interface IResolvedRepoInfoRemote {
42 > readonly remoteUrl: string;
43 > readonly repoId: string;
44 > readonly repoType: 'github' | 'ado';
45 > }
46 >
47 > /** Resolves a GitHub, GitHub Enterprise, or Azure DevOps fetch URL. */
48 > export function resolveRepoInfoRemote(remoteUrl: string, enterpriseHost: string | undefined): IResolvedRepoInfoRemote | undefined {
49 > const scpMatch = remoteUrl.includes('://') ? undefined : /^(?:[^@\s]+@)?(?<host>[^:\s]+):(?<path>.+)$/.exec(remoteUrl); agentHostRepoInfoTelemetry.ts ×5
50 > let host: string;
51 > let path: string;
52 > let normalizedRemoteUrl: string;
53 > if (scpMatch?.groups) {
54 > host = scpMatch.groups['host']; agentHostRepoInfoTelemetry.ts ×1
55 > path = scpMatch.groups['path'];
56 > normalizedRemoteUrl = `https://${host}/${path}`;
58 > let parsed: URL; agentHostRepoInfoTelemetry.ts ×2
59 > try {
60 > parsed = new URL(remoteUrl);
61 > } catch {
62 return undefined;
63 }
64 > host = parsed.host; agentHostRepoInfoTelemetry.ts ×2
65 > path = parsed.pathname;
66 > normalizedRemoteUrl = `https://${host}${path}`;
67 > }
69 > const normalizedHost = host.toLowerCase();
70 > const normalizedHostname = normalizedHost.replace(/:\d+$/, '');
71 > const normalizedPath = path.replace(/^\/+|\/+$/g, '');
72 > if (normalizedHostname === 'github.com' || normalizedHost === enterpriseHost?.toLowerCase() || normalizedHostname === 'ghe.com' || normalizedHostname.endsWith('.ghe.com')) {
73 > const match = /^(?<owner>[^/]+)\/(?<repo>[^/]+?)(?:\.git)?$/i.exec(normalizedPath);
74 > if (!match?.groups) {
75 return undefined;
76 }
78 > remoteUrl: normalizedRemoteUrl,
79 > repoId: `${match.groups['owner']}/${match.groups['repo']}`.toLowerCase(),
80 > repoType: 'github',
81 > };
82 > }
84 > let adoMatch: RegExpExecArray | null = null;
85 > if (normalizedHostname === 'dev.azure.com') {
86 > adoMatch = /^(?<org>[^/]+)\/(?<project>[^/]+)\/_git\/(?:_(?:optimized|full)\/)?(?<repo>[^/]+?)(?:\.git)?$/i.exec(normalizedPath);
87 > } else if (normalizedHostname === 'ssh.dev.azure.com') {
88 > adoMatch = /^v3\/(?<org>[^/]+)\/(?<project>[^/]+)\/(?:_(?:optimized|full)\/)?(?<repo>[^/]+?)(?:\.git)?$/i.exec(normalizedPath);
89 > } else if (normalizedHostname.endsWith('.visualstudio.com')) {
90 adoMatch = /^v3\/(?<org>[^/]+)\/(?<project>[^/]+)\/(?:_(?:optimized|full)\/)?(?<repo>[^/]+?)(?:\.git)?$/i.exec(normalizedPath)
91 ?? /^(?:[^/]+\/)?(?<project>[^/]+)\/_git\/(?:_(?:optimized|full)\/)?(?<repo>[^/]+?)(?:\.git)?$/i.exec(normalizedPath);
92 if (adoMatch?.groups && !adoMatch.groups['org']) {
93 adoMatch.groups['org'] = normalizedHostname.substring(0, normalizedHostname.length - '.visualstudio.com'.length);
94 }
95 }
96 > if (!adoMatch?.groups?.['org'] || !adoMatch.groups['project'] || !adoMatch.groups['repo']) { agentHostRepoInfoTelemetry.ts ×5
97 > return undefined; agentHostRepoInfoTelemetry.ts ×2
98 > }
99 > return {
100 > remoteUrl: normalizedRemoteUrl,
101 > repoId: `${adoMatch.groups['org']}/${adoMatch.groups['project']}/${adoMatch.groups['repo']}`.toLowerCase(),
102 > repoType: 'ado',
103 > };
104 > }
106 > /** Measures a serialized diff payload using the two limits applied by the legacy extension. */
107 > export function measureRepoInfoDiffsJSON(diffsJSON: string): { readonly diffSizeBytes: number; readonly tooLarge: boolean } {
108 > const diffSizeBytes = Buffer.byteLength(diffsJSON, 'utf8'); agentHostRepoInfoTelemetry.ts ×1
109 > return {
110 > diffSizeBytes,
111 > tooLarge: diffSizeBytes > MAX_DIFFS_JSON_BYTES || diffsJSON.length > MAX_DIFFS_JSON_CHARS,
112 > };
113 > }
115 > export class AgentHostRepoInfoTelemetry extends Disposable {
116 > private readonly _beginResults = new Map<string, Promise<AgentHostRepoInfoResult | undefined>>();
117 > private _isDisposed = false;
118 >
119 > constructor(
120 > private readonly _reporter: RepoInfoTelemetryReporter, agentHostRepoInfoTelemetry.ts ×2
121 > @IAgentHostGitService private readonly _gitService: IAgentHostGitService,
122 > @IAgentHostGitHubEndpointService private readonly _gitHubEndpointService: IAgentHostGitHubEndpointService,
123 > @ILogService private readonly _logService: ILogService,
124 > ) {
125 > super();
126 > }
128 > async reportBegin(context: IAgentHostRestrictedTelemetryContext, sessionUri: string, telemetryMessageId: string, workingDirectory: URI | undefined, baseBranch: string | undefined, isContextCurrent: () => boolean): Promise<void> {
129 > let result = this._beginResults.get(telemetryMessageId); agentHostRepoInfoTelemetry.ts ×11
130 > if (!result) {
131 > result = this._captureSafely(context, sessionUri, telemetryMessageId, 'begin', workingDirectory, baseBranch, isContextCurrent);
132 > this._beginResults.set(telemetryMessageId, result);
133 > }
134 > await result;
135 > }
137 > async reportEnd(context: IAgentHostRestrictedTelemetryContext, sessionUri: string, telemetryMessageId: string, workingDirectory: URI | undefined, baseBranch: string | undefined, isContextCurrent: () => boolean): Promise<void> {
138 > const begin = this._beginResults.get(telemetryMessageId); agentHostRepoInfoTelemetry.ts ×3
139 > if (!begin) {
140 return;
141 }
143 > const beginResult = await begin;
144 > if (beginResult === 'success' || beginResult === 'noChanges') {
145 > await this._captureSafely(context, sessionUri, telemetryMessageId, 'end', workingDirectory, baseBranch, isContextCurrent); agentHostRepoInfoTelemetry.ts ×1
146 > }
148 > this._beginResults.delete(telemetryMessageId);
149 > }
150 > }
152 > clearTurn(telemetryMessageId: string): void {
153 > this._beginResults.delete(telemetryMessageId); copilotAgentSession.ts ×2
154 > }
156 > override dispose(): void {
157 > this._isDisposed = true; agentHostRepoInfoTelemetry.ts ×2
158 > this._beginResults.clear();
159 > super.dispose();
160 > }
162 > private async _captureSafely(context: IAgentHostRestrictedTelemetryContext, sessionUri: string, telemetryMessageId: string, location: 'begin' | 'end', workingDirectory: URI | undefined, baseBranch: string | undefined, isContextCurrent: () => boolean): Promise<AgentHostRepoInfoResult | undefined> {
164 > return await this._capture(context, sessionUri, telemetryMessageId, location, workingDirectory, baseBranch, isContextCurrent);
165 > } catch (error) {
166 this._logService.warn(`[AgentHostRepoInfoTelemetry] Failed to capture ${location} repo info: ${error instanceof Error ? error.message : String(error)}`);
167 return undefined;
168 }
171 > private async _capture(telemetryContext: IAgentHostRestrictedTelemetryContext, sessionUri: string, telemetryMessageId: string, location: 'begin' | 'end', workingDirectory: URI | undefined, persistedBaseBranch: string | undefined, isContextCurrent: () => boolean): Promise<AgentHostRepoInfoResult | undefined> {
172 > if (!workingDirectory || !isContextCurrent() || (!telemetryContext.restrictedTelemetryEnabled && !telemetryContext.isInternal)) { agentHostRepoInfoTelemetry.ts ×11
173 > return undefined; agentHostRepoInfoTelemetry.ts ×1
174 > }
176 > const [gitState, untrackedPaths] = await Promise.all([
177 > this._gitService.getSessionGitState(workingDirectory),
178 > this._gitService.getUntrackedPaths(workingDirectory),
179 > ]);
180 > const upstreamRemote = gitState?.upstreamBranchName?.split('/')[0]; agentHostRepoInfoTelemetry.ts ×11
181 > const fetchRemoteUrls = await this._gitService.getFetchRemoteUrls(workingDirectory, upstreamRemote);
182 > const remote = fetchRemoteUrls agentHostRepoInfoTelemetry.ts ×11
183 > ?.map(url => resolveRepoInfoRemote(url, this._gitHubEndpointService.getEnterpriseHost())) agentHostRepoInfoTelemetry.ts ×11
184 > .find((candidate): candidate is IResolvedRepoInfoRemote => candidate !== undefined);
185 > if (!remote) {
186 return undefined;
187 }
189 > const baseBranch = persistedBaseBranch ?? gitState?.upstreamBranchName ?? gitState?.baseBranchName ?? (await this._gitService.getDefaultBranch(workingDirectory))?.name; agentHostRepoInfoTelemetry.ts ×11
190 > const [headBranchName, headCommitHash] = await Promise.all([
191 > gitState?.branchName ? Promise.resolve(gitState.branchName) : this._gitService.getCurrentBranch(workingDirectory),
192 > this._gitService.resolveBranchBaselineCommit(workingDirectory, baseBranch),
193 > ]);
194 > if (!headCommitHash) { agentHostRepoInfoTelemetry.ts ×11
195 return undefined;
196 }
197 > const repoInfo: IRepoInfoContext = { ...remote, headCommitHash, headBranchName }; agentHostRepoInfoTelemetry.ts ×11
198 > const safety = await this._gitService.getBranchDiffSafetyInfo(workingDirectory, headCommitHash);
199 > if (!safety) {
200 return undefined;
201 }
202 > if (safety.hasVirtualFileSystem) { agentHostRepoInfoTelemetry.ts ×11
203 return this._report(telemetryContext, isContextCurrent, telemetryMessageId, location, repoInfo, 'virtualFileSystem', 0, 0, 0);
204 }
205 > if (safety.baselineCommitTimestamp === undefined || Date.now() - safety.baselineCommitTimestamp > MAX_MERGE_BASE_AGE_MS) { agentHostRepoInfoTelemetry.ts ×11
206 return this._report(telemetryContext, isContextCurrent, telemetryMessageId, location, repoInfo, 'mergeBaseTooOld', 0, 0, 0);
207 }
208 > if (safety.commitCount === undefined || safety.commitCount >= MAX_DIFF_COMMITS) { agentHostRepoInfoTelemetry.ts ×11
209 return this._report(telemetryContext, isContextCurrent, telemetryMessageId, location, repoInfo, 'tooManyCommits', 0, 0, 0);
210 }
211 > const tree = await this._gitService.captureWorkingTreeAsTree(workingDirectory); agentHostRepoInfoTelemetry.ts ×11
212 > if (!tree) {
213 return undefined;
214 }
216 > const fileDiffs = await this._gitService.computeFileDiffsBetweenRefs(workingDirectory, {
217 > sessionUri,
218 > fromRef: headCommitHash,
219 > toRef: tree,
220 > });
221 > if (!fileDiffs) {
222 return undefined;
223 }
224 > if (fileDiffs.length === 0) { agentHostRepoInfoTelemetry.ts ×11
225 > return await this._reportIfTreeUnchanged(telemetryContext, isContextCurrent, telemetryMessageId, location, repoInfo, workingDirectory, tree, 'noChanges', safety.workspaceFileCount, 0, 0); copilotAgentSession.ts ×3
226 > }
227 > if (fileDiffs.length > MAX_CHANGES) { agentHostRepoInfoTelemetry.ts ×1
228 > return this._report(telemetryContext, isContextCurrent, telemetryMessageId, location, repoInfo, 'tooManyChanges', safety.workspaceFileCount, fileDiffs.length, 0); agentHostRepoInfoTelemetry.ts ×1
229 > }
231 > const repositoryRoot = await this._gitService.getRepositoryRoot(workingDirectory);
232 > if (!repositoryRoot) {
233 return undefined;
234 }
235 > const untracked = new Set(untrackedPaths ?? []); agentHostRepoInfoTelemetry.ts ×11
236 > const descriptors = fileDiffs.map(diff => this._describeFileDiff(repositoryRoot, diff, untracked));
237 > if (descriptors.some(descriptor => descriptor === undefined)) {
238 return undefined;
239 }
240 > const resolvedDescriptors = descriptors as IRepoInfoFileDescriptor[]; agentHostRepoInfoTelemetry.ts ×6
241 > const fileRelativePaths = JSON.stringify([...new Set(resolvedDescriptors.map(descriptor => descriptor.newPath ?? descriptor.oldPath).filter((path): path is string => path !== undefined))]);
242 > // The SDK does not expose per-path exclusion decisions yet, so withhold patch content unless exclusion is explicitly disabled.
243 > if (telemetryContext.copilotIgnoreEnabled !== false) {
244 > return await this._reportIfTreeUnchanged(telemetryContext, isContextCurrent, telemetryMessageId, location, repoInfo, workingDirectory, tree, 'success', safety.workspaceFileCount, fileDiffs.length, 0, fileRelativePaths); agentHostRepoInfoTelemetry.ts ×1
245 > }
246 > let patchTooLarge = false; agentHostRepoInfoTelemetry.ts ×6
247 > const limiter = new Limiter<{ readonly uri: string; readonly originalUri: string; readonly renameUri: string | undefined; readonly status: string; readonly diff: string }>(DIFF_PATCH_CONCURRENCY);
248 > const diffs = await Promise.all(resolvedDescriptors.map(descriptor => limiter.queue(async () => {
249 > const paths = [descriptor.oldPath, descriptor.newPath].filter((path): path is string => path !== undefined);
250 > const result = await this._gitService.getDiffPatchBetweenRefs(workingDirectory, { fromRef: headCommitHash, toRef: tree, paths, maxBuffer: MAX_DIFFS_JSON_BYTES });
251 > if (!result) {
252 throw new Error(`Failed to compute diff for ${paths.join(', ')}`);
253 }
254 > if (result.tooLarge) { agentHostRepoInfoTelemetry.ts ×6
255 patchTooLarge = true;
256 }
258 > uri: descriptor.uri,
259 > originalUri: descriptor.originalUri,
260 > renameUri: descriptor.renameUri,
261 > status: descriptor.status,
262 > diff: truncateRepoInfoDiff(result.patch ?? '', descriptor.uri),
263 > };
264 > })));
265 > if (patchTooLarge) {
266 return await this._reportIfTreeUnchanged(telemetryContext, isContextCurrent, telemetryMessageId, location, repoInfo, workingDirectory, tree, 'diffTooLarge', safety.workspaceFileCount, fileDiffs.length, MAX_DIFFS_JSON_BYTES + 1, fileRelativePaths);
267 }
268 > const diffsJSON = JSON.stringify(diffs); agentHostRepoInfoTelemetry.ts ×6
269 > const measurement = measureRepoInfoDiffsJSON(diffsJSON);
270 > if (measurement.tooLarge) {
271 return await this._reportIfTreeUnchanged(telemetryContext, isContextCurrent, telemetryMessageId, location, repoInfo, workingDirectory, tree, 'diffTooLarge', safety.workspaceFileCount, fileDiffs.length, measurement.diffSizeBytes, fileRelativePaths);
272 }
273 > return await this._reportIfTreeUnchanged(telemetryContext, isContextCurrent, telemetryMessageId, location, repoInfo, workingDirectory, tree, 'success', safety.workspaceFileCount, fileDiffs.length, measurement.diffSizeBytes, fileRelativePaths, diffsJSON); agentHostRepoInfoTelemetry.ts ×6
276 > private async _reportIfTreeUnchanged(telemetryContext: IAgentHostRestrictedTelemetryContext, isContextCurrent: () => boolean, telemetryMessageId: string, location: 'begin' | 'end', repoInfo: IRepoInfoContext, workingDirectory: URI, capturedTree: string, stableResult: 'success' | 'noChanges' | 'diffTooLarge', workspaceFileCount: number, changedFileCount: number, diffSizeBytes: number, fileRelativePaths?: string, diffsJSON?: string): Promise<AgentHostRepoInfoResult> {
277 > const currentTree = await this._gitService.captureWorkingTreeAsTree(workingDirectory); agentHostRepoInfoTelemetry.ts ×2
278 > if (!currentTree || currentTree !== capturedTree) {
279 > return this._report(telemetryContext, isContextCurrent, telemetryMessageId, location, repoInfo, 'filesChanged', workspaceFileCount, changedFileCount, 0); agentHostRepoInfoTelemetry.ts ×1
280 > }
281 > return this._report(telemetryContext, isContextCurrent, telemetryMessageId, location, repoInfo, stableResult, workspaceFileCount, changedFileCount, diffSizeBytes, fileRelativePaths, diffsJSON); agentHostRepoInfoTelemetry.ts ×1
284 > private _describeFileDiff(repositoryRoot: URI, diff: ISessionFileDiff, untrackedPaths: ReadonlySet<string>): IRepoInfoFileDescriptor | undefined {
285 > const beforeUri = diff.before?.uri; agentHostRepoInfoTelemetry.ts ×6
286 > const afterUri = diff.after?.uri;
287 > const oldPath = beforeUri ? relativePath(repositoryRoot, URI.parse(beforeUri)) : undefined;
288 > const newPath = afterUri ? relativePath(repositoryRoot, URI.parse(afterUri)) : undefined;
289 > if ((!oldPath && !newPath) || (!beforeUri && !afterUri)) {
290 return undefined;
291 }
292 > const uri = afterUri ?? beforeUri!; agentHostRepoInfoTelemetry.ts ×6
293 > let status: IRepoInfoFileDescriptor['status'];
294 > if (!beforeUri) {
295 > status = newPath && untrackedPaths.has(newPath) ? 'UNTRACKED' : 'INDEX_ADDED'; agentHostRepoInfoTelemetry.ts ×1
296 > } else if (!afterUri) { agentHostRepoInfoTelemetry.ts ×6
297 status = 'DELETED';
298 > } else if (beforeUri !== afterUri) { agentHostRepoInfoTelemetry.ts ×3
299 status = 'INDEX_RENAMED';
301 > status = 'MODIFIED';
302 > }
304 > uri,
305 > originalUri: beforeUri ?? uri,
306 > renameUri: status === 'INDEX_RENAMED' ? afterUri : undefined,
307 > status,
308 > oldPath,
309 > newPath,
310 > };
311 > }
313 > private _report(telemetryContext: IAgentHostRestrictedTelemetryContext, isContextCurrent: () => boolean, telemetryMessageId: string, location: 'begin' | 'end', repoInfo: IRepoInfoContext, result: AgentHostRepoInfoResult, workspaceFileCount: number, changedFileCount: number, diffSizeBytes: number, fileRelativePaths?: string, diffsJSON?: string): AgentHostRepoInfoResult {
314 > if (this._isDisposed || !isContextCurrent()) { agentHostRepoInfoTelemetry.ts ×11
315 return result;
316 }
317 > this._reporter.reportRepoInfo(telemetryContext, { agentHostRepoInfoTelemetry.ts ×11
318 > telemetryMessageId,
319 > location,
320 > remoteUrl: repoInfo.remoteUrl,
321 > repoId: repoInfo.repoId,
322 > repoType: repoInfo.repoType,
323 > headCommitHash: repoInfo.headCommitHash,
324 > headBranchName: repoInfo.headBranchName,
325 > fileRelativePaths,
326 > diffsJSON,
327 > result,
328 > isActiveRepository: 'true',
329 > workspaceFileCount,
330 > changedFileCount,
331 > diffSizeBytes,
332 > });
333 > return result;
334 > }
336 >
337 > function truncateRepoInfoDiff(diff: string, uri: string): string { agentHostRepoInfoTelemetry.ts ×6
338 > if (diff.length <= MAX_DIFF_SIZE) {
340 > }
341 > return `${diff.substring(0, MAX_DIFF_SIZE)}\n... Diff truncated (exceeded ${MAX_DIFF_SIZE} characters) for ${uri}`; agentHostRepoInfoTelemetry.ts ×1
342 > }