From 0426223be59443e237cccdd7bc2d2a5307b2d9e4 Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Thu, 29 Oct 2026 16:32:49 -0700 Subject: [PATCH] feat(telemetry): count plugin installs from Orca's public catalogs (#36132) * feat(telemetry): report plugin installs and removals from Orca's public catalogs * refactor(plugins): move marketplace install origin out of the installer --- docs/site/content/docs/telemetry.mdx | 3 +- src/main/ipc/plugin-marketplaces.test.ts | 5 +- src/main/ipc/plugin-marketplaces.ts | 11 +- src/main/ipc/plugins.ts | 2 + src/main/ipc/telemetry.ts | 2 + .../catalog/plugin-auto-updater.test.ts | 5 +- .../plugins/catalog/plugin-auto-updater.ts | 2 +- .../plugin-marketplace-install-origin.ts | 20 ++++ .../plugin-marketplace-installer.test.ts | 43 +++++++- .../catalog/plugin-marketplace-installer.ts | 43 +++++--- .../plugin-install-telemetry.test.ts | 104 ++++++++++++++++++ .../telemetry/plugin-install-telemetry.ts | 67 +++++++++++ src/preload/api/plugin-host-api.ts | 2 + .../components/plugins/list/PluginManager.tsx | 16 ++- .../state/use-plugin-install-action.test.tsx | 17 ++- .../state/use-plugin-install-action.ts | 8 +- src/shared/telemetry-event-registry.ts | 6 +- src/shared/telemetry-plugin-event-schemas.ts | 33 ++++++ 18 files changed, 352 insertions(+), 37 deletions(-) create mode 100644 src/main/plugins/catalog/plugin-marketplace-install-origin.ts create mode 100644 src/main/telemetry/plugin-install-telemetry.test.ts create mode 100644 src/main/telemetry/plugin-install-telemetry.ts create mode 100644 src/shared/telemetry-plugin-event-schemas.ts diff --git a/docs/site/content/docs/telemetry.mdx b/docs/site/content/docs/telemetry.mdx index be513f9eda1..cf8f8b86233 100644 --- a/docs/site/content/docs/telemetry.mdx +++ b/docs/site/content/docs/telemetry.mdx @@ -28,8 +28,9 @@ The categories of behavior we observe: - **Tab actions** — for a consistent 10% sample of anonymous installs, the first time per app session you choose “Close Tabs To The Left” or “Close Tabs To The Right,” limited to two events per session across all windows. For that first request in each direction, we record the selected tab's type, whether it is in a workspace or floating panel, how many unpinned tabs are eligible to close, and the sampling rate. The app-open event includes sample membership so we can measure the share of sampled sessions that use each command. Repeat requests are not recorded, so these events do not measure click frequency, overall usage by tab type or panel, or total tabs closed. This counts the request, including when you cancel a save prompt; never tab titles, file paths, or workspace identifiers. - **Privacy controls** — when you opt in or out of telemetry, so we can tell from aggregate data whether our consent UI is working. - **SSH remote runtime** — once per SSH host and outcome per app session, which runtime Orca used to run on that host and why, or that its runtime check could not be confirmed or failed: coarse host facts (operating system, CPU architecture, C library family and glibc minor version, and the host's Node.js major version when Orca runs on it), the category of any refusal (including security software blocking the runtime on Windows), whether a runtime was uploaded, and a coarse duration bucket. Never the hostname, user name, paths, or raw error text. +- **Plugins** — when you install, update or remove a plugin from Orca's official or community catalog. We record the plugin's public name (`publisher.id`), its version, which of those two catalogs it came from, whether it was a first install, an update or a reinstall, and whether you started it from the catalog, from an install link, or through automatic updates. These events power the public install counts on the plugin directory, which therefore count only users who share usage data. Plugins from a catalog you added yourself, from a Git URL, or from a local or development folder are never reported. -Fields include fixed enum values, version strings, numeric counts and revisions, and random local IDs. Conversation IDs allow repeated token summaries for the same conversation to be associated; they are pseudonymous. No free-form strings from any UI input ever leave your machine. +Fields include fixed enum values, version strings, numeric counts and revisions, and random local IDs. Conversation IDs allow repeated token summaries for the same conversation to be associated; they are pseudonymous. Plugin names are the public identifiers their catalog lists. No free-form strings from any UI input ever leave your machine. ## What we never send diff --git a/src/main/ipc/plugin-marketplaces.test.ts b/src/main/ipc/plugin-marketplaces.test.ts index a43cb1bd1e4..a6b52a47f26 100644 --- a/src/main/ipc/plugin-marketplaces.test.ts +++ b/src/main/ipc/plugin-marketplaces.test.ts @@ -168,8 +168,11 @@ describe('plugin marketplace IPC authority', () => { } await invoke('plugins:installMarketplacePlugin', preview) - expect(services.installer.install).toHaveBeenCalledWith(preview) + expect(services.installer.install).toHaveBeenCalledWith(preview, { trigger: 'catalog' }) expect(pluginService.refresh).toHaveBeenCalledOnce() + await invoke('plugins:installMarketplacePlugin', { ...preview, fromInstallLink: true }) + expect(services.installer.install).toHaveBeenLastCalledWith(preview, { trigger: 'deep_link' }) + vi.mocked(pluginService.refresh).mockClear() vi.mocked(pluginService.refresh).mockClear() vi.mocked(services.installer.install).mockResolvedValueOnce({ ok: false, error: 'failed' }) diff --git a/src/main/ipc/plugin-marketplaces.ts b/src/main/ipc/plugin-marketplaces.ts index 86ad257865a..5ee0ad78c2f 100644 --- a/src/main/ipc/plugin-marketplaces.ts +++ b/src/main/ipc/plugin-marketplaces.ts @@ -43,7 +43,9 @@ const installMarketplacePluginSchema = marketplacePluginSchema.extend({ /** Ask main to approve in the same step if the installed plugin is content only. */ enableIfData: z.literal(true).optional(), /** The preview's consentFingerprint: one-click approves only these exact bytes. */ - expectedFingerprint: z.string().min(1).max(256).optional() + expectedFingerprint: z.string().min(1).max(256).optional(), + /** The page was opened from an `orca://plugins/...` link; only labels install telemetry. */ + fromInstallLink: z.literal(true).optional() }) const installedPluginSchema = z.strictObject({ pluginKey: z.string().refine(isQualifiedPluginKey, 'invalid qualified plugin key') @@ -106,12 +108,15 @@ export function registerPluginMarketplaceHandlers( return services.installer.preview(parsed.marketplaceSourceId, parsed.pluginKey) }) ipcMain.handle('plugins:installMarketplacePlugin', async (event, args: unknown) => { - const { enableIfData, expectedFingerprint, ...identity } = + const { enableIfData, expectedFingerprint, fromInstallLink, ...identity } = installMarketplacePluginSchema.parse(args) return installCatalogReleaseWithApproval({ pluginKey: identity.pluginKey, pluginService, - install: () => services.installer.install(identity), + install: () => + services.installer.install(identity, { + trigger: fromInstallLink ? 'deep_link' : 'catalog' + }), approve: (pluginKey, reviewedFingerprint) => approveConsent(pluginKey, reviewedFingerprint, event.sender.id), ...(enableIfData ? { enableIfData: { expectedFingerprint } } : {}) diff --git a/src/main/ipc/plugins.ts b/src/main/ipc/plugins.ts index 834526a7899..7558134f101 100644 --- a/src/main/ipc/plugins.ts +++ b/src/main/ipc/plugins.ts @@ -23,6 +23,7 @@ import { isAllowedPluginGitUrl, type PluginLockfile } from '../../shared/plugins/installation/plugin-install-lockfile' +import { trackPluginUninstalled } from '../telemetry/plugin-install-telemetry' import { registerPluginMarketplaceHandlers, type PluginMarketplaceHandlerServices @@ -258,6 +259,7 @@ export function registerPluginHandlers( pluginKey: parsed.pluginKey }) ) + trackPluginUninstalled(parsed.pluginKey, lock.plugins[parsed.pluginKey]?.source) const settings = store.getSettings() const consents = { ...settings.pluginConsents } delete consents[parsed.pluginKey] diff --git a/src/main/ipc/telemetry.ts b/src/main/ipc/telemetry.ts index cacb8bb38f3..d5f46c036b4 100644 --- a/src/main/ipc/telemetry.ts +++ b/src/main/ipc/telemetry.ts @@ -29,6 +29,8 @@ const MAIN_OWNED_TELEMETRY_EVENTS = new Set([ 'daemon_pty_cwd_readable', 'star_nag_outcome', 'feature_interaction_usage_bucket_reached', + 'plugin_installed', + 'plugin_uninstalled', 'ssh_remote_runtime_resolved', 'ssh_host_server_decided', 'ssh_host_server_conversion', diff --git a/src/main/plugins/catalog/plugin-auto-updater.test.ts b/src/main/plugins/catalog/plugin-auto-updater.test.ts index 7443d01f875..f81c7ab6bde 100644 --- a/src/main/plugins/catalog/plugin-auto-updater.test.ts +++ b/src/main/plugins/catalog/plugin-auto-updater.test.ts @@ -183,7 +183,10 @@ describe('plugin auto-update', () => { advance(HOUR) await updater.run() expect(install).toHaveBeenCalledTimes(1) - expect(install).toHaveBeenCalledWith(expect.anything(), { requireInstalled: true }) + expect(install).toHaveBeenCalledWith(expect.anything(), { + requireInstalled: true, + trigger: 'auto_update' + }) expect(consents[KEY]).toBe(next.consentFingerprint) expect(updater.getState()).toEqual({ updated: { [KEY]: '1.1.0' }, needsReview: {} }) expect(changes.at(-1)?.updated).toEqual({ [KEY]: '1.1.0' }) diff --git a/src/main/plugins/catalog/plugin-auto-updater.ts b/src/main/plugins/catalog/plugin-auto-updater.ts index b18ba0672aa..a7b404ba648 100644 --- a/src/main/plugins/catalog/plugin-auto-updater.ts +++ b/src/main/plugins/catalog/plugin-auto-updater.ts @@ -200,7 +200,7 @@ export class PluginAutoUpdater { // Why at the last await: the user may turn auto-update off while this pass waits. install: async () => this.options.isEnabled() - ? installer.install(preview, { requireInstalled: true }) + ? installer.install(preview, { requireInstalled: true, trigger: 'auto_update' }) : { ok: false, error: 'automatic updates were turned off' }, approve: this.options.approve }) diff --git a/src/main/plugins/catalog/plugin-marketplace-install-origin.ts b/src/main/plugins/catalog/plugin-marketplace-install-origin.ts new file mode 100644 index 00000000000..e128e613306 --- /dev/null +++ b/src/main/plugins/catalog/plugin-marketplace-install-origin.ts @@ -0,0 +1,20 @@ +import type { + PluginInstallSource, + PluginLockEntry +} from '../../../shared/plugins/installation/plugin-install-lockfile' +import { marketplaceSourceId } from './plugin-marketplace-store' + +type MarketplaceInstallSource = Extract +export type MarketplaceInstall = { sourceId: string; plugin: MarketplaceInstallSource['plugin'] } + +/** The catalog a lock entry was installed from, or null when it was not a marketplace install. */ +export function marketplaceInstallOf(entry: PluginLockEntry | null): MarketplaceInstall | null { + if (entry?.source.kind !== 'marketplace') { + return null + } + const { marketplace, plugin } = entry.source + return { + sourceId: marketplaceSourceId({ kind: 'git', url: marketplace.url, ref: marketplace.ref }), + plugin + } +} diff --git a/src/main/plugins/catalog/plugin-marketplace-installer.test.ts b/src/main/plugins/catalog/plugin-marketplace-installer.test.ts index e6445c5b3ce..9d85a9392b1 100644 --- a/src/main/plugins/catalog/plugin-marketplace-installer.test.ts +++ b/src/main/plugins/catalog/plugin-marketplace-installer.test.ts @@ -50,6 +50,9 @@ vi.mock('../installation/plugin-git-repository', () => ({ checkoutPluginGitSource: git.checkout })) +const telemetry = vi.hoisted(() => ({ track: vi.fn() })) +vi.mock('../../telemetry/client', () => telemetry) + const roots: string[] = [] async function tempRoot(): Promise { @@ -126,7 +129,10 @@ function squarePng(side: number): Buffer { return Buffer.from([...signature, ...ihdr, 0, 0, 0, 0]) } -async function setup(imageCache?: PluginImageCache): Promise<{ +async function setup( + imageCache?: PluginImageCache, + catalogUrl = 'https://github.com/community/plugins.git' +): Promise<{ root: string marketplace: PluginMarketplaceService installer: PluginMarketplaceInstaller @@ -137,11 +143,7 @@ async function setup(imageCache?: PluginImageCache): Promise<{ pluginsDataDir: join(root, 'plugins-data'), fetcher: async () => ({ marketplaceCommit: git.marketplaceCommit, marketplace: marketplace() }) }) - const added = await service.addSource({ - kind: 'git', - url: 'https://github.com/community/plugins.git', - ref: 'main' - }) + const added = await service.addSource({ kind: 'git', url: catalogUrl, ref: 'main' }) return { root, marketplace: service, @@ -168,6 +170,7 @@ beforeEach(() => { git.worker = false git.icon = false git.paths = null + telemetry.track.mockReset() git.checkout.mockReset() git.checkout.mockImplementation( async ({ destination, path }: { destination: string; path?: string }) => { @@ -467,4 +470,32 @@ describe('PluginMarketplaceInstaller', () => { repositoryMoved: true }) }) + + it('reports public-catalog installs as install, reinstall, then update', async () => { + const communityCatalog = 'https://github.com/stablyai/orca-community-plugins.git' + const { installer, sourceId } = await setup(undefined, communityCatalog) + const preview = await installer.preview(sourceId, 'community.notes') + await installer.install(preview, { trigger: 'deep_link' }) + await installer.install(preview) + git.commit = 'b'.repeat(40) + git.version = '2.0.0' + await installer.install(await installer.previewInstalledUpdate('community.notes'), { + trigger: 'auto_update' + }) + + const base = { plugin_key: 'community.notes', catalog: 'community' } + expect(telemetry.track.mock.calls).toEqual([ + ['plugin_installed', { ...base, version: '1.0.0', kind: 'install', trigger: 'deep_link' }], + ['plugin_installed', { ...base, version: '1.0.0', kind: 'reinstall', trigger: 'catalog' }], + ['plugin_installed', { ...base, version: '2.0.0', kind: 'update', trigger: 'auto_update' }] + ]) + }) + + it('never reports installs from a private catalog', async () => { + const { installer, sourceId } = await setup() + const result = await installer.install(await installer.preview(sourceId, 'community.notes')) + + expect(result.ok).toBe(true) + expect(telemetry.track).not.toHaveBeenCalled() + }) }) diff --git a/src/main/plugins/catalog/plugin-marketplace-installer.ts b/src/main/plugins/catalog/plugin-marketplace-installer.ts index 4484f9df43f..f8c86053a84 100644 --- a/src/main/plugins/catalog/plugin-marketplace-installer.ts +++ b/src/main/plugins/catalog/plugin-marketplace-installer.ts @@ -29,12 +29,17 @@ import type { PluginMarketplaceListing, PluginMarketplaceService } from './plugin-marketplace-service' -import { marketplaceSourceId } from './plugin-marketplace-store' +import { marketplaceInstallOf, type MarketplaceInstall } from './plugin-marketplace-install-origin' import { isSamePluginSource } from '../../../shared/plugins/catalog/plugin-marketplace' import type { - PluginInstallSource, + PluginLockEntry, PluginLockfile } from '../../../shared/plugins/installation/plugin-install-lockfile' +import { + pluginInstallKind, + trackPluginInstalled, + type PluginInstallTrigger +} from '../../telemetry/plugin-install-telemetry' export type PluginMarketplaceInstallPreview = { marketplaceSourceId: string @@ -61,8 +66,6 @@ export type PluginMarketplaceInstallPreview = { /** `repositoryMoved`: this replaced an install that came from a different repository. */ export type PluginMarketplaceInstallResult = PluginInstallResult & { repositoryMoved?: true } -type MarketplaceInstallSource = Extract - export type PluginMarketplacePreviewIdentity = Pick< PluginMarketplaceInstallPreview, 'marketplaceSourceId' | 'marketplaceCommit' | 'pluginKey' | 'resolvedCommit' @@ -146,7 +149,7 @@ export class PluginMarketplaceInstaller { async install( preview: PluginMarketplacePreviewIdentity, - options: { requireInstalled?: boolean } = {} + options: { requireInstalled?: boolean; trigger?: PluginInstallTrigger } = {} ): Promise { const listing = await this.requireListing(preview.marketplaceSourceId, preview.pluginKey) const blockedReason = @@ -168,7 +171,8 @@ export class PluginMarketplaceInstaller { return { ok: false, error: 'marketplace source is no longer configured' } } // Why read first: the install rewrites the lock entry this comparison needs. - const installed = await this.readMarketplaceInstall(preview.pluginKey) + const priorEntry = await this.readLockEntry(preview.pluginKey) + const installed = marketplaceInstallOf(priorEntry) const repositoryMoved = installed !== null && !isSamePluginSource(installed.plugin, listing.source) const result = await installPluginFromMarketplace({ @@ -190,6 +194,15 @@ export class PluginMarketplaceInstaller { blockedPluginReason: this.blockedPluginReason, ...(options.requireInstalled ? { requireInstalled: true } : {}) }) + if (result.ok) { + trackPluginInstalled({ + marketplaceUrl: sourceState.source.url, + plugin_key: result.pluginKey, + version: result.version, + kind: pluginInstallKind(priorEntry?.version ?? null, result.version), + trigger: options.trigger ?? 'catalog' + }) + } return result.ok && repositoryMoved ? { ...result, repositoryMoved: true } : result } @@ -198,20 +211,18 @@ export class PluginMarketplaceInstaller { return readPluginLockfile(getUserPluginsDir(this.userDataPath)) } + private async readLockEntry(pluginKey: string): Promise { + return (await this.readInstallLockfile()).plugins[pluginKey] ?? null + } + /** The catalog an installed plugin came from, or null when it was not a marketplace install. */ private async readMarketplaceInstall( pluginKey: string, lock?: PluginLockfile - ): Promise<{ sourceId: string; plugin: MarketplaceInstallSource['plugin'] } | null> { - const entry = (lock ?? (await this.readInstallLockfile())).plugins[pluginKey] - if (entry?.source.kind !== 'marketplace') { - return null - } - const { marketplace, plugin } = entry.source - return { - sourceId: marketplaceSourceId({ kind: 'git', url: marketplace.url, ref: marketplace.ref }), - plugin - } + ): Promise { + return marketplaceInstallOf( + lock ? (lock.plugins[pluginKey] ?? null) : await this.readLockEntry(pluginKey) + ) } async previewInstalledUpdate(pluginKey: string): Promise { diff --git a/src/main/telemetry/plugin-install-telemetry.test.ts b/src/main/telemetry/plugin-install-telemetry.test.ts new file mode 100644 index 00000000000..027ed9bfd57 --- /dev/null +++ b/src/main/telemetry/plugin-install-telemetry.test.ts @@ -0,0 +1,104 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { eventSchemas } from '../../shared/telemetry-events' +import { + pluginInstallKind, + pluginTelemetryCatalog, + trackPluginInstalled, + trackPluginUninstalled +} from './plugin-install-telemetry' + +const telemetry = vi.hoisted(() => ({ track: vi.fn() })) +vi.mock('./client', () => telemetry) + +const marketplaceSource = (url: string) => ({ + kind: 'marketplace' as const, + marketplace: { url, ref: 'main', resolvedCommit: 'f'.repeat(40) }, + plugin: { url: 'https://github.com/acme/notes.git', ref: 'main' } +}) + +beforeEach(() => { + telemetry.track.mockReset() +}) + +describe('plugin install telemetry', () => { + it('names only the official and community catalogs', () => { + expect(pluginTelemetryCatalog('https://github.com/stablyai/orca-plugins.git')).toBe('official') + expect(pluginTelemetryCatalog('git@github.com:stablyai/orca-plugins.git')).toBe('official') + expect(pluginTelemetryCatalog('https://github.com/stablyai/orca-community-plugins')).toBe( + 'community' + ) + expect(pluginTelemetryCatalog('https://github.com/acme/private-plugins.git')).toBeNull() + expect( + pluginTelemetryCatalog('https://gitlab.example.com/stablyai/orca-plugins.git') + ).toBeNull() + }) + + it('tells a first install from a reinstall and an update', () => { + expect(pluginInstallKind(null, '1.0.0')).toBe('install') + expect(pluginInstallKind('1.0.0', '1.0.0')).toBe('reinstall') + expect(pluginInstallKind('1.0.0', '1.1.0')).toBe('update') + }) + + it('sends nothing for a private catalog install', () => { + trackPluginInstalled({ + marketplaceUrl: 'https://github.com/acme/private-plugins.git', + plugin_key: 'acme.notes', + version: '1.0.0', + kind: 'install', + trigger: 'catalog' + }) + expect(telemetry.track).not.toHaveBeenCalled() + }) + + it('reports an uninstall only for a public-catalog plugin', () => { + trackPluginUninstalled('acme.notes', { kind: 'local-path', path: '/Users/me/notes' }) + trackPluginUninstalled('acme.notes', marketplaceSource('https://github.com/acme/private.git')) + trackPluginUninstalled('acme.notes', undefined) + expect(telemetry.track).not.toHaveBeenCalled() + + trackPluginUninstalled( + 'acme.notes', + marketplaceSource('https://github.com/stablyai/orca-community-plugins.git') + ) + expect(telemetry.track).toHaveBeenCalledWith('plugin_uninstalled', { + plugin_key: 'acme.notes', + catalog: 'community' + }) + }) + + it('never lets a telemetry failure escape', () => { + telemetry.track.mockImplementation(() => { + throw new Error('boom') + }) + expect(() => + trackPluginUninstalled( + 'acme.notes', + marketplaceSource('https://github.com/stablyai/orca-plugins.git') + ) + ).not.toThrow() + }) + + it('schemas reject anything but a plugin key and enums', () => { + const valid = { + plugin_key: 'acme.notes', + version: '1.2.0-beta.1', + catalog: 'official', + kind: 'install', + trigger: 'catalog' + } + expect(eventSchemas.plugin_installed.safeParse(valid).success).toBe(true) + for (const bad of [ + { ...valid, plugin_key: '/Users/me/dev-plugin' }, + { ...valid, plugin_key: 'notes' }, + { ...valid, catalog: 'dev' }, + { ...valid, version: '1.0.0 /tmp' }, + { ...valid, path: '/Users/me' } + ]) { + expect(eventSchemas.plugin_installed.safeParse(bad).success).toBe(false) + } + expect( + eventSchemas.plugin_uninstalled.safeParse({ plugin_key: 'acme.notes', catalog: 'community' }) + .success + ).toBe(true) + }) +}) diff --git a/src/main/telemetry/plugin-install-telemetry.ts b/src/main/telemetry/plugin-install-telemetry.ts new file mode 100644 index 00000000000..5cfd6728b3d --- /dev/null +++ b/src/main/telemetry/plugin-install-telemetry.ts @@ -0,0 +1,67 @@ +/** + * Install and uninstall rows for plugins from Orca's public catalogs, which feed the directory's + * install counts. Anything else (private catalogs, git, local and dev installs) never reports. + */ +import { COMMUNITY_MARKETPLACE_GIT_SOURCE } from '../../shared/plugins/catalog/plugin-install-link' +import { + isOfficialMarketplaceGitSource, + isSameGitRepository +} from '../../shared/plugins/catalog/plugin-marketplace' +import type { PluginInstallSource } from '../../shared/plugins/installation/plugin-install-lockfile' +import type { EventProps } from '../../shared/telemetry-events' +import { track } from './client' + +export type PluginInstallTelemetryProps = EventProps<'plugin_installed'> +export type PluginInstallTrigger = PluginInstallTelemetryProps['trigger'] +type Catalog = PluginInstallTelemetryProps['catalog'] + +/** The public catalog a marketplace URL names, or null for any other catalog. */ +export function pluginTelemetryCatalog(marketplaceUrl: string): Catalog | null { + if (isOfficialMarketplaceGitSource(marketplaceUrl)) { + return 'official' + } + return isSameGitRepository(marketplaceUrl, COMMUNITY_MARKETPLACE_GIT_SOURCE.url) + ? 'community' + : null +} + +export function pluginInstallKind( + priorVersion: string | null, + version: string +): PluginInstallTelemetryProps['kind'] { + if (priorVersion === null) { + return 'install' + } + return priorVersion === version ? 'reinstall' : 'update' +} + +export function trackPluginInstalled( + input: Omit & { marketplaceUrl: string } +): void { + const { marketplaceUrl, ...rest } = input + const catalog = pluginTelemetryCatalog(marketplaceUrl) + if (catalog) { + safeTrack(() => track('plugin_installed', { ...rest, catalog })) + } +} + +/** Reports only when the removed plugin came from a public catalog. */ +export function trackPluginUninstalled( + pluginKey: string, + source: PluginInstallSource | undefined +): void { + const catalog = + source?.kind === 'marketplace' ? pluginTelemetryCatalog(source.marketplace.url) : null + if (catalog) { + safeTrack(() => track('plugin_uninstalled', { plugin_key: pluginKey, catalog })) + } +} + +function safeTrack(emit: () => void): void { + try { + // track() applies the consent gate and validates against the strict schema. + emit() + } catch { + // Telemetry never costs an install. + } +} diff --git a/src/preload/api/plugin-host-api.ts b/src/preload/api/plugin-host-api.ts index 4c6c7282873..b590328768b 100644 --- a/src/preload/api/plugin-host-api.ts +++ b/src/preload/api/plugin-host-api.ts @@ -189,6 +189,8 @@ export type PluginsApi = { enableIfData?: true /** The preview's consentFingerprint; with it, main approves only those exact bytes. */ expectedFingerprint?: string + /** The page was opened from an install link; only labels telemetry. */ + fromInstallLink?: true } ) => Promise previewMarketplaceUpdate: (args: { diff --git a/src/renderer/src/components/plugins/list/PluginManager.tsx b/src/renderer/src/components/plugins/list/PluginManager.tsx index a829ded96f9..336cccb5710 100644 --- a/src/renderer/src/components/plugins/list/PluginManager.tsx +++ b/src/renderer/src/components/plugins/list/PluginManager.tsx @@ -1,4 +1,4 @@ -import { useMemo, useState } from 'react' +import { useCallback, useMemo, useState } from 'react' import { Blocks } from 'lucide-react' import type { PluginHostListEntry } from '../../../../../preload/api-types' import { translate } from '@/i18n/i18n' @@ -104,11 +104,16 @@ export function PluginManager({ onRefreshInstalled, onOpenRequested: open }) + // Why: an install counts as from a link only while the page that link opened is showing. + const [linkedKey, setLinkedKey] = useState(null) const installLink = usePluginInstallLink({ sources: catalog.sources, loading: catalog.loading, loadMarketplaceData: catalog.loadMarketplaceData, - open + open: (pluginKey) => { + setLinkedKey(pluginKey) + open(pluginKey) + } }) const openEntry = (pluginKey: string): void => { open(pluginKey) @@ -116,9 +121,14 @@ export function PluginManager({ onShowLogs(pluginKey) } } + const isFromInstallLink = useCallback( + (pluginKey: string) => pluginKey === linkedKey && openKey === linkedKey, + [linkedKey, openKey] + ) const install = usePluginInstallAction({ fetchPreview: catalog.fetchPreview, - onInstalled + onInstalled, + isFromInstallLink }) const prefetch = usePluginHoverPrefetch(catalog.fetchPreview) const orcaVersion = useOrcaVersion() diff --git a/src/renderer/src/components/plugins/state/use-plugin-install-action.test.tsx b/src/renderer/src/components/plugins/state/use-plugin-install-action.test.tsx index dc299c6af87..ffb2e5670d0 100644 --- a/src/renderer/src/components/plugins/state/use-plugin-install-action.test.tsx +++ b/src/renderer/src/components/plugins/state/use-plugin-install-action.test.tsx @@ -60,12 +60,12 @@ const codePreview: PluginMarketplaceHostInstallPreview = { }) } -function setup() { +function setup(isFromInstallLink?: (pluginKey: string) => boolean) { const onInstalled = vi.fn().mockResolvedValue(undefined) const fetchPreview = vi.fn().mockResolvedValue({ ok: true, preview }) const hook: { current: ReturnType | null } = { current: null } function Probe(): null { - hook.current = usePluginInstallAction({ fetchPreview, onInstalled }) + hook.current = usePluginInstallAction({ fetchPreview, onInstalled, isFromInstallLink }) return null } act(() => createRoot(document.createElement('div')).render()) @@ -99,6 +99,19 @@ beforeEach(() => { }) describe('usePluginInstallAction', () => { + it('labels an install from a page an install link opened, and only that one', async () => { + const { hook } = setup((pluginKey) => pluginKey === listing.pluginKey) + await act(async () => hook.current?.install(listing)) + expect(window.api.plugins.installMarketplacePlugin).toHaveBeenCalledWith( + expect.objectContaining({ fromInstallLink: true }) + ) + const plain = setup() + await act(async () => plain.hook.current?.install(listing)) + expect(window.api.plugins.installMarketplacePlugin).toHaveBeenLastCalledWith( + expect.not.objectContaining({ fromInstallLink: true }) + ) + }) + it('asks main to turn on a content-only release and leaves nothing pending after', async () => { const { hook, onInstalled } = setup() await act(async () => hook.current?.install(listing)) diff --git a/src/renderer/src/components/plugins/state/use-plugin-install-action.ts b/src/renderer/src/components/plugins/state/use-plugin-install-action.ts index 1b2238cc8db..609542e48ff 100644 --- a/src/renderer/src/components/plugins/state/use-plugin-install-action.ts +++ b/src/renderer/src/components/plugins/state/use-plugin-install-action.ts @@ -52,11 +52,14 @@ const downloadFailed = (): string => */ export function usePluginInstallAction({ fetchPreview, - onInstalled + onInstalled, + isFromInstallLink }: { fetchPreview: (listing: PluginMarketplaceHostListing) => Promise /** Reloads the list; opens review on the page for any plugin still waiting for it. */ onInstalled: (pluginKey: string) => Promise + /** Whether this install is on the page an install link opened; only labels telemetry. */ + isFromInstallLink?: (pluginKey: string) => boolean }): { phaseOf: (pluginKey: string) => PluginInstallPhase | null install: ( @@ -124,6 +127,7 @@ export function usePluginInstallAction({ marketplaceCommit: preview.marketplaceCommit, pluginKey, resolvedCommit: preview.resolvedCommit, + ...(isFromInstallLink?.(pluginKey) ? { fromInstallLink: true as const } : {}), // Why the fingerprint: main turns on only the exact bytes this preview showed. ...(execution === 'data' ? { enableIfData: true as const, expectedFingerprint: preview.consentFingerprint } @@ -183,7 +187,7 @@ export function usePluginInstallAction({ setPhase(pluginKey, { kind: 'error', message: catalogInstallErrorMessage(cause) }) } }, - [dismiss, fetchPreview, onInstalled, setPhase] + [dismiss, fetchPreview, isFromInstallLink, onInstalled, setPhase] ) const phaseOf = useCallback( diff --git a/src/shared/telemetry-event-registry.ts b/src/shared/telemetry-event-registry.ts index 7d45ea6f94a..a9de0c352ca 100644 --- a/src/shared/telemetry-event-registry.ts +++ b/src/shared/telemetry-event-registry.ts @@ -103,6 +103,7 @@ import { setupScriptPromptShownSchema, workspaceCreateFailedSchema } from './telemetry-repository-event-schemas' +import { pluginInstalledSchema, pluginUninstalledSchema } from './telemetry-plugin-event-schemas' import { sshRemoteRuntimeResolvedSchema } from './telemetry-ssh-runtime-event-schemas' import { wslUsageSnapshotSchema } from './telemetry-wsl-usage-event-schemas' import { @@ -234,5 +235,8 @@ export const eventSchemas = { wsl_move_orchestrator: wslMoveOrchestratorSchema, wsl_move_undo: wslMoveUndoSchema, - wsl_usage_snapshot: wslUsageSnapshotSchema + wsl_usage_snapshot: wslUsageSnapshotSchema, + + plugin_installed: pluginInstalledSchema, + plugin_uninstalled: pluginUninstalledSchema } as const diff --git a/src/shared/telemetry-plugin-event-schemas.ts b/src/shared/telemetry-plugin-event-schemas.ts new file mode 100644 index 00000000000..a18a296dbb5 --- /dev/null +++ b/src/shared/telemetry-plugin-event-schemas.ts @@ -0,0 +1,33 @@ +import { z } from 'zod' +import { isQualifiedPluginKey } from './plugins/frames/plugin-tab-key' + +// Why only these two: both catalogs are public, so a key from them names nothing private. +// Private catalogs, git URLs, local folders and dev plugins never report. +export const PLUGIN_TELEMETRY_CATALOGS = ['official', 'community'] as const + +/** `reinstall` is the same version again; totals count `install` only. */ +export const PLUGIN_INSTALL_KINDS = ['install', 'update', 'reinstall'] as const + +export const PLUGIN_INSTALL_TRIGGERS = ['catalog', 'deep_link', 'auto_update'] as const + +const pluginKeySchema = z.string().max(129).refine(isQualifiedPluginKey) + +export const pluginInstalledSchema = z + .object({ + plugin_key: pluginKeySchema, + version: z + .string() + .max(64) + .regex(/^[0-9A-Za-z.+-]+$/), + catalog: z.enum(PLUGIN_TELEMETRY_CATALOGS), + kind: z.enum(PLUGIN_INSTALL_KINDS), + trigger: z.enum(PLUGIN_INSTALL_TRIGGERS) + }) + .strict() + +export const pluginUninstalledSchema = z + .object({ + plugin_key: pluginKeySchema, + catalog: z.enum(PLUGIN_TELEMETRY_CATALOGS) + }) + .strict()