diff --git a/migrations/0019_product_usage_event_role.sql b/migrations/0019_product_usage_event_role.sql new file mode 100644 index 0000000000..ac00854f8b --- /dev/null +++ b/migrations/0019_product_usage_event_role.sql @@ -0,0 +1,4 @@ +ALTER TABLE product_usage_events ADD COLUMN role TEXT NOT NULL DEFAULT 'unknown'; + +CREATE INDEX IF NOT EXISTS product_usage_events_role_occurred_idx + ON product_usage_events(role, occurred_at); diff --git a/src/api/routes.ts b/src/api/routes.ts index 8574124dc8..a611313922 100644 --- a/src/api/routes.ts +++ b/src/api/routes.ts @@ -184,6 +184,7 @@ import type { JobMessage, JsonValue, ProductUsageOutcome, + ProductUsageRole, ProductUsageSurface, PullRequestRecord, RepoSyncSegmentRecord, @@ -199,6 +200,7 @@ async function recordRouteProductUsage( event: { surface: ProductUsageSurface; eventName: string; + role?: ProductUsageRole | string | null | undefined; outcome?: ProductUsageOutcome; identity?: AuthIdentity | null | undefined; actor?: string | null | undefined; @@ -215,6 +217,7 @@ async function recordRouteProductUsage( await recordProductUsageEvent(c.env, { surface: event.surface, eventName: event.eventName, + role: event.role, route: c.req.path, actor: event.actor ?? event.identity?.actor, sessionId: event.sessionId ?? (event.identity?.kind === "session" ? event.identity.session.id : undefined), @@ -651,6 +654,7 @@ export function createApp() { await recordRouteProductUsage(c, { surface: "browser_extension", eventName: "extension_session_created", + role: "maintainer", identity, sessionId: session.id, outcome: "success", diff --git a/src/db/repositories.ts b/src/db/repositories.ts index a42886406e..d15c2202e8 100644 --- a/src/db/repositories.ts +++ b/src/db/repositories.ts @@ -986,6 +986,7 @@ export async function recordProductUsageEvent( event: { surface: ProductUsageSurface; eventName: string; + role?: ProductUsageRole | string | null | undefined; actor?: string | null | undefined; sessionId?: string | null | undefined; route?: string | null | undefined; @@ -1001,9 +1002,16 @@ export async function recordProductUsageEvent( ): Promise { const db = getDb(env.DB); const actorRedactor = buildProductUsageActorRedactor(event.actor); + const sanitizedMetadata = sanitizeProductUsageMetadata(event.metadata, actorRedactor); const record: ProductUsageEventRecord = { id: crypto.randomUUID(), surface: normalizeProductUsageSurface(event.surface), + role: resolveProductUsageRole({ + explicitRole: event.role, + surface: normalizeProductUsageSurface(event.surface), + eventName: boundedProductUsageField(event.eventName, 96) ?? "unknown", + metadata: sanitizedMetadata, + }), eventName: boundedProductUsageField(event.eventName, 96) ?? "unknown", route: boundedProductUsageField(event.route, 160), actorHash: await hashProductUsageIdentifier(env, "actor", event.actor), @@ -1014,12 +1022,13 @@ export async function recordProductUsageEvent( latencyMs: normalizeProductUsageLatency(event.latencyMs), clientName: boundedProductUsageField(event.clientName, 80), clientVersion: boundedProductUsageField(event.clientVersion, 80), - metadata: sanitizeProductUsageMetadata(event.metadata, actorRedactor), + metadata: sanitizedMetadata, occurredAt: event.occurredAt ?? nowIso(), }; await db.insert(productUsageEvents).values({ id: record.id, surface: record.surface, + role: record.role, eventName: record.eventName, route: record.route ?? null, actorHash: record.actorHash ?? null, @@ -3288,6 +3297,7 @@ function toProductUsageEventRecord(row: typeof productUsageEvents.$inferSelect): return { id: row.id, surface: normalizeProductUsageSurface(row.surface), + role: normalizeProductUsageRole(row.role) ?? "unknown", eventName: row.eventName, route: row.route, actorHash: row.actorHash, @@ -3676,6 +3686,7 @@ function productUsageRolesForEvent(event: ProductUsageEventRecord, actorRoles: M function productUsageBaseRolesForEvent(event: ProductUsageEventRecord): ProductUsageRole[] { const roles = new Set(); + if (event.role && event.role !== "unknown") roles.add(event.role); addProductUsageRolesFromValue(roles, event.metadata.role); addProductUsageRolesFromValue(roles, event.metadata.roles); addProductUsageRolesFromValue(roles, event.metadata.audience); @@ -3710,6 +3721,35 @@ function addProductUsageRolesFromValue(roles: Set, value: Json if (role) roles.add(role); } +function resolveProductUsageRole(args: { + explicitRole?: ProductUsageRole | string | null | undefined; + surface: ProductUsageSurface; + eventName: string; + metadata: Record; +}): ProductUsageRole { + if (typeof args.explicitRole === "string") { + const normalized = normalizeProductUsageRole(args.explicitRole); + if (normalized) return normalized; + } + const fromMetadata = new Set(); + addProductUsageRolesFromValue(fromMetadata, args.metadata.role); + addProductUsageRolesFromValue(fromMetadata, args.metadata.roles); + addProductUsageRolesFromValue(fromMetadata, args.metadata.audience); + addProductUsageRolesFromValue(fromMetadata, args.metadata.actorRole); + addProductUsageRolesFromValue(fromMetadata, args.metadata.actorKind); + if (fromMetadata.size > 0) return [...fromMetadata].sort((a, b) => productUsageRoleSortValue(a) - productUsageRoleSortValue(b))[0] ?? "unknown"; + const [inferred] = productUsageBaseRolesForEvent({ + id: "", + surface: args.surface, + role: "unknown", + eventName: args.eventName, + outcome: "success", + metadata: args.metadata, + occurredAt: nowIso(), + }); + return inferred ?? "unknown"; +} + function normalizeProductUsageRole(value: string): ProductUsageRole | null { switch (value.trim().toLowerCase().replace(/[\s-]+/g, "_")) { case "miner": @@ -3890,8 +3930,9 @@ const PRODUCT_USAGE_USEFUL_ACTION_EVENTS = new Set([ const PRODUCT_USAGE_SURFACES = new Set(["api", "mcp", "github_app", "control_panel", "browser_extension", "internal"]); const PRODUCT_USAGE_OUTCOMES = new Set(["success", "denied", "error", "queued", "completed", "skipped"]); const PRODUCT_USAGE_SENSITIVE_KEY = - /authorization|cookie|token|secret|password|private[_-]?key|source|body|diff|patch|raw[_-]?trust|trust[_-]?score|wallet|hotkey|coldkey|seed|mnemonic|local[_-]?path|repo[_-]?root|cwd/i; -const PRODUCT_USAGE_SENSITIVE_VALUE = /\b(seed phrase|mnemonic|private key|raw trust|trust score|wallet|hotkey|coldkey)\b/i; + /authorization|cookie|token|secret|password|private[_-]?key|source|body|diff|patch|prompt|raw[_-]?trust|trust[_-]?score|wallet|hotkey|coldkey|seed|mnemonic|local[_-]?path|repo[_-]?root|cwd|scoreability|reviewability|farming/i; +const PRODUCT_USAGE_SENSITIVE_VALUE = + /\b(seed phrase|mnemonic|private key|raw trust|trust score|wallet|hotkey|coldkey|scoreability|reviewability|farming|reward estimate|payout)\b/i; const PRODUCT_USAGE_LOCAL_PATH = /(?:\/Users|\/home|\/tmp)\/[^\s"',;)]*|[A-Za-z]:\\Users\\[^\s"',;)]*/g; const PRODUCT_USAGE_TOKEN_VALUE = /\b(?:ghp_|github_pat_|gts_|glpat-|sk-)[A-Za-z0-9_=-]{8,}/g; const PRODUCT_USAGE_BEARER_VALUE = /\bBearer\s+[A-Za-z0-9._~+/=-]{12,}/gi; diff --git a/src/db/schema.ts b/src/db/schema.ts index fa52ecb698..5620845a04 100644 --- a/src/db/schema.ts +++ b/src/db/schema.ts @@ -835,6 +835,7 @@ export const productUsageEvents = sqliteTable( { id: text("id").primaryKey(), surface: text("surface").notNull(), + role: text("role").notNull().default("unknown"), eventName: text("event_name").notNull(), route: text("route"), actorHash: text("actor_hash"), @@ -850,6 +851,7 @@ export const productUsageEvents = sqliteTable( }, (table) => ({ surfaceOccurred: index("product_usage_events_surface_occurred_idx").on(table.surface, table.occurredAt), + roleOccurred: index("product_usage_events_role_occurred_idx").on(table.role, table.occurredAt), eventOccurred: index("product_usage_events_event_occurred_idx").on(table.eventName, table.occurredAt), actorOccurred: index("product_usage_events_actor_occurred_idx").on(table.actorHash, table.occurredAt), repoOccurred: index("product_usage_events_repo_occurred_idx").on(table.repoFullName, table.occurredAt), diff --git a/src/mcp/server.ts b/src/mcp/server.ts index 5476cb0d47..09116f7c8d 100644 --- a/src/mcp/server.ts +++ b/src/mcp/server.ts @@ -363,6 +363,7 @@ export async function handleMcpRequest(c: AppContext): Promise { const response = await createMcpHandler(server, { route: "/mcp", enableJsonResponse: true })(c.req.raw, c.env, getExecutionContext(c)); await recordProductUsageEvent(c.env, { surface: "mcp", + role: "miner", eventName: typeof usageMetadata.toolName === "string" ? "mcp_tool_called" : "mcp_request", route: "/mcp", actor: identity.actor, @@ -377,6 +378,7 @@ export async function handleMcpRequest(c: AppContext): Promise { } catch (error) { await recordProductUsageEvent(c.env, { surface: "mcp", + role: "miner", eventName: typeof usageMetadata.toolName === "string" ? "mcp_tool_called" : "mcp_request", route: "/mcp", actor: identity.actor, diff --git a/src/queue/processors.ts b/src/queue/processors.ts index e5e3f931b5..ba701ea3ed 100644 --- a/src/queue/processors.ts +++ b/src/queue/processors.ts @@ -824,9 +824,11 @@ async function recordGithubProductUsage( metadata?: Record; }, ): Promise { + const actorRole = typeof event.metadata?.actorKind === "string" ? event.metadata.actorKind : typeof event.metadata?.role === "string" ? event.metadata.role : undefined; await recordProductUsageEvent(env, { surface: "github_app", eventName, + role: actorRole, actor: event.actor, repoFullName: event.repoFullName, targetKey: event.targetKey, diff --git a/src/types.ts b/src/types.ts index 16e28420b4..f5a5ba2b99 100644 --- a/src/types.ts +++ b/src/types.ts @@ -1074,6 +1074,7 @@ export type ProductUsageRole = "miner" | "maintainer" | "owner" | "operator" | " export type ProductUsageEventRecord = { id: string; surface: ProductUsageSurface; + role: ProductUsageRole; eventName: string; route?: string | null | undefined; actorHash?: string | null | undefined; diff --git a/test/unit/product-usage.test.ts b/test/unit/product-usage.test.ts index 0167d17d68..55a9992df3 100644 --- a/test/unit/product-usage.test.ts +++ b/test/unit/product-usage.test.ts @@ -38,6 +38,7 @@ describe("product usage events", () => { if (!row) throw new Error("expected product usage event"); expect(row).toMatchObject({ surface: "control_panel", + role: "unknown", eventName: "command_previewed", route: "/v1/app/commands/preview", repoFullName: "/private-tool", @@ -172,6 +173,46 @@ describe("product usage events", () => { expect(JSON.stringify(row.metadata)).not.toMatch(/\/Users|github_pat|ghp_|source code|private patch|trustScore|wallet/i); }); + it("persists normalized role on the event row and strips private scoreability metadata", async () => { + const env = createTestEnv({ PRODUCT_USAGE_HASH_SALT: "fixed-test-salt" }); + + const recorded = await recordProductUsageEvent(env, { + surface: "control_panel", + eventName: "command_previewed", + role: "maintainer", + actor: "oktofeesh1", + metadata: { + role: "owner", + privateScoreability: "must not persist", + reviewability: "private context", + farming: "optimization tactic", + prompt: "raw prompt text", + sourceContents: "file contents", + }, + }); + + expect(recorded.role).toBe("maintainer"); + const [row] = await listProductUsageEvents(env); + expect(row?.role).toBe("maintainer"); + expect(row?.metadata).not.toHaveProperty("privateScoreability"); + expect(row?.metadata).not.toHaveProperty("reviewability"); + expect(row?.metadata).not.toHaveProperty("farming"); + expect(row?.metadata).not.toHaveProperty("prompt"); + expect(row?.metadata).not.toHaveProperty("sourceContents"); + expect(JSON.stringify(row)).not.toMatch(/wallet|hotkey|raw trust|reward estimate|farming|privateScoreability|reviewability/i); + }); + + it("infers miner role for MCP usage when role is omitted", async () => { + const env = createTestEnv({ PRODUCT_USAGE_HASH_SALT: "fixed-test-salt" }); + const recorded = await recordProductUsageEvent(env, { + surface: "mcp", + eventName: "mcp_tool_called", + actor: "miner-user", + metadata: { toolName: "gittensory_get_repo_context" }, + }); + expect(recorded.role).toBe("miner"); + }); + it("does not use API credentials as hash salt fallback", async () => { const env = createTestEnv({ PRODUCT_USAGE_HASH_SALT: "", GITTENSORY_API_TOKEN: "private-api-token" }); @@ -819,10 +860,11 @@ describe("product usage events", () => { await env.DB.batch( Array.from({ length: 5001 }, (_, index) => env.DB.prepare( - "insert into product_usage_events (id, surface, event_name, route, actor_hash, session_hash, repo_full_name, target_key, outcome, latency_ms, client_name, client_version, metadata_json, occurred_at) values (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)", + "insert into product_usage_events (id, surface, role, event_name, route, actor_hash, session_hash, repo_full_name, target_key, outcome, latency_ms, client_name, client_version, metadata_json, occurred_at) values (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)", ).bind( `cap-event-${index}`, "api", + "miner", "agent_pr_packet_completed", "/v1/agent/prepare-pr-packet", null, @@ -865,10 +907,11 @@ describe("product usage events", () => { await env.DB.batch( Array.from({ length: 5001 }, (_, index) => env.DB.prepare( - "insert into product_usage_events (id, surface, event_name, route, actor_hash, session_hash, repo_full_name, target_key, outcome, latency_ms, client_name, client_version, metadata_json, occurred_at) values (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)", + "insert into product_usage_events (id, surface, role, event_name, route, actor_hash, session_hash, repo_full_name, target_key, outcome, latency_ms, client_name, client_version, metadata_json, occurred_at) values (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)", ).bind( `retention-cap-event-${index}`, "mcp", + "miner", "mcp_request", "/mcp", index === 5000 ? "retained-actor-hash" : `previous-actor-${index}`, @@ -885,11 +928,12 @@ describe("product usage events", () => { ), ); await env.DB.prepare( - "insert into product_usage_events (id, surface, event_name, route, actor_hash, session_hash, repo_full_name, target_key, outcome, latency_ms, client_name, client_version, metadata_json, occurred_at) values (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)", + "insert into product_usage_events (id, surface, role, event_name, route, actor_hash, session_hash, repo_full_name, target_key, outcome, latency_ms, client_name, client_version, metadata_json, occurred_at) values (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)", ) .bind( "retention-cap-current-event", "mcp", + "miner", "mcp_request", "/mcp", "retained-actor-hash",