Skip to content

Commit 656ead7

Browse files
authored
Track AI usage (#53)
* Track AI usage * clean up * grab model and provider from result * Clean up - create ai trpc procedure * fix input * updates * update to usage schema * PR updates
1 parent 5f5a83c commit 656ead7

File tree

6 files changed

+126
-5
lines changed

6 files changed

+126
-5
lines changed

apps/web/src/app/api/ai/journl-agent/route.ts

Lines changed: 27 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import { journlAgent } from "~/ai/mastra/agents/journl-agent";
22
import { getSession } from "~/auth/server";
3+
import { api } from "~/trpc/server";
34

45
export const maxDuration = 30; // Allow streaming responses up to 30 seconds
56

@@ -13,7 +14,32 @@ export async function POST(req: Request) {
1314
return new Response("Unauthorized", { status: 401 });
1415
}
1516

16-
const result = await journlAgent.stream(messages);
17+
const result = await journlAgent.stream(messages, {
18+
onFinish: async (result) => {
19+
const modelData = await journlAgent.getModel();
20+
21+
const provider = modelData.provider;
22+
const model = modelData.modelId;
23+
24+
if (result.usage && session.user?.id) {
25+
await api.usage.trackAiModelUsage({
26+
metrics: [
27+
{
28+
quantity: result.usage.promptTokens,
29+
unit: "input_tokens",
30+
},
31+
{
32+
quantity: result.usage.completionTokens,
33+
unit: "output_tokens",
34+
},
35+
],
36+
model_id: model,
37+
model_provider: provider,
38+
user_id: session.user.id,
39+
});
40+
}
41+
},
42+
});
1743

1844
return result.toDataStreamResponse({
1945
getErrorMessage(error) {

apps/web/src/app/api/supabase/embed-document/route.ts

Lines changed: 13 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@ import removeMarkdown from "remove-markdown";
1010
import type { z } from "zod/v4";
1111
import { model } from "~/ai/providers/openai/embedding";
1212
import { schema } from "~/components/editor/block-schema";
13-
import { embedder } from "~/trpc/server";
13+
import { api, embedder } from "~/trpc/server";
1414
import { handler } from "../_lib/webhook-handler";
1515

1616
const CHUNK_PARAMS: ChunkParams = {
@@ -102,14 +102,23 @@ export const POST = handler(zDocumentEmbeddingTask, async (payload) => {
102102
const mDocument =
103103
await MDocument.fromMarkdown(markdown).chunk(CHUNK_PARAMS);
104104

105-
// ! TODO: Here's where we get the usage tokens, we must process these in a different transaction
106-
// ! To guarantee that the usage is tracked regardless of the success of the embedding.
107-
const { embeddings, usage: _usage } = await embedMany({
105+
const { embeddings, usage } = await embedMany({
108106
maxRetries: 5,
109107
model,
110108
values: mDocument.map((chunk) => chunk.text),
111109
});
112110

111+
await api.usage.trackAiModelUsage({
112+
metadata: {
113+
document_id: document.id,
114+
model_version: model.specificationVersion,
115+
},
116+
metrics: [{ quantity: usage.tokens, unit: "tokens" }],
117+
model_id: model.modelId,
118+
model_provider: model.provider,
119+
user_id: document.user_id,
120+
});
121+
113122
const insertions: z.infer<typeof zInsertDocumentEmbedding>[] = [];
114123

115124
for (const [index, embedding] of embeddings.entries()) {

packages/api/src/api-router/index.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import { documentRouter } from "./document.js";
55
import { journalRouter } from "./journal.js";
66
import { notesRouter } from "./notes.js";
77
import { pagesRouter } from "./pages.js";
8+
import { usageRouter } from "./usage.js";
89

910
export const apiRouter = createTRPCRouter({
1011
auth: authRouter,
@@ -13,6 +14,7 @@ export const apiRouter = createTRPCRouter({
1314
journal: journalRouter,
1415
notes: notesRouter,
1516
pages: pagesRouter,
17+
usage: usageRouter,
1618
});
1719

1820
export type ApiRouter = typeof apiRouter;
Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,52 @@
1+
import { eq } from "@acme/db";
2+
import { UsageEvent, UsageEventStatus } from "@acme/db/schema";
3+
import { TRPCError, type TRPCRouterRecord } from "@trpc/server";
4+
import { z } from "zod/v4";
5+
import { publicProcedure } from "../trpc.js";
6+
7+
export const usageRouter = {
8+
trackAiModelUsage: publicProcedure
9+
.input(
10+
z.object({
11+
metadata: z.record(z.string(), z.string()).optional(),
12+
metrics: z.array(z.object({ quantity: z.number(), unit: z.string() })),
13+
model_id: z.string(),
14+
model_provider: z.string(),
15+
user_id: z.string(),
16+
}),
17+
)
18+
.mutation(async ({ ctx, input }) => {
19+
try {
20+
return await ctx.db.insert(UsageEvent).values(input);
21+
} catch (error) {
22+
console.error("Database error in usage.createUsageEvent:", error);
23+
throw new TRPCError({
24+
code: "INTERNAL_SERVER_ERROR",
25+
message: "Failed to create usage event",
26+
});
27+
}
28+
}),
29+
updateUsageEventStatus: publicProcedure
30+
.input(
31+
z.object({
32+
id: z.string(),
33+
status: z.enum(UsageEventStatus.enumValues),
34+
}),
35+
)
36+
.mutation(async ({ ctx, input }) => {
37+
try {
38+
return await ctx.db
39+
.update(UsageEvent)
40+
.set({
41+
status: input.status,
42+
})
43+
.where(eq(UsageEvent.id, input.id));
44+
} catch (error) {
45+
console.error("Database error in usage.processUsageEvent:", error);
46+
throw new TRPCError({
47+
code: "INTERNAL_SERVER_ERROR",
48+
message: "Failed to process usage event",
49+
});
50+
}
51+
}),
52+
} satisfies TRPCRouterRecord;

packages/db/src/schema.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,3 +9,4 @@ export * from "./core/document-embedding.schema.js";
99
export * from "./core/document-embedding-task.schema.js";
1010
export * from "./core/journal-entry.schema.js";
1111
export * from "./core/page.schema.js";
12+
export * from "./usage/usage-event.schema.js";
Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
import { jsonb, pgEnum, pgTable, text, timestamp } from "drizzle-orm/pg-core";
2+
import { user } from "../schema.js";
3+
4+
type UsageEventMetrics = {
5+
unit: string;
6+
quantity: number;
7+
};
8+
9+
export const UsageEventStatus = pgEnum("usage_event_status", [
10+
"pending",
11+
"processed",
12+
"failed",
13+
]);
14+
15+
export const UsageEvent = pgTable("usage_event", (t) => ({
16+
id: t.uuid().notNull().primaryKey().defaultRandom(),
17+
created_at: timestamp().defaultNow(),
18+
updated_at: timestamp()
19+
.defaultNow()
20+
.$onUpdateFn(() => new Date()),
21+
user_id: text()
22+
.notNull()
23+
.references(() => user.id),
24+
model_id: text().notNull(),
25+
model_provider: text().notNull(),
26+
metadata: jsonb(),
27+
metrics: jsonb().$type<UsageEventMetrics[]>().notNull(),
28+
status: UsageEventStatus().notNull().default("pending"),
29+
}));
30+
31+
export type UsageEvent = typeof UsageEvent.$inferSelect;

0 commit comments

Comments
 (0)