mirror of
https://github.com/stablyai/orca.git
synced 2026-10-30 16:47:34 +00:00
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
This commit is contained in:
@@ -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
|
||||
|
||||
|
||||
@@ -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' })
|
||||
|
||||
@@ -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 } } : {})
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -29,6 +29,8 @@ const MAIN_OWNED_TELEMETRY_EVENTS = new Set<EventName>([
|
||||
'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',
|
||||
|
||||
@@ -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' })
|
||||
|
||||
@@ -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
|
||||
})
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
import type {
|
||||
PluginInstallSource,
|
||||
PluginLockEntry
|
||||
} from '../../../shared/plugins/installation/plugin-install-lockfile'
|
||||
import { marketplaceSourceId } from './plugin-marketplace-store'
|
||||
|
||||
type MarketplaceInstallSource = Extract<PluginInstallSource, { kind: 'marketplace' }>
|
||||
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
|
||||
}
|
||||
}
|
||||
@@ -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<string> {
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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<PluginInstallSource, { kind: 'marketplace' }>
|
||||
|
||||
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<PluginMarketplaceInstallResult> {
|
||||
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<PluginLockEntry | null> {
|
||||
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<MarketplaceInstall | null> {
|
||||
return marketplaceInstallOf(
|
||||
lock ? (lock.plugins[pluginKey] ?? null) : await this.readLockEntry(pluginKey)
|
||||
)
|
||||
}
|
||||
|
||||
async previewInstalledUpdate(pluginKey: string): Promise<PluginMarketplaceInstallPreview> {
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -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<PluginInstallTelemetryProps, 'catalog'> & { 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.
|
||||
}
|
||||
}
|
||||
@@ -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<PluginHostInstallResult>
|
||||
previewMarketplaceUpdate: (args: {
|
||||
|
||||
@@ -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<string | null>(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()
|
||||
|
||||
@@ -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<typeof usePluginInstallAction> | 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(<Probe />))
|
||||
@@ -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))
|
||||
|
||||
@@ -52,11 +52,14 @@ const downloadFailed = (): string =>
|
||||
*/
|
||||
export function usePluginInstallAction({
|
||||
fetchPreview,
|
||||
onInstalled
|
||||
onInstalled,
|
||||
isFromInstallLink
|
||||
}: {
|
||||
fetchPreview: (listing: PluginMarketplaceHostListing) => Promise<PluginPagePreview>
|
||||
/** Reloads the list; opens review on the page for any plugin still waiting for it. */
|
||||
onInstalled: (pluginKey: string) => Promise<void>
|
||||
/** 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(
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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()
|
||||
Reference in New Issue
Block a user