Add v2 telemetry infrastructure to browser extensions and native inte… (#63458)

…grations

This PR adds v2t telemetry infrastructure to the Sourcegraph browser
extensions and native integrations code base.

## Test plan

- Tested locally using instructions at
https://github.com/sourcegraph/sourcegraph/tree/main/client/browser
- CI

## Changelog

---------

Co-authored-by: Dan Adler <5589410+dadlerj@users.noreply.github.com>
This commit is contained in:
Dan Adler 2024-07-03 16:47:37 -07:00 committed by GitHub
parent 239f42947b
commit 4c824b4aa8
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
10 changed files with 216 additions and 8 deletions

View File

@ -211,6 +211,8 @@ ts_project(
"src/shared/repo/backend.tsx",
"src/shared/repo/index.tsx",
"src/shared/sentry/index.ts",
"src/shared/telemetry/gqlTelemetryExporter.ts",
"src/shared/telemetry/index.ts",
"src/shared/tracking/eventLogger.tsx",
"src/shared/util/context.tsx",
"src/shared/util/dom.tsx",
@ -231,6 +233,7 @@ ts_project(
":node_modules/@sourcegraph/extension-api-types",
":node_modules/@sourcegraph/http-client",
":node_modules/@sourcegraph/shared",
":node_modules/@sourcegraph/telemetry",
":node_modules/@sourcegraph/wildcard",
"//:node_modules/@mdi/js",
"//:node_modules/@reach/combobox",

View File

@ -43,6 +43,7 @@
"@sourcegraph/branded": "workspace:*",
"@sourcegraph/http-client": "workspace:*",
"@sourcegraph/client-api": "workspace:*",
"@sourcegraph/codeintellify": "workspace:*"
"@sourcegraph/codeintellify": "workspace:*",
"@sourcegraph/telemetry": "^0.16.0"
}
}

View File

@ -32,6 +32,7 @@ import { initializeOmniboxInterface } from '../../shared/cli'
import { browserPortToMessagePort, findMessagePorts } from '../../shared/platform/ports'
import { createBlobURLForBundle } from '../../shared/platform/worker'
import { initSentry } from '../../shared/sentry'
import { ConditionalTelemetryRecorderProvider } from '../../shared/telemetry'
import { EventLogger } from '../../shared/tracking/eventLogger'
import { getExtensionVersion, getPlatformName, observeSourcegraphURL } from '../../shared/util/context'
import { type BrowserActionIconState, setBrowserActionIconState } from '../browser-action-icon'
@ -148,6 +149,9 @@ async function main(): Promise<void> {
.log('BrowserExtensionInstalled')
.then(() => console.log(`Triggered "BrowserExtensionInstalled" using ${sourcegraphURL}`))
.catch(error => console.error('Error triggering "BrowserExtensionInstalled" event:', error))
new ConditionalTelemetryRecorderProvider(of(true), requestGraphQL)
.getRecorder()
.recordEvent('browserExtension', 'install')
})
)
}

View File

@ -14,6 +14,7 @@ import type { Optional } from 'utility-types'
import { asError, isDefined } from '@sourcegraph/common'
import { gql, type GraphQLResult } from '@sourcegraph/http-client'
import type { BillingCategory, BillingProduct } from '@sourcegraph/shared/src/telemetry'
import type { TelemetryService } from '@sourcegraph/shared/src/telemetry/telemetryService'
import { setLinkComponent, AnchorLink, useObservable } from '@sourcegraph/wildcard'
@ -21,6 +22,11 @@ import type { CurrentUserResult } from '../../graphql-operations'
import { fetchSite } from '../../shared/backend/server'
import { WildcardThemeProvider } from '../../shared/components/WildcardThemeProvider'
import { initSentry } from '../../shared/sentry'
import {
type ConditionalTelemetryRecorder,
ConditionalTelemetryRecorderProvider,
noOpTelemetryRecorder,
} from '../../shared/telemetry'
import { ConditionalTelemetryService, EventLogger } from '../../shared/tracking/eventLogger'
import { observeSourcegraphURL, getExtensionVersion, isDefaultSourcegraphUrl } from '../../shared/util/context'
import { featureFlags } from '../../shared/util/featureFlags'
@ -141,6 +147,31 @@ function useTelemetryService(sourcegraphUrl: string | undefined): TelemetryServi
return telemetryService
}
function useTelemetryRecorder(
sourcegraphUrl: string | undefined
): ConditionalTelemetryRecorder<BillingCategory, BillingProduct> {
const telemetryRecorder = useMemo(() => {
if (!sourcegraphUrl) {
return noOpTelemetryRecorder
}
const telemetryRecorderProvider = new ConditionalTelemetryRecorderProvider(
observingSendTelemetry,
createRequestGraphQL(sourcegraphUrl)
)
return telemetryRecorderProvider.getRecorder()
}, [sourcegraphUrl])
useEffect(
() => () => {
if (telemetryRecorder !== noOpTelemetryRecorder) {
telemetryRecorder.unsubscribe()
}
},
[telemetryRecorder]
)
return telemetryRecorder
}
const fetchCurrentUser = (
sourcegraphURL: string
): Observable<Pick<NonNullable<CurrentUserResult['currentUser']>, 'settingsURL' | 'siteAdmin'>> => {
@ -173,6 +204,7 @@ const Options: React.FunctionComponent<React.PropsWithChildren<unknown>> = () =>
const sourcegraphUrl = useObservable(observingSourcegraphUrl)
const [previousSourcegraphUrl, setPreviousSourcegraphUrl] = useState(sourcegraphUrl)
const telemetryService = useTelemetryService(sourcegraphUrl)
const telemetryRecorder = useTelemetryRecorder(sourcegraphUrl)
const previouslyUsedUrls = useObservable(observingPreviouslyUsedUrls)
const isActivated = useObservable(observingIsActivated)
const optionFlagsWithValues = useObservable(observingOptionFlagsWithValues)
@ -246,9 +278,10 @@ const Options: React.FunctionComponent<React.PropsWithChildren<unknown>> = () =>
const handleToggleActivated = useCallback(
(isActivated: boolean): void => {
telemetryService.log(isActivated ? 'BrowserExtensionEnabled' : 'BrowserExtensionDisabled')
telemetryRecorder.recordEvent('browserExtension', isActivated ? 'enabled' : 'disabled')
storage.sync.set({ disableExtension: !isActivated }).catch(console.error)
},
[telemetryService]
[telemetryService, telemetryRecorder]
)
const handleRemovePreviousSourcegraphUrl = useCallback(

View File

@ -97,6 +97,7 @@ import { WildcardThemeProvider } from '../../components/WildcardThemeProvider'
import { isExtension, isInPage } from '../../context'
import type { SourcegraphIntegrationURLs, BrowserPlatformContext } from '../../platform/context'
import { resolveRevision, retryWhenCloneInProgressError, resolvePrivateRepo } from '../../repo/backend'
import { ConditionalTelemetryRecorderProvider } from '../../telemetry'
import { ConditionalTelemetryService, EventLogger } from '../../tracking/eventLogger'
import { DEFAULT_SOURCEGRAPH_URL, getPlatformName, isDefaultSourcegraphUrl } from '../../util/context'
import { type MutationRecordLike, querySelectorOrSelf } from '../../util/dom'
@ -1364,6 +1365,11 @@ export function injectCodeIntelligenceToCodeHost(
const telemetryService = new ConditionalTelemetryService(innerTelemetryService, isTelemetryEnabled)
subscriptions.add(telemetryService)
const telemetryRecorderProvider = new ConditionalTelemetryRecorderProvider(isTelemetryEnabled, requestGraphQL)
const telemetryRecorder = telemetryRecorderProvider.getRecorder()
subscriptions.add(telemetryRecorder)
platformContext.telemetryRecorder = telemetryRecorder
let codeHostSubscription: Subscription
// In the browser extension, observe whether the `disableExtension` storage flag is set.
// In the native integration, this flag does not exist.
@ -1402,7 +1408,7 @@ export function injectCodeIntelligenceToCodeHost(
extensionsController,
platformContext,
telemetryService,
telemetryRecorder: platformContext.telemetryRecorder,
telemetryRecorder,
render: renderWithThemeProvider as Renderer,
minimalUI,
hideActions,

View File

@ -6,11 +6,11 @@ import { isHTTPAuthError } from '@sourcegraph/http-client'
import type { PlatformContext } from '@sourcegraph/shared/src/platform/context'
import { mutateSettings, updateSettings } from '@sourcegraph/shared/src/settings/edit'
import { EMPTY_SETTINGS_CASCADE, gqlToCascade, type SettingsSubject } from '@sourcegraph/shared/src/settings/settings'
import { NoOpTelemetryRecorderProvider } from '@sourcegraph/shared/src/telemetry'
import { toPrettyBlobURL } from '@sourcegraph/shared/src/util/url'
import { createGraphQLHelpers } from '../backend/requestGraphQl'
import type { CodeHost } from '../code-hosts/shared/codeHost'
import { noOpTelemetryRecorder } from '../telemetry'
import { createExtensionHost } from './extensionHost'
import { getInlineExtensions } from './inlineExtensionsService'
@ -130,11 +130,9 @@ export function createPlatformContext(
clientApplication: 'other',
getStaticExtensions: () => getInlineExtensions(assetsURL),
/**
* @todo Not yet implemented!
* This will be replaced by a real telemetry recorder in codeHost.tsx.
*/
telemetryRecorder: new NoOpTelemetryRecorderProvider({
errorOnRecord: true, // should not be used at all
}).getRecorder(),
telemetryRecorder: noOpTelemetryRecorder,
}
return context
}

View File

@ -0,0 +1,40 @@
import { gql } from '@sourcegraph/http-client'
import type { PlatformContext } from '@sourcegraph/shared/src/platform/context'
import type { TelemetryEventInput, TelemetryExporter } from '@sourcegraph/telemetry'
// todo(dan) update with new recordeventresult type?
import type { logEventResult } from '../../graphql-operations'
/**
* GraphQLTelemetryExporter exports events via the new Sourcegraph telemetry
* framework: https://sourcegraph.com/docs/dev/background-information/telemetry
*/
export class GraphQLTelemetryExporter implements TelemetryExporter {
constructor(private requestGraphQL: PlatformContext['requestGraphQL']) {}
public exportEvents(events: TelemetryEventInput[]): Promise<void> {
const req = this.requestGraphQL<logEventResult>({
request: gql`
mutation ExportTelemetryEventsFromBrowserExtension($events: [TelemetryEventInput!]!) {
telemetry {
recordEvents(events: $events) {
alwaysNil
}
}
}
`,
variables: { events },
mightContainPrivateInfo: false,
})
// eslint-disable-next-line rxjs/no-ignored-subscription
req.subscribe({
error: _ => {
// Swallow errors. If a Sourcegraph instance isn't upgraded, this request may fail.
// However, end users shouldn't experience this failure, as their admin is
// responsible for updating the instance, and has been (or will be) notified
// that an upgrade is available via site-admin messaging.
},
})
return Promise.resolve()
}
}

View File

@ -0,0 +1,104 @@
import { type Observable, Subscription } from 'rxjs'
import type { PlatformContext } from '@sourcegraph/shared/src/platform/context'
import type { BillingCategory, BillingProduct } from '@sourcegraph/shared/src/telemetry'
import {
type TelemetryRecorder as BaseTelemetryRecorder,
TelemetryRecorderProvider as BaseTelemetryRecorderProvider,
type KnownKeys,
type KnownString,
NoOpTelemetryExporter,
type TelemetryEventParameters,
} from '@sourcegraph/telemetry'
import { getTelemetryClientName } from '../util/context'
import { GraphQLTelemetryExporter } from './gqlTelemetryExporter'
/**
* TelemetryRecorderProvider is the default provider implementation for the
* Sourcegraph web app.
*/
export class ConditionalTelemetryRecorderProvider extends BaseTelemetryRecorderProvider<
BillingCategory,
BillingProduct
> {
constructor(private telemetryEnabled: Observable<boolean>, requestGraphQL: PlatformContext['requestGraphQL']) {
super(
{
client: getTelemetryClientName(),
},
new GraphQLTelemetryExporter(requestGraphQL),
[],
{
/**
* Disable buffering for now
*/
bufferTimeMs: 0,
bufferMaxSize: 1,
errorHandler: error => {
throw new Error(error)
},
}
)
}
public getRecorder(): ConditionalTelemetryRecorder<BillingCategory, BillingProduct> {
return new ConditionalTelemetryRecorder(this.telemetryEnabled, super.getRecorder())
}
}
export class ConditionalTelemetryRecorder<BillingCategory extends string, BillingProduct extends string>
implements BaseTelemetryRecorder<BillingCategory, BillingProduct>
{
/** The enabled state set by an observable, provided upon instantiation */
private isEnabled = false
/** Log events are passed on to the inner TelemetryService */
private subscription = new Subscription()
constructor(
telemetryEnabled: Observable<boolean>,
private innerRecorder: BaseTelemetryRecorder<BillingCategory, BillingProduct>
) {
this.subscription.add(
telemetryEnabled.subscribe(enabled => {
this.isEnabled = enabled
})
)
}
public recordEvent<Feature extends string, Action extends string, MetadataKey extends string>(
feature: KnownString<Feature>,
action: KnownString<Action>,
parameters?:
| TelemetryEventParameters<
KnownKeys<MetadataKey, { [key in MetadataKey]: number }>,
BillingCategory,
BillingProduct
>
| undefined
): void {
setTimeout(() => {
if (this.isEnabled) {
this.innerRecorder.recordEvent(feature, action, parameters)
}
})
}
public unsubscribe(): void {
this.isEnabled = false
this.subscription.unsubscribe()
}
}
export class NoOpTelemetryRecorderProvider extends BaseTelemetryRecorderProvider<BillingCategory, BillingProduct> {
constructor() {
super({ client: '' }, new NoOpTelemetryExporter(), [])
}
}
export const noOptelemetryRecorderProvider = new NoOpTelemetryRecorderProvider()
export const noOpTelemetryRecorder = noOptelemetryRecorderProvider.getRecorder() as ConditionalTelemetryRecorder<
BillingCategory,
BillingProduct
>

View File

@ -50,6 +50,22 @@ export function getPlatformName(): PlatformName {
return isFirefox() ? 'firefox-extension' : 'chrome-extension'
}
export function getTelemetryClientName(): string {
if (window.SOURCEGRAPH_PHABRICATOR_EXTENSION || window.SOURCEGRAPH_INTEGRATION === 'phabricator-integration') {
return 'phabricator.integration'
}
if (window.SOURCEGRAPH_INTEGRATION === 'bitbucket-integration') {
return 'bitbucket.integration'
}
if (window.SOURCEGRAPH_INTEGRATION === 'gitlab-integration') {
return 'gitlab.integration'
}
if (isSafari()) {
return 'safari.browserExtension'
}
return isFirefox() ? 'firefox.browserExtension' : 'chrome.browserExtension'
}
export function getExtensionVersion(): string {
if (globalThis.browser) {
const manifest = browser.runtime.getManifest()

View File

@ -1149,6 +1149,9 @@ importers:
'@sourcegraph/shared':
specifier: workspace:*
version: link:../shared
'@sourcegraph/telemetry':
specifier: ^0.16.0
version: 0.16.0
'@sourcegraph/wildcard':
specifier: workspace:*
version: link:../wildcard