From 4955afd11265d4d15a6ebc30b983d8b1576009e7 Mon Sep 17 00:00:00 2001 From: samir Date: Tue, 11 Aug 2026 21:33:23 +0530 Subject: [PATCH 1/2] fix: populate OTel repository attributes for non-GitHub git remotes `github.copilot.git.repository`, `copilot_chat.repo.remote_url`, and `github.copilot.github.org` were silently absent on `invoke_agent` spans for workspaces whose remote points at a self-hosted GitHub Enterprise Server instance on a custom domain, while `github.copilot.git.branch` and `github.copilot.git.commit_sha` were present on the same span. GitLab, Bitbucket Server, and plain git servers were affected the same way. `buildWorkspaceMetadata` derived the remote URL exclusively from `getOrderedRepoInfosFromContext`, which only yields remotes that resolve to a repo id via the `['github.com', 'ghe.com']` allowlist or the Azure DevOps hosts. Branch and commit are read straight off `RepoContext` with no host check, hence the asymmetry. Add a `pickRemoteUrl` helper that prefers a remote resolving to a repo id, so repositories that already reported a URL keep reporting the same one, and only falls back to the highest-priority remote of any host when nothing resolves. The fallback requires `parseRemoteUrl` to succeed, which admits only ssh, https and http, keeping local remotes (`file://`, absolute and relative paths) out of telemetry. Rewrite `extractGitHubOrg` to delegate to `getGithubRepoIdFromFetchUrl` instead of a `github.com`-only regex. This keeps the org attribute scoped to recognized GitHub hosts while recovering it for `*.ghe.com` and ssh host aliases, which the regex previously dropped. It remains absent for custom-domain GitHub Enterprise Server, which cannot be distinguished from an arbitrary git host without reading `github-enterprise.uri`. Also fix an unrelated latent crash surfaced by the new tests: strategy 1 of `getOrderedRemoteUrlsFromContext` asserted `remoteFetchUrls[0]!`, but a push-only remote carries no fetch URL, so `undefined` escaped the declared `Iterable` and threw inside `parseRemoteUrl`. The repo id allowlist, `getOrderedRepoInfosFromContext`, and every consumer that depends on repo identity for GitHub API calls are left untouched. Fixes microsoft/vscode#330239 Co-authored-by: Mohammed Samir <51956976+SamirSaji@users.noreply.github.com> --- .../docs/monitoring/agent_monitoring.md | 2 +- .../src/platform/git/common/gitService.ts | 7 +- .../platform/git/test/node/gitService.spec.ts | 45 +++++- .../platform/otel/common/genAiAttributes.ts | 7 +- .../common/test/workspaceOTelMetadata.spec.ts | 138 +++++++++++++++++- .../otel/common/workspaceOTelMetadata.ts | 53 +++++-- 6 files changed, 233 insertions(+), 19 deletions(-) diff --git a/extensions/copilot/docs/monitoring/agent_monitoring.md b/extensions/copilot/docs/monitoring/agent_monitoring.md index 444d38919fb623..f2f0fd8df0b897 100644 --- a/extensions/copilot/docs/monitoring/agent_monitoring.md +++ b/extensions/copilot/docs/monitoring/agent_monitoring.md @@ -162,7 +162,7 @@ Inline chat uses the same invocation shape, with `invoke_agent Inline Chat` as t | `github.copilot.git.repository` | When in a repo | `https://github.com/microsoft/vscode.git` | | `github.copilot.git.branch` | When in a repo | `main` | | `github.copilot.git.commit_sha` | When in a repo | `deadbeef...` | -| `github.copilot.github.org` | GitHub remotes only | `microsoft` | +| `github.copilot.github.org` | Recognized GitHub hosts only (github.com, `*.ghe.com`) — absent for custom-domain GitHub Enterprise Server | `microsoft` | | `copilot_chat.repo.remote_url` | **Legacy** — prefer `github.copilot.git.repository` | `https://github.com/...` | | `copilot_chat.repo.head_branch_name` | **Legacy** — prefer `github.copilot.git.branch` | `main` | | `copilot_chat.repo.head_commit_hash` | **Legacy** — prefer `github.copilot.git.commit_sha` | `deadbeef...` | diff --git a/extensions/copilot/src/platform/git/common/gitService.ts b/extensions/copilot/src/platform/git/common/gitService.ts index bcdb99d2d24c2e..844316dc380141 100644 --- a/extensions/copilot/src/platform/git/common/gitService.ts +++ b/extensions/copilot/src/platform/git/common/gitService.ts @@ -129,7 +129,12 @@ export function getOrderedRemoteUrlsFromContext(repoContext: RepoContext): Itera // Strategy 1: If there's only one remote, use that if (repoContext.remoteFetchUrls?.length === 1) { - out.add(repoContext.remoteFetchUrls[0]!); + // A remote can be push-only and carry no fetch URL, so this entry may be undefined + // even though the declared return type is `Iterable`. + const fetchUrl = repoContext.remoteFetchUrls[0]; + if (fetchUrl) { + out.add(fetchUrl); + } return out; } diff --git a/extensions/copilot/src/platform/git/test/node/gitService.spec.ts b/extensions/copilot/src/platform/git/test/node/gitService.spec.ts index 9f9b84cc760eb2..a6d476f5c4eed1 100644 --- a/extensions/copilot/src/platform/git/test/node/gitService.spec.ts +++ b/extensions/copilot/src/platform/git/test/node/gitService.spec.ts @@ -5,7 +5,7 @@ import assert from 'assert'; import { suite, test } from 'vitest'; -import { AdoRepoId, getAdoRepoIdFromFetchUrl, getGithubRepoIdFromFetchUrl, GithubRepoId, normalizeFetchUrl, parseRemoteUrl, toGithubWebUrl } from '../../common/gitService'; +import { AdoRepoId, getAdoRepoIdFromFetchUrl, getGithubRepoIdFromFetchUrl, GithubRepoId, getOrderedRemoteUrlsFromContext, getOrderedRepoInfosFromContext, normalizeFetchUrl, parseRemoteUrl, type RepoContext, toGithubWebUrl } from '../../common/gitService'; function assertGitIdEquals(a: GithubRepoId | undefined, b: { org: string; repo: string; host?: string } | undefined, message?: string) { assert.strictEqual(a?.org, b?.org, message); @@ -15,6 +15,49 @@ function assertGitIdEquals(a: GithubRepoId | undefined, b: { org: string; repo: } } +function createRepoContext(remotes: string[], remoteFetchUrls: Array, upstreamRemote?: string): RepoContext { + return { remotes, remoteFetchUrls, upstreamRemote } as RepoContext; +} + +suite('getOrderedRemoteUrlsFromContext', () => { + test('Should skip a lone remote that has no fetch URL', () => { + assert.deepStrictEqual( + Array.from(getOrderedRemoteUrlsFromContext(createRepoContext(['origin'], [undefined]))), + []); + }); + + test('Should return a lone remote with a fetch URL', () => { + assert.deepStrictEqual( + Array.from(getOrderedRemoteUrlsFromContext(createRepoContext(['origin'], ['https://github.com/microsoft/vscode.git']))), + ['https://github.com/microsoft/vscode.git']); + }); + + test('Should order upstream before origin', () => { + assert.deepStrictEqual( + Array.from(getOrderedRemoteUrlsFromContext(createRepoContext( + ['origin', 'upstream'], + ['https://github.com/fork/vscode.git', 'https://github.com/microsoft/vscode.git'], + 'upstream'))), + ['https://github.com/microsoft/vscode.git', 'https://github.com/fork/vscode.git']); + }); +}); + +suite('getOrderedRepoInfosFromContext', () => { + test('Should not throw for a lone remote that has no fetch URL', () => { + assert.deepStrictEqual( + Array.from(getOrderedRepoInfosFromContext(createRepoContext(['origin'], [undefined]))), + []); + }); + + test('Should skip remotes on unrecognized hosts', () => { + const infos = Array.from(getOrderedRepoInfosFromContext(createRepoContext( + ['origin', 'github'], + ['https://git.mycompany.com/owner/repo.git', 'https://github.com/microsoft/vscode.git']))); + assert.strictEqual(infos.length, 1); + assert.strictEqual(infos[0].fetchUrl, 'https://github.com/microsoft/vscode.git'); + }); +}); + suite('parseRemoteUrl', () => { test('Should handle basic https', () => { assert.deepStrictEqual( diff --git a/extensions/copilot/src/platform/otel/common/genAiAttributes.ts b/extensions/copilot/src/platform/otel/common/genAiAttributes.ts index cdbd773cf4154e..10b04f85aea1db 100644 --- a/extensions/copilot/src/platform/otel/common/genAiAttributes.ts +++ b/extensions/copilot/src/platform/otel/common/genAiAttributes.ts @@ -203,13 +203,16 @@ export const GitHubCopilotAttr = { /** Agent type classifier: `builtin` | `plugin` | `custom`. */ AGENT_TYPE: 'github.copilot.agent.type', - /** Git remote URL (normalized). Dual of `copilot_chat.repo.remote_url`. */ + /** Git remote URL (normalized, any host). Dual of `copilot_chat.repo.remote_url`. */ GIT_REPOSITORY: 'github.copilot.git.repository', /** Git HEAD branch. Dual of `copilot_chat.repo.head_branch_name`. */ GIT_BRANCH: 'github.copilot.git.branch', /** Git HEAD commit. Dual of `copilot_chat.repo.head_commit_hash`. */ GIT_COMMIT_SHA: 'github.copilot.git.commit_sha', - /** GitHub `owner` segment derived from the remote URL (gated like the URL itself). */ + /** + * GitHub `owner` segment derived from the remote URL. Only emitted for hosts recognized as + * GitHub (github.com, `*.ghe.com`); absent for custom-domain GitHub Enterprise Server. + */ GITHUB_ORG: 'github.copilot.github.org', /** Hook decision result (`block` | `approve` | `non_blocking_error` | `pass`). */ diff --git a/extensions/copilot/src/platform/otel/common/test/workspaceOTelMetadata.spec.ts b/extensions/copilot/src/platform/otel/common/test/workspaceOTelMetadata.spec.ts index 00e93ab2766299..62d018bd0563bd 100644 --- a/extensions/copilot/src/platform/otel/common/test/workspaceOTelMetadata.spec.ts +++ b/extensions/copilot/src/platform/otel/common/test/workspaceOTelMetadata.spec.ts @@ -53,8 +53,108 @@ describe('resolveWorkspaceOTelMetadata', () => { remotes: ['origin'], }); const result = resolveWorkspaceOTelMetadata(gitService); - expect(result.remoteUrl).toBeDefined(); - expect(result.remoteUrl).toContain('github.com'); + expect(result.remoteUrl).toBe('https://github.com/microsoft/vscode.git'); + }); + + it('resolves remote URL for self-hosted GitHub Enterprise Server on a custom domain', () => { + const gitService = createMockGitService({ + remoteFetchUrls: ['https://git.mycompany.com/owner/repo.git'], + remotes: ['origin'], + }); + const result = resolveWorkspaceOTelMetadata(gitService); + expect(result.remoteUrl).toBe('https://git.mycompany.com/owner/repo.git'); + }); + + it('normalizes an scp-style remote on a custom domain', () => { + const gitService = createMockGitService({ + remoteFetchUrls: ['git@git.mycompany.com:owner/repo.git'], + remotes: ['origin'], + }); + const result = resolveWorkspaceOTelMetadata(gitService); + expect(result.remoteUrl).toBe('https://git.mycompany.com/owner/repo.git'); + }); + + it('resolves remote URL for GitLab', () => { + const gitService = createMockGitService({ + remoteFetchUrls: ['https://gitlab.com/org/repo.git'], + remotes: ['origin'], + }); + const result = resolveWorkspaceOTelMetadata(gitService); + expect(result.remoteUrl).toBe('https://gitlab.com/org/repo.git'); + }); + + it('resolves remote URL for Bitbucket Server, stripping the /scm/ prefix', () => { + const gitService = createMockGitService({ + remoteFetchUrls: ['https://bitbucket.mycorp.com/scm/proj/repo.git'], + remotes: ['origin'], + }); + const result = resolveWorkspaceOTelMetadata(gitService); + expect(result.remoteUrl).toBe('https://bitbucket.mycorp.com/proj/repo.git'); + }); + + it('resolves remote URL for Azure DevOps', () => { + const gitService = createMockGitService({ + remoteFetchUrls: ['https://dev.azure.com/org/project/_git/repo'], + remotes: ['origin'], + }); + const result = resolveWorkspaceOTelMetadata(gitService); + expect(result.remoteUrl).toBe('https://dev.azure.com/org/project/_git/repo'); + }); + + it('ignores local remotes so filesystem paths never reach telemetry', () => { + for (const localRemote of ['file:///Users/someone/dev/repo', '/Users/someone/dev/repo', '../sibling-repo']) { + const gitService = createMockGitService({ + remoteFetchUrls: [localRemote], + remotes: ['origin'], + }); + expect(resolveWorkspaceOTelMetadata(gitService).remoteUrl).toBeUndefined(); + } + }); + + it('handles a remote with no fetch URL', () => { + const gitService = createMockGitService({ + remoteFetchUrls: [undefined], + remotes: ['origin'], + }); + const result = resolveWorkspaceOTelMetadata(gitService); + expect(result.remoteUrl).toBeUndefined(); + }); + + it('prefers a resolvable GitHub remote over a higher-priority unrecognized one', () => { + const gitService = createMockGitService({ + remotes: ['origin', 'github'], + remoteFetchUrls: ['https://git.mycompany.com/mirror/repo.git', 'https://github.com/microsoft/vscode.git'], + }); + const result = resolveWorkspaceOTelMetadata(gitService); + expect(result.remoteUrl).toBe('https://github.com/microsoft/vscode.git'); + }); + + it('prefers a resolvable Azure DevOps remote over a higher-priority unrecognized one', () => { + const gitService = createMockGitService({ + remotes: ['origin', 'ado'], + remoteFetchUrls: ['https://git.mycompany.com/mirror/repo.git', 'https://dev.azure.com/org/project/_git/repo'], + }); + const result = resolveWorkspaceOTelMetadata(gitService); + expect(result.remoteUrl).toBe('https://dev.azure.com/org/project/_git/repo'); + }); + + it('falls back to an unrecognized remote only when no remote resolves', () => { + const gitService = createMockGitService({ + remotes: ['origin', 'mirror'], + remoteFetchUrls: ['https://git.mycompany.com/owner/repo.git', 'https://gitlab.com/org/repo.git'], + }); + const result = resolveWorkspaceOTelMetadata(gitService); + expect(result.remoteUrl).toBe('https://git.mycompany.com/owner/repo.git'); + }); + + it('prefers the upstream remote over origin for unrecognized hosts', () => { + const gitService = createMockGitService({ + remotes: ['origin', 'upstream'], + remoteFetchUrls: ['https://git.mycompany.com/fork/repo.git', 'https://git.mycompany.com/upstream/repo.git'], + upstreamRemote: 'upstream', + }); + const result = resolveWorkspaceOTelMetadata(gitService); + expect(result.remoteUrl).toBe('https://git.mycompany.com/upstream/repo.git'); }); it('computes relative file path from repo root', () => { @@ -130,4 +230,38 @@ describe('workspaceMetadataToOTelAttributes', () => { expect(attrs[GitHubCopilotAttr.GIT_REPOSITORY]).toBe('https://gitlab.com/org/repo.git'); expect(attrs[GitHubCopilotAttr.GITHUB_ORG]).toBeUndefined(); }); + + it('derives github.org for GitHub Enterprise Cloud remotes', () => { + const attrs = workspaceMetadataToOTelAttributes({ + remoteUrl: 'https://myco.ghe.com/acme/repo.git', + }); + expect(attrs[GitHubCopilotAttr.GIT_REPOSITORY]).toBe('https://myco.ghe.com/acme/repo.git'); + expect(attrs[GitHubCopilotAttr.GITHUB_ORG]).toBe('acme'); + }); + + it('derives github.org through an ssh host alias', () => { + const attrs = workspaceMetadataToOTelAttributes({ + remoteUrl: 'https://alias-github.com/microsoft/vscode.git', + }); + expect(attrs[GitHubCopilotAttr.GITHUB_ORG]).toBe('microsoft'); + }); + + it('emits the repository URL but no github.org for custom-domain GitHub Enterprise Server', () => { + const attrs = workspaceMetadataToOTelAttributes({ + remoteUrl: 'https://git.mycompany.com/owner/repo.git', + }); + expect(attrs[GitHubCopilotAttr.GIT_REPOSITORY]).toBe('https://git.mycompany.com/owner/repo.git'); + expect(attrs[CopilotChatAttr.REPO_REMOTE_URL]).toBe('https://git.mycompany.com/owner/repo.git'); + // Distinguishing a custom GHES domain from an arbitrary git host needs the configured + // `github-enterprise.uri`, which this module cannot read. + expect(attrs[GitHubCopilotAttr.GITHUB_ORG]).toBeUndefined(); + }); + + it('emits the repository URL but no github.org for Bitbucket Server', () => { + const attrs = workspaceMetadataToOTelAttributes({ + remoteUrl: 'https://bitbucket.mycorp.com/proj/repo.git', + }); + expect(attrs[GitHubCopilotAttr.GIT_REPOSITORY]).toBe('https://bitbucket.mycorp.com/proj/repo.git'); + expect(attrs[GitHubCopilotAttr.GITHUB_ORG]).toBeUndefined(); + }); }); diff --git a/extensions/copilot/src/platform/otel/common/workspaceOTelMetadata.ts b/extensions/copilot/src/platform/otel/common/workspaceOTelMetadata.ts index 81e46aa16b4bba..4bcedf2c4fa8fe 100644 --- a/extensions/copilot/src/platform/otel/common/workspaceOTelMetadata.ts +++ b/extensions/copilot/src/platform/otel/common/workspaceOTelMetadata.ts @@ -5,7 +5,7 @@ import { URI } from '../../../util/vs/base/common/uri'; import { isEqualOrParent, relativePath } from '../../../util/vs/base/common/resources'; -import { getOrderedRepoInfosFromContext, type IGitService, normalizeFetchUrl, type RepoContext } from '../../git/common/gitService'; +import { getGithubRepoIdFromFetchUrl, getOrderedRemoteUrlsFromContext, getOrderedRepoInfosFromContext, type IGitService, normalizeFetchUrl, parseRemoteUrl, type RepoContext } from '../../git/common/gitService'; import { CopilotChatAttr, GitHubCopilotAttr } from './genAiAttributes'; export interface WorkspaceOTelMetadata { @@ -31,11 +31,7 @@ export function resolveWorkspaceOTelMetadata( } function buildWorkspaceMetadata(repoContext: RepoContext, fileUri?: URI): WorkspaceOTelMetadata { - let remoteUrl: string | undefined; - const repoInfo = Array.from(getOrderedRepoInfosFromContext(repoContext))[0]; - if (repoInfo?.fetchUrl) { - remoteUrl = normalizeFetchUrl(repoInfo.fetchUrl); - } + const remoteUrl = pickRemoteUrl(repoContext); let fileRelativePath: string | undefined; if (fileUri && isEqualOrParent(fileUri, repoContext.rootUri)) { @@ -50,6 +46,37 @@ function buildWorkspaceMetadata(repoContext: RepoContext, fileUri?: URI): Worksp }; } +/** + * Picks the normalized remote fetch URL to report for a repository. + * + * A remote that resolves to a repo id (github.com, `*.ghe.com`, Azure DevOps) always wins, + * so repositories that already reported a URL keep reporting the same one. Only when no + * remote resolves do we fall back to the highest-priority remote of any host: the branch + * and commit attributes are read straight off the repo context with no host check, so the + * repository must not be silently dropped for self-hosted GitHub Enterprise Server on a + * custom domain, GitLab, Bitbucket Server, or a plain git server. + * + * Both passes walk remotes in the same priority order (a lone remote first, then the + * upstream remote, then `origin`, then the rest). The fallback accepts only URLs that parse + * as ssh, https, or http, which keeps local remotes (`file://`, absolute or relative paths) + * out of telemetry rather than emitting a user's filesystem layout. + */ +function pickRemoteUrl(repoContext: RepoContext): string | undefined { + const resolved = Array.from(getOrderedRepoInfosFromContext(repoContext))[0]; + if (resolved?.fetchUrl) { + return normalizeFetchUrl(resolved.fetchUrl); + } + for (const remoteUrl of getOrderedRemoteUrlsFromContext(repoContext)) { + // `getOrderedRemoteUrlsFromContext` types its result as `Iterable` but reads + // from `remoteFetchUrls: Array`, so guard against empty entries. + if (!remoteUrl || !parseRemoteUrl(remoteUrl)) { + continue; + } + return normalizeFetchUrl(remoteUrl); + } + return undefined; +} + /** * Convert workspace metadata to OTel attributes, omitting undefined values. * Emits both the legacy `copilot_chat.repo.*` namespace and the canonical @@ -85,12 +112,14 @@ export function workspaceMetadataToOTelAttributes( } /** - * Extract the `owner` segment from a normalized GitHub remote URL. - * Returns undefined for non-GitHub hosts or malformed inputs. + * Extract the `owner` segment from a remote URL that resolves to a GitHub repository. + * + * Unlike the remote URL itself, this stays scoped to hosts recognized as GitHub — + * github.com, `*.ghe.com`, and the ssh host aliases those resolve through. Returns + * undefined for every other host, which notably includes self-hosted GitHub Enterprise + * Server on a custom domain: telling that apart from an arbitrary git server needs the + * configured `github-enterprise.uri`, which this module has no access to. */ function extractGitHubOrg(remoteUrl: string): string | undefined { - // Match `(https://|git@)[:/]/` — normalizeFetchUrl already - // strips credentials and `.git` suffixes. - const m = remoteUrl.match(/github\.com[/:]([^/]+)\/[^/]+\/?$/i); - return m?.[1]; + return getGithubRepoIdFromFetchUrl(remoteUrl)?.org; } From 93b4a38a31707c38c2486b527d897612124d1816 Mon Sep 17 00:00:00 2001 From: samir Date: Tue, 11 Aug 2026 21:55:20 +0530 Subject: [PATCH 2/2] fix: address review feedback on OTel remote reporting MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Scope the host-agnostic remote to OTel emission. `WorkspaceOTelMetadata` now carries both `remoteUrl` (any host, for OTel attributes) and `recognizedRemoteUrl` (restricted to remotes resolving to a repo id). The two consumers that forward a remote to GitHub telemetry rather than to the user's own OTel exporter — `inline.trackEditSurvival` in userActions and `fastApply/editOutcome` in MultiFileEditInternalTelemetryService — now read `recognizedRemoteUrl`, so those channels collect exactly what they collected before and the OTel fix carries no privacy expansion. Reject loopback hosts in the fallback. `parseRemoteUrl` admits ssh, https, and http, so scp-style syntax could smuggle a local filesystem path through an accepted scheme (`git@localhost:/Users/alice/dev/repo.git`). Loopback hosts (localhost, *.localhost, 127.0.0.0/8, ::1, 0.0.0.0) are now skipped. Absolute paths on routable hosts are still reported, since those describe the server's layout rather than the user's. Document that ssh host aliases normalizing to github.com or *.ghe.com also produce `github.copilot.github.org`, in both the attribute reference and the monitoring docs. --- .../docs/monitoring/agent_monitoring.md | 2 +- .../conversation/vscode-node/userActions.ts | 4 +- .../common/multiFileEditQualityTelemetry.ts | 4 +- .../platform/otel/common/genAiAttributes.ts | 4 +- .../common/test/workspaceOTelMetadata.spec.ts | 72 +++++++++++++++ .../otel/common/workspaceOTelMetadata.ts | 89 ++++++++++++++----- 6 files changed, 148 insertions(+), 27 deletions(-) diff --git a/extensions/copilot/docs/monitoring/agent_monitoring.md b/extensions/copilot/docs/monitoring/agent_monitoring.md index f2f0fd8df0b897..cd76271f0de939 100644 --- a/extensions/copilot/docs/monitoring/agent_monitoring.md +++ b/extensions/copilot/docs/monitoring/agent_monitoring.md @@ -162,7 +162,7 @@ Inline chat uses the same invocation shape, with `invoke_agent Inline Chat` as t | `github.copilot.git.repository` | When in a repo | `https://github.com/microsoft/vscode.git` | | `github.copilot.git.branch` | When in a repo | `main` | | `github.copilot.git.commit_sha` | When in a repo | `deadbeef...` | -| `github.copilot.github.org` | Recognized GitHub hosts only (github.com, `*.ghe.com`) — absent for custom-domain GitHub Enterprise Server | `microsoft` | +| `github.copilot.github.org` | Recognized GitHub hosts only — github.com, `*.ghe.com`, and ssh host aliases normalizing to either (e.g. `alias-github.com`); absent for custom-domain GitHub Enterprise Server | `microsoft` | | `copilot_chat.repo.remote_url` | **Legacy** — prefer `github.copilot.git.repository` | `https://github.com/...` | | `copilot_chat.repo.head_branch_name` | **Legacy** — prefer `github.copilot.git.branch` | `main` | | `copilot_chat.repo.head_commit_hash` | **Legacy** — prefer `github.copilot.git.commit_sha` | `deadbeef...` | diff --git a/extensions/copilot/src/extension/conversation/vscode-node/userActions.ts b/extensions/copilot/src/extension/conversation/vscode-node/userActions.ts index c1bf1b3df063a7..c9f9cf1aeb3a31 100644 --- a/extensions/copilot/src/extension/conversation/vscode-node/userActions.ts +++ b/extensions/copilot/src/extension/conversation/vscode-node/userActions.ts @@ -577,7 +577,9 @@ function reportInlineEditSurvivalEvent(res: EditSurvivalResult, sharedProps: Tel ...sharedProps, headBranchName: res.workspace?.headBranchName, headCommitHash: res.workspace?.headCommitHash, - remoteUrl: res.workspace?.remoteUrl, + // Restricted to recognized hosts: this channel is not the user's own OTel exporter, + // so it must keep collecting exactly the remotes it collected before. + remoteUrl: res.workspace?.recognizedRemoteUrl, fileRelativePath: res.workspace?.fileRelativePath, }, { ...sharedMeasures, diff --git a/extensions/copilot/src/platform/multiFileEdit/common/multiFileEditQualityTelemetry.ts b/extensions/copilot/src/platform/multiFileEdit/common/multiFileEditQualityTelemetry.ts index 298dd0882f0b3e..464ecef1e20854 100644 --- a/extensions/copilot/src/platform/multiFileEdit/common/multiFileEditQualityTelemetry.ts +++ b/extensions/copilot/src/platform/multiFileEdit/common/multiFileEditQualityTelemetry.ts @@ -179,7 +179,9 @@ export class MultiFileEditInternalTelemetryService extends Disposable implements messageId: edit.chatRequestId, headBranchName: workspace.headBranchName, headCommitHash: workspace.headCommitHash, - remoteUrl: workspace.remoteUrl, + // Restricted to recognized hosts: this channel is not the user's own OTel + // exporter, so it must keep collecting exactly the remotes it collected before. + remoteUrl: workspace.recognizedRemoteUrl, fileRelativePath: workspace.fileRelativePath, }).then(gitHubEnhancedTelemetryProperties => this.telemetryService.sendEnhancedGHTelemetryEvent('fastApply/editOutcome', gitHubEnhancedTelemetryProperties)).catch(() => { /* best-effort telemetry */ }); this.logService.debug(`Sent telemetry for ${uri.toString()} with request ID ${edit.chatRequestId}, SD request ID ${edit.speculationRequestId}, and outcome ${outcome}`); diff --git a/extensions/copilot/src/platform/otel/common/genAiAttributes.ts b/extensions/copilot/src/platform/otel/common/genAiAttributes.ts index 10b04f85aea1db..506ba51288554c 100644 --- a/extensions/copilot/src/platform/otel/common/genAiAttributes.ts +++ b/extensions/copilot/src/platform/otel/common/genAiAttributes.ts @@ -211,7 +211,9 @@ export const GitHubCopilotAttr = { GIT_COMMIT_SHA: 'github.copilot.git.commit_sha', /** * GitHub `owner` segment derived from the remote URL. Only emitted for hosts recognized as - * GitHub (github.com, `*.ghe.com`); absent for custom-domain GitHub Enterprise Server. + * GitHub: github.com, `*.ghe.com`, and ssh host aliases that normalize to either (for + * example `alias-github.com` or `github.com-alias`). Absent for custom-domain GitHub + * Enterprise Server, which is indistinguishable from an arbitrary git host here. */ GITHUB_ORG: 'github.copilot.github.org', diff --git a/extensions/copilot/src/platform/otel/common/test/workspaceOTelMetadata.spec.ts b/extensions/copilot/src/platform/otel/common/test/workspaceOTelMetadata.spec.ts index 62d018bd0563bd..f64cc64e73c1f6 100644 --- a/extensions/copilot/src/platform/otel/common/test/workspaceOTelMetadata.spec.ts +++ b/extensions/copilot/src/platform/otel/common/test/workspaceOTelMetadata.spec.ts @@ -111,6 +111,78 @@ describe('resolveWorkspaceOTelMetadata', () => { } }); + it('ignores loopback remotes that smuggle a filesystem path through scp syntax', () => { + const loopbackRemotes = [ + 'git@localhost:/Users/alice/dev/repo.git', + 'ssh://git@localhost/Users/alice/dev/repo.git', + 'git@127.0.0.1:/Users/alice/dev/repo.git', + 'git@dev.localhost:/Users/alice/dev/repo.git', + 'https://localhost:8443/Users/alice/dev/repo.git', + 'ssh://git@[::1]/Users/alice/dev/repo.git', + ]; + for (const loopbackRemote of loopbackRemotes) { + const gitService = createMockGitService({ + remoteFetchUrls: [loopbackRemote], + remotes: ['origin'], + }); + expect(resolveWorkspaceOTelMetadata(gitService).remoteUrl, loopbackRemote).toBeUndefined(); + } + }); + + it('still reports a non-loopback host that serves repositories from an absolute path', () => { + const gitService = createMockGitService({ + remoteFetchUrls: ['git@git.mycompany.com:/srv/git/repo.git'], + remotes: ['origin'], + }); + const result = resolveWorkspaceOTelMetadata(gitService); + // An absolute path on a routable host is the server's layout, not the user's, so it is + // reported. The doubled slash is existing `normalizeFetchUrl` behaviour for scp-style + // remotes whose path is absolute. + expect(result.remoteUrl).toBe('https://git.mycompany.com//srv/git/repo.git'); + }); + + it('skips a loopback remote but still reports a later routable one', () => { + const gitService = createMockGitService({ + remotes: ['origin', 'mirror'], + remoteFetchUrls: ['git@localhost:/Users/alice/dev/repo.git', 'https://git.mycompany.com/owner/repo.git'], + }); + const result = resolveWorkspaceOTelMetadata(gitService); + expect(result.remoteUrl).toBe('https://git.mycompany.com/owner/repo.git'); + }); + + it('exposes recognizedRemoteUrl only for remotes that resolve to a repo id', () => { + const recognized = resolveWorkspaceOTelMetadata(createMockGitService({ + remoteFetchUrls: ['https://github.com/microsoft/vscode.git'], + remotes: ['origin'], + })); + expect(recognized.recognizedRemoteUrl).toBe('https://github.com/microsoft/vscode.git'); + expect(recognized.remoteUrl).toBe('https://github.com/microsoft/vscode.git'); + + const ado = resolveWorkspaceOTelMetadata(createMockGitService({ + remoteFetchUrls: ['https://dev.azure.com/org/project/_git/repo'], + remotes: ['origin'], + })); + expect(ado.recognizedRemoteUrl).toBe('https://dev.azure.com/org/project/_git/repo'); + + // The whole point of the split: non-OTel telemetry channels must not widen. + const unrecognized = resolveWorkspaceOTelMetadata(createMockGitService({ + remoteFetchUrls: ['https://git.mycompany.com/owner/repo.git'], + remotes: ['origin'], + })); + expect(unrecognized.remoteUrl).toBe('https://git.mycompany.com/owner/repo.git'); + expect(unrecognized.recognizedRemoteUrl).toBeUndefined(); + }); + + it('keeps recognizedRemoteUrl on the resolvable remote in a mixed-remote repository', () => { + const gitService = createMockGitService({ + remotes: ['origin', 'github'], + remoteFetchUrls: ['https://git.mycompany.com/mirror/repo.git', 'https://github.com/microsoft/vscode.git'], + }); + const result = resolveWorkspaceOTelMetadata(gitService); + expect(result.recognizedRemoteUrl).toBe('https://github.com/microsoft/vscode.git'); + expect(result.remoteUrl).toBe('https://github.com/microsoft/vscode.git'); + }); + it('handles a remote with no fetch URL', () => { const gitService = createMockGitService({ remoteFetchUrls: [undefined], diff --git a/extensions/copilot/src/platform/otel/common/workspaceOTelMetadata.ts b/extensions/copilot/src/platform/otel/common/workspaceOTelMetadata.ts index 4bcedf2c4fa8fe..b4d1356fec2a43 100644 --- a/extensions/copilot/src/platform/otel/common/workspaceOTelMetadata.ts +++ b/extensions/copilot/src/platform/otel/common/workspaceOTelMetadata.ts @@ -11,7 +11,22 @@ import { CopilotChatAttr, GitHubCopilotAttr } from './genAiAttributes'; export interface WorkspaceOTelMetadata { readonly headBranchName?: string; readonly headCommitHash?: string; + /** + * Normalized remote fetch URL for OTel attributes, reported for any host so that a + * repository is never silently dropped. Exported only to the user-configured OTel + * pipeline. + */ readonly remoteUrl?: string; + /** + * Normalized remote fetch URL restricted to remotes that resolve to a repo id + * (github.com, `*.ghe.com`, Azure DevOps) — the set that was reportable before + * host-agnostic reporting was introduced. + * + * Consumers that forward the remote to a channel other than the user's own OTel + * exporter (GitHub telemetry events) must use this field, so that broadening OTel + * coverage does not silently widen what those channels collect. + */ + readonly recognizedRemoteUrl?: string; readonly fileRelativePath?: string; } @@ -31,7 +46,8 @@ export function resolveWorkspaceOTelMetadata( } function buildWorkspaceMetadata(repoContext: RepoContext, fileUri?: URI): WorkspaceOTelMetadata { - const remoteUrl = pickRemoteUrl(repoContext); + const recognizedRemoteUrl = pickRecognizedRemoteUrl(repoContext); + const remoteUrl = recognizedRemoteUrl ?? pickFallbackRemoteUrl(repoContext); let fileRelativePath: string | undefined; if (fileUri && isEqualOrParent(fileUri, repoContext.rootUri)) { @@ -42,34 +58,47 @@ function buildWorkspaceMetadata(repoContext: RepoContext, fileUri?: URI): Worksp headBranchName: repoContext.headBranchName, headCommitHash: repoContext.headCommitHash, remoteUrl, + recognizedRemoteUrl, fileRelativePath, }; } /** - * Picks the normalized remote fetch URL to report for a repository. + * Picks the normalized remote fetch URL of the highest-priority remote that resolves to a + * repo id (github.com, `*.ghe.com`, Azure DevOps). * - * A remote that resolves to a repo id (github.com, `*.ghe.com`, Azure DevOps) always wins, - * so repositories that already reported a URL keep reporting the same one. Only when no - * remote resolves do we fall back to the highest-priority remote of any host: the branch - * and commit attributes are read straight off the repo context with no host check, so the - * repository must not be silently dropped for self-hosted GitHub Enterprise Server on a - * custom domain, GitLab, Bitbucket Server, or a plain git server. - * - * Both passes walk remotes in the same priority order (a lone remote first, then the - * upstream remote, then `origin`, then the rest). The fallback accepts only URLs that parse - * as ssh, https, or http, which keeps local remotes (`file://`, absolute or relative paths) - * out of telemetry rather than emitting a user's filesystem layout. + * This is the set of remotes that was reportable before host-agnostic reporting existed, so + * preferring it keeps every repository that already reported a URL reporting the same one. */ -function pickRemoteUrl(repoContext: RepoContext): string | undefined { +function pickRecognizedRemoteUrl(repoContext: RepoContext): string | undefined { const resolved = Array.from(getOrderedRepoInfosFromContext(repoContext))[0]; - if (resolved?.fetchUrl) { - return normalizeFetchUrl(resolved.fetchUrl); - } + return resolved?.fetchUrl ? normalizeFetchUrl(resolved.fetchUrl) : undefined; +} + +/** + * Picks the normalized remote fetch URL of the highest-priority remote on any host, used + * only when no remote resolves to a repo id. The branch and commit attributes are read + * straight off the repo context with no host check, so the repository must not be silently + * dropped for self-hosted GitHub Enterprise Server on a custom domain, GitLab, Bitbucket + * Server, or a plain git server. + * + * Remotes are walked in the same priority order as {@link pickRecognizedRemoteUrl} (a lone + * remote first, then the upstream remote, then `origin`, then the rest). + * + * Local remotes are excluded so a user's filesystem layout is never reported: `parseRemoteUrl` + * admits only ssh, https, and http, which rejects `file://` and bare paths, and loopback + * hosts are rejected on top of that because scp-style syntax can smuggle a local path through + * an accepted scheme (`git@localhost:/Users/alice/dev/repo.git`). + */ +function pickFallbackRemoteUrl(repoContext: RepoContext): string | undefined { for (const remoteUrl of getOrderedRemoteUrlsFromContext(repoContext)) { // `getOrderedRemoteUrlsFromContext` types its result as `Iterable` but reads // from `remoteFetchUrls: Array`, so guard against empty entries. - if (!remoteUrl || !parseRemoteUrl(remoteUrl)) { + if (!remoteUrl) { + continue; + } + const parsed = parseRemoteUrl(remoteUrl); + if (!parsed || isLoopbackHost(parsed.rawHost)) { continue; } return normalizeFetchUrl(remoteUrl); @@ -77,6 +106,19 @@ function pickRemoteUrl(repoContext: RepoContext): string | undefined { return undefined; } +/** + * Whether a host refers to the local machine. `parseRemoteUrl` has already lowercased the + * host and stripped any port. + */ +function isLoopbackHost(rawHost: string): boolean { + const host = rawHost.replace(/^\[|\]$/g, ''); + return host === 'localhost' + || host.endsWith('.localhost') + || host === '::1' + || host === '0.0.0.0' + || /^127(?:\.\d{1,3}){3}$/.test(host); +} + /** * Convert workspace metadata to OTel attributes, omitting undefined values. * Emits both the legacy `copilot_chat.repo.*` namespace and the canonical @@ -114,11 +156,12 @@ export function workspaceMetadataToOTelAttributes( /** * Extract the `owner` segment from a remote URL that resolves to a GitHub repository. * - * Unlike the remote URL itself, this stays scoped to hosts recognized as GitHub — - * github.com, `*.ghe.com`, and the ssh host aliases those resolve through. Returns - * undefined for every other host, which notably includes self-hosted GitHub Enterprise - * Server on a custom domain: telling that apart from an arbitrary git server needs the - * configured `github-enterprise.uri`, which this module has no access to. + * Unlike the remote URL itself, this stays scoped to hosts recognized as GitHub: github.com, + * `*.ghe.com`, and ssh host aliases that normalize to either (for example `alias-github.com` + * or `github.com-alias`). Returns undefined for every other host, which notably includes + * self-hosted GitHub Enterprise Server on a custom domain: telling that apart from an + * arbitrary git server needs the configured `github-enterprise.uri`, which this module has + * no access to. */ function extractGitHubOrg(remoteUrl: string): string | undefined { return getGithubRepoIdFromFetchUrl(remoteUrl)?.org;