diff --git a/client/browser/BUILD.bazel b/client/browser/BUILD.bazel index a86a11540e5..c6cce7565b9 100644 --- a/client/browser/BUILD.bazel +++ b/client/browser/BUILD.bazel @@ -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", diff --git a/client/browser/package.json b/client/browser/package.json index 4b500fcd6e6..692438fbac4 100644 --- a/client/browser/package.json +++ b/client/browser/package.json @@ -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" } } diff --git a/client/browser/src/browser-extension/scripts/backgroundPage.main.ts b/client/browser/src/browser-extension/scripts/backgroundPage.main.ts index c9b87f6f542..ea95d4416aa 100644 --- a/client/browser/src/browser-extension/scripts/backgroundPage.main.ts +++ b/client/browser/src/browser-extension/scripts/backgroundPage.main.ts @@ -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 { .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') }) ) } diff --git a/client/browser/src/browser-extension/scripts/optionsPage.main.tsx b/client/browser/src/browser-extension/scripts/optionsPage.main.tsx index 6363431d0b2..c03adea96b8 100644 --- a/client/browser/src/browser-extension/scripts/optionsPage.main.tsx +++ b/client/browser/src/browser-extension/scripts/optionsPage.main.tsx @@ -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 { + 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, 'settingsURL' | 'siteAdmin'>> => { @@ -173,6 +204,7 @@ const Options: React.FunctionComponent> = () => 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> = () => 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( diff --git a/client/browser/src/shared/code-hosts/shared/codeHost.tsx b/client/browser/src/shared/code-hosts/shared/codeHost.tsx index 306daeeedd6..42208bfae34 100644 --- a/client/browser/src/shared/code-hosts/shared/codeHost.tsx +++ b/client/browser/src/shared/code-hosts/shared/codeHost.tsx @@ -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, diff --git a/client/browser/src/shared/platform/context.ts b/client/browser/src/shared/platform/context.ts index 68bc573bcfa..2751fca4c32 100644 --- a/client/browser/src/shared/platform/context.ts +++ b/client/browser/src/shared/platform/context.ts @@ -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 } diff --git a/client/browser/src/shared/telemetry/gqlTelemetryExporter.ts b/client/browser/src/shared/telemetry/gqlTelemetryExporter.ts new file mode 100644 index 00000000000..279b240e252 --- /dev/null +++ b/client/browser/src/shared/telemetry/gqlTelemetryExporter.ts @@ -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 { + const req = this.requestGraphQL({ + 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() + } +} diff --git a/client/browser/src/shared/telemetry/index.ts b/client/browser/src/shared/telemetry/index.ts new file mode 100644 index 00000000000..743ca1c6f64 --- /dev/null +++ b/client/browser/src/shared/telemetry/index.ts @@ -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, 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 { + return new ConditionalTelemetryRecorder(this.telemetryEnabled, super.getRecorder()) + } +} + +export class ConditionalTelemetryRecorder + implements BaseTelemetryRecorder +{ + /** 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, + private innerRecorder: BaseTelemetryRecorder + ) { + this.subscription.add( + telemetryEnabled.subscribe(enabled => { + this.isEnabled = enabled + }) + ) + } + + public recordEvent( + feature: KnownString, + action: KnownString, + parameters?: + | TelemetryEventParameters< + KnownKeys, + 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 { + constructor() { + super({ client: '' }, new NoOpTelemetryExporter(), []) + } +} + +export const noOptelemetryRecorderProvider = new NoOpTelemetryRecorderProvider() +export const noOpTelemetryRecorder = noOptelemetryRecorderProvider.getRecorder() as ConditionalTelemetryRecorder< + BillingCategory, + BillingProduct +> diff --git a/client/browser/src/shared/util/context.tsx b/client/browser/src/shared/util/context.tsx index 497c6b0e172..9b8aeb710a8 100644 --- a/client/browser/src/shared/util/context.tsx +++ b/client/browser/src/shared/util/context.tsx @@ -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() diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 2e1319f1ce4..eebd67d2545 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -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