Skip to content

Commit 9fecede

Browse files
committed
Renamed llm_usage_v1 to llm_metrics_v1, added some additional fields to track as well
1 parent 463f972 commit 9fecede

File tree

13 files changed

+269
-105
lines changed

13 files changed

+269
-105
lines changed

.server-changes/llm-cost-tracking.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,4 +3,4 @@ area: webapp
33
type: feature
44
---
55

6-
Add automatic LLM cost calculation for spans with GenAI semantic conventions. When a span arrives with `gen_ai.response.model` and token usage data, costs are calculated from an in-memory pricing registry backed by Postgres and dual-written to both span attributes (`trigger.llm.*`) and a new `llm_usage_v1` ClickHouse table.
6+
Add automatic LLM cost calculation for spans with GenAI semantic conventions. When a span arrives with `gen_ai.response.model` and token usage data, costs are calculated from an in-memory pricing registry backed by Postgres and dual-written to both span attributes (`trigger.llm.*`) and a new `llm_metrics_v1` ClickHouse table that captures usage, cost, performance (TTFC, tokens/sec), and behavioral (finish reason, operation type) metrics.

apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.query/ExamplesContent.tsx

Lines changed: 12 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -126,25 +126,25 @@ LIMIT 100`,
126126
SUM(total_cost) AS total_cost,
127127
SUM(input_tokens) AS input_tokens,
128128
SUM(output_tokens) AS output_tokens
129-
FROM llm_usage
129+
FROM llm_metrics
130130
WHERE start_time > now() - INTERVAL 7 DAY
131131
GROUP BY response_model
132132
ORDER BY total_cost DESC`,
133133
scope: "environment",
134-
table: "llm_usage",
134+
table: "llm_metrics",
135135
},
136136
{
137137
title: "LLM cost over time",
138138
description: "Total LLM cost bucketed over time. The bucket size adjusts automatically.",
139139
query: `SELECT
140140
timeBucket(),
141141
SUM(total_cost) AS total_cost
142-
FROM llm_usage
142+
FROM llm_metrics
143143
GROUP BY timeBucket
144144
ORDER BY timeBucket
145145
LIMIT 1000`,
146146
scope: "environment",
147-
table: "llm_usage",
147+
table: "llm_metrics",
148148
},
149149
{
150150
title: "Most expensive runs by LLM cost (top 50)",
@@ -155,12 +155,12 @@ LIMIT 1000`,
155155
SUM(total_cost) AS llm_cost,
156156
SUM(input_tokens) AS input_tokens,
157157
SUM(output_tokens) AS output_tokens
158-
FROM llm_usage
158+
FROM llm_metrics
159159
GROUP BY run_id, task_identifier
160160
ORDER BY llm_cost DESC
161161
LIMIT 50`,
162162
scope: "environment",
163-
table: "llm_usage",
163+
table: "llm_metrics",
164164
},
165165
{
166166
title: "LLM calls by provider",
@@ -169,11 +169,11 @@ LIMIT 50`,
169169
gen_ai_system,
170170
count() AS call_count,
171171
SUM(total_cost) AS total_cost
172-
FROM llm_usage
172+
FROM llm_metrics
173173
GROUP BY gen_ai_system
174174
ORDER BY total_cost DESC`,
175175
scope: "environment",
176-
table: "llm_usage",
176+
table: "llm_metrics",
177177
},
178178
{
179179
title: "LLM cost by user",
@@ -184,13 +184,13 @@ ORDER BY total_cost DESC`,
184184
SUM(total_cost) AS total_cost,
185185
SUM(total_tokens) AS total_tokens,
186186
count() AS call_count
187-
FROM llm_usage
187+
FROM llm_metrics
188188
WHERE metadata.userId != ''
189189
GROUP BY metadata.userId
190190
ORDER BY total_cost DESC
191191
LIMIT 50`,
192192
scope: "environment",
193-
table: "llm_usage",
193+
table: "llm_metrics",
194194
},
195195
{
196196
title: "LLM cost by metadata key",
@@ -202,11 +202,11 @@ LIMIT 50`,
202202
total_cost,
203203
total_tokens,
204204
run_id
205-
FROM llm_usage
205+
FROM llm_metrics
206206
ORDER BY start_time DESC
207207
LIMIT 20`,
208208
scope: "environment",
209-
table: "llm_usage",
209+
table: "llm_metrics",
210210
},
211211
];
212212

apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.test.tasks.$taskParam/route.tsx

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -120,6 +120,12 @@ export const loader = async ({ request, params }: LoaderFunctionArgs) => {
120120

121121
return typedjson({ ...result, regions: regionsResult.regions });
122122
} catch (error) {
123+
logger.error("Failed to load test page", {
124+
taskParam,
125+
error: error instanceof Error ? error.message : error,
126+
stack: error instanceof Error ? error.stack : undefined,
127+
});
128+
123129
return redirectWithErrorMessage(
124130
v3TestPath({ slug: organizationSlug }, { slug: projectParam }, environment),
125131
request,

apps/webapp/app/v3/eventRepository/clickhouseEventRepository.server.ts

Lines changed: 36 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import type {
22
ClickHouse,
3-
LlmUsageV1Input,
3+
LlmMetricsV1Input,
44
TaskEventDetailedSummaryV1Result,
55
TaskEventDetailsV1Result,
66
TaskEventSummaryV1Result,
@@ -96,7 +96,7 @@ export class ClickhouseEventRepository implements IEventRepository {
9696
private _clickhouse: ClickHouse;
9797
private _config: ClickhouseEventRepositoryConfig;
9898
private readonly _flushScheduler: DynamicFlushScheduler<TaskEventV1Input | TaskEventV2Input>;
99-
private readonly _llmUsageFlushScheduler: DynamicFlushScheduler<LlmUsageV1Input>;
99+
private readonly _llmMetricsFlushScheduler: DynamicFlushScheduler<LlmMetricsV1Input>;
100100
private _tracer: Tracer;
101101
private _version: "v1" | "v2";
102102

@@ -122,10 +122,10 @@ export class ClickhouseEventRepository implements IEventRepository {
122122
},
123123
});
124124

125-
this._llmUsageFlushScheduler = new DynamicFlushScheduler({
125+
this._llmMetricsFlushScheduler = new DynamicFlushScheduler({
126126
batchSize: 5000,
127127
flushInterval: 2000,
128-
callback: this.#flushLlmUsageBatch.bind(this),
128+
callback: this.#flushLlmMetricsBatch.bind(this),
129129
minConcurrency: 1,
130130
maxConcurrency: 2,
131131
maxBatchSize: 10000,
@@ -230,9 +230,9 @@ export class ClickhouseEventRepository implements IEventRepository {
230230
});
231231
}
232232

233-
async #flushLlmUsageBatch(flushId: string, rows: LlmUsageV1Input[]) {
233+
async #flushLlmMetricsBatch(flushId: string, rows: LlmMetricsV1Input[]) {
234234

235-
const [insertError] = await this._clickhouse.llmUsage.insert(rows, {
235+
const [insertError] = await this._clickhouse.llmMetrics.insert(rows, {
236236
params: {
237237
clickhouse_settings: this.#getClickhouseInsertSettings(),
238238
},
@@ -242,13 +242,13 @@ export class ClickhouseEventRepository implements IEventRepository {
242242
throw insertError;
243243
}
244244

245-
logger.info("ClickhouseEventRepository.flushLlmUsageBatch Inserted LLM usage batch", {
245+
logger.info("ClickhouseEventRepository.flushLlmMetricsBatch Inserted LLM metrics batch", {
246246
rows: rows.length,
247247
});
248248
}
249249

250-
#createLlmUsageInput(event: CreateEventInput): LlmUsageV1Input {
251-
const llmUsage = event._llmUsage!;
250+
#createLlmMetricsInput(event: CreateEventInput): LlmMetricsV1Input {
251+
const llmMetrics = event._llmMetrics!;
252252

253253
return {
254254
organization_id: event.organizationId,
@@ -258,22 +258,27 @@ export class ClickhouseEventRepository implements IEventRepository {
258258
task_identifier: event.taskSlug,
259259
trace_id: event.traceId,
260260
span_id: event.spanId,
261-
gen_ai_system: llmUsage.genAiSystem,
262-
request_model: llmUsage.requestModel,
263-
response_model: llmUsage.responseModel,
264-
matched_model_id: llmUsage.matchedModelId,
265-
operation_name: llmUsage.operationName,
266-
pricing_tier_id: llmUsage.pricingTierId,
267-
pricing_tier_name: llmUsage.pricingTierName,
268-
input_tokens: llmUsage.inputTokens,
269-
output_tokens: llmUsage.outputTokens,
270-
total_tokens: llmUsage.totalTokens,
271-
usage_details: llmUsage.usageDetails,
272-
input_cost: llmUsage.inputCost,
273-
output_cost: llmUsage.outputCost,
274-
total_cost: llmUsage.totalCost,
275-
cost_details: llmUsage.costDetails,
276-
metadata: llmUsage.metadata,
261+
gen_ai_system: llmMetrics.genAiSystem,
262+
request_model: llmMetrics.requestModel,
263+
response_model: llmMetrics.responseModel,
264+
matched_model_id: llmMetrics.matchedModelId,
265+
operation_id: llmMetrics.operationId,
266+
finish_reason: llmMetrics.finishReason,
267+
cost_source: llmMetrics.costSource,
268+
pricing_tier_id: llmMetrics.pricingTierId,
269+
pricing_tier_name: llmMetrics.pricingTierName,
270+
input_tokens: llmMetrics.inputTokens,
271+
output_tokens: llmMetrics.outputTokens,
272+
total_tokens: llmMetrics.totalTokens,
273+
usage_details: llmMetrics.usageDetails,
274+
input_cost: llmMetrics.inputCost,
275+
output_cost: llmMetrics.outputCost,
276+
total_cost: llmMetrics.totalCost,
277+
cost_details: llmMetrics.costDetails,
278+
provider_cost: llmMetrics.providerCost,
279+
ms_to_first_chunk: llmMetrics.msToFirstChunk,
280+
tokens_per_second: llmMetrics.tokensPerSecond,
281+
metadata: llmMetrics.metadata,
277282
start_time: this.#clampAndFormatStartTime(event.startTime.toString()),
278283
duration: formatClickhouseUnsignedIntegerString(event.duration ?? 0),
279284
};
@@ -300,13 +305,13 @@ export class ClickhouseEventRepository implements IEventRepository {
300305
async insertMany(events: CreateEventInput[]): Promise<void> {
301306
this.addToBatch(events.flatMap((event) => this.createEventToTaskEventV1Input(event)));
302307

303-
// Dual-write LLM usage records for spans with cost enrichment
304-
const llmUsageRows = events
305-
.filter((e) => e._llmUsage != null)
306-
.map((e) => this.#createLlmUsageInput(e));
308+
// Dual-write LLM metrics records for spans with cost enrichment
309+
const llmMetricsRows = events
310+
.filter((e) => e._llmMetrics != null)
311+
.map((e) => this.#createLlmMetricsInput(e));
307312

308-
if (llmUsageRows.length > 0) {
309-
this._llmUsageFlushScheduler.addToBatch(llmUsageRows);
313+
if (llmMetricsRows.length > 0) {
314+
this._llmMetricsFlushScheduler.addToBatch(llmMetricsRows);
310315
}
311316
}
312317

apps/webapp/app/v3/eventRepository/eventRepository.types.ts

Lines changed: 8 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -21,12 +21,14 @@ export type { ExceptionEventProperties };
2121
// Event Creation Types
2222
// ============================================================================
2323

24-
export type LlmUsageData = {
24+
export type LlmMetricsData = {
2525
genAiSystem: string;
2626
requestModel: string;
2727
responseModel: string;
2828
matchedModelId: string;
29-
operationName: string;
29+
operationId: string;
30+
finishReason: string;
31+
costSource: string;
3032
pricingTierId: string;
3133
pricingTierName: string;
3234
inputTokens: number;
@@ -37,6 +39,9 @@ export type LlmUsageData = {
3739
outputCost: number;
3840
totalCost: number;
3941
costDetails: Record<string, number>;
42+
providerCost: number;
43+
msToFirstChunk: number;
44+
tokensPerSecond: number;
4045
metadata: Record<string, string>;
4146
};
4247

@@ -78,7 +83,7 @@ export type CreateEventInput = Omit<
7883
machineId?: string;
7984
runTags?: string[];
8085
/** Side-channel data for LLM cost tracking, populated by enrichCreatableEvents */
81-
_llmUsage?: LlmUsageData;
86+
_llmMetrics?: LlmMetricsData;
8287
};
8388

8489
export type CreatableEventKind = TaskEventKind;

apps/webapp/app/v3/querySchemas.ts

Lines changed: 49 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -600,12 +600,12 @@ export const metricsSchema: TableSchema = {
600600
* All available schemas for the query editor
601601
*/
602602
/**
603-
* Schema definition for the llm_usage table (trigger_dev.llm_usage_v1)
603+
* Schema definition for the llm_metrics table (trigger_dev.llm_metrics_v1)
604604
*/
605-
export const llmUsageSchema: TableSchema = {
606-
name: "llm_usage",
607-
clickhouseName: "trigger_dev.llm_usage_v1",
608-
description: "LLM token usage and cost data from GenAI spans",
605+
export const llmMetricsSchema: TableSchema = {
606+
name: "llm_metrics",
607+
clickhouseName: "trigger_dev.llm_metrics_v1",
608+
description: "LLM metrics: token usage, cost, performance, and behavior from GenAI spans",
609609
timeConstraint: "start_time",
610610
tenantColumns: {
611611
organizationId: "organization_id",
@@ -669,11 +669,26 @@ export const llmUsageSchema: TableSchema = {
669669
coreColumn: true,
670670
}),
671671
},
672-
operation_name: {
673-
name: "operation_name",
672+
operation_id: {
673+
name: "operation_id",
674674
...column("LowCardinality(String)", {
675-
description: "Operation type (e.g. chat, completion)",
676-
example: "chat",
675+
description: "Operation type (e.g. ai.streamText.doStream, ai.generateText.doGenerate)",
676+
example: "ai.streamText.doStream",
677+
}),
678+
},
679+
finish_reason: {
680+
name: "finish_reason",
681+
...column("LowCardinality(String)", {
682+
description: "Why the LLM stopped generating (e.g. stop, tool-calls, length)",
683+
example: "stop",
684+
coreColumn: true,
685+
}),
686+
},
687+
cost_source: {
688+
name: "cost_source",
689+
...column("LowCardinality(String)", {
690+
description: "Where cost data came from (registry, gateway, openrouter)",
691+
example: "registry",
677692
}),
678693
},
679694
input_tokens: {
@@ -700,14 +715,14 @@ export const llmUsageSchema: TableSchema = {
700715
input_cost: {
701716
name: "input_cost",
702717
...column("Decimal64(12)", {
703-
description: "Input cost in USD",
718+
description: "Input cost in USD (from pricing registry)",
704719
customRenderType: "costInDollars",
705720
}),
706721
},
707722
output_cost: {
708723
name: "output_cost",
709724
...column("Decimal64(12)", {
710-
description: "Output cost in USD",
725+
description: "Output cost in USD (from pricing registry)",
711726
customRenderType: "costInDollars",
712727
}),
713728
},
@@ -719,6 +734,28 @@ export const llmUsageSchema: TableSchema = {
719734
coreColumn: true,
720735
}),
721736
},
737+
provider_cost: {
738+
name: "provider_cost",
739+
...column("Decimal64(12)", {
740+
description: "Provider-reported cost in USD (from gateway or openrouter)",
741+
customRenderType: "costInDollars",
742+
}),
743+
},
744+
ms_to_first_chunk: {
745+
name: "ms_to_first_chunk",
746+
...column("Float64", {
747+
description: "Time to first chunk in milliseconds (TTFC)",
748+
example: "245.3",
749+
coreColumn: true,
750+
}),
751+
},
752+
tokens_per_second: {
753+
name: "tokens_per_second",
754+
...column("Float64", {
755+
description: "Average output tokens per second",
756+
example: "72.5",
757+
}),
758+
},
722759
pricing_tier_name: {
723760
name: "pricing_tier_name",
724761
...column("LowCardinality(String)", {
@@ -751,7 +788,7 @@ export const llmUsageSchema: TableSchema = {
751788
},
752789
};
753790

754-
export const querySchemas: TableSchema[] = [runsSchema, metricsSchema, llmUsageSchema];
791+
export const querySchemas: TableSchema[] = [runsSchema, metricsSchema, llmMetricsSchema];
755792

756793
/**
757794
* Default query for the query editor

0 commit comments

Comments
 (0)