diff --git a/docs/telemetry-events.md b/docs/telemetry-events.md index aa3723df781c8..feeb0a3dd0379 100644 --- a/docs/telemetry-events.md +++ b/docs/telemetry-events.md @@ -335,6 +335,179 @@ or } ``` +### graph/shown + +```typescript +{ + 'id': string, + 'instanceId': string, + 'host': 'editor' | 'view', + 'duration': number, + 'loading': false | true, + 'context.repository.id': string, + 'context.repository.scheme': string, + 'context.repository.closed': false | true, + 'context.repository.folder.scheme': string, + 'context.repository.provider.id': string, + 'context.config.allowMultiple': false | true, + 'context.config.avatars': false | true, + 'context.config.branchesVisibility': 'all' | 'smart' | 'current', + 'context.config.commitOrdering': 'date' | 'author-date' | 'topo', + 'context.config.dateFormat': string, + 'context.config.dateStyle': 'absolute' | 'relative', + 'context.config.defaultItemLimit': number, + 'context.config.dimMergeCommits': false | true, + 'context.config.highlightRowsOnRefHover': false | true, + 'context.config.layout': 'editor' | 'panel', + 'context.config.minimap.enabled': false | true, + 'context.config.minimap.dataType': 'commits' | 'lines', + 'context.config.minimap.additionalTypes': string, + 'context.config.onlyFollowFirstParent': false | true, + 'context.config.pageItemLimit': number, + 'context.config.pullRequests.enabled': false | true, + 'context.config.scrollMarkers.enabled': false | true, + 'context.config.scrollMarkers.additionalTypes': string, + 'context.config.scrollRowPadding': number, + 'context.config.searchItemLimit': number, + 'context.config.showDetailsView': false | 'open' | 'selection', + 'context.config.showGhostRefsOnRowHover': false | true, + 'context.config.showRemoteNames': false | true, + 'context.config.showUpstreamStatus': false | true, + 'context.config.sidebar.enabled': false | true, + 'context.config.statusBar.enabled': false | true +} +``` + +### graph/columns/changed + +> Sent when the user interacts with the graph + +```typescript +{} +``` + +### graph/exclude/toggled + +```typescript +{ + 'key': string, + 'value': false | true +} +``` + +### graph/jumpToRef + +```typescript +{ + 'alt': false | true +} +``` + +### graph/minimap/daySelected + +```typescript +undefined +``` + +### graph/repository/changed + +```typescript +undefined +``` + +### graph/repository/openOnRemote + +```typescript +{ + 'id': string, + 'instanceId': string, + 'host': 'editor' | 'view' +} +``` + +### graph/row/hovered + +```typescript +undefined +``` + +### graph/row/selected + +```typescript +{ + 'rows': number +} +``` + +### graph/rows/loaded + +```typescript +{ + 'duration': number, + 'rows': number +} +``` + +### graph/sidebar/action + +```typescript +{ + 'action': string +} +``` + +### graph/searched + +```typescript +{ + 'types': string, + 'duration': number, + 'matches': number +} +``` + +### graph/branchesVisibility/changed + +```typescript +{ + 'branchesVisibility': 'all' | 'smart' | 'current' +} +``` + +### timeline/period/change + +> Sent when the user interacts with the visual file history + +```typescript +{ + 'period': string +} +``` + +### timeline/chart/selectCommit + +```typescript +undefined +``` + +### timeline/chart/toggleLegend + +```typescript +undefined +``` + +### timeline/openInEditor + +```typescript +undefined +``` + +### timeline/editorChanged + +```typescript +undefined +``` + ### launchpad/title/action > Sent when the user takes an action on a launchpad item @@ -345,7 +518,7 @@ or 'initialState.group': string, 'initialState.selectTopItem': false | true, 'items.error': string, - 'action': 'feedback' | 'open-on-gkdev' | 'refresh' | 'settings' | 'connect' + 'action': 'settings' | 'feedback' | 'open-on-gkdev' | 'refresh' | 'connect' } ``` @@ -381,7 +554,7 @@ or 'groups.draft.collapsed': false | true, 'groups.other.collapsed': false | true, 'groups.snoozed.collapsed': false | true, - 'action': 'feedback' | 'open-on-gkdev' | 'refresh' | 'settings' | 'connect' + 'action': 'settings' | 'feedback' | 'open-on-gkdev' | 'refresh' | 'connect' } ``` @@ -757,7 +930,7 @@ void 'repository.visibility': 'private' | 'public' | 'local', 'repoPrivacy': 'private' | 'public' | 'local', 'filesChanged': number, - 'source': 'settings' | 'code-suggest' | 'account' | 'cloud-patches' | 'commandPalette' | 'deeplink' | 'graph' | 'home' | 'inspect' | 'inspect-overview' | 'integrations' | 'launchpad' | 'launchpad-indicator' | 'launchpad-view' | 'notification' | 'patchDetails' | 'prompt' | 'quick-wizard' | 'remoteProvider' | 'timeline' | 'trial-indicator' | 'scm-input' | 'subscription' | 'walkthrough' | 'welcome' | 'worktrees' + 'source': 'graph' | 'patchDetails' | 'settings' | 'timeline' | 'welcome' | 'home' | 'code-suggest' | 'account' | 'cloud-patches' | 'commandPalette' | 'deeplink' | 'inspect' | 'inspect-overview' | 'integrations' | 'launchpad' | 'launchpad-indicator' | 'launchpad-view' | 'notification' | 'prompt' | 'quick-wizard' | 'remoteProvider' | 'trial-indicator' | 'scm-input' | 'subscription' | 'walkthrough' | 'worktrees' } ``` @@ -911,7 +1084,7 @@ or ```typescript { - 'usage.key': 'settingsWebview:shown' | 'graphWebview:shown' | 'patchDetailsWebview:shown' | 'timelineWebview:shown' | 'welcomeWebview:shown' | 'launchpadView:shown' | 'worktreesView:shown' | 'branchesView:shown' | 'commitsView:shown' | 'contributorsView:shown' | 'draftsView:shown' | 'fileHistoryView:shown' | 'lineHistoryView:shown' | 'pullRequestView:shown' | 'remotesView:shown' | 'repositoriesView:shown' | 'searchAndCompareView:shown' | 'stashesView:shown' | 'tagsView:shown' | 'workspacesView:shown' | 'graphView:shown' | 'homeView:shown' | 'patchDetailsView:shown' | 'timelineView:shown' | 'commitDetailsView:shown' | 'graphDetailsView:shown' | 'rebaseEditor:shown', + 'usage.key': 'graphWebview:shown' | 'patchDetailsWebview:shown' | 'settingsWebview:shown' | 'timelineWebview:shown' | 'welcomeWebview:shown' | 'graphView:shown' | 'patchDetailsView:shown' | 'timelineView:shown' | 'commitDetailsView:shown' | 'graphDetailsView:shown' | 'homeView:shown' | 'commitsView:shown' | 'stashesView:shown' | 'tagsView:shown' | 'launchpadView:shown' | 'worktreesView:shown' | 'branchesView:shown' | 'contributorsView:shown' | 'draftsView:shown' | 'fileHistoryView:shown' | 'lineHistoryView:shown' | 'pullRequestView:shown' | 'remotesView:shown' | 'repositoriesView:shown' | 'searchAndCompareView:shown' | 'workspacesView:shown' | 'rebaseEditor:shown', 'usage.count': number } ``` @@ -926,3 +1099,183 @@ or } ``` +### graph/showAborted + +```typescript +{ + 'id': string, + 'instanceId': string, + 'host': 'editor' | 'view', + 'duration': number, + 'loading': false | true +} +``` + +### patchDetails/showAborted + +```typescript +{ + 'id': string, + 'instanceId': string, + 'host': 'editor' | 'view', + 'duration': number, + 'loading': false | true +} +``` + +### settings/showAborted + +```typescript +{ + 'id': string, + 'instanceId': string, + 'host': 'editor' | 'view', + 'duration': number, + 'loading': false | true +} +``` + +### timeline/showAborted + +```typescript +{ + 'id': string, + 'instanceId': string, + 'host': 'editor' | 'view', + 'duration': number, + 'loading': false | true +} +``` + +### welcome/showAborted + +```typescript +{ + 'id': string, + 'instanceId': string, + 'host': 'editor' | 'view', + 'duration': number, + 'loading': false | true +} +``` + +### commitDetails/showAborted + +```typescript +{ + 'id': string, + 'instanceId': string, + 'host': 'editor' | 'view', + 'duration': number, + 'loading': false | true +} +``` + +### graphDetails/showAborted + +```typescript +{ + 'id': string, + 'instanceId': string, + 'host': 'editor' | 'view', + 'duration': number, + 'loading': false | true +} +``` + +### home/showAborted + +```typescript +{ + 'id': string, + 'instanceId': string, + 'host': 'editor' | 'view', + 'duration': number, + 'loading': false | true +} +``` + +### patchDetails/shown + +```typescript +{ + 'id': string, + 'instanceId': string, + 'host': 'editor' | 'view', + 'duration': number, + 'loading': false | true +} +``` + +### settings/shown + +```typescript +{ + 'id': string, + 'instanceId': string, + 'host': 'editor' | 'view', + 'duration': number, + 'loading': false | true +} +``` + +### timeline/shown + +```typescript +{ + 'id': string, + 'instanceId': string, + 'host': 'editor' | 'view', + 'duration': number, + 'loading': false | true +} +``` + +### welcome/shown + +```typescript +{ + 'id': string, + 'instanceId': string, + 'host': 'editor' | 'view', + 'duration': number, + 'loading': false | true +} +``` + +### commitDetails/shown + +```typescript +{ + 'id': string, + 'instanceId': string, + 'host': 'editor' | 'view', + 'duration': number, + 'loading': false | true +} +``` + +### graphDetails/shown + +```typescript +{ + 'id': string, + 'instanceId': string, + 'host': 'editor' | 'view', + 'duration': number, + 'loading': false | true +} +``` + +### home/shown + +```typescript +{ + 'id': string, + 'instanceId': string, + 'host': 'editor' | 'view', + 'duration': number, + 'loading': false | true +} +``` + diff --git a/src/constants.telemetry.ts b/src/constants.telemetry.ts index 8f0d716c7bd1b..1a910f5dff9cb 100644 --- a/src/constants.telemetry.ts +++ b/src/constants.telemetry.ts @@ -1,9 +1,11 @@ +import type { GraphBranchesVisibility, GraphConfig } from './config'; import type { AIModels, AIProviders } from './constants.ai'; import type { Commands } from './constants.commands'; import type { IntegrationId, SupportedCloudIntegrationIds } from './constants.integrations'; import type { SubscriptionState } from './constants.subscription'; import type { CustomEditorTypes, TreeViewTypes, WebviewTypes, WebviewViewTypes } from './constants.views'; import type { GitContributionTiers } from './git/models/contributor'; +import type { Flatten } from './system/object'; export type TelemetryGlobalContext = { 'cloudIntegrations.connected.count': number; @@ -162,6 +164,29 @@ export type TelemetryEvents = { /** Sent when a "Graph" command is executed */ 'graph/command': Omit; + 'graph/shown': WebviewShownEventData & GraphShownEventData; + + /** Sent when the user interacts with the graph */ + 'graph/columns/changed': Record<`column.${string}`, boolean | string | number>; + 'graph/exclude/toggled': { key: string; value: boolean }; + 'graph/jumpToRef': { alt: boolean }; + 'graph/minimap/daySelected': undefined; + 'graph/repository/changed': undefined; + 'graph/repository/openOnRemote': WebviewEventData; + 'graph/row/hovered': undefined; + 'graph/row/selected': { rows: number }; + 'graph/rows/loaded': { duration: number; rows: number }; + 'graph/sidebar/action': { action: string }; + 'graph/searched': { types: string; duration: number; matches: number }; + 'graph/branchesVisibility/changed': { branchesVisibility: GraphBranchesVisibility }; + + /** Sent when the user interacts with the visual file history */ + 'timeline/period/change': { period: string }; + 'timeline/chart/selectCommit': undefined; + 'timeline/chart/toggleLegend': undefined; + 'timeline/openInEditor': undefined; + 'timeline/editorChanged': undefined; + /** Sent when the user takes an action on a launchpad item */ 'launchpad/title/action': LaunchpadEventData & { action: 'feedback' | 'open-on-gkdev' | 'refresh' | 'settings' | 'connect'; @@ -282,12 +307,7 @@ export type TelemetryEvents = { }; /** Sent when a repository is opened */ - 'repository/opened': { - 'repository.id': string; - 'repository.scheme': string; - 'repository.closed': boolean; - 'repository.folder.scheme': string | undefined; - 'repository.provider.id': string; + 'repository/opened': RepositoryEventData & { 'repository.remoteProviders': string; 'repository.contributors.commits.count': number | undefined; 'repository.contributors.commits.avgPerContributor': number | undefined; @@ -295,12 +315,7 @@ export type TelemetryEvents = { 'repository.contributors.since': '1.year.ago'; } & Record<`repository.contributors.distribution.${GitContributionTiers}`, number>; /** Sent when a repository's visibility is first requested */ - 'repository/visibility': { - 'repository.id': string | undefined; - 'repository.scheme': string | undefined; - 'repository.closed': boolean | undefined; - 'repository.folder.scheme': string | undefined; - 'repository.provider.id': string | undefined; + 'repository/visibility': Partial & { 'repository.visibility': 'private' | 'public' | 'local' | undefined; }; @@ -348,7 +363,11 @@ export type TelemetryEvents = { | 'integrations' | 'more'; }; -}; +} & Record<`${WebviewTypes | WebviewViewTypes}/showAborted`, WebviewShownEventData> & + Record< + `${Exclude}/shown`, + WebviewShownEventData & Record<`context.${string}`, string | number | boolean | undefined> + >; type AIEventDataBase = { 'model.id': AIModels; @@ -423,6 +442,30 @@ type LaunchpadGroups = | 'other' | 'snoozed'; +type FlattenedGraphConfig = { + [K in keyof Flatten]: Flatten[K]; +}; +type GraphContextEventData = {} & WebviewTelemetryContext & + Partial<{ + [K in keyof RepositoryEventData as `context.${K}`]: RepositoryEventData[K]; + }>; + +type GraphShownEventData = GraphContextEventData & + FlattenedGraphConfig & + Partial> & + Partial>; + +export type GraphTelemetryContext = GraphContextEventData; +export type GraphShownTelemetryContext = GraphShownEventData; + +type RepositoryEventData = { + 'repository.id': string; + 'repository.scheme': string; + 'repository.closed': boolean; + 'repository.folder.scheme': string | undefined; + 'repository.provider.id': string; +}; + type SubscriptionEventData = { 'subscription.state'?: SubscriptionState; 'subscription.status'?: @@ -444,6 +487,18 @@ type SubscriptionEventData = { Record<`previous.subscription.previewTrial.${string}`, string | number | boolean | undefined> >; +type WebviewEventData = { + id: string; + instanceId: string | undefined; + host: 'editor' | 'view'; +}; +export type WebviewTelemetryContext = WebviewEventData; + +type WebviewShownEventData = WebviewEventData & { + duration: number; + loading: boolean; +}; + export type LoginContext = 'start_trial'; export type ConnectIntegrationContext = 'launchpad'; export type Context = LoginContext | ConnectIntegrationContext; diff --git a/src/plus/webviews/graph/graphWebview.ts b/src/plus/webviews/graph/graphWebview.ts index 147f965620807..13360fae43485 100644 --- a/src/plus/webviews/graph/graphWebview.ts +++ b/src/plus/webviews/graph/graphWebview.ts @@ -19,6 +19,7 @@ import type { import { GlyphChars } from '../../../constants'; import { Commands } from '../../../constants.commands'; import type { StoredGraphFilters, StoredGraphRefType } from '../../../constants.storage'; +import type { GraphShownTelemetryContext, GraphTelemetryContext } from '../../../constants.telemetry'; import type { Container } from '../../../container'; import { CancellationError } from '../../../errors'; import type { CommitSelectedEvent } from '../../../eventBus'; @@ -87,7 +88,7 @@ import { } from '../../../git/models/repository'; import { getWorktreesByBranch } from '../../../git/models/worktree'; import type { GitSearch } from '../../../git/search'; -import { getSearchQueryComparisonKey } from '../../../git/search'; +import { getSearchQueryComparisonKey, parseSearchQuery } from '../../../git/search'; import { splitGitCommitMessage } from '../../../git/utils/commit-utils'; import { ReferencesQuickPickIncludes, showReferencePicker } from '../../../quickpicks/referencePicker'; import { showRepositoryPicker } from '../../../quickpicks/repositoryPicker'; @@ -96,12 +97,13 @@ import { debug, log } from '../../../system/decorators/log'; import type { Deferrable } from '../../../system/function'; import { debounce, disposableInterval } from '../../../system/function'; import { count, find, last, map } from '../../../system/iterable'; -import { updateRecordValue } from '../../../system/object'; +import { flatten, updateRecordValue } from '../../../system/object'; import { getSettledValue, pauseOnCancelOrTimeout, pauseOnCancelOrTimeoutMapTuplePromise, } from '../../../system/promise'; +import { Stopwatch } from '../../../system/stopwatch'; import { executeActionCommand, executeCommand, @@ -159,6 +161,7 @@ import type { GraphRefMetadataType, GraphRepository, GraphScrollMarkerTypes, + GraphSearchResults, GraphSelectedRows, GraphStashContextValue, GraphTagContextValue, @@ -352,11 +355,48 @@ export class GraphWebviewProvider implements WebviewProvider]: GraphShownTelemetryContext[K]; + }> = {}; + const columns = this.getColumns(); + if (columns != null) { + for (const [name, config] of Object.entries(columns)) { + if (!config.isHidden) { + columnContext[`context.column.${name}.visible`] = true; + } + if (config.mode != null) { + columnContext[`context.column.${name}.mode`] = config.mode; + } + } + } + + const cfg = flatten(configuration.get('graph'), 'context.config', { joinArrays: true }); + const context: GraphShownTelemetryContext = { + ...this.getTelemetryContext(), + ...columnContext, + ...cfg, + }; + + return context; + } + async onShowing( loading: boolean, _options?: WebviewShowOptions, ...args: WebviewShowingArgs - ): Promise { + ): Promise<[boolean, Record<`context.${string}`, string | number | boolean | undefined> | undefined]> { this._firstSelection = true; this._etag = this.container.git.etag; @@ -386,7 +426,7 @@ export class GraphWebviewProvider implements WebviewProvider = {}; + for (const [key, config] of Object.entries(e.config)) { + for (const [prop, value] of Object.entries(config)) { + sendEvent[`column.${key}.${prop}`] = value; + } + } + this.container.telemetry.sendEvent('graph/columns/changed', sendEvent); } private onRefsVisibilityChanged(e: UpdateRefsVisibilityParams) { @@ -1330,7 +1378,12 @@ export class GraphWebviewProvider implements WebviewProvider(requestType: T, msg: IpcCallMessageType) { try { + using sw = new Stopwatch(`GraphWebviewProvider.onSearchRequest(${this.host.id})`); const results = await this.getSearchResults(msg.params); + const query = msg.params.search ? parseSearchQuery(msg.params.search) : undefined; + const types = new Set(); + if (query != null) { + for (const [_, values] of query) { + values.forEach(v => types.add(v)); + } + } + this.container.telemetry.sendEvent('graph/searched', { + types: [...types].join(','), + duration: sw.elapsed(), + matches: (results.results as GraphSearchResults)?.count ?? 0, + }); void this.host.respond(requestType, msg, results); } catch (ex) { void this.host.respond(requestType, msg, { @@ -1483,6 +1549,7 @@ export class GraphWebviewProvider implements WebviewProvider(requestType: T, msg: IpcCallMessageType) { @@ -2763,6 +2830,12 @@ export class GraphWebviewProvider implements WebviewProvider { + ): Promise<[boolean, Record<`context.${string}`, string | number | boolean> | undefined]> { const [arg] = args; if (arg?.mode === 'view' && arg.draft != null) { await this.updateViewDraftState(arg.draft); @@ -201,9 +201,9 @@ export class PatchDetailsWebviewProvider this.updateCreateDraftState(create); } - if (options?.preserveVisibility && !this.host.visible) return false; + if (options?.preserveVisibility && !this.host.visible) return [false, undefined]; - return true; + return [true, undefined]; } includeBootstrap(): Promise> { diff --git a/src/plus/webviews/patchDetails/registration.ts b/src/plus/webviews/patchDetails/registration.ts index e86d291bb4960..2c4fc677c7ef6 100644 --- a/src/plus/webviews/patchDetails/registration.ts +++ b/src/plus/webviews/patchDetails/registration.ts @@ -30,6 +30,7 @@ export function registerPatchDetailsWebviewView(controller: WebviewsController) title: 'Patch', contextKeyPrefix: `gitlens:webviewView:patchDetails`, trackingFeature: 'patchDetailsView', + type: 'patchDetails', plusFeature: true, webviewHostOptions: { retainContextWhenHidden: false, @@ -66,6 +67,7 @@ export function registerPatchDetailsWebviewPanel(controller: WebviewsController) title: 'Patch', contextKeyPrefix: `gitlens:webview:patchDetails`, trackingFeature: 'patchDetailsWebview', + type: 'patchDetails', plusFeature: true, column: ViewColumn.Active, webviewHostOptions: { diff --git a/src/plus/webviews/timeline/registration.ts b/src/plus/webviews/timeline/registration.ts index 767ecf5985e7e..f8f065f130b87 100644 --- a/src/plus/webviews/timeline/registration.ts +++ b/src/plus/webviews/timeline/registration.ts @@ -19,6 +19,7 @@ export function registerTimelineWebviewPanel(controller: WebviewsController) { title: 'Visual File History', contextKeyPrefix: `gitlens:webview:timeline`, trackingFeature: 'timelineWebview', + type: 'timeline', plusFeature: true, column: ViewColumn.Active, webviewHostOptions: { @@ -44,6 +45,7 @@ export function registerTimelineWebviewView(controller: WebviewsController) { title: 'Visual File History', contextKeyPrefix: `gitlens:webviewView:timeline`, trackingFeature: 'timelineView', + type: 'timeline', plusFeature: true, webviewHostOptions: { retainContextWhenHidden: false, diff --git a/src/plus/webviews/timeline/timelineWebview.ts b/src/plus/webviews/timeline/timelineWebview.ts index 7f279307a6456..2b421e676f881 100644 --- a/src/plus/webviews/timeline/timelineWebview.ts +++ b/src/plus/webviews/timeline/timelineWebview.ts @@ -114,7 +114,7 @@ export class TimelineWebviewProvider implements WebviewProvider - ): boolean { + ): [boolean, Record<`context.${string}`, string | number | boolean> | undefined] { const [arg] = args; if (arg != null) { if (arg instanceof Uri) { @@ -138,7 +138,7 @@ export class TimelineWebviewProvider implements WebviewProvider { @@ -162,6 +162,7 @@ export class TimelineWebviewProvider implements WebviewProvider = { }; }[keyof T]; -type Flatten< +export type Flatten< T extends object | null | undefined, P extends string | undefined, JoinArrays extends boolean, diff --git a/src/webviews/apps/plus/graph/GraphWrapper.tsx b/src/webviews/apps/plus/graph/GraphWrapper.tsx index d6924889b58f1..59005e48e0e01 100644 --- a/src/webviews/apps/plus/graph/GraphWrapper.tsx +++ b/src/webviews/apps/plus/graph/GraphWrapper.tsx @@ -67,7 +67,7 @@ import { import { createCommandLink } from '../../../../system/commands'; import { filterMap, first, groupByFilterMap, join } from '../../../../system/iterable'; import { createWebviewCommandLink } from '../../../../system/webview'; -import type { IpcNotification } from '../../../protocol'; +import type { IpcNotification, TelemetrySendEventParams } from '../../../protocol'; import { DidChangeHostWindowFocusNotification } from '../../../protocol'; import { GlButton } from '../../shared/components/button.react'; import { GlCheckbox } from '../../shared/components/checkbox'; @@ -86,6 +86,7 @@ import { GlSearchBox } from '../../shared/components/search/react'; import type { SearchNavigationEventDetail } from '../../shared/components/search/search-box'; import type { DateTimeFormat } from '../../shared/date'; import { formatDate, fromNow } from '../../shared/date'; +import { emitTelemetrySentEvent } from '../../shared/telemetry'; import { GitActionsButtons } from './actions/gitActionsButtons'; import { GlGraphHover } from './hover/graphHover.react'; import type { GraphMinimapDaySelectedEventDetail } from './minimap/minimap'; @@ -464,6 +465,10 @@ export function GraphWrapper({ } graphRef.current?.selectCommits([sha], false, true); + + queueMicrotask( + () => e.target && emitTelemetrySentEvent(e.target, { name: 'graph/minimap/daySelected', data: {} }), + ); }; const handleOnMinimapToggle = (_e: React.MouseEvent) => { @@ -1007,6 +1012,14 @@ export function GraphWrapper({ style={{ marginRight: '-0.5rem' }} aria-label={`Open Repository on ${repo.provider.name}`} slot="anchor" + onClick={e => + emitTelemetrySentEvent< + TelemetrySendEventParams<'graph/repository/openOnRemote'> + >(e.target, { + name: 'graph/repository/openOnRemote', + data: {}, + }) + } > { private async onHoverRowPromise(row: GraphRow) { try { - return await this.sendRequest(GetRowHoverRequest, { type: row.type as GitGraphRowType, id: row.sha }); + const request = await this.sendRequest(GetRowHoverRequest, { + type: row.type as GitGraphRowType, + id: row.sha, + }); + this._telemetry.sendEvent({ name: 'graph/row/hovered', data: {} }); + return request; } catch (ex) { return { id: row.sha, markdown: { status: 'rejected' as const, reason: ex } }; } @@ -576,6 +581,7 @@ export class GraphApp extends App { try { // Assuming we have a command to get the ref details const rsp = await this.sendRequest(ChooseRefRequest, { alt: alt }); + this._telemetry.sendEvent({ name: 'graph/jumpToRef', data: { alt: alt } }); return rsp; } catch { return undefined; @@ -650,6 +656,7 @@ export class GraphApp extends App { private onSelectionChanged(rows: GraphRow[]) { const selection = rows.filter(r => r != null).map(r => ({ id: r.sha, type: r.type as GitGraphRowType })); + this._telemetry.sendEvent({ name: 'graph/row/selected', data: { rows: selection.length } }); this.sendCommand(UpdateSelectionCommand, { selection: selection, }); diff --git a/src/webviews/apps/plus/graph/sidebar/sidebar.ts b/src/webviews/apps/plus/graph/sidebar/sidebar.ts index ed18e116e8cb0..cbf7a89de9075 100644 --- a/src/webviews/apps/plus/graph/sidebar/sidebar.ts +++ b/src/webviews/apps/plus/graph/sidebar/sidebar.ts @@ -9,6 +9,7 @@ import type { Disposable } from '../../../shared/events'; import type { HostIpc } from '../../../shared/ipc'; import '../../../shared/components/code-icon'; import '../../../shared/components/overlays/tooltip'; +import { emitTelemetrySentEvent } from '../../../shared/telemetry'; interface Icon { type: IconTypes; @@ -147,7 +148,7 @@ export class GlGraphSideBar extends LitElement { if (this.include != null && !this.include.includes(icon.type)) return; return html` - + this.sendTelemetry(icon.command)}> ${this._countsTask.render({ pending: () => @@ -160,6 +161,13 @@ export class GlGraphSideBar extends LitElement { `; } + + private sendTelemetry(command: string) { + emitTelemetrySentEvent(this, { + name: 'graph/sidebar/action', + data: { action: command }, + }); + } } function renderCount(count: number | undefined) { diff --git a/src/webviews/apps/plus/timeline/chart.ts b/src/webviews/apps/plus/timeline/chart.ts index aa546f7d3d088..190bff2f1706d 100644 --- a/src/webviews/apps/plus/timeline/chart.ts +++ b/src/webviews/apps/plus/timeline/chart.ts @@ -8,6 +8,7 @@ import { defer } from '../../../../system/promise'; import { formatDate, fromNow } from '../../shared/date'; import type { Disposable, Event } from '../../shared/events'; import { Emitter } from '../../shared/events'; +import { emitTelemetrySentEvent } from '../../shared/telemetry'; export interface DataPointClickEvent { data: { @@ -412,6 +413,9 @@ export class TimelineChart implements Disposable { show: !this.compact, // hide: this.compact ? [...this._authorsByIndex.values()] : undefined, padding: 10, + item: { + onclick: this.onToggleLegend.bind(this), + }, }, point: { sensitivity: 'radius', @@ -501,6 +505,13 @@ export class TimelineChart implements Disposable { }, }); } + + private onToggleLegend(_id: string) { + emitTelemetrySentEvent(this.$container, { + name: 'timeline/chart/toggleLegend', + data: {}, + }); + } } function capitalize(s: string): string { diff --git a/src/webviews/apps/shared/app.ts b/src/webviews/apps/shared/app.ts index 2eba513249a9e..6fb21a87986be 100644 --- a/src/webviews/apps/shared/app.ts +++ b/src/webviews/apps/shared/app.ts @@ -7,7 +7,7 @@ import { debounce } from '../../../system/function'; import type { WebviewFocusChangedParams } from '../../protocol'; import { DidChangeWebviewFocusNotification, WebviewFocusChangedCommand, WebviewReadyCommand } from '../../protocol'; import { GlElement } from './components/element'; -import { ipcContext, loggerContext, LoggerContext } from './context'; +import { ipcContext, LoggerContext, loggerContext, telemetryContext, TelemetryContext } from './context'; import type { Disposable } from './events'; import { HostIpc } from './ipc'; @@ -31,6 +31,9 @@ export abstract class GlApp< @provide({ context: loggerContext }) protected _logger!: LoggerContext; + @provide({ context: telemetryContext }) + protected _telemetry!: TelemetryContext; + @property({ type: Object }) state!: State; @@ -65,6 +68,7 @@ export abstract class GlApp< } }), this._ipc, + (this._telemetry = new TelemetryContext(this._ipc)), ); this._ipc.sendCommand(WebviewReadyCommand, undefined); diff --git a/src/webviews/apps/shared/appBase.ts b/src/webviews/apps/shared/appBase.ts index c3632963a97ee..c24c7b146f2c5 100644 --- a/src/webviews/apps/shared/appBase.ts +++ b/src/webviews/apps/shared/appBase.ts @@ -12,11 +12,12 @@ import type { WebviewFocusChangedParams, } from '../../protocol'; import { DidChangeWebviewFocusNotification, WebviewFocusChangedCommand, WebviewReadyCommand } from '../../protocol'; -import { ipcContext, loggerContext, LoggerContext } from './context'; +import { ipcContext, loggerContext, LoggerContext, telemetryContext, TelemetryContext } from './context'; import { DOM } from './dom'; import type { Disposable } from './events'; import type { HostIpcApi } from './ipc'; import { getHostIpcApi, HostIpc } from './ipc'; +import { telemetryEventName } from './telemetry'; import type { ThemeChangeEvent } from './theme'; import { computeThemeColors, onDidChangeTheme, watchThemeColors } from './theme'; @@ -29,6 +30,7 @@ export abstract class App< private readonly _api: HostIpcApi; private readonly _hostIpc: HostIpc; private readonly _logger: LoggerContext; + protected readonly _telemetry: TelemetryContext; protected state: State; protected readonly placement: 'editor' | 'view'; @@ -54,11 +56,18 @@ export abstract class App< this._hostIpc = new HostIpc(this.appName); disposables.push(this._hostIpc); + this._telemetry = new TelemetryContext(this._hostIpc); + disposables.push(this._telemetry); + new ContextProvider(document.body, { context: ipcContext, initialValue: this._hostIpc }); new ContextProvider(document.body, { context: loggerContext, initialValue: this._logger, }); + new ContextProvider(document.body, { + context: telemetryContext, + initialValue: this._telemetry, + }); if (this.state != null) { const state = this.getState(); @@ -116,6 +125,12 @@ export abstract class App< }), ); + disposables.push( + DOM.on(window, telemetryEventName, e => { + this._telemetry.sendEvent(e.detail); + }), + ); + this.log('opened'); } diff --git a/src/webviews/apps/shared/context.ts b/src/webviews/apps/shared/context.ts index 8b0791f7ff85a..6883a7931c199 100644 --- a/src/webviews/apps/shared/context.ts +++ b/src/webviews/apps/shared/context.ts @@ -3,6 +3,9 @@ import { Logger } from '../../../system/logger'; import type { LogScope } from '../../../system/logger.scope'; import { getNewLogScope } from '../../../system/logger.scope'; import { padOrTruncateEnd } from '../../../system/string'; +import type { TelemetrySendEventParams } from '../../protocol'; +import { TelemetrySendEventCommand } from '../../protocol'; +import type { Disposable } from './events'; import type { HostIpc } from './ipc'; export class LoggerContext { @@ -35,5 +38,23 @@ export class LoggerContext { } } +export class TelemetryContext implements Disposable { + private readonly ipc: HostIpc; + private readonly disposables: Disposable[] = []; + + constructor(ipc: HostIpc) { + this.ipc = ipc; + } + + sendEvent(detail: TelemetrySendEventParams): void { + this.ipc.sendCommand(TelemetrySendEventCommand, detail); + } + + dispose(): void { + this.disposables.forEach(d => d.dispose()); + } +} + export const ipcContext = createContext('ipc'); export const loggerContext = createContext('logger'); +export const telemetryContext = createContext('telemetry'); diff --git a/src/webviews/apps/shared/telemetry.ts b/src/webviews/apps/shared/telemetry.ts new file mode 100644 index 0000000000000..ff4624f31d4d6 --- /dev/null +++ b/src/webviews/apps/shared/telemetry.ts @@ -0,0 +1,18 @@ +import type { TelemetrySendEventParams } from '../../protocol'; + +export const telemetryEventName = 'gl-telemetry-fired'; + +export function emitTelemetrySentEvent(el: EventTarget, params: T) { + el.dispatchEvent( + new CustomEvent(telemetryEventName, { + bubbles: true, + detail: params, + }), + ); +} + +declare global { + interface WindowEventMap { + [telemetryEventName]: CustomEvent; + } +} diff --git a/src/webviews/commitDetails/commitDetailsWebview.ts b/src/webviews/commitDetails/commitDetailsWebview.ts index 4952ce1914717..c458574cb6bbe 100644 --- a/src/webviews/commitDetails/commitDetailsWebview.ts +++ b/src/webviews/commitDetails/commitDetailsWebview.ts @@ -226,12 +226,15 @@ export class CommitDetailsWebviewProvider _loading: boolean, options?: WebviewShowOptions, ...args: WebviewShowingArgs> - ): Promise { + ): Promise<[boolean, Record<`context.${string}`, string | number | boolean> | undefined]> { const [arg] = args; if ((arg as ShowWipArgs)?.type === 'wip') { - return this.onShowingWip(arg as ShowWipArgs); + return [await this.onShowingWip(arg as ShowWipArgs), undefined]; } - return this.onShowingCommit(arg as Partial | undefined, options); + return [ + await this.onShowingCommit(arg as Partial | undefined, options), + undefined, + ]; } private get inReview(): boolean { diff --git a/src/webviews/commitDetails/registration.ts b/src/webviews/commitDetails/registration.ts index aa5cc7c113aca..ebeeeba2ec8a4 100644 --- a/src/webviews/commitDetails/registration.ts +++ b/src/webviews/commitDetails/registration.ts @@ -13,6 +13,7 @@ export function registerCommitDetailsWebviewView(controller: WebviewsController) title: 'Inspect', contextKeyPrefix: `gitlens:webviewView:commitDetails`, trackingFeature: 'commitDetailsView', + type: 'commitDetails', plusFeature: false, webviewHostOptions: { retainContextWhenHidden: false, @@ -35,6 +36,7 @@ export function registerGraphDetailsWebviewView(controller: WebviewsController) title: 'Commit Graph Inspect', contextKeyPrefix: `gitlens:webviewView:graphDetails`, trackingFeature: 'graphDetailsView', + type: 'graphDetails', plusFeature: false, webviewHostOptions: { retainContextWhenHidden: false, diff --git a/src/webviews/home/homeWebview.ts b/src/webviews/home/homeWebview.ts index 7f4e9ba83737e..948f0c40c4062 100644 --- a/src/webviews/home/homeWebview.ts +++ b/src/webviews/home/homeWebview.ts @@ -53,17 +53,17 @@ export class HomeWebviewProvider implements WebviewProvider - ) { + ): [boolean, Record<`context.${string}`, string | number | boolean> | undefined] { const [arg] = args as HomeWebviewShowingArgs; if (arg?.focusAccount === true) { if (!loading && this.host.ready && this.host.visible) { queueMicrotask(() => void this.host.notify(DidFocusAccount, undefined)); - return true; + return [true, undefined]; } this._pendingFocusAccount = true; } - return true; + return [true, undefined]; } private onChangeConnectionState() { diff --git a/src/webviews/home/registration.ts b/src/webviews/home/registration.ts index bbb2397721194..7696c821dfbd2 100644 --- a/src/webviews/home/registration.ts +++ b/src/webviews/home/registration.ts @@ -11,6 +11,7 @@ export function registerHomeWebviewView(controller: WebviewsController) { title: 'Home', contextKeyPrefix: `gitlens:webviewView:home`, trackingFeature: 'homeView', + type: 'home', plusFeature: false, webviewHostOptions: { retainContextWhenHidden: false, diff --git a/src/webviews/protocol.ts b/src/webviews/protocol.ts index d200208e46309..8240167ec70be 100644 --- a/src/webviews/protocol.ts +++ b/src/webviews/protocol.ts @@ -1,4 +1,6 @@ +import type { TimeInput } from '@opentelemetry/api'; import type { Config } from '../config'; +import type { Source, TelemetryEvents, WebviewTelemetryContext } from '../constants.telemetry'; import type { CustomEditorIds, CustomEditorTypes, @@ -92,6 +94,15 @@ export interface UpdateConfigurationParams { } export const UpdateConfigurationCommand = new IpcCommand('core', 'configuration/update'); +export interface TelemetrySendEventParams { + name: T; + data: Omit; + source?: Source; + startTime?: TimeInput; + endTime?: TimeInput; +} +export const TelemetrySendEventCommand = new IpcCommand('core', 'telemetry/sendEvent'); + // NOTIFICATIONS export interface DidChangeHostWindowFocusParams { diff --git a/src/webviews/settings/registration.ts b/src/webviews/settings/registration.ts index 9ffdae6e4173a..87ec4c5e8beae 100644 --- a/src/webviews/settings/registration.ts +++ b/src/webviews/settings/registration.ts @@ -16,6 +16,7 @@ export function registerSettingsWebviewPanel(controller: WebviewsController) { title: 'GitLens Settings', contextKeyPrefix: `gitlens:webview:settings`, trackingFeature: 'settingsWebview', + type: 'settings', plusFeature: false, column: ViewColumn.Active, webviewHostOptions: { diff --git a/src/webviews/settings/settingsWebview.ts b/src/webviews/settings/settingsWebview.ts index ee2b49063e191..5930b504c6949 100644 --- a/src/webviews/settings/settingsWebview.ts +++ b/src/webviews/settings/settingsWebview.ts @@ -95,7 +95,7 @@ export class SettingsWebviewProvider implements WebviewProvider { + ): [boolean, Record<`context.${string}`, string | number | boolean> | undefined] { const anchor = args[0]; if (anchor && typeof anchor === 'string') { if (!loading && this.host.ready && this.host.visible) { @@ -106,13 +106,13 @@ export class SettingsWebviewProvider implements WebviewProvider; isHost(type: 'view'): this is WebviewViewController; isHost( @@ -280,11 +290,23 @@ export class WebviewController< options = {}; } - const result = this.provider.onShowing?.(loading, options, ...args); + const eventBase = { + ...this.getTelemetryContext(), + loading: loading, + }; + + using sw = new Stopwatch(`WebviewController.show(${this.id})`); + + let context; + const result = await this.provider.onShowing?.(loading, options, ...args); if (result != null) { - if (isPromise(result)) { - if ((await result) === false) return; - } else if (result === false) { + let show; + [show, context] = result; + if (show === false) { + this.container.telemetry.sendEvent(`${this.descriptor.type}/showAborted`, { + ...eventBase, + duration: sw.elapsed(), + }); return; } } @@ -308,6 +330,12 @@ export class WebviewController< } setContextKeys(this.descriptor.contextKeyPrefix); + + this.container.telemetry.sendEvent(`${this.descriptor.type}/shown`, { + ...eventBase, + duration: sw.elapsed(), + ...context, + }); } get baseWebviewState(): WebviewState { @@ -387,6 +415,14 @@ export class WebviewController< } break; + case TelemetrySendEventCommand.is(e): + this.container.telemetry.sendEvent( + e.params.name, + { ...e.params.data, ...(this.provider.getTelemetryContext?.() ?? this.getTelemetryContext()) }, + e.params.source, + ); + break; + default: this.provider.onMessageReceived?.(e); break; diff --git a/src/webviews/webviewProvider.ts b/src/webviews/webviewProvider.ts index 5b7681554cd27..c80b29e36544b 100644 --- a/src/webviews/webviewProvider.ts +++ b/src/webviews/webviewProvider.ts @@ -1,4 +1,5 @@ import type { Disposable, Uri, ViewBadge } from 'vscode'; +import type { WebviewTelemetryContext } from '../constants.telemetry'; import type { WebviewContext } from '../system/webview'; import type { IpcCallMessageType, @@ -22,11 +23,15 @@ export interface WebviewProvider): boolean | undefined; getSplitArgs?(): WebviewShowingArgs; + getTelemetryContext?(): Record<`context.${string}`, string | number | boolean | undefined> & + WebviewTelemetryContext; onShowing?( loading: boolean, options: WebviewShowOptions, ...args: WebviewShowingArgs - ): boolean | Promise; + ): + | [boolean, Record<`context.${string}`, string | number | boolean | undefined> | undefined] + | Promise<[boolean, Record<`context.${string}`, string | number | boolean | undefined> | undefined]>; registerCommands?(): Disposable[]; includeBootstrap?(): SerializedState | Promise; @@ -71,6 +76,7 @@ export interface WebviewHost< clearPendingIpcNotifications(): void; sendPendingIpcNotifications(): void; + getTelemetryContext(): WebviewTelemetryContext; isHost(type: 'editor'): this is WebviewHost; isHost(type: 'view'): this is WebviewHost; diff --git a/src/webviews/webviewsController.ts b/src/webviews/webviewsController.ts index eb7e1d66efcde..165a55a908a1e 100644 --- a/src/webviews/webviewsController.ts +++ b/src/webviews/webviewsController.ts @@ -29,6 +29,7 @@ export interface WebviewPanelDescriptor { readonly title: string; readonly contextKeyPrefix: `gitlens:webview:${WebviewTypes}`; readonly trackingFeature: TrackedUsageFeatures; + readonly type: WebviewTypes; readonly plusFeature: boolean; readonly column?: ViewColumn; readonly webviewOptions?: WebviewOptions; @@ -79,6 +80,7 @@ export interface WebviewViewDescriptor { readonly title: string; readonly contextKeyPrefix: `gitlens:webviewView:${WebviewViewTypes}`; readonly trackingFeature: TrackedUsageFeatures; + readonly type: WebviewViewTypes; readonly plusFeature: boolean; readonly webviewOptions?: WebviewOptions; readonly webviewHostOptions?: { diff --git a/src/webviews/welcome/registration.ts b/src/webviews/welcome/registration.ts index c968d968e70ab..5462a11255e42 100644 --- a/src/webviews/welcome/registration.ts +++ b/src/webviews/welcome/registration.ts @@ -13,6 +13,7 @@ export function registerWelcomeWebviewPanel(controller: WebviewsController) { title: 'Welcome to GitLens', contextKeyPrefix: `gitlens:webview:welcome`, trackingFeature: 'welcomeWebview', + type: 'welcome', plusFeature: false, column: ViewColumn.Active, webviewHostOptions: {