From 5915788ef0b2fc38bd5d07936799b38795bd9153 Mon Sep 17 00:00:00 2001 From: Vova Kulikov Date: Tue, 4 Jun 2024 11:12:32 +0200 Subject: [PATCH] Svelte: Add telemetry v2 to svelte client (#63041) * Svelte: Add telemetry v2 to svelte client * Run format * Add telemetry to the bazel config --- .../results/filters/NewSearchFilters.tsx | 8 +- client/shared/src/search/helpers.ts | 13 +++ client/shared/src/search/stream.ts | 2 +- client/web-sveltekit/BUILD.bazel | 1 + client/web-sveltekit/package.json | 1 + .../lib/search/dynamicFilters/Sidebar.svelte | 5 + client/web-sveltekit/src/lib/telemetry2.ts | 109 ++++++++++++++++++ .../(validrev)/(code)/+page.svelte | 2 + .../(code)/-/blob/[...path]/+page.svelte | 2 + .../(code)/-/blob/[...path]/FileView.svelte | 2 + .../[...path]/OpenInCodeHostAction.svelte | 5 +- .../[...repo=reporev]/RepoSearchInput.svelte | 14 ++- .../src/routes/search/SearchHome.svelte | 6 + .../src/routes/search/SearchResults.svelte | 26 ++++- client/web/src/search/helpers.tsx | 17 +-- pnpm-lock.yaml | 3 + 16 files changed, 187 insertions(+), 29 deletions(-) create mode 100644 client/web-sveltekit/src/lib/telemetry2.ts diff --git a/client/branded/src/search-ui/results/filters/NewSearchFilters.tsx b/client/branded/src/search-ui/results/filters/NewSearchFilters.tsx index 23e104d47d6..093dd1c2196 100644 --- a/client/branded/src/search-ui/results/filters/NewSearchFilters.tsx +++ b/client/branded/src/search-ui/results/filters/NewSearchFilters.tsx @@ -7,7 +7,7 @@ import { findFilters } from '@sourcegraph/shared/src/search/query/query' import { scanSearchQuery, succeedScan } from '@sourcegraph/shared/src/search/query/scanner' import type { Filter as QueryFilter } from '@sourcegraph/shared/src/search/query/token' import { omitFilter } from '@sourcegraph/shared/src/search/query/transformer' -import { V2FilterTypes, type Filter } from '@sourcegraph/shared/src/search/stream' +import { TELEMETRY_V2_FILTER_TYPES, type Filter } from '@sourcegraph/shared/src/search/stream' import type { TelemetryV2Props } from '@sourcegraph/shared/src/telemetry' import type { TelemetryProps } from '@sourcegraph/shared/src/telemetry/telemetryService' import { Button, H1, H3, Icon, Tooltip } from '@sourcegraph/wildcard' @@ -72,7 +72,7 @@ export const NewSearchFilters: FC = ({ (filter: URLQueryFilter, remove: boolean): void => { telemetryService.log('SearchFiltersTypeClick', { filterType: filter.label }, { filterType: filter.label }) telemetryRecorder.recordEvent('search.filters.type', 'click', { - metadata: { filterKind: V2FilterTypes[filter.kind] }, + metadata: { filterKind: TELEMETRY_V2_FILTER_TYPES[filter.kind] }, }) if (remove) { setSelectedFilters( @@ -96,7 +96,7 @@ export const NewSearchFilters: FC = ({ setSelectedFilters(filters) telemetryService.log('SearchFiltersSelectFilter', { filterKind }, { filterKind }) telemetryRecorder.recordEvent('search.filters', 'select', { - metadata: { filterKind: V2FilterTypes[filterKind] }, + metadata: { filterKind: TELEMETRY_V2_FILTER_TYPES[filterKind] }, }) }, [setSelectedFilters, telemetryService, telemetryRecorder] @@ -303,7 +303,7 @@ const SyntheticCountFilter: FC = props => { const handleCountAllFilter = (filterKind: Filter['kind'], countFilters: URLQueryFilter[]): void => { telemetryService.log('SearchFiltersSelectFilter', { filterKind }, { filterKind }) telemetryRecorder.recordEvent('search.filters', 'select', { - metadata: { filterKind: V2FilterTypes[filterKind] }, + metadata: { filterKind: TELEMETRY_V2_FILTER_TYPES[filterKind] }, }) if (countFilters.length > 0) { diff --git a/client/shared/src/search/helpers.ts b/client/shared/src/search/helpers.ts index 50f22a9d3ee..e525d34bc7b 100644 --- a/client/shared/src/search/helpers.ts +++ b/client/shared/src/search/helpers.ts @@ -91,6 +91,19 @@ export interface SubmitSearchParameters searchMode?: SearchMode } +export const TELEMETRY_V2_SEARCH_SOURCE_TYPE: { [key in SubmitSearchParameters['source']]: number } = { + home: 1, + nav: 2, + repo: 3, + tree: 4, + filter: 5, + type: 6, + scopePage: 7, + communitySearchContextPage: 8, + excludedResults: 9, + smartSearchDisabled: 10, +} + export interface SubmitSearchProps { submitSearch: (parameters: Partial>) => void } diff --git a/client/shared/src/search/stream.ts b/client/shared/src/search/stream.ts index 8ec4cbf3daf..a2051866e7f 100644 --- a/client/shared/src/search/stream.ts +++ b/client/shared/src/search/stream.ts @@ -284,7 +284,7 @@ export interface Filter { kind: 'file' | 'repo' | 'lang' | 'utility' | 'author' | 'commit date' | 'symbol type' | 'type' } -export const V2FilterTypes: { [key in Filter['kind']]: number } = { +export const TELEMETRY_V2_FILTER_TYPES: { [key in Filter['kind']]: number } = { file: 1, repo: 2, lang: 3, diff --git a/client/web-sveltekit/BUILD.bazel b/client/web-sveltekit/BUILD.bazel index 7e731f86e6a..836708a1506 100644 --- a/client/web-sveltekit/BUILD.bazel +++ b/client/web-sveltekit/BUILD.bazel @@ -86,6 +86,7 @@ BUILD_DEPS = [ ":node_modules/@sourcegraph/shared", ":node_modules/@sourcegraph/web", ":node_modules/@sourcegraph/wildcard", + ":node_modules/@sourcegraph/telemetry", ":node_modules/@storybook/svelte", ":node_modules/@sveltejs/adapter-static", ":node_modules/@sveltejs/kit", diff --git a/client/web-sveltekit/package.json b/client/web-sveltekit/package.json index 1595e9d7323..17662c36795 100644 --- a/client/web-sveltekit/package.json +++ b/client/web-sveltekit/package.json @@ -75,6 +75,7 @@ "@sourcegraph/common": "workspace:*", "@sourcegraph/http-client": "workspace:*", "@sourcegraph/shared": "workspace:*", + "@sourcegraph/telemetry": "^0.11.0", "@sourcegraph/web": "workspace:*", "@sourcegraph/wildcard": "workspace:*", "@storybook/test": "^8.0.5", diff --git a/client/web-sveltekit/src/lib/search/dynamicFilters/Sidebar.svelte b/client/web-sveltekit/src/lib/search/dynamicFilters/Sidebar.svelte index 8dcdb0cd37b..c33e7277ae2 100644 --- a/client/web-sveltekit/src/lib/search/dynamicFilters/Sidebar.svelte +++ b/client/web-sveltekit/src/lib/search/dynamicFilters/Sidebar.svelte @@ -42,6 +42,8 @@ import SymbolKindIcon from '$lib/search/SymbolKindIcon.svelte' import { displayRepoName, scanSearchQuery, type Filter } from '$lib/shared' import { SVELTE_LOGGER, SVELTE_TELEMETRY_EVENTS } from '$lib/telemetry' + import { TELEMETRY_V2_RECORDER } from '$lib/telemetry2' + import { TELEMETRY_V2_FILTER_TYPES } from '@sourcegraph/shared/src/search/stream' import { delay } from '$lib/utils' import { Alert } from '$lib/wildcard' import Button from '$lib/wildcard/Button.svelte' @@ -90,6 +92,9 @@ function handleFilterSelect(kind: SectionItemData['kind']): void { SVELTE_LOGGER.log(SVELTE_TELEMETRY_EVENTS.SelectSearchFilter, { kind }, { kind }) + TELEMETRY_V2_RECORDER.recordEvent('search.filters', 'select', { + metadata: { filterKind: TELEMETRY_V2_FILTER_TYPES[kind] }, + }) } onMount(() => { diff --git a/client/web-sveltekit/src/lib/telemetry2.ts b/client/web-sveltekit/src/lib/telemetry2.ts new file mode 100644 index 00000000000..2c2ee672b63 --- /dev/null +++ b/client/web-sveltekit/src/lib/telemetry2.ts @@ -0,0 +1,109 @@ +import { gql } from '@urql/core' + +import type { BillingCategory, BillingProduct } from '@sourcegraph/shared/src/telemetry' +import { sessionTracker } from '@sourcegraph/shared/src/telemetry/web/sessionTracker' +import { userTracker } from '@sourcegraph/shared/src/telemetry/web/userTracker' +import { + type MarketingTrackingProvider, + type TelemetryEventMarketingTrackingInput, + type TelemetryEventInput, + type TelemetryExporter, + MarketingTrackingTelemetryProcessor, + TelemetryRecorderProvider as BaseTelemetryRecorderProvider, +} from '@sourcegraph/telemetry' + +import type { GraphQLClient } from '$lib/graphql' +import { getGraphQLClient } from '$lib/graphql' +import type { ExportTelemetryEventsResult } from '$lib/graphql-operations' + +/** + * TelemetryRecorderProvider is the default provider implementation for the + * Sourcegraph web app. + */ +export class TelemetryRecorderProvider extends BaseTelemetryRecorderProvider { + constructor( + graphQlClient: GraphQLClient, + options: { + /** + * Enables buffering of events for export. Only enable if there is a + * reliable unsubscribe mechanism available. + */ + enableBuffering: boolean + } + ) { + super( + { + client: getTelemetrySourceClient(), + clientVersion: window.context.version, + }, + new GraphQlTelemetryExporter(graphQlClient), + [new MarketingTrackingTelemetryProcessor(new TrackingMetadataProvider())], + { + /** + * Use buffer time of 100ms - some existing buffering uses + * 1000ms, but we use a more conservative value. + */ + bufferTimeMs: options.enableBuffering ? 100 : 0, + bufferMaxSize: 10, + errorHandler: error => { + throw new Error(error) + }, + } + ) + } +} + +function getTelemetrySourceClient(): string { + if (window.context?.sourcegraphDotComMode) { + return 'dotcom.svelte-web' + } + return 'server.svelte-web' +} + +/** + * ApolloTelemetryExporter exports events via the new Sourcegraph telemetry + * framework: https://docs-legacy.sourcegraph.com/dev/background-information/telemetry + */ +export class GraphQlTelemetryExporter implements TelemetryExporter { + constructor(private client: GraphQLClient) {} + + public async exportEvents(events: TelemetryEventInput[]): Promise { + await this.client.mutation( + gql` + mutation ExportTelemetryEvents($events: [TelemetryEventInput!]!) { + telemetry { + recordEvents(events: $events) { + alwaysNil + } + } + } + `, + { events } + ) + } +} + +class TrackingMetadataProvider implements MarketingTrackingProvider { + private user = userTracker + private session = sessionTracker + + public getMarketingTrackingMetadata(): TelemetryEventMarketingTrackingInput | null { + if (!window.context?.sourcegraphDotComMode) { + return null // don't report this data outside dotcom + } + + return { + cohortID: this.user.cohortID, + deviceSessionID: this.user.deviceSessionID, + firstSourceURL: this.session.getFirstSourceURL(), + lastSourceURL: this.session.getLastSourceURL(), + referrer: this.session.getReferrer(), + sessionFirstURL: this.session.getSessionFirstURL(), + sessionReferrer: this.session.getSessionReferrer(), + url: window.location.href, + } + } +} + +export const TELEMETRY_V2 = new TelemetryRecorderProvider(getGraphQLClient(), { enableBuffering: true }) +export const TELEMETRY_V2_RECORDER = TELEMETRY_V2.getRecorder() diff --git a/client/web-sveltekit/src/routes/[...repo=reporev]/(validrev)/(code)/+page.svelte b/client/web-sveltekit/src/routes/[...repo=reporev]/(validrev)/(code)/+page.svelte index 2f3488dc7e6..41f91287b17 100644 --- a/client/web-sveltekit/src/routes/[...repo=reporev]/(validrev)/(code)/+page.svelte +++ b/client/web-sveltekit/src/routes/[...repo=reporev]/(validrev)/(code)/+page.svelte @@ -4,6 +4,7 @@ import { createPromiseStore } from '$lib/utils' import { SVELTE_LOGGER, SVELTE_TELEMETRY_EVENTS } from '$lib/telemetry' + import { TELEMETRY_V2_RECORDER } from '$lib/telemetry2' import Readme from '$lib/repo/Readme.svelte' import type { PageData } from './$types' @@ -16,6 +17,7 @@ onMount(() => { SVELTE_LOGGER.logViewEvent(SVELTE_TELEMETRY_EVENTS.ViewRepositoryPage) + TELEMETRY_V2_RECORDER.recordEvent('repo', 'view') }) diff --git a/client/web-sveltekit/src/routes/[...repo=reporev]/(validrev)/(code)/-/blob/[...path]/+page.svelte b/client/web-sveltekit/src/routes/[...repo=reporev]/(validrev)/(code)/-/blob/[...path]/+page.svelte index b41d6354224..4c8f719f9bd 100644 --- a/client/web-sveltekit/src/routes/[...repo=reporev]/(validrev)/(code)/-/blob/[...path]/+page.svelte +++ b/client/web-sveltekit/src/routes/[...repo=reporev]/(validrev)/(code)/-/blob/[...path]/+page.svelte @@ -4,6 +4,7 @@ // @sg EnableRollout import { onMount } from 'svelte' + import { TELEMETRY_V2_RECORDER } from '$lib/telemetry2' import { SVELTE_LOGGER, SVELTE_TELEMETRY_EVENTS } from '$lib/telemetry' import type { PageData, Snapshot } from './$types' @@ -29,6 +30,7 @@ onMount(() => { SVELTE_LOGGER.logViewEvent(SVELTE_TELEMETRY_EVENTS.ViewBlobPage) + TELEMETRY_V2_RECORDER.recordEvent('blob', 'view') }) diff --git a/client/web-sveltekit/src/routes/[...repo=reporev]/(validrev)/(code)/-/blob/[...path]/FileView.svelte b/client/web-sveltekit/src/routes/[...repo=reporev]/(validrev)/(code)/-/blob/[...path]/FileView.svelte index 9b81a0554a3..07264a05988 100644 --- a/client/web-sveltekit/src/routes/[...repo=reporev]/(validrev)/(code)/-/blob/[...path]/FileView.svelte +++ b/client/web-sveltekit/src/routes/[...repo=reporev]/(validrev)/(code)/-/blob/[...path]/FileView.svelte @@ -24,6 +24,7 @@ import Permalink from '$lib/repo/Permalink.svelte' import { createCodeIntelAPI } from '$lib/shared' import { isLightTheme, settings } from '$lib/stores' + import { TELEMETRY_V2_RECORDER } from '$lib/telemetry2' import { codeCopiedEvent, SVELTE_LOGGER, SVELTE_TELEMETRY_EVENTS } from '$lib/telemetry' import { createPromiseStore, formatBytes } from '$lib/utils' import { Alert, Badge, MenuButton, MenuLink } from '$lib/wildcard' @@ -126,6 +127,7 @@ // TODO: track other blob mode if (event.detail === CodeViewMode.Blame) { SVELTE_LOGGER.log(SVELTE_TELEMETRY_EVENTS.GitBlameEnabled) + TELEMETRY_V2_RECORDER.recordEvent('repo.gitBlame', 'enable') } goto(viewModeURL(event.detail), { replaceState: true, keepFocus: true }) diff --git a/client/web-sveltekit/src/routes/[...repo=reporev]/(validrev)/(code)/-/blob/[...path]/OpenInCodeHostAction.svelte b/client/web-sveltekit/src/routes/[...repo=reporev]/(validrev)/(code)/-/blob/[...path]/OpenInCodeHostAction.svelte index 044396d1887..88302db9204 100644 --- a/client/web-sveltekit/src/routes/[...repo=reporev]/(validrev)/(code)/-/blob/[...path]/OpenInCodeHostAction.svelte +++ b/client/web-sveltekit/src/routes/[...repo=reporev]/(validrev)/(code)/-/blob/[...path]/OpenInCodeHostAction.svelte @@ -1,7 +1,9 @@ diff --git a/client/web-sveltekit/src/routes/[...repo=reporev]/RepoSearchInput.svelte b/client/web-sveltekit/src/routes/[...repo=reporev]/RepoSearchInput.svelte index 4d10d3bfc17..33e6cc656c5 100644 --- a/client/web-sveltekit/src/routes/[...repo=reporev]/RepoSearchInput.svelte +++ b/client/web-sveltekit/src/routes/[...repo=reporev]/RepoSearchInput.svelte @@ -3,13 +3,16 @@ import { mdiMagnify } from '@mdi/js' import { createDialog } from '@melt-ui/svelte' - import Icon from '$lib/Icon.svelte' - import SearchInput from '$lib/search/input/SearchInput.svelte' - import { QueryState, queryStateStore } from '$lib/search/state' import { settings } from '$lib/stores' + import { registerHotkey } from '$lib/Hotkey' import { repositoryInsertText } from '$lib/shared' import { SVELTE_LOGGER, SVELTE_TELEMETRY_EVENTS } from '$lib/telemetry' - import { registerHotkey } from '$lib/Hotkey' + import { TELEMETRY_V2_RECORDER } from '$lib/telemetry2' + import { TELEMETRY_V2_SEARCH_SOURCE_TYPE } from '@sourcegraph/shared/src/search' + import { QueryState, queryStateStore } from '$lib/search/state' + + import Icon from '$lib/Icon.svelte' + import SearchInput from '$lib/search/input/SearchInput.svelte' export let repoName: string /** @@ -35,6 +38,9 @@ { source: 'repo', query: state.query }, { source: 'repo', patternType: state.patternType } ) + TELEMETRY_V2_RECORDER.recordEvent('search', 'submit', { + metadata: { source: TELEMETRY_V2_SEARCH_SOURCE_TYPE['repo'] }, + }) } $: query = `repo:${repositoryInsertText({ repository: repoName })}${revision ? `@${revision}` : ''} ` diff --git a/client/web-sveltekit/src/routes/search/SearchHome.svelte b/client/web-sveltekit/src/routes/search/SearchHome.svelte index 59f5b53cc9a..041445222c8 100644 --- a/client/web-sveltekit/src/routes/search/SearchHome.svelte +++ b/client/web-sveltekit/src/routes/search/SearchHome.svelte @@ -2,6 +2,8 @@ import { setContext, onMount } from 'svelte' import { SVELTE_LOGGER, SVELTE_TELEMETRY_EVENTS } from '$lib/telemetry' + import { TELEMETRY_V2_RECORDER } from '$lib/telemetry2' + import { TELEMETRY_V2_SEARCH_SOURCE_TYPE } from '@sourcegraph/shared/src/search' import { logoLight, logoDark } from '$lib/images' import SearchInput from '$lib/search/input/SearchInput.svelte' import type { QueryStateStore, QueryState } from '$lib/search/state' @@ -20,6 +22,7 @@ onMount(() => { SVELTE_LOGGER.logViewEvent(SVELTE_TELEMETRY_EVENTS.ViewHomePage) + TELEMETRY_V2_RECORDER.recordEvent('home', 'view') }) function handleSubmit(state: QueryState) { @@ -28,6 +31,9 @@ { source: 'home', query: state.query }, { source: 'home', patternType: state.patternType } ) + TELEMETRY_V2_RECORDER.recordEvent('search', 'submit', { + metadata: { source: TELEMETRY_V2_SEARCH_SOURCE_TYPE['home'] }, + }) } diff --git a/client/web-sveltekit/src/routes/search/SearchResults.svelte b/client/web-sveltekit/src/routes/search/SearchResults.svelte index f4120b7bd38..e3b708274f6 100644 --- a/client/web-sveltekit/src/routes/search/SearchResults.svelte +++ b/client/web-sveltekit/src/routes/search/SearchResults.svelte @@ -38,6 +38,8 @@ type ContentMatch, } from '$lib/shared' import { SVELTE_LOGGER, SVELTE_TELEMETRY_EVENTS, codeCopiedEvent } from '$lib/telemetry' + import { TELEMETRY_V2_RECORDER } from '$lib/telemetry2' + import { TELEMETRY_V2_SEARCH_SOURCE_TYPE } from '@sourcegraph/shared/src/search' import Panel from '$lib/wildcard/resizable-panel/Panel.svelte' import PanelGroup from '$lib/wildcard/resizable-panel/PanelGroup.svelte' import PanelResizeHandle from '$lib/wildcard/resizable-panel/PanelResizeHandle.svelte' @@ -108,6 +110,7 @@ onMount(() => { SVELTE_LOGGER.logViewEvent(SVELTE_TELEMETRY_EVENTS.ViewSearchResultsPage) + TELEMETRY_V2_RECORDER.recordEvent('search.results', 'view') }) function loadMore(event: { detail: boolean }) { @@ -133,8 +136,14 @@ SVELTE_LOGGER.log(...codeCopiedEvent('search-result')) } - function handleSearchResultClick(): void { + function handleSearchResultClick(index: number): void { SVELTE_LOGGER.log(SVELTE_TELEMETRY_EVENTS.SearchResultClick) + TELEMETRY_V2_RECORDER.recordEvent('search.result.area', 'click', { + metadata: { + index, + resultsLength: results.length, + }, + }) } function handleSubmit(state: QueryState) { @@ -143,6 +152,9 @@ { source: 'nav', query: state.query }, { source: 'nav', patternType: state.patternType } ) + TELEMETRY_V2_RECORDER.recordEvent('search', 'submit', { + metadata: { source: TELEMETRY_V2_SEARCH_SOURCE_TYPE['nav'] }, + }) } @@ -181,15 +193,21 @@ 2. A11y: Non-interactive element
    should not be assigned mouse or keyboard event listeners. --> -
      +
        {#each resultsToShow as result, i} {@const component = getSearchResultComponent(result)} {#if i === resultsToShow.length - 1} -
      1. +
      2. handleSearchResultClick(i)} + >
      3. {:else} -
      4. +
      5. handleSearchResultClick(i)}> + +
      6. {/if} {/each}
      diff --git a/client/web/src/search/helpers.tsx b/client/web/src/search/helpers.tsx index 31e4bb85d96..f453ba62c1a 100644 --- a/client/web/src/search/helpers.tsx +++ b/client/web/src/search/helpers.tsx @@ -1,6 +1,6 @@ import { FILTERS_URL_KEY } from '@sourcegraph/branded/src/search-ui/results/filters/hooks' import { compatNavigate } from '@sourcegraph/common' -import type { SubmitSearchParameters } from '@sourcegraph/shared/src/search' +import { type SubmitSearchParameters, TELEMETRY_V2_SEARCH_SOURCE_TYPE } from '@sourcegraph/shared/src/search' import { appendContextFilter } from '@sourcegraph/shared/src/search/query/transformer' import type { TelemetryV2Props } from '@sourcegraph/shared/src/telemetry' import { EVENT_LOGGER } from '@sourcegraph/shared/src/telemetry/web/eventLogger' @@ -8,19 +8,6 @@ import { buildSearchURLQuery } from '@sourcegraph/shared/src/util/url' import { AGGREGATION_MODE_URL_KEY, AGGREGATION_UI_MODE_URL_KEY } from './results/components/aggregation/constants' -const v2SearchSourceType: { [key in SubmitSearchParameters['source']]: number } = { - home: 1, - nav: 2, - repo: 3, - tree: 4, - filter: 5, - type: 6, - scopePage: 7, - communitySearchContextPage: 8, - excludedResults: 9, - smartSearchDisabled: 10, -} - /** * By default {@link submitSearch} overrides all existing query parameters. * This breaks all functionality that is built on top of URL query params and history @@ -85,7 +72,7 @@ export function submitSearch({ }, { source, patternType } ) - telemetryRecorder.recordEvent('search', 'submit', { metadata: { source: v2SearchSourceType[source] } }) + telemetryRecorder.recordEvent('search', 'submit', { metadata: { source: TELEMETRY_V2_SEARCH_SOURCE_TYPE[source] } }) const state = { ...(typeof location.state === 'object' ? location.state : null), diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index f1bbda76283..e9bed6f0ddb 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1548,6 +1548,9 @@ importers: '@sourcegraph/shared': specifier: workspace:* version: link:../shared + '@sourcegraph/telemetry': + specifier: ^0.11.0 + version: 0.11.0 '@sourcegraph/web': specifier: workspace:* version: link:../web