Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions migrations/0019_product_usage_event_role.sql
Original file line number Diff line number Diff line change
@@ -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);
4 changes: 4 additions & 0 deletions src/api/routes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -184,6 +184,7 @@ import type {
JobMessage,
JsonValue,
ProductUsageOutcome,
ProductUsageRole,
ProductUsageSurface,
PullRequestRecord,
RepoSyncSegmentRecord,
Expand All @@ -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;
Expand All @@ -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),
Expand Down Expand Up @@ -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",
Expand Down
47 changes: 44 additions & 3 deletions src/db/repositories.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -1001,9 +1002,16 @@ export async function recordProductUsageEvent(
): Promise<ProductUsageEventRecord> {
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),
Expand All @@ -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,
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -3676,6 +3686,7 @@ function productUsageRolesForEvent(event: ProductUsageEventRecord, actorRoles: M

function productUsageBaseRolesForEvent(event: ProductUsageEventRecord): ProductUsageRole[] {
const roles = new Set<ProductUsageRole>();
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);
Expand Down Expand Up @@ -3710,6 +3721,35 @@ function addProductUsageRolesFromValue(roles: Set<ProductUsageRole>, value: Json
if (role) roles.add(role);
}

function resolveProductUsageRole(args: {
explicitRole?: ProductUsageRole | string | null | undefined;
surface: ProductUsageSurface;
eventName: string;
metadata: Record<string, JsonValue>;
}): ProductUsageRole {
if (typeof args.explicitRole === "string") {
const normalized = normalizeProductUsageRole(args.explicitRole);
if (normalized) return normalized;
}
const fromMetadata = new Set<ProductUsageRole>();
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":
Expand Down Expand Up @@ -3890,8 +3930,9 @@ const PRODUCT_USAGE_USEFUL_ACTION_EVENTS = new Set([
const PRODUCT_USAGE_SURFACES = new Set<ProductUsageSurface>(["api", "mcp", "github_app", "control_panel", "browser_extension", "internal"]);
const PRODUCT_USAGE_OUTCOMES = new Set<ProductUsageOutcome>(["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;
Expand Down
2 changes: 2 additions & 0 deletions src/db/schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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"),
Expand All @@ -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),
Expand Down
2 changes: 2 additions & 0 deletions src/mcp/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -363,6 +363,7 @@ export async function handleMcpRequest(c: AppContext): Promise<Response> {
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,
Expand All @@ -377,6 +378,7 @@ export async function handleMcpRequest(c: AppContext): Promise<Response> {
} 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,
Expand Down
2 changes: 2 additions & 0 deletions src/queue/processors.ts
Original file line number Diff line number Diff line change
Expand Up @@ -824,9 +824,11 @@ async function recordGithubProductUsage(
metadata?: Record<string, unknown>;
},
): Promise<void> {
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,
Expand Down
1 change: 1 addition & 0 deletions src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
50 changes: 47 additions & 3 deletions test/unit/product-usage.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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: "<redacted-actor>/private-tool",
Expand Down Expand Up @@ -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" });

Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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}`,
Expand All @@ -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",
Expand Down