Skip to content

Commit 3973bc5

Browse files
committed
Adds telemetry for new graph features
- Adds graph/scope events (changed and cleared) for the new Focus Branch feature, attributing the entry point (popover vs overview card) and whether the merge target was resolved at scope time - Adds graph/overview events (shown and action) for the new overview panel, isolating overview engagement from generic command telemetry and surfacing per-action usage on branch cards (Pull/Push/Fetch/Switch/Compare/etc.) with inline-vs-hover location - Adds graph/virtualFile events (opened and failed) for the new Compose virtual-FS infrastructure, with VirtualFsError encoding a typed reason in error.name so failures survive the host→webview RPC boundary as provider-missing / parent-missing / unknown rather than opaque - Adds graphDetails/mode/changed so in-panel mode transitions (compose ↔ review ↔ compare, swap-to-close) are captured separately from the open/close lifecycle, dispatched from gl-graph-details-panel where SignalWatcher tracks activeMode - Adds graph/command telemetry for the openInNewWindow and openInTab inline registrations in graphWebview, and 'panel' to the context.webview.host union so events from the graph view (location: 'panel') are no longer mis-tagged as 'view' - Populates the existing customInstructions.* fields on ai/explain so caller-passed prompts and the ai.explainChanges.customInstructions setting are visible in AI telemetry
1 parent dbda02c commit 3973bc5

13 files changed

Lines changed: 611 additions & 129 deletions

docs/telemetry-events.md

Lines changed: 241 additions & 89 deletions
Large diffs are not rendered by default.

src/constants.telemetry.ts

Lines changed: 77 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -235,6 +235,10 @@ export interface TelemetryEvents extends WebviewShowAbortedEvents, WebviewShownE
235235

236236
/** Sent when the user changes the "branches visibility" on the Commit Graph */
237237
'graph/branchesVisibility/changed': GraphBranchesVisibilityChangedEvent;
238+
/** Sent when the user scopes the Commit Graph to a specific branch (Focus Branch feature) */
239+
'graph/scope/changed': GraphScopeChangedEvent;
240+
/** Sent when the user clears the active Commit Graph scope */
241+
'graph/scope/cleared': GraphContextEventData;
238242
/** Sent when the user changes the columns on the Commit Graph */
239243
'graph/columns/changed': GraphColumnsChangedEvent;
240244
/** Sent when the user changes the filters on the Commit Graph */
@@ -255,10 +259,22 @@ export interface TelemetryEvents extends WebviewShowAbortedEvents, WebviewShownE
255259
/** Sent when a search was performed on the Commit Graph */
256260
'graph/searched': GraphSearchedEvent;
257261

262+
/** Sent when a virtual-FS-backed file (e.g. a Graph Compose proposed commit) is opened */
263+
'graph/virtualFile/opened': GraphVirtualFileOpenedEvent;
264+
/** Sent when opening a virtual-FS-backed file fails (e.g. the compose session is no longer registered) */
265+
'graph/virtualFile/failed': GraphVirtualFileFailedEvent;
266+
267+
/** Sent when the Graph Overview panel becomes visible (mounted in the active sidebar slot) */
268+
'graph/overview/shown': GraphOverviewShownEvent;
269+
/** Sent when the user invokes an action item on a Graph Overview branch card */
270+
'graph/overview/action': GraphOverviewActionEvent;
271+
258272
/** Sent when the integrated graph details panel is expanded */
259273
'graphDetails/shown': GraphDetailsShownEvent;
260274
/** Sent when the integrated graph details panel is collapsed */
261275
'graphDetails/closed': GraphDetailsClosedEvent;
276+
/** Sent when the active mode of the integrated graph details panel changes while open */
277+
'graphDetails/mode/changed': GraphDetailsModeChangedEvent;
262278
/** Sent when commit reachability is successfully loaded in Graph Details */
263279
'graphDetails/reachability/loaded': DetailsReachabilityLoadedEvent;
264280
/** Sent when commit reachability fails to load in Graph Details */
@@ -838,7 +854,7 @@ type DetailsModeChangedEvent = InspectContextEventData & {
838854
'mode.new': 'wip' | 'commit';
839855
};
840856

841-
type GraphDetailsMode = 'commit' | 'wip' | 'multicommit' | 'review' | 'compose' | 'compare' | 'none';
857+
export type GraphDetailsMode = 'commit' | 'wip' | 'multicommit' | 'review' | 'compose' | 'compare' | 'none';
842858

843859
interface GraphDetailsShownEvent {
844860
/** What caused the panel to be shown */
@@ -864,6 +880,11 @@ interface GraphDetailsClosedEvent {
864880
mode: GraphDetailsMode;
865881
}
866882

883+
interface GraphDetailsModeChangedEvent extends GraphContextEventData {
884+
'mode.old': GraphDetailsMode;
885+
'mode.new': GraphDetailsMode;
886+
}
887+
867888
interface DetailsReachabilityLoadedEvent {
868889
'refs.count': number;
869890
duration: number;
@@ -938,6 +959,15 @@ interface GraphBranchesVisibilityChangedEvent extends GraphContextEventData {
938959
'branchesVisibility.new': GraphBranchesVisibility;
939960
}
940961

962+
interface GraphScopeChangedEvent extends GraphContextEventData {
963+
/** Where the user initiated the scope change */
964+
source: 'popover' | 'overview-card';
965+
/** Whether the scoped branch has a tracked upstream resolved at the time of the scope change */
966+
'scope.hasUpstream': boolean;
967+
/** Whether the scope's merge-target tip SHA is known at scope time (proxy for "merge-target resolved") */
968+
'scope.hasMergeTarget': boolean;
969+
}
970+
941971
type GraphColumnEventData = {
942972
[K in `column.${string}.${keyof GraphColumnConfig}`]?: K extends `column.${string}.${infer P}`
943973
? P extends keyof GraphColumnConfig
@@ -986,6 +1016,51 @@ interface GraphSearchedEvent extends GraphContextEventData {
9861016
'failed.error.detail'?: string;
9871017
}
9881018

1019+
export type GraphVirtualFileMode = 'diff' | 'comparePrevious' | 'multiDiff';
1020+
export type GraphVirtualFileFailureReason = 'provider-missing' | 'parent-missing' | 'unknown';
1021+
1022+
interface GraphVirtualFileOpenedEvent extends GraphContextEventData {
1023+
/** Which open operation the user triggered */
1024+
mode: GraphVirtualFileMode;
1025+
/** Number of files being opened (1 for single-file modes, N for multiDiff) */
1026+
'files.count': number;
1027+
}
1028+
1029+
interface GraphVirtualFileFailedEvent extends GraphContextEventData {
1030+
mode: GraphVirtualFileMode;
1031+
/** Best-effort categorization of the failure */
1032+
reason: GraphVirtualFileFailureReason;
1033+
'files.count': number;
1034+
'error.message'?: string;
1035+
}
1036+
1037+
interface GraphOverviewShownEvent extends GraphContextEventData {
1038+
/** Number of branches in the "active" section at the time of show */
1039+
'branches.active.count': number;
1040+
/** Number of branches in the "recent" section at the time of show */
1041+
'branches.recent.count': number;
1042+
}
1043+
1044+
export type GraphOverviewActionName =
1045+
| 'pull'
1046+
| 'push'
1047+
| 'fetch'
1048+
| 'publishBranch'
1049+
| 'switch'
1050+
| 'openWorktree'
1051+
| 'compareWithHead'
1052+
| 'compareWithWorking'
1053+
| 'compareWithPr'
1054+
| 'other';
1055+
1056+
interface GraphOverviewActionEvent extends GraphContextEventData {
1057+
name: GraphOverviewActionName;
1058+
/** Where on the card the action was invoked */
1059+
location: 'inline' | 'hover';
1060+
/** Whether the user held Alt/Shift to swap to the alt action */
1061+
alt: boolean;
1062+
}
1063+
9891064
export type HomeTelemetryContext = WebviewTelemetryContext;
9901065

9911066
interface HomeFailedEvent {
@@ -1696,7 +1771,7 @@ type WebviewContextEventData = {
16961771
'context.webview.id': string;
16971772
'context.webview.type': string;
16981773
'context.webview.instanceId': string | undefined;
1699-
'context.webview.host': 'editor' | 'view';
1774+
'context.webview.host': 'editor' | 'view' | 'panel';
17001775
};
17011776
export type WebviewTelemetryContext = WebviewContextEventData;
17021777

src/plus/ai/actions/explainChanges.ts

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -37,12 +37,20 @@ export async function explainChanges(
3737
promptContext = await promptContext(cancellation);
3838
}
3939

40+
const callerInstructions = promptContext.instructions;
41+
const settingInstructions = configuration.get('ai.explainChanges.customInstructions');
42+
4043
promptContext.instructions = mergeUserInstructions(
41-
configuration.get('ai.explainChanges.customInstructions'),
42-
promptContext.instructions,
44+
settingInstructions,
45+
callerInstructions,
4346
'The user provided the following guidance for this explanation — incorporate it into your response:',
4447
);
4548

49+
reporting['customInstructions.used'] = Boolean(callerInstructions);
50+
reporting['customInstructions.length'] = callerInstructions?.length ?? 0;
51+
reporting['customInstructions.setting.used'] = Boolean(settingInstructions);
52+
reporting['customInstructions.setting.length'] = settingInstructions?.length ?? 0;
53+
4654
if (cancellation.isCancellationRequested) throw new CancellationError();
4755

4856
const { prompt } = await service.getPrompt(

src/virtual/virtualFileSystemService.ts

Lines changed: 17 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import { basename } from '@gitlens/utils/path.js';
66
import { GlyphChars } from '../constants.js';
77
import type { Container } from '../container.js';
88
import type { VirtualContentProvider, VirtualParent, VirtualRef } from './virtualContentProvider.js';
9+
import { VirtualFsError } from './virtualFsError.js';
910
import type { VirtualUriAuthority } from './virtualFileSystemProvider.js';
1011
import { encodeVirtualUri, VirtualFileSystemProvider } from './virtualFileSystemProvider.js';
1112

@@ -112,12 +113,16 @@ export class VirtualFileSystemService implements Disposable {
112113
async getComparePreviousUris(ref: VirtualRef, file: GitFileChangeShape): Promise<VirtualDiffArgs> {
113114
const provider = this.getProviderOrThrow(ref.namespace);
114115
if (provider.getParent == null) {
115-
throw new Error(`VirtualFileSystemService: provider '${ref.namespace}' does not support getParent`);
116+
throw new VirtualFsError(
117+
'parent-missing',
118+
`VirtualFileSystemService: provider '${ref.namespace}' does not support getParent`,
119+
);
116120
}
117121

118122
const parent = await provider.getParent(ref.sessionId, ref.commitId);
119123
if (parent == null) {
120-
throw new Error(
124+
throw new VirtualFsError(
125+
'parent-missing',
121126
`VirtualFileSystemService: no parent for '${ref.namespace}/${ref.sessionId}/${ref.commitId}' — use buildDiffArgs with explicit sides`,
122127
);
123128
}
@@ -143,12 +148,16 @@ export class VirtualFileSystemService implements Disposable {
143148
): Promise<{ resources: { uri: Uri; lhs: Uri; rhs: Uri }[]; title: string }> {
144149
const provider = this.getProviderOrThrow(ref.namespace);
145150
if (provider.getParent == null) {
146-
throw new Error(`VirtualFileSystemService: provider '${ref.namespace}' does not support getParent`);
151+
throw new VirtualFsError(
152+
'parent-missing',
153+
`VirtualFileSystemService: provider '${ref.namespace}' does not support getParent`,
154+
);
147155
}
148156

149157
const parent = await provider.getParent(ref.sessionId, ref.commitId);
150158
if (parent == null) {
151-
throw new Error(
159+
throw new VirtualFsError(
160+
'parent-missing',
152161
`VirtualFileSystemService: no parent for '${ref.namespace}/${ref.sessionId}/${ref.commitId}' — use buildDiffArgs with explicit sides`,
153162
);
154163
}
@@ -209,7 +218,10 @@ export class VirtualFileSystemService implements Disposable {
209218
private getProviderOrThrow(namespace: string): VirtualContentProvider {
210219
const provider = this._providers.get(namespace);
211220
if (provider == null) {
212-
throw new Error(`VirtualFileSystemService: no provider registered for namespace '${namespace}'`);
221+
throw new VirtualFsError(
222+
'provider-missing',
223+
`VirtualFileSystemService: no provider registered for namespace '${namespace}'`,
224+
);
213225
}
214226
return provider;
215227
}

src/virtual/virtualFsError.ts

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
export type VirtualFsErrorReason = 'provider-missing' | 'parent-missing';
2+
3+
const virtualFsErrorNamePrefix = 'VirtualFsError:';
4+
5+
/** Thrown by {@link VirtualFileSystemService} when a session/parent cannot be resolved.
6+
* Carries a discrete `reason` for callers to categorize without parsing the message.
7+
*
8+
* The reason is encoded into `error.name` (e.g. `VirtualFsError:provider-missing`) so it survives
9+
* the host → webview RPC boundary, where supertalk's `serializeError` preserves `name`/`message`
10+
* but strips the prototype chain and custom properties. Use {@link getVirtualFsErrorReason} on the
11+
* receiving side instead of `instanceof`. */
12+
export class VirtualFsError extends Error {
13+
constructor(
14+
readonly reason: VirtualFsErrorReason,
15+
message: string,
16+
) {
17+
super(message);
18+
this.name = `${virtualFsErrorNamePrefix}${reason}`;
19+
}
20+
}
21+
22+
/** Returns the {@link VirtualFsErrorReason} for any error originating from {@link VirtualFsError},
23+
* including instances reconstructed across an RPC boundary (where `instanceof` fails). */
24+
export function getVirtualFsErrorReason(error: unknown): VirtualFsErrorReason | undefined {
25+
if (error instanceof VirtualFsError) return error.reason;
26+
if (error instanceof Error && error.name.startsWith(virtualFsErrorNamePrefix)) {
27+
return error.name.slice(virtualFsErrorNamePrefix.length) as VirtualFsErrorReason;
28+
}
29+
return undefined;
30+
}

src/webviews/apps/plus/graph/components/detailsActions.ts

Lines changed: 40 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -27,7 +27,12 @@ import { LruMap } from '@gitlens/utils/lruMap.js';
2727
import { pluralize } from '@gitlens/utils/string.js';
2828
import type { Autolink } from '../../../../../autolinks/models/autolinks.js';
2929
import type { ViewFilesLayout } from '../../../../../config.js';
30-
import type { TelemetryEvents } from '../../../../../constants.telemetry.js';
30+
import type {
31+
GraphVirtualFileFailureReason,
32+
GraphVirtualFileMode,
33+
TelemetryEvents,
34+
} from '../../../../../constants.telemetry.js';
35+
import { getVirtualFsErrorReason } from '../../../../../virtual/virtualFsError.js';
3136
import type { CommitDetails, CommitSignatureShape, CompareDiff, Wip } from '../../../../plus/graph/detailsProtocol.js';
3237
import type {
3338
BranchComparisonContributorsScope,
@@ -1860,17 +1865,48 @@ export class DetailsActions {
18601865

18611866
/** Open the virtual revision of `detail` via the virtual FS provider (no real SHA needed). */
18621867
openVirtualFile(detail: FileChangeListItemDetail, ref: fileActions.VirtualRefShape): void {
1863-
fileActions.openVirtualFile(this.services.files, ref, detail, detail.showOptions);
1868+
void this.runVirtualFileOpen('diff', 1, () =>
1869+
this.services.files.openVirtualFile(ref, detail, detail.showOptions),
1870+
);
18641871
}
18651872

18661873
/** Diff the virtual revision against its virtual (or real) parent via the virtual FS service. */
18671874
openVirtualFileComparePrevious(detail: FileChangeListItemDetail, ref: fileActions.VirtualRefShape): void {
1868-
fileActions.openVirtualFileComparePrevious(this.services.files, ref, detail, detail.showOptions);
1875+
void this.runVirtualFileOpen('comparePrevious', 1, () =>
1876+
this.services.files.openVirtualFileComparePrevious(ref, detail, detail.showOptions),
1877+
);
18691878
}
18701879

18711880
/** Open all files in the proposed-commit's virtual ref in VS Code's multi-diff editor. */
18721881
openVirtualMultipleChanges(ref: fileActions.VirtualRefShape, files: readonly FileChangeListItemDetail[]): void {
1873-
fileActions.openVirtualMultipleChanges(this.services.files, ref, files);
1882+
void this.runVirtualFileOpen('multiDiff', files.length, () =>
1883+
this.services.files.openVirtualMultipleChanges(ref, files),
1884+
);
1885+
}
1886+
1887+
/**
1888+
* Awaits a virtual-FS-backed open operation and emits adoption/reliability telemetry. Rejections
1889+
* raised as `VirtualFsError` (including ones reconstructed across the host → webview RPC
1890+
* boundary) are categorized via {@link getVirtualFsErrorReason}; anything else is `'unknown'`.
1891+
*/
1892+
private async runVirtualFileOpen(
1893+
mode: GraphVirtualFileMode,
1894+
fileCount: number,
1895+
open: () => Promise<void>,
1896+
): Promise<void> {
1897+
try {
1898+
await open();
1899+
this.sendTelemetryEvent('graph/virtualFile/opened', { mode: mode, 'files.count': fileCount });
1900+
} catch (ex) {
1901+
const message = ex instanceof Error ? ex.message : String(ex);
1902+
const reason: GraphVirtualFileFailureReason = getVirtualFsErrorReason(ex) ?? 'unknown';
1903+
this.sendTelemetryEvent('graph/virtualFile/failed', {
1904+
mode: mode,
1905+
'files.count': fileCount,
1906+
reason: reason,
1907+
'error.message': message,
1908+
});
1909+
}
18741910
}
18751911

18761912
executeFileAction(detail: FileChangeListItemDetail, ref?: string): void {

src/webviews/apps/plus/graph/components/gl-graph-details-panel.ts

Lines changed: 32 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import type { GitCommitReachability } from '@gitlens/git/providers/commits.js';
88
import type { AgentSessionState } from '../../../../../agents/models/agentSessionState.js';
99
import type { StashApplyCommandArgs } from '../../../../../commands/stashApply.js';
1010
import type { ViewFilesLayout } from '../../../../../config.js';
11+
import type { GraphDetailsMode } from '../../../../../constants.telemetry.js';
1112
import type { CommitDetails } from '../../../../commitDetails/protocol.js';
1213
import type { Wip } from '../../../../plus/graph/detailsProtocol.js';
1314
import type { GraphServices, VirtualRefShape } from '../../../../plus/graph/graphService.js';
@@ -46,6 +47,15 @@ interface ResolvedContent {
4647
context: DetailsContext;
4748
}
4849

50+
declare global {
51+
interface GlobalEventHandlersEventMap {
52+
'gl-graph-details-mode-changed': CustomEvent<{
53+
previous: GraphDetailsMode;
54+
current: GraphDetailsMode;
55+
}>;
56+
}
57+
}
58+
4959
@customElement('gl-graph-details-panel')
5060
export class GlGraphDetailsPanel extends SignalWatcher(LitElement) {
5161
@consume({ context: graphServicesContext, subscribe: true })
@@ -113,13 +123,17 @@ export class GlGraphDetailsPanel extends SignalWatcher(LitElement) {
113123

114124
/** Active mode used for telemetry — combines `activeMode` (review/compose/compare) and the
115125
* effective selection context (commit/wip/multicommit). Returns `'none'` when no selection. */
116-
get currentMode(): 'commit' | 'wip' | 'multicommit' | 'review' | 'compose' | 'compare' | 'none' {
126+
get currentMode(): GraphDetailsMode {
117127
const active = this._state.activeMode.get();
118128
if (active != null) return active;
119129
if (this.sha == null && (this.shas == null || this.shas.length === 0)) return 'none';
120130
return this.isMultiCommit ? 'multicommit' : this.isWip ? 'wip' : 'commit';
121131
}
122132

133+
/** Last value reported via `gl-graph-details-mode-changed` — guards the dispatch in `updated()`
134+
* so the event fires only on real transitions, not on re-renders that don't change the mode. */
135+
private _lastNotifiedMode: GraphDetailsMode = 'none';
136+
123137
/** Returns the effective context, respecting mode lock when active. */
124138
private get effectiveContext(): DetailsContext {
125139
return (
@@ -381,6 +395,23 @@ export class GlGraphDetailsPanel extends SignalWatcher(LitElement) {
381395
}
382396
}
383397
}
398+
399+
// Detect mode transitions and bubble a custom event up to graph-app so it can emit telemetry.
400+
// Lives here because SignalWatcher on this component tracks `activeMode`; graph-app doesn't
401+
// access that signal during its own render and so wouldn't re-run `updated()` on mode toggles
402+
// (compose ⇄ review ⇄ swap-to-close) — making it the wrong place to detect the transition.
403+
const currentMode = this.currentMode;
404+
if (currentMode !== this._lastNotifiedMode) {
405+
const previous = this._lastNotifiedMode;
406+
this._lastNotifiedMode = currentMode;
407+
this.dispatchEvent(
408+
new CustomEvent('gl-graph-details-mode-changed', {
409+
detail: { previous: previous, current: currentMode },
410+
bubbles: true,
411+
composed: true,
412+
}),
413+
);
414+
}
384415
}
385416

386417
private async resolveServices(services: Remote<GraphServices>): Promise<void> {

0 commit comments

Comments
 (0)