feat(plugins): classify install beacons by kind and client id
This commit is contained in:
5 files changed
+150
-14
No files matched your search
@@ -204,7 +204,7 @@ async function installListing(listing: MarketplacePluginListing): Promise<Plugin
|
||||
version: listing.plugin.latestVersion,
|
||||
});
|
||||
notifyPluginRuntimeReplaced(result.plugin.manifest.id);
|
||||
beaconPluginInstall(listing.plugin.id, listing.plugin.latestVersion);
|
||||
beaconPluginInstall(listing.plugin.id, listing.plugin.latestVersion, listing.status === "update" ? "update" : "install");
|
||||
return result;
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,71 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
import { beaconPluginInstall } from "@/lib/plugins/pluginMarketplace";
|
||||
|
||||
function lastBeaconBody(): Record<string, unknown> {
|
||||
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<string, string>();
|
||||
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("");
|
||||
});
|
||||
});
|
||||
@@ -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 {
|
||||
|
||||
Reference in new issue
Block a user