mirror of
https://github.com/openclaw/clawhub.git
synced 2026-08-14 08:52:21 +00:00
345 lines
10 KiB
TypeScript
345 lines
10 KiB
TypeScript
import { v } from "convex/values";
|
|
import { internal } from "./_generated/api";
|
|
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,
|
|
getSkillSlugAliasBySlugForPublisher,
|
|
resolvePublisherByOwnerHandle,
|
|
} from "./lib/skills/slugResolution";
|
|
import { insertStatEvent } from "./skillStatEvents";
|
|
|
|
const DAY_MS = 86_400_000;
|
|
const INSTALL_TELEMETRY_DEDUPE_RETENTION_MS = 14 * DAY_MS;
|
|
const PRUNE_BATCH_SIZE = RETENTION_STANDARD_BATCH_SIZE;
|
|
const CLEAR_INSTALLS_BATCH_SIZE = 5_000;
|
|
const CLEAR_DEDUPES_BATCH_SIZE = 10_000;
|
|
const INSTALL_TELEMETRY_SLUG_MATCH_LIMIT = 25;
|
|
|
|
export const reportCliInstallInternal = internalMutation({
|
|
args: {
|
|
userId: v.id("users"),
|
|
slug: v.string(),
|
|
ownerHandle: v.optional(v.string()),
|
|
sourceRef: v.optional(v.string()),
|
|
version: v.optional(v.string()),
|
|
},
|
|
handler: async (ctx, args) => {
|
|
// Unclaimed catalog installs have no native skill row yet. Keep the source
|
|
// identity in the request without guessing from a same-slug native skill.
|
|
if (args.sourceRef?.trim().toLowerCase().startsWith("skills-sh/")) return;
|
|
await upsertUserSkillInstall(ctx, args);
|
|
},
|
|
});
|
|
|
|
export const reportCliLegacyInstallBatchInternal = internalMutation({
|
|
args: {
|
|
userId: v.id("users"),
|
|
skills: v.array(
|
|
v.object({
|
|
slug: v.string(),
|
|
version: v.optional(v.string()),
|
|
}),
|
|
),
|
|
},
|
|
handler: async (ctx, args) => {
|
|
const seen = new Set<string>();
|
|
for (const entry of args.skills) {
|
|
const slug = entry.slug.trim().toLowerCase();
|
|
if (!slug || seen.has(slug)) continue;
|
|
seen.add(slug);
|
|
await upsertUserSkillInstall(ctx, {
|
|
userId: args.userId,
|
|
slug,
|
|
version: entry.version,
|
|
});
|
|
}
|
|
},
|
|
});
|
|
|
|
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) => {
|
|
const cutoffDayStart = getDayStart(Date.now() - INSTALL_TELEMETRY_DEDUPE_RETENTION_MS);
|
|
const stale = await ctx.db
|
|
.query("installTelemetryDedupes")
|
|
.withIndex("by_day", (q) => q.lt("dayStart", cutoffDayStart))
|
|
.take(PRUNE_BATCH_SIZE);
|
|
|
|
for (const entry of stale) {
|
|
await ctx.db.delete(entry._id);
|
|
}
|
|
|
|
const hasMore = stale.length === PRUNE_BATCH_SIZE;
|
|
if (hasMore) {
|
|
await ctx.scheduler.runAfter(0, internal.telemetry.pruneInstallTelemetryDedupesInternal, {});
|
|
}
|
|
|
|
return { deleted: stale.length, hasMore };
|
|
},
|
|
});
|
|
|
|
export const clearMyTelemetry = mutation({
|
|
args: {},
|
|
handler: async (ctx) => {
|
|
const { userId } = await requireUser(ctx);
|
|
await clearTelemetryForUser(ctx, { userId, clearStartedAt: Date.now() });
|
|
},
|
|
});
|
|
|
|
export const clearUserTelemetryInternal = internalMutation({
|
|
args: { userId: v.id("users"), clearStartedAt: v.optional(v.number()) },
|
|
handler: async (ctx, args) => {
|
|
await clearTelemetryForUser(ctx, {
|
|
userId: args.userId,
|
|
clearStartedAt: args.clearStartedAt ?? Date.now(),
|
|
});
|
|
},
|
|
});
|
|
|
|
async function upsertUserSkillInstall(
|
|
ctx: MutationCtx,
|
|
params: { userId: Id<"users">; slug: string; ownerHandle?: string; version?: string },
|
|
) {
|
|
const slug = params.slug.trim().toLowerCase();
|
|
if (!slug) return;
|
|
|
|
const skill = await resolveInstallTelemetrySkill(ctx, {
|
|
slug,
|
|
ownerHandle: params.ownerHandle,
|
|
});
|
|
if (!skill) return;
|
|
|
|
const now = Date.now();
|
|
const duplicate = await markInstallTelemetrySeen(ctx, {
|
|
userId: params.userId,
|
|
skillId: skill._id,
|
|
now,
|
|
});
|
|
if (duplicate) return;
|
|
|
|
const version = params.version?.trim() || undefined;
|
|
const existing = await ctx.db
|
|
.query("userSkillInstalls")
|
|
.withIndex("by_user_skill", (q) => q.eq("userId", params.userId).eq("skillId", skill._id))
|
|
.unique();
|
|
|
|
if (existing) {
|
|
await ctx.db.patch(existing._id, {
|
|
lastSeenAt: now,
|
|
lastVersion: version ?? existing.lastVersion,
|
|
});
|
|
return;
|
|
}
|
|
|
|
await ctx.db.insert("userSkillInstalls", {
|
|
userId: params.userId,
|
|
skillId: skill._id,
|
|
firstSeenAt: now,
|
|
lastSeenAt: now,
|
|
lastVersion: version,
|
|
});
|
|
await insertStatEvent(ctx, { skillId: skill._id, kind: "install_new" });
|
|
}
|
|
|
|
async function resolveInstallTelemetrySkill(
|
|
ctx: MutationCtx,
|
|
params: { slug: string; ownerHandle?: string },
|
|
) {
|
|
if (params.ownerHandle) {
|
|
const { publisher } = await resolvePublisherByOwnerHandle(ctx, params.ownerHandle);
|
|
if (!publisher) return null;
|
|
let skill = await getSkillBySlugForPublisher(ctx, params.slug, publisher);
|
|
if (!skill) {
|
|
const alias = await getSkillSlugAliasBySlugForPublisher(ctx, params.slug, publisher);
|
|
skill = alias ? await ctx.db.get(alias.skillId) : null;
|
|
}
|
|
return skill && !skill.softDeletedAt ? skill : null;
|
|
}
|
|
|
|
// Older clients only report a bare slug. Once slugs are owner-scoped,
|
|
// telemetry must not guess which publisher should receive the install.
|
|
const candidates = await ctx.db
|
|
.query("skills")
|
|
.withIndex("by_slug", (q) => q.eq("slug", params.slug))
|
|
.take(INSTALL_TELEMETRY_SLUG_MATCH_LIMIT + 1);
|
|
if (candidates.length > INSTALL_TELEMETRY_SLUG_MATCH_LIMIT) return null;
|
|
const activeSkills = candidates.filter((candidate) => !candidate.softDeletedAt);
|
|
return activeSkills.length === 1 ? activeSkills[0] : null;
|
|
}
|
|
|
|
async function clearTelemetryForUser(
|
|
ctx: MutationCtx,
|
|
params: { userId: Id<"users">; clearStartedAt: number },
|
|
) {
|
|
const installs = await ctx.db
|
|
.query("userSkillInstalls")
|
|
.withIndex("by_user_lastSeenAt", (q) =>
|
|
q.eq("userId", params.userId).lte("lastSeenAt", params.clearStartedAt),
|
|
)
|
|
.take(CLEAR_INSTALLS_BATCH_SIZE);
|
|
|
|
for (const entry of installs) {
|
|
const skill = await ctx.db.get(entry.skillId);
|
|
if (!skill) {
|
|
await ctx.db.delete(entry._id);
|
|
continue;
|
|
}
|
|
await insertStatEvent(ctx, {
|
|
skillId: skill._id,
|
|
kind: "install_clear",
|
|
delta: {
|
|
allTime: -1,
|
|
current: -1,
|
|
},
|
|
});
|
|
await ctx.db.delete(entry._id);
|
|
}
|
|
if (installs.length === CLEAR_INSTALLS_BATCH_SIZE) {
|
|
await scheduleClearUserTelemetry(ctx, params.userId, params.clearStartedAt);
|
|
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) =>
|
|
q.eq("userId", params.userId).lte("createdAt", params.clearStartedAt),
|
|
)
|
|
.take(CLEAR_DEDUPES_BATCH_SIZE);
|
|
for (const entry of dedupes) {
|
|
await ctx.db.delete(entry._id);
|
|
}
|
|
if (dedupes.length === CLEAR_DEDUPES_BATCH_SIZE) {
|
|
await scheduleClearUserTelemetry(ctx, params.userId, params.clearStartedAt);
|
|
}
|
|
}
|
|
|
|
async function scheduleClearUserTelemetry(
|
|
ctx: MutationCtx,
|
|
userId: Id<"users">,
|
|
clearStartedAt: number,
|
|
) {
|
|
await ctx.scheduler.runAfter(0, internal.telemetry.clearUserTelemetryInternal, {
|
|
userId,
|
|
clearStartedAt,
|
|
});
|
|
}
|
|
|
|
async function markInstallTelemetrySeen(
|
|
ctx: MutationCtx,
|
|
params: { userId: Id<"users">; skillId: Id<"skills">; now: number },
|
|
) {
|
|
const dayStart = getDayStart(params.now);
|
|
const existing = await ctx.db
|
|
.query("installTelemetryDedupes")
|
|
.withIndex("by_user_skill_day", (q) =>
|
|
q.eq("userId", params.userId).eq("skillId", params.skillId).eq("dayStart", dayStart),
|
|
)
|
|
.unique();
|
|
if (existing) return true;
|
|
|
|
await ctx.db.insert("installTelemetryDedupes", {
|
|
userId: params.userId,
|
|
skillId: params.skillId,
|
|
dayStart,
|
|
createdAt: params.now,
|
|
});
|
|
return false;
|
|
}
|
|
|
|
function getDayStart(timestamp: number) {
|
|
return Math.floor(timestamp / DAY_MS) * DAY_MS;
|
|
}
|
|
|
|
export const __test = {
|
|
getDayStart,
|
|
};
|