mirror of
https://github.com/openclaw/clawhub.git
synced 2026-08-14 00:47:57 +00:00
feat: count successful OpenClaw plugin installs (#3242)
This commit is contained in:
Vendored
+2
@@ -98,6 +98,7 @@ import type * as lib_packageArtifacts from "../lib/packageArtifacts.js";
|
||||
import type * as lib_packageRegistry from "../lib/packageRegistry.js";
|
||||
import type * as lib_packageSearchDigest from "../lib/packageSearchDigest.js";
|
||||
import type * as lib_packageSecurity from "../lib/packageSecurity.js";
|
||||
import type * as lib_packageStatEvents from "../lib/packageStatEvents.js";
|
||||
import type * as lib_public from "../lib/public.js";
|
||||
import type * as lib_publicBrowse from "../lib/publicBrowse.js";
|
||||
import type * as lib_publicRouteReservations from "../lib/publicRouteReservations.js";
|
||||
@@ -278,6 +279,7 @@ declare const fullApi: ApiFromModules<{
|
||||
"lib/packageRegistry": typeof lib_packageRegistry;
|
||||
"lib/packageSearchDigest": typeof lib_packageSearchDigest;
|
||||
"lib/packageSecurity": typeof lib_packageSecurity;
|
||||
"lib/packageStatEvents": typeof lib_packageStatEvents;
|
||||
"lib/public": typeof lib_public;
|
||||
"lib/publicBrowse": typeof lib_publicBrowse;
|
||||
"lib/publicRouteReservations": typeof lib_publicRouteReservations;
|
||||
|
||||
@@ -345,6 +345,66 @@ describe("httpApi handlers", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("cliTelemetryInstallHttp forwards a successful plugin install", async () => {
|
||||
vi.mocked(requireApiTokenUser).mockResolvedValueOnce({ userId: "users:1" } as never);
|
||||
const runMutation = vi.fn().mockResolvedValue(null);
|
||||
const response = await __handlers.cliTelemetryInstallHandler(
|
||||
makeCtx({ runMutation }),
|
||||
new Request("https://x/api/cli/telemetry/install", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
event: "plugin_install",
|
||||
packageName: "@openclaw/voice-call",
|
||||
version: "2026.7.23",
|
||||
}),
|
||||
}),
|
||||
);
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(await response.json()).toEqual({ ok: true });
|
||||
expect(runMutation).toHaveBeenCalledWith(expect.anything(), {
|
||||
userId: "users:1",
|
||||
packageName: "@openclaw/voice-call",
|
||||
version: "2026.7.23",
|
||||
});
|
||||
});
|
||||
|
||||
it("cliTelemetryInstallHttp rejects malformed plugin install reports", async () => {
|
||||
vi.mocked(requireApiTokenUser).mockResolvedValueOnce({ userId: "users:1" } as never);
|
||||
const runMutation = vi.fn();
|
||||
const response = await __handlers.cliTelemetryInstallHandler(
|
||||
makeCtx({ runMutation }),
|
||||
new Request("https://x/api/cli/telemetry/install", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ event: "plugin_install", version: "2026.7.23" }),
|
||||
}),
|
||||
);
|
||||
|
||||
expect(response.status).toBe(400);
|
||||
expect(runMutation).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("cliTelemetryInstallHttp accepts unknown plugin packages as telemetry no-ops", async () => {
|
||||
vi.mocked(requireApiTokenUser).mockResolvedValueOnce({ userId: "users:1" } as never);
|
||||
const runMutation = vi.fn().mockResolvedValue(null);
|
||||
const response = await __handlers.cliTelemetryInstallHandler(
|
||||
makeCtx({ runMutation }),
|
||||
new Request("https://x/api/cli/telemetry/install", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
event: "plugin_install",
|
||||
packageName: "@missing/plugin",
|
||||
}),
|
||||
}),
|
||||
);
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(await response.json()).toEqual({ ok: true });
|
||||
});
|
||||
|
||||
it("cliTelemetryInstallHttp accepts legacy roots snapshots", async () => {
|
||||
vi.mocked(requireApiTokenUser).mockResolvedValueOnce({ userId: "users:1" } as never);
|
||||
const runMutation = vi.fn().mockResolvedValue(null);
|
||||
|
||||
@@ -327,6 +327,12 @@ async function cliTelemetryInstallHandler(ctx: ActionCtx, request: Request) {
|
||||
});
|
||||
}
|
||||
}
|
||||
} else if (args.event === "plugin_install") {
|
||||
await ctx.runMutation(internal.telemetry.reportCliPluginInstallInternal, {
|
||||
userId,
|
||||
packageName: args.packageName,
|
||||
version: args.version,
|
||||
});
|
||||
} else {
|
||||
await ctx.runMutation(internal.telemetry.reportCliInstallInternal, {
|
||||
userId,
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
import type { Id } from "../_generated/dataModel";
|
||||
import type { MutationCtx } from "../_generated/server";
|
||||
|
||||
export async function insertPackageInstallStatEvent(
|
||||
ctx: Pick<MutationCtx, "db">,
|
||||
params: {
|
||||
packageId: Id<"packages">;
|
||||
kind?: "install" | "install_clear";
|
||||
occurredAt?: number;
|
||||
},
|
||||
) {
|
||||
await ctx.db.insert("packageStatEvents", {
|
||||
packageId: params.packageId,
|
||||
kind: params.kind ?? "install",
|
||||
occurredAt: params.occurredAt ?? Date.now(),
|
||||
processedAt: undefined,
|
||||
});
|
||||
}
|
||||
@@ -299,6 +299,7 @@ export const RETENTION_POLICIES = {
|
||||
registryArtifactBackupSyncState: permanent("Legacy registry artifact backup cursor state."),
|
||||
registryArtifactBackupJobs: permanent("Legacy registry artifact backup job history."),
|
||||
userSkillInstalls: permanent("Current user install records."),
|
||||
userPackageInstalls: permanent("Current user package install records."),
|
||||
skillOwnershipTransfers: ephemeral("Ownership transfer invitations expire.", {
|
||||
expirationField: "expiresAt",
|
||||
prune: "usage-time validation plus pending retention cleanup",
|
||||
|
||||
@@ -242,6 +242,12 @@ describe("package stat events", () => {
|
||||
kind: "download",
|
||||
occurredAt: dayStart * 2,
|
||||
},
|
||||
{
|
||||
_id: "packageStatEvents:5",
|
||||
packageId: "packages:one",
|
||||
kind: "install_clear",
|
||||
occurredAt: dayStart,
|
||||
},
|
||||
];
|
||||
const insert = vi.fn();
|
||||
const patch = vi.fn();
|
||||
@@ -282,14 +288,14 @@ describe("package stat events", () => {
|
||||
|
||||
const result = await processStatsHandler(ctx, { batchSize: 10 });
|
||||
|
||||
expect(result).toEqual({ processed: 4, packagesUpdated: 2 });
|
||||
expect(result).toEqual({ processed: 5, packagesUpdated: 2 });
|
||||
expect(insert).toHaveBeenCalledWith(
|
||||
"packageDailyStats",
|
||||
expect.objectContaining({
|
||||
packageId: "packages:one",
|
||||
day: 1,
|
||||
downloads: 1,
|
||||
installs: 1,
|
||||
installs: 0,
|
||||
}),
|
||||
);
|
||||
expect(insert).toHaveBeenCalledWith(
|
||||
@@ -316,7 +322,7 @@ describe("package stat events", () => {
|
||||
stats: expect.objectContaining({ downloads: 12 }),
|
||||
recommendedScore: computeRecommendationScore({
|
||||
downloads: 12,
|
||||
installs: 2,
|
||||
installs: 1,
|
||||
stars: 2,
|
||||
}),
|
||||
recommendedScoreVersion: RECOMMENDATION_SCORE_VERSION,
|
||||
@@ -325,7 +331,7 @@ describe("package stat events", () => {
|
||||
expect(patch).toHaveBeenCalledWith(
|
||||
"packages:one",
|
||||
expect.objectContaining({
|
||||
stats: expect.objectContaining({ installs: 2 }),
|
||||
stats: expect.objectContaining({ installs: 1 }),
|
||||
}),
|
||||
);
|
||||
expect(patch).toHaveBeenCalledWith(
|
||||
|
||||
+7
-5
@@ -88,6 +88,7 @@ import {
|
||||
normalizePackageScanStatus,
|
||||
resolvePackageReleaseScanStatus,
|
||||
} from "./lib/packageSecurity";
|
||||
import { insertPackageInstallStatEvent } from "./lib/packageStatEvents";
|
||||
import { toPublicPublisher } from "./lib/public";
|
||||
import {
|
||||
assertCanManageOwnedResource,
|
||||
@@ -4538,11 +4539,9 @@ export const recordPackageInstallInternal = internalMutation({
|
||||
});
|
||||
}
|
||||
|
||||
await ctx.db.insert("packageStatEvents", {
|
||||
await insertPackageInstallStatEvent(ctx, {
|
||||
packageId: args.packageId,
|
||||
kind: "install",
|
||||
occurredAt: args.occurredAt ?? Date.now(),
|
||||
processedAt: undefined,
|
||||
occurredAt: args.occurredAt,
|
||||
});
|
||||
},
|
||||
});
|
||||
@@ -4630,6 +4629,9 @@ export const processPackageStatEventsInternal = internalMutation({
|
||||
if (event.kind === "install") {
|
||||
stats.installs += 1;
|
||||
dailyStats.installs += 1;
|
||||
} else if (event.kind === "install_clear") {
|
||||
stats.installs -= 1;
|
||||
dailyStats.installs -= 1;
|
||||
} else {
|
||||
stats.downloads += 1;
|
||||
dailyStats.downloads += 1;
|
||||
@@ -4646,7 +4648,7 @@ export const processPackageStatEventsInternal = internalMutation({
|
||||
}
|
||||
const nextStats = {
|
||||
downloads: (pkg.stats?.downloads ?? 0) + stats.downloads,
|
||||
installs: (pkg.stats?.installs ?? 0) + stats.installs,
|
||||
installs: Math.max(0, (pkg.stats?.installs ?? 0) + stats.installs),
|
||||
stars: pkg.stats?.stars ?? 0,
|
||||
versions: pkg.stats?.versions ?? 0,
|
||||
};
|
||||
|
||||
+15
-1
@@ -1982,7 +1982,7 @@ const skillCardGenerationJobs = defineTable({
|
||||
|
||||
const packageStatEvents = defineTable({
|
||||
packageId: v.id("packages"),
|
||||
kind: v.union(v.literal("download"), v.literal("install")),
|
||||
kind: v.union(v.literal("download"), v.literal("install"), v.literal("install_clear")),
|
||||
occurredAt: v.number(),
|
||||
processedAt: v.optional(v.number()),
|
||||
})
|
||||
@@ -3557,6 +3557,19 @@ const userSkillInstalls = defineTable({
|
||||
.index("by_user_skill", ["userId", "skillId"])
|
||||
.index("by_skill", ["skillId"]);
|
||||
|
||||
const userPackageInstalls = defineTable({
|
||||
userId: v.id("users"),
|
||||
packageId: v.id("packages"),
|
||||
firstSeenAt: v.number(),
|
||||
lastSeenAt: v.number(),
|
||||
lastVersion: v.optional(v.string()),
|
||||
metricRecordedAt: v.optional(v.number()),
|
||||
})
|
||||
.index("by_user", ["userId"])
|
||||
.index("by_user_lastSeenAt", ["userId", "lastSeenAt"])
|
||||
.index("by_user_package", ["userId", "packageId"])
|
||||
.index("by_package", ["packageId"]);
|
||||
|
||||
const skillOwnershipTransfers = defineTable({
|
||||
skillId: v.id("skills"),
|
||||
fromUserId: v.id("users"),
|
||||
@@ -3669,5 +3682,6 @@ export default defineSchema({
|
||||
registryArtifactBackupSyncState,
|
||||
registryArtifactBackupJobs,
|
||||
userSkillInstalls,
|
||||
userPackageInstalls,
|
||||
skillOwnershipTransfers,
|
||||
});
|
||||
|
||||
+273
-1
@@ -30,6 +30,7 @@ const {
|
||||
pruneInstallTelemetryDedupesInternal,
|
||||
reportCliInstallInternal,
|
||||
reportCliLegacyInstallBatchInternal,
|
||||
reportCliPluginInstallInternal,
|
||||
} = await import("./telemetry");
|
||||
|
||||
const reportCliInstallHandler = (
|
||||
@@ -59,6 +60,19 @@ const reportCliLegacyInstallBatchHandler = (
|
||||
}
|
||||
)._handler;
|
||||
|
||||
const reportCliPluginInstallHandler = (
|
||||
reportCliPluginInstallInternal as unknown as {
|
||||
_handler: (
|
||||
ctx: unknown,
|
||||
args: {
|
||||
userId: string;
|
||||
packageName: string;
|
||||
version?: string;
|
||||
},
|
||||
) => Promise<void>;
|
||||
}
|
||||
)._handler;
|
||||
|
||||
const clearUserTelemetryHandler = (
|
||||
clearUserTelemetryInternal as unknown as {
|
||||
_handler: (ctx: unknown, args: { userId: string; clearStartedAt?: number }) => Promise<void>;
|
||||
@@ -193,6 +207,235 @@ describe("telemetry install events", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("records the first plugin install for a canonical scoped package", async () => {
|
||||
const insert = vi.fn(async (table: string) =>
|
||||
table === "userPackageInstalls" ? "userPackageInstalls:one" : "packageStatEvents:one",
|
||||
);
|
||||
const patch = vi.fn();
|
||||
const packageDoc = {
|
||||
_id: "packages:voice-call",
|
||||
normalizedName: "@openclaw/voice-call",
|
||||
};
|
||||
const ctx = {
|
||||
db: {
|
||||
query: vi.fn((table: string) => ({
|
||||
withIndex: vi.fn(
|
||||
(indexName: string, callback: (q: ReturnType<typeof makeIndexBuilder>) => unknown) => {
|
||||
callback(makeIndexBuilder());
|
||||
if (table === "packages" && indexName === "by_name") {
|
||||
return { unique: async () => packageDoc };
|
||||
}
|
||||
if (table === "userPackageInstalls" && indexName === "by_user_package") {
|
||||
return { unique: async () => null };
|
||||
}
|
||||
throw new Error(`unexpected query ${table}.${indexName}`);
|
||||
},
|
||||
),
|
||||
})),
|
||||
insert,
|
||||
patch,
|
||||
},
|
||||
};
|
||||
|
||||
await reportCliPluginInstallHandler(ctx, {
|
||||
userId: "users:one",
|
||||
packageName: "@OpenClaw/Voice-Call",
|
||||
version: "2026.7.23",
|
||||
});
|
||||
|
||||
expect(insert).toHaveBeenCalledWith("userPackageInstalls", {
|
||||
userId: "users:one",
|
||||
packageId: "packages:voice-call",
|
||||
firstSeenAt: expect.any(Number),
|
||||
lastSeenAt: expect.any(Number),
|
||||
lastVersion: "2026.7.23",
|
||||
});
|
||||
expect(insert).toHaveBeenCalledWith("packageStatEvents", {
|
||||
packageId: "packages:voice-call",
|
||||
kind: "install",
|
||||
occurredAt: expect.any(Number),
|
||||
processedAt: undefined,
|
||||
});
|
||||
expect(patch).toHaveBeenCalledWith("userPackageInstalls:one", {
|
||||
metricRecordedAt: expect.any(Number),
|
||||
});
|
||||
});
|
||||
|
||||
it("updates repeated plugin installs without incrementing package metrics", async () => {
|
||||
const insert = vi.fn();
|
||||
const patch = vi.fn();
|
||||
const ctx = {
|
||||
db: {
|
||||
query: vi.fn((table: string) => ({
|
||||
withIndex: vi.fn(
|
||||
(indexName: string, callback: (q: ReturnType<typeof makeIndexBuilder>) => unknown) => {
|
||||
callback(makeIndexBuilder());
|
||||
if (table === "packages" && indexName === "by_name") {
|
||||
return {
|
||||
unique: async () => ({
|
||||
_id: "packages:voice-call",
|
||||
normalizedName: "@openclaw/voice-call",
|
||||
}),
|
||||
};
|
||||
}
|
||||
if (table === "userPackageInstalls" && indexName === "by_user_package") {
|
||||
return {
|
||||
unique: async () => ({
|
||||
_id: "userPackageInstalls:one",
|
||||
lastVersion: "2026.7.22",
|
||||
metricRecordedAt: 123,
|
||||
}),
|
||||
};
|
||||
}
|
||||
throw new Error(`unexpected query ${table}.${indexName}`);
|
||||
},
|
||||
),
|
||||
})),
|
||||
insert,
|
||||
patch,
|
||||
},
|
||||
};
|
||||
|
||||
await reportCliPluginInstallHandler(ctx, {
|
||||
userId: "users:one",
|
||||
packageName: "@openclaw/voice-call",
|
||||
version: "2026.7.23",
|
||||
});
|
||||
|
||||
expect(patch).toHaveBeenCalledWith("userPackageInstalls:one", {
|
||||
lastSeenAt: expect.any(Number),
|
||||
lastVersion: "2026.7.23",
|
||||
});
|
||||
expect(insert).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("retries a pending package metric on a repeated plugin install", async () => {
|
||||
const insert = vi.fn();
|
||||
const patch = vi.fn();
|
||||
const ctx = {
|
||||
db: {
|
||||
query: vi.fn((table: string) => ({
|
||||
withIndex: vi.fn(
|
||||
(indexName: string, callback: (q: ReturnType<typeof makeIndexBuilder>) => unknown) => {
|
||||
callback(makeIndexBuilder());
|
||||
if (table === "packages" && indexName === "by_name") {
|
||||
return {
|
||||
unique: async () => ({
|
||||
_id: "packages:voice-call",
|
||||
normalizedName: "@openclaw/voice-call",
|
||||
}),
|
||||
};
|
||||
}
|
||||
if (table === "userPackageInstalls" && indexName === "by_user_package") {
|
||||
return {
|
||||
unique: async () => ({
|
||||
_id: "userPackageInstalls:one",
|
||||
lastVersion: "2026.7.22",
|
||||
}),
|
||||
};
|
||||
}
|
||||
throw new Error(`unexpected query ${table}.${indexName}`);
|
||||
},
|
||||
),
|
||||
})),
|
||||
insert,
|
||||
patch,
|
||||
},
|
||||
};
|
||||
|
||||
await reportCliPluginInstallHandler(ctx, {
|
||||
userId: "users:one",
|
||||
packageName: "@openclaw/voice-call",
|
||||
version: "2026.7.23",
|
||||
});
|
||||
|
||||
expect(insert).toHaveBeenCalledWith("packageStatEvents", {
|
||||
packageId: "packages:voice-call",
|
||||
kind: "install",
|
||||
occurredAt: expect.any(Number),
|
||||
processedAt: undefined,
|
||||
});
|
||||
expect(patch).toHaveBeenCalledWith("userPackageInstalls:one", {
|
||||
metricRecordedAt: expect.any(Number),
|
||||
});
|
||||
});
|
||||
|
||||
it("ignores unknown or deleted plugin packages", async () => {
|
||||
const insert = vi.fn();
|
||||
const packages = [
|
||||
null,
|
||||
{
|
||||
_id: "packages:deleted",
|
||||
normalizedName: "@openclaw/deleted",
|
||||
softDeletedAt: 123,
|
||||
},
|
||||
];
|
||||
const ctx = {
|
||||
db: {
|
||||
query: vi.fn(() => ({
|
||||
withIndex: vi.fn(
|
||||
(_indexName: string, callback: (q: ReturnType<typeof makeIndexBuilder>) => unknown) => {
|
||||
callback(makeIndexBuilder());
|
||||
return { unique: async () => packages.shift() ?? null };
|
||||
},
|
||||
),
|
||||
})),
|
||||
insert,
|
||||
patch: vi.fn(),
|
||||
},
|
||||
};
|
||||
|
||||
await reportCliPluginInstallHandler(ctx, {
|
||||
userId: "users:one",
|
||||
packageName: "@openclaw/missing",
|
||||
});
|
||||
await reportCliPluginInstallHandler(ctx, {
|
||||
userId: "users:one",
|
||||
packageName: "@openclaw/deleted",
|
||||
});
|
||||
|
||||
expect(insert).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("keeps package metric queueing best-effort after persisting the install relationship", async () => {
|
||||
const insert = vi.fn(async (table: string) => {
|
||||
if (table === "packageStatEvents") {
|
||||
throw new Error("metrics unavailable");
|
||||
}
|
||||
return "userPackageInstalls:one";
|
||||
});
|
||||
const ctx = {
|
||||
db: {
|
||||
query: vi.fn((table: string) => ({
|
||||
withIndex: vi.fn(
|
||||
(_indexName: string, callback: (q: ReturnType<typeof makeIndexBuilder>) => unknown) => {
|
||||
callback(makeIndexBuilder());
|
||||
return {
|
||||
unique: async () =>
|
||||
table === "packages"
|
||||
? { _id: "packages:voice-call", normalizedName: "@openclaw/voice-call" }
|
||||
: null,
|
||||
};
|
||||
},
|
||||
),
|
||||
})),
|
||||
insert,
|
||||
patch: vi.fn(),
|
||||
},
|
||||
};
|
||||
|
||||
await expect(
|
||||
reportCliPluginInstallHandler(ctx, {
|
||||
userId: "users:one",
|
||||
packageName: "@openclaw/voice-call",
|
||||
}),
|
||||
).resolves.toBeUndefined();
|
||||
expect(insert).toHaveBeenCalledWith(
|
||||
"userPackageInstalls",
|
||||
expect.objectContaining({ packageId: "packages:voice-call" }),
|
||||
);
|
||||
});
|
||||
|
||||
it("does not attribute an unclaimed skills.sh install to a same-slug native skill", async () => {
|
||||
const query = vi.fn();
|
||||
const insert = vi.fn();
|
||||
@@ -476,6 +719,17 @@ describe("telemetry install events", () => {
|
||||
{ _id: "installs:one", skillId: "skills:one" },
|
||||
{ _id: "installs:two", skillId: "skills:two" },
|
||||
];
|
||||
const packageInstalls = [
|
||||
{
|
||||
_id: "userPackageInstalls:one",
|
||||
packageId: "packages:one",
|
||||
metricRecordedAt: 86_500_000,
|
||||
},
|
||||
{
|
||||
_id: "userPackageInstalls:pending",
|
||||
packageId: "packages:pending",
|
||||
},
|
||||
];
|
||||
const dedupes = [{ _id: "installTelemetryDedupes:one" }];
|
||||
const ctx = {
|
||||
db: {
|
||||
@@ -486,6 +740,9 @@ describe("telemetry install events", () => {
|
||||
if (table === "userSkillInstalls" && indexName === "by_user_lastSeenAt") {
|
||||
return { take: async () => installs };
|
||||
}
|
||||
if (table === "userPackageInstalls" && indexName === "by_user_lastSeenAt") {
|
||||
return { take: async () => packageInstalls };
|
||||
}
|
||||
if (table === "installTelemetryDedupes" && indexName === "by_user_createdAt") {
|
||||
return { take: async () => dedupes };
|
||||
}
|
||||
@@ -518,7 +775,16 @@ describe("telemetry install events", () => {
|
||||
delta: { allTime: -1, current: -1 },
|
||||
}),
|
||||
);
|
||||
expect(deleteDoc).toHaveBeenCalledTimes(3);
|
||||
expect(insert).toHaveBeenCalledWith("packageStatEvents", {
|
||||
packageId: "packages:one",
|
||||
kind: "install_clear",
|
||||
occurredAt: 86_500_000,
|
||||
processedAt: undefined,
|
||||
});
|
||||
expect(insert.mock.calls.filter(([table]) => table === "packageStatEvents")).toHaveLength(1);
|
||||
expect(deleteDoc).toHaveBeenCalledTimes(5);
|
||||
expect(deleteDoc).toHaveBeenCalledWith("userPackageInstalls:one");
|
||||
expect(deleteDoc).toHaveBeenCalledWith("userPackageInstalls:pending");
|
||||
expect(deleteDoc).toHaveBeenCalledWith("installTelemetryDedupes:one");
|
||||
});
|
||||
|
||||
@@ -527,6 +793,12 @@ describe("telemetry install events", () => {
|
||||
table: "userSkillInstalls",
|
||||
indexName: "by_user_lastSeenAt",
|
||||
batchSize: 5_000,
|
||||
laterTables: ["userPackageInstalls", "installTelemetryDedupes"],
|
||||
},
|
||||
{
|
||||
table: "userPackageInstalls",
|
||||
indexName: "by_user_lastSeenAt",
|
||||
batchSize: 5_000,
|
||||
laterTables: ["installTelemetryDedupes"],
|
||||
},
|
||||
{
|
||||
|
||||
@@ -4,6 +4,8 @@ import type { Id } from "./_generated/dataModel";
|
||||
import type { MutationCtx } from "./_generated/server";
|
||||
import { internalMutation, mutation } from "./functions";
|
||||
import { requireUser } from "./lib/access";
|
||||
import { normalizePackageName } from "./lib/packageRegistry";
|
||||
import { insertPackageInstallStatEvent } from "./lib/packageStatEvents";
|
||||
import { RETENTION_STANDARD_BATCH_SIZE } from "./lib/retentionPolicy";
|
||||
import {
|
||||
getSkillBySlugForPublisher,
|
||||
@@ -60,6 +62,68 @@ export const reportCliLegacyInstallBatchInternal = internalMutation({
|
||||
},
|
||||
});
|
||||
|
||||
export const reportCliPluginInstallInternal = internalMutation({
|
||||
args: {
|
||||
userId: v.id("users"),
|
||||
packageName: v.string(),
|
||||
version: v.optional(v.string()),
|
||||
},
|
||||
handler: async (ctx, args) => {
|
||||
const packageName = normalizePackageName(args.packageName);
|
||||
if (!packageName) return;
|
||||
const pkg = await ctx.db
|
||||
.query("packages")
|
||||
.withIndex("by_name", (q) => q.eq("normalizedName", packageName))
|
||||
.unique();
|
||||
if (!pkg || pkg.softDeletedAt) return;
|
||||
|
||||
const now = Date.now();
|
||||
const version = args.version?.trim() || undefined;
|
||||
const existing = await ctx.db
|
||||
.query("userPackageInstalls")
|
||||
.withIndex("by_user_package", (q) => q.eq("userId", args.userId).eq("packageId", pkg._id))
|
||||
.unique();
|
||||
if (existing) {
|
||||
await ctx.db.patch(existing._id, {
|
||||
lastSeenAt: now,
|
||||
lastVersion: version ?? existing.lastVersion,
|
||||
});
|
||||
if (existing.metricRecordedAt === undefined) {
|
||||
try {
|
||||
await insertPackageInstallStatEvent(ctx, {
|
||||
packageId: pkg._id,
|
||||
occurredAt: now,
|
||||
});
|
||||
} catch {
|
||||
// A later successful report retries the aggregate metric.
|
||||
return;
|
||||
}
|
||||
await ctx.db.patch(existing._id, { metricRecordedAt: now });
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const installId = await ctx.db.insert("userPackageInstalls", {
|
||||
userId: args.userId,
|
||||
packageId: pkg._id,
|
||||
firstSeenAt: now,
|
||||
lastSeenAt: now,
|
||||
lastVersion: version,
|
||||
});
|
||||
try {
|
||||
await insertPackageInstallStatEvent(ctx, {
|
||||
packageId: pkg._id,
|
||||
occurredAt: now,
|
||||
});
|
||||
} catch {
|
||||
// The durable install relationship is authoritative; aggregate metric
|
||||
// processing is best-effort and will retry on a later successful report.
|
||||
return;
|
||||
}
|
||||
await ctx.db.patch(installId, { metricRecordedAt: now });
|
||||
},
|
||||
});
|
||||
|
||||
export const pruneInstallTelemetryDedupesInternal = internalMutation({
|
||||
args: {},
|
||||
handler: async (ctx) => {
|
||||
@@ -203,6 +267,27 @@ async function clearTelemetryForUser(
|
||||
return;
|
||||
}
|
||||
|
||||
const packageInstalls = await ctx.db
|
||||
.query("userPackageInstalls")
|
||||
.withIndex("by_user_lastSeenAt", (q) =>
|
||||
q.eq("userId", params.userId).lte("lastSeenAt", params.clearStartedAt),
|
||||
)
|
||||
.take(CLEAR_INSTALLS_BATCH_SIZE);
|
||||
for (const entry of packageInstalls) {
|
||||
if (entry.metricRecordedAt !== undefined) {
|
||||
await insertPackageInstallStatEvent(ctx, {
|
||||
packageId: entry.packageId,
|
||||
kind: "install_clear",
|
||||
occurredAt: entry.metricRecordedAt,
|
||||
});
|
||||
}
|
||||
await ctx.db.delete(entry._id);
|
||||
}
|
||||
if (packageInstalls.length === CLEAR_INSTALLS_BATCH_SIZE) {
|
||||
await scheduleClearUserTelemetry(ctx, params.userId, params.clearStartedAt);
|
||||
return;
|
||||
}
|
||||
|
||||
const dedupes = await ctx.db
|
||||
.query("installTelemetryDedupes")
|
||||
.withIndex("by_user_createdAt", (q) =>
|
||||
|
||||
+13
-6
@@ -7,25 +7,27 @@ read_when:
|
||||
|
||||
# Telemetry
|
||||
|
||||
ClawHub uses minimal CLI telemetry to compute aggregate install counts.
|
||||
ClawHub uses minimal CLI telemetry to compute aggregate skill and plugin install counts.
|
||||
|
||||
## When telemetry is collected
|
||||
|
||||
Telemetry is only sent when:
|
||||
|
||||
- You are logged in in the CLI.
|
||||
- You run `clawhub install <slug>`.
|
||||
- You run `clawhub install <slug>`, or complete an authenticated
|
||||
`openclaw plugins install clawhub:<package>` install.
|
||||
- Telemetry is **not disabled** (see “How to disable” below).
|
||||
|
||||
If you are not logged in, nothing is reported.
|
||||
|
||||
## What we collect
|
||||
|
||||
On each reported `clawhub install`, the CLI sends one best-effort install event.
|
||||
After a skill or plugin has installed and its local install record has been persisted, the CLI
|
||||
sends one best-effort install event.
|
||||
|
||||
The event includes:
|
||||
|
||||
- `slug`: the installed skill slug.
|
||||
- The installed skill slug or canonical plugin package name.
|
||||
- `version`: the installed version, when known.
|
||||
|
||||
### What we do _not_ collect
|
||||
@@ -36,17 +38,22 @@ The event includes:
|
||||
|
||||
## Install counts
|
||||
|
||||
ClawHub maintains aggregate counters per skill:
|
||||
For skills, ClawHub maintains:
|
||||
|
||||
- `installsAllTime`: unique users who have reported at least one CLI install for the skill.
|
||||
- `installsCurrent`: unique users who have reported an install and have not deleted their
|
||||
telemetry.
|
||||
|
||||
For plugins, ClawHub counts the first successful install reported by each user and package.
|
||||
Repeated installs and updates refresh the recorded version without increasing the aggregate
|
||||
install count.
|
||||
|
||||
## Transparency + user controls
|
||||
|
||||
Everyone only sees **aggregated install counters**.
|
||||
|
||||
Deleting your account also deletes your telemetry data.
|
||||
Deleting your account also deletes your telemetry data and removes its contribution from install
|
||||
counters.
|
||||
|
||||
## How to disable telemetry
|
||||
|
||||
|
||||
@@ -209,17 +209,23 @@ export const CliTelemetryInstallRequestSchema = type({
|
||||
// Deprecated compatibility fields accepted and ignored by the backend.
|
||||
rootId: "string?",
|
||||
rootLabel: "string?",
|
||||
}).or({
|
||||
// Legacy bulk snapshots remain accepted while older CLIs are in circulation.
|
||||
roots: type({
|
||||
rootId: "string",
|
||||
label: "string",
|
||||
skills: type({
|
||||
slug: "string",
|
||||
version: "string|null?",
|
||||
})
|
||||
.or({
|
||||
event: '"plugin_install"',
|
||||
packageName: "string",
|
||||
version: "string?",
|
||||
})
|
||||
.or({
|
||||
// Legacy bulk snapshots remain accepted while older CLIs are in circulation.
|
||||
roots: type({
|
||||
rootId: "string",
|
||||
label: "string",
|
||||
skills: type({
|
||||
slug: "string",
|
||||
version: "string|null?",
|
||||
}).array(),
|
||||
}).array(),
|
||||
}).array(),
|
||||
});
|
||||
});
|
||||
export type CliTelemetryInstallRequest = (typeof CliTelemetryInstallRequestSchema)[inferred];
|
||||
|
||||
export const ApiCliTelemetryInstallResponseSchema = type({
|
||||
|
||||
Vendored
+4
@@ -204,6 +204,10 @@ export declare const CliTelemetryInstallRequestSchema: import("arktype/internal/
|
||||
version?: string | undefined;
|
||||
rootId?: string | undefined;
|
||||
rootLabel?: string | undefined;
|
||||
} | {
|
||||
event: "plugin_install";
|
||||
packageName: string;
|
||||
version?: string | undefined;
|
||||
} | {
|
||||
roots: {
|
||||
rootId: string;
|
||||
|
||||
Vendored
+7
-1
@@ -182,7 +182,13 @@ export const CliTelemetryInstallRequestSchema = type({
|
||||
// Deprecated compatibility fields accepted and ignored by the backend.
|
||||
rootId: "string?",
|
||||
rootLabel: "string?",
|
||||
}).or({
|
||||
})
|
||||
.or({
|
||||
event: '"plugin_install"',
|
||||
packageName: "string",
|
||||
version: "string?",
|
||||
})
|
||||
.or({
|
||||
// Legacy bulk snapshots remain accepted while older CLIs are in circulation.
|
||||
roots: type({
|
||||
rootId: "string",
|
||||
|
||||
Vendored
+1
-1
File diff suppressed because one or more lines are too long
@@ -228,7 +228,7 @@ describe("clawhub-schema", () => {
|
||||
expect(blocked.ok).toBe(false);
|
||||
});
|
||||
|
||||
it("accepts current and legacy install telemetry payloads", () => {
|
||||
it("accepts skill, plugin, and legacy install telemetry payloads", () => {
|
||||
const current = parseArk(
|
||||
CliTelemetryInstallRequestSchema,
|
||||
{
|
||||
@@ -240,6 +240,15 @@ describe("clawhub-schema", () => {
|
||||
},
|
||||
"Install telemetry",
|
||||
);
|
||||
const plugin = parseArk(
|
||||
CliTelemetryInstallRequestSchema,
|
||||
{
|
||||
event: "plugin_install",
|
||||
packageName: "@openclaw/voice-call",
|
||||
version: "2026.7.23",
|
||||
},
|
||||
"Install telemetry",
|
||||
);
|
||||
const legacy = parseArk(
|
||||
CliTelemetryInstallRequestSchema,
|
||||
{
|
||||
@@ -260,6 +269,11 @@ describe("clawhub-schema", () => {
|
||||
ownerHandle: "alice",
|
||||
sourceRef: "skills-sh/alice/skills/demo",
|
||||
});
|
||||
expect(plugin).toEqual({
|
||||
event: "plugin_install",
|
||||
packageName: "@openclaw/voice-call",
|
||||
version: "2026.7.23",
|
||||
});
|
||||
expect(legacy).toMatchObject({ roots: [{ rootId: "root" }] });
|
||||
});
|
||||
|
||||
|
||||
@@ -210,17 +210,23 @@ export const CliTelemetryInstallRequestSchema = type({
|
||||
// Deprecated compatibility fields accepted and ignored by the backend.
|
||||
rootId: "string?",
|
||||
rootLabel: "string?",
|
||||
}).or({
|
||||
// Legacy bulk snapshots remain accepted while older CLIs are in circulation.
|
||||
roots: type({
|
||||
rootId: "string",
|
||||
label: "string",
|
||||
skills: type({
|
||||
slug: "string",
|
||||
version: "string|null?",
|
||||
})
|
||||
.or({
|
||||
event: '"plugin_install"',
|
||||
packageName: "string",
|
||||
version: "string?",
|
||||
})
|
||||
.or({
|
||||
// Legacy bulk snapshots remain accepted while older CLIs are in circulation.
|
||||
roots: type({
|
||||
rootId: "string",
|
||||
label: "string",
|
||||
skills: type({
|
||||
slug: "string",
|
||||
version: "string|null?",
|
||||
}).array(),
|
||||
}).array(),
|
||||
}).array(),
|
||||
});
|
||||
});
|
||||
export type CliTelemetryInstallRequest = (typeof CliTelemetryInstallRequestSchema)[inferred];
|
||||
|
||||
export const ApiCliTelemetryInstallResponseSchema = type({
|
||||
|
||||
Reference in New Issue
Block a user