From 3100a918cc300a483cda9df2578d4c3b78f1d5e9 Mon Sep 17 00:00:00 2001 From: t8y2 <1156263951@qq.com> Date: Sat, 19 Sep 2026 10:54:48 +0800 Subject: [PATCH] feat(plugins): classify install beacons by kind and client id --- .../plugins/PluginContributionsPanel.vue | 2 +- .../__tests__/pluginMarketplaceBeacon.spec.ts | 71 +++++++++++++++++++ .../src/lib/plugins/pluginMarketplace.ts | 38 +++++++++- deploy/plugin-stats-worker/README.md | 21 ++++-- deploy/plugin-stats-worker/worker.ts | 32 +++++++-- 5 files changed, 150 insertions(+), 14 deletions(-) create mode 100644 apps/desktop/src/lib/plugins/__tests__/pluginMarketplaceBeacon.spec.ts diff --git a/apps/desktop/src/components/plugins/PluginContributionsPanel.vue b/apps/desktop/src/components/plugins/PluginContributionsPanel.vue index d92ccf2f9a..061574d89a 100644 --- a/apps/desktop/src/components/plugins/PluginContributionsPanel.vue +++ b/apps/desktop/src/components/plugins/PluginContributionsPanel.vue @@ -204,7 +204,7 @@ async function installListing(listing: MarketplacePluginListing): Promise { + const calls = vi.mocked(globalThis.fetch).mock.calls; + const body = calls[calls.length - 1]?.[1]?.body; + return typeof body === "string" ? JSON.parse(body) : {}; +} + +function storedInstallationId(): string { + return localStorage.getItem("dbx-installation-id") ?? ""; +} + +describe("beaconPluginInstall", () => { + const backing = new Map(); + beforeEach(() => { + backing.clear(); + vi.stubGlobal("localStorage", { + getItem: (key: string) => backing.get(key) ?? null, + setItem: (key: string, value: string) => void backing.set(key, value), + removeItem: (key: string) => void backing.delete(key), + }); + vi.stubGlobal("fetch", vi.fn().mockResolvedValue(undefined)); + }); + afterEach(() => { + vi.unstubAllGlobals(); + }); + + it("sends id, version and install kind by default", () => { + beaconPluginInstall("io.dbx.ssh", "1.2.3"); + expect(lastBeaconBody()).toMatchObject({ id: "io.dbx.ssh", version: "1.2.3", kind: "install" }); + }); + + it("forwards the update kind so updates cannot inflate install counts", () => { + beaconPluginInstall("io.dbx.ssh", "1.2.4", "update"); + expect(lastBeaconBody()).toMatchObject({ kind: "update" }); + }); + + it("generates a uuid installation id once and reuses it across beacons", () => { + beaconPluginInstall("io.dbx.ssh", "1.2.3"); + const first = lastBeaconBody().clientId; + expect(first).toBe(storedInstallationId()); + expect(String(first)).toMatch(/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/); + + beaconPluginInstall("io.dbx.ldap", "2.0.0"); + expect(lastBeaconBody().clientId).toBe(first); + }); + + it("regenerates a malformed stored installation id instead of sending it", () => { + backing.set("dbx-installation-id", "not-a-uuid"); + beaconPluginInstall("io.dbx.ssh", "1.2.3"); + const clientId = String(lastBeaconBody().clientId); + expect(clientId).toMatch(/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/); + expect(clientId).toBe(storedInstallationId()); + }); + + it("sends an empty clientId when storage is unavailable", () => { + vi.stubGlobal("localStorage", { + getItem: () => { + throw new Error("unavailable"); + }, + setItem: () => { + throw new Error("unavailable"); + }, + removeItem: () => undefined, + }); + beaconPluginInstall("io.dbx.ssh", "1.2.3"); + expect(lastBeaconBody().clientId).toBe(""); + }); +}); diff --git a/apps/desktop/src/lib/plugins/pluginMarketplace.ts b/apps/desktop/src/lib/plugins/pluginMarketplace.ts index f71d6dc21a..d3b7bd2fc6 100644 --- a/apps/desktop/src/lib/plugins/pluginMarketplace.ts +++ b/apps/desktop/src/lib/plugins/pluginMarketplace.ts @@ -65,14 +65,46 @@ export function listingRepositoryCanVerify(repository: PluginRepository): boolea } const INSTALL_BEACON_URL = "https://dbxio.com/api/plugins/install"; +const INSTALLATION_ID_STORAGE_KEY = "dbx-installation-id"; +const INSTALLATION_ID_PATTERN = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/; -// Fire-and-forget install beacon for marketplace statistics; never blocks or fails the install. -export function beaconPluginInstall(pluginId: string, version: string): void { +// Anonymous, purely random per-installation id so server-side stats can count +// distinct machines without any user or hardware fingerprint. Clearing local +// storage (or reinstalling) regenerates it, which is acceptable for +// decorative statistics. +function installationClientId(): string { + try { + if (typeof localStorage === "undefined") return ""; + let id = localStorage.getItem(INSTALLATION_ID_STORAGE_KEY); + if (!id || !INSTALLATION_ID_PATTERN.test(id)) { + id = typeof crypto.randomUUID === "function" ? crypto.randomUUID() : randomUuidFallback(); + localStorage.setItem(INSTALLATION_ID_STORAGE_KEY, id); + } + return id; + } catch { + return ""; + } +} + +function randomUuidFallback(): string { + const hex = () => + Math.floor(Math.random() * 0x10000) + .toString(16) + .padStart(4, "0"); + return `${hex()}${hex()}-${hex()}-${hex()}-${hex()}-${hex()}${hex()}${hex()}`; +} + +export type PluginInstallBeaconKind = "install" | "update"; + +// Fire-and-forget install beacon for marketplace statistics; never blocks or +// fails the install. `kind` separates fresh installs from version updates so +// update traffic cannot inflate the install numbers. +export function beaconPluginInstall(pluginId: string, version: string, kind: PluginInstallBeaconKind = "install"): void { try { void fetch(INSTALL_BEACON_URL, { method: "POST", headers: { "Content-Type": "text/plain" }, - body: JSON.stringify({ id: pluginId, version }), + body: JSON.stringify({ id: pluginId, version, kind, clientId: installationClientId() }), keepalive: true, }).catch(() => undefined); } catch { diff --git a/deploy/plugin-stats-worker/README.md b/deploy/plugin-stats-worker/README.md index 977a4e7b27..3cebfc8515 100644 --- a/deploy/plugin-stats-worker/README.md +++ b/deploy/plugin-stats-worker/README.md @@ -13,15 +13,22 @@ directory; OAuth login required). domain. Artifact bytes, headers, and Range semantics are untouched. - `dbxio.com/api/plugins/install` — `POST` fire-and-forget beacon the desktop app can send after a successful marketplace install - (`{"id": "", "version": ""}`, 204 on accept). Decorative - statistics only: no auth, no PII. + (`{"id": "", "version": "", "kind": "install"|"update", + "clientId": ""}`, 204 on accept; `kind` and `clientId` are + optional and absent on legacy app versions). Decorative statistics only: no + auth, no PII — `clientId` is a purely random per-installation id the app + generates locally, not a hardware or user fingerprint. ## Storage — Workers Analytics Engine One datapoint per event (binding `PLUGIN_STATS`): -- `blobs = [kind, pluginId, version]` where kind is `dl` (artifact GET) or - `inst` (install beacon); `doubles = [1]`; `indexes = [pluginId]`. +- `blobs = [kind, pluginId, version, identity]` where kind is `dl` (artifact + GET), `inst` (fresh install; legacy beacons without `kind` also land here so + the historical mixed-event base stays continuous) or `updt` (version update); + `identity` is the beacon `clientId` when present (stable per-machine unique), + otherwise a day-scoped IP HMAC (all `dl` events use the latter). + `doubles = [1]`; `indexes = [pluginId]`. Chosen over KV counters: KV read-modify-write costs 1 write per request, and the free tier's 1k writes/day is below what a 30k-user base generates on a hot @@ -35,7 +42,11 @@ trailing window into the `plugin_stats_archive` KV namespace — the permanent layer future UI reads: - `total:{kind}:{pluginId}:{version}` — all-time cumulative events -- `summary` — one JSON blob `{dl: {pluginId: n}, inst: {...}}` for single-read display +- `summary` — one JSON blob `{dl: {pluginId: n}, inst: {...}, updt: {...}}` for + single-read display; `inst` carries the pre-2026-09-19 mixed install+update + base (the switch to classified kinds), so treat it as the display number +- `uniqd:{kind}:{pluginId}:{day}` — per-day distinct identities (machines for + classified inst/updt events, distinct downloaders for dl) - `meta:last-success` — window marker; only advanced after all writes land, so a failed run retries the same window on the next cron diff --git a/deploy/plugin-stats-worker/worker.ts b/deploy/plugin-stats-worker/worker.ts index 62183f5d64..5bcb4f16ce 100644 --- a/deploy/plugin-stats-worker/worker.ts +++ b/deploy/plugin-stats-worker/worker.ts @@ -11,6 +11,18 @@ // - dbxio.com/api/plugins/archive: token-gated manual trigger for the daily // aggregation (same handler the cron runs), for verification and backfills. // +// Event kinds (blob1): +// - dl: artifact download (server-side, per .dbxp GET) +// - inst: fresh install (beacon `kind:"install"`); legacy beacons without a +// kind also land here — they keep feeding the historical mixed-event +// base until the fleet upgrades, so the inst curve stays comparable +// - updt: version update (beacon `kind:"update"`) +// +// Identity (blob4, only used for per-day unique counts): +// - inst/updt events carry the app's anonymous random installation id when +// available (stable across days → true per-machine uniques) +// - dl events and legacy beacons fall back to a day-scoped IP HMAC +// // Storage: // - Workers Analytics Engine (binding PLUGIN_STATS) — one datapoint per event. // AE only retains three months, so a daily cron aggregates the trailing @@ -34,12 +46,15 @@ type Env = { STATS_SALT: string; }; +type EventKind = "dl" | "inst" | "updt"; + const DOWNLOAD_PATTERN = /^\/plugins\/([A-Za-z0-9._-]{1,64})\/([0-9A-Za-z.+-]{1,32})\//; const ARTIFACT_SUFFIX_PATTERN = /\.dbxp$/; const PLUGIN_ID_PATTERN = /^[A-Za-z0-9._-]{1,64}$/; const VERSION_PATTERN = /^[0-9A-Za-z.+-]{1,32}$/; +const CLIENT_ID_PATTERN = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/; const INSTALL_BODY_LIMIT_BYTES = 512; -const COUNTED_KINDS = new Set(["dl", "inst"]); +const COUNTED_KINDS = new Set(["dl", "inst", "updt"]); const ARCHIVE_META_KEY = "meta:last-success"; const ARCHIVE_SUMMARY_KEY = "summary"; const ARCHIVE_TRIGGER_HEADER = "x-archive-token"; @@ -64,7 +79,7 @@ async function ipDayIdentity(env: Env, request: Request): Promise { return [...new Uint8Array(mac)].slice(0, 8).map((byte) => byte.toString(16).padStart(2, "0")).join(""); } -async function recordEvent(env: Env, kind: "dl" | "inst", pluginId: string, version: string, identity: string): Promise { +async function recordEvent(env: Env, kind: EventKind, pluginId: string, version: string, identity: string): Promise { try { env.PLUGIN_STATS.writeDataPoint({ // index on pluginId keeps per-plugin queries well-sharded @@ -179,7 +194,7 @@ async function fetchWindowCounts(env: Env, from: Date, to: Date): Promise { +async function runAggregation(env: Env, fromOverride?: string): Promise<{ from: string; to: string; activeKeys: number; uniqueDays: number }> { const to = new Date(); // `x-archive-from: reset` re-aggregates from the epoch (backfill after a fix); // totals are additive, so only use it when the archived window is known-empty. @@ -242,11 +257,18 @@ async function handleInstallBeacon(request: Request, env: Env): Promise; + const { id, version, kind, clientId } = (payload ?? {}) as Record; if (typeof id !== "string" || !PLUGIN_ID_PATTERN.test(id)) return emptyResponse(400); if (typeof version !== "string" || !VERSION_PATTERN.test(version)) return emptyResponse(400); + // `kind:"update"` classifies version updates separately; every other value + // (including the absent field on legacy beacons) counts as a fresh install so + // the historical mixed-event base stays continuous until the fleet upgrades. + const eventKind: "inst" | "updt" = kind === "update" ? "updt" : "inst"; + // The app's random installation id gives stable per-machine uniques; legacy + // beacons fall back to the day-scoped IP HMAC. + const identity = typeof clientId === "string" && CLIENT_ID_PATTERN.test(clientId) ? clientId : await ipDayIdentity(env, request); - await recordEvent(env, "inst", id, version, await ipDayIdentity(env, request)); + await recordEvent(env, eventKind, id, version, identity); return emptyResponse(204); }