Skip to content

Commit 93309c3

Browse files
committed
feat: add metadata map to llm_usage_v1 for cost attribution by user/tenant
1 parent c4047a5 commit 93309c3

File tree

11 files changed

+151
-3
lines changed

11 files changed

+151
-3
lines changed
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"@trigger.dev/core": patch
3+
---
4+
5+
Propagate run tags to span attributes so they can be extracted server-side for LLM cost attribution metadata.

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

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -175,6 +175,39 @@ ORDER BY total_cost DESC`,
175175
scope: "environment",
176176
table: "llm_usage",
177177
},
178+
{
179+
title: "LLM cost by user",
180+
description:
181+
"Total LLM cost per user from run tags or AI SDK telemetry metadata. Uses metadata.userId which comes from experimental_telemetry metadata or run tags like user:123.",
182+
query: `SELECT
183+
metadata.userId AS user_id,
184+
SUM(total_cost) AS total_cost,
185+
SUM(total_tokens) AS total_tokens,
186+
count() AS call_count
187+
FROM llm_usage
188+
WHERE metadata.userId != ''
189+
GROUP BY metadata.userId
190+
ORDER BY total_cost DESC
191+
LIMIT 50`,
192+
scope: "environment",
193+
table: "llm_usage",
194+
},
195+
{
196+
title: "LLM cost by metadata key",
197+
description:
198+
"Browse all metadata keys and their LLM cost. Metadata comes from run tags (key:value) and AI SDK telemetry metadata.",
199+
query: `SELECT
200+
metadata,
201+
response_model,
202+
total_cost,
203+
total_tokens,
204+
run_id
205+
FROM llm_usage
206+
ORDER BY start_time DESC
207+
LIMIT 20`,
208+
scope: "environment",
209+
table: "llm_usage",
210+
},
178211
];
179212

180213
const tableOptions = querySchemas.map((s) => ({ label: s.name, value: s.name }));

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

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -279,6 +279,7 @@ export class ClickhouseEventRepository implements IEventRepository {
279279
output_cost: llmUsage.outputCost,
280280
total_cost: llmUsage.totalCost,
281281
cost_details: llmUsage.costDetails,
282+
metadata: llmUsage.metadata,
282283
start_time: this.#clampAndFormatStartTime(event.startTime.toString()),
283284
duration: formatClickhouseUnsignedIntegerString(event.duration ?? 0),
284285
};
@@ -311,7 +312,7 @@ export class ClickhouseEventRepository implements IEventRepository {
311312
.map((e) => this.#createLlmUsageInput(e));
312313

313314
if (llmUsageRows.length > 0) {
314-
crumb("queuing llm usage rows", { count: llmUsageRows.length, firstRunId: llmUsageRows[0]?.run_id }); // @crumbs
315+
crumb("queuing llm usage rows", { count: llmUsageRows.length, firstRunId: llmUsageRows[0]?.run_id, metadataKeys: llmUsageRows.map((r) => Object.keys(r.metadata)) }); // @crumbs
315316
this._llmUsageFlushScheduler.addToBatch(llmUsageRows);
316317
}
317318
}

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

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,7 @@ export type LlmUsageData = {
3737
outputCost: number;
3838
totalCost: number;
3939
costDetails: Record<string, number>;
40+
metadata: Record<string, string>;
4041
};
4142

4243
export type CreateEventInput = Omit<
@@ -75,6 +76,7 @@ export type CreateEventInput = Omit<
7576
metadata: Attributes | undefined;
7677
style: Attributes | undefined;
7778
machineId?: string;
79+
runTags?: string[];
7880
/** Side-channel data for LLM cost tracking, populated by enrichCreatableEvents */
7981
_llmUsage?: LlmUsageData;
8082
};

apps/webapp/app/v3/otlpExporter.server.ts

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,8 @@ import type {
3838
import { startSpan } from "./tracing.server";
3939
import { enrichCreatableEvents } from "./utils/enrichCreatableEvents.server";
4040
import "./llmPricingRegistry.server"; // Initialize LLM pricing registry on startup
41+
import { trail } from "agentcrumbs"; // @crumbs
42+
const crumbOtlp = trail("webapp:otlp-exporter"); // @crumbs
4143
import { env } from "~/env.server";
4244
import { detectBadJsonStrings } from "~/utils/detectBadJsonStrings";
4345
import { singleton } from "~/utils/singleton";
@@ -392,6 +394,9 @@ function convertSpansToCreateableEvents(
392394
SemanticInternalAttributes.METADATA
393395
);
394396

397+
const runTags = extractArrayAttribute(span.attributes ?? [], SemanticInternalAttributes.RUN_TAGS);
398+
if (runTags && runTags.length > 0) { crumbOtlp("extracted runTags from span", { runTags, spanId: binaryToHex(span.spanId) }); } // @crumbs
399+
395400
const properties =
396401
truncateAttributes(
397402
convertKeyValueItemsToMap(span.attributes ?? [], [], undefined, [
@@ -440,6 +445,7 @@ function convertSpansToCreateableEvents(
440445
runId: spanProperties.runId ?? resourceProperties.runId ?? "unknown",
441446
taskSlug: spanProperties.taskSlug ?? resourceProperties.taskSlug ?? "unknown",
442447
machineId: spanProperties.machineId ?? resourceProperties.machineId,
448+
runTags,
443449
attemptNumber:
444450
extractNumberAttribute(
445451
span.attributes ?? [],
@@ -1001,6 +1007,21 @@ function extractBooleanAttribute(
10011007
return isBoolValue(attribute?.value) ? attribute.value.boolValue : fallback;
10021008
}
10031009

1010+
function extractArrayAttribute(
1011+
attributes: KeyValue[],
1012+
name: string | Array<string | undefined>
1013+
): string[] | undefined {
1014+
const key = Array.isArray(name) ? name.filter(Boolean).join(".") : name;
1015+
1016+
const attribute = attributes.find((attribute) => attribute.key === key);
1017+
1018+
if (!attribute?.value?.arrayValue?.values) return undefined;
1019+
1020+
return attribute.value.arrayValue.values
1021+
.filter((v): v is { stringValue: string } => isStringValue(v))
1022+
.map((v) => v.stringValue);
1023+
}
1024+
10041025
function isPartialSpan(span: Span): boolean {
10051026
if (!span.attributes) return false;
10061027

apps/webapp/app/v3/querySchemas.ts

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -740,6 +740,15 @@ export const llmUsageSchema: TableSchema = {
740740
customRenderType: "durationNs",
741741
}),
742742
},
743+
metadata: {
744+
name: "metadata",
745+
...column("Map(LowCardinality(String), String)", {
746+
description:
747+
"Key-value metadata from run tags (key:value format) and AI SDK telemetry metadata. Access keys with dot notation (metadata.userId) or bracket syntax (metadata['userId']).",
748+
example: "{'userId':'user_123','org':'acme'}",
749+
coreColumn: true,
750+
}),
751+
},
743752
},
744753
};
745754

apps/webapp/app/v3/utils/enrichCreatableEvents.server.ts

Lines changed: 52 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -48,7 +48,24 @@ function enrichLlmCost(event: CreateEventInput): void {
4848
const props = event.properties;
4949
if (!props) return;
5050

51-
crumb("enrichLlmCost called", { kind: event.kind, isPartial: event.isPartial, spanId: event.spanId, message: event.message, props }); // @crumbs
51+
// #region @crumbs
52+
// Log all spans (not just gen_ai) that have conversation/chat/session/user context
53+
if (!event.isPartial) {
54+
const contextKeys = Object.entries(props).filter(([k]) =>
55+
k.startsWith("ai.telemetry.") || k.startsWith("gen_ai.conversation") ||
56+
k.startsWith("chat.") || k.includes("session") || k.includes("user")
57+
);
58+
if (contextKeys.length > 0) {
59+
crumb("span with context", {
60+
spanId: event.spanId,
61+
parentId: event.parentId,
62+
runId: event.runId,
63+
message: event.message,
64+
contextAttrs: Object.fromEntries(contextKeys),
65+
});
66+
}
67+
}
68+
// #endregion @crumbs
5269

5370
// Only enrich span-like events (INTERNAL, SERVER, CLIENT, CONSUMER, PRODUCER — not LOG, UNSPECIFIED)
5471
const enrichableKinds = new Set(["INTERNAL", "SERVER", "CLIENT", "CONSUMER", "PRODUCER"]);
@@ -130,6 +147,39 @@ function enrichLlmCost(event: CreateEventInput): void {
130147
},
131148
};
132149

150+
// Build metadata map from run tags and ai.telemetry.metadata.*
151+
const metadata: Record<string, string> = {};
152+
153+
if (event.runTags) {
154+
for (const tag of event.runTags) {
155+
const colonIdx = tag.indexOf(":");
156+
if (colonIdx > 0) {
157+
metadata[tag.substring(0, colonIdx)] = tag.substring(colonIdx + 1);
158+
}
159+
}
160+
}
161+
162+
for (const [key, value] of Object.entries(props)) {
163+
if (key.startsWith("ai.telemetry.metadata.") && typeof value === "string") {
164+
metadata[key.slice("ai.telemetry.metadata.".length)] = value;
165+
}
166+
}
167+
168+
// #region @crumbs
169+
const metadataKeyCount = Object.keys(metadata).length;
170+
if (metadataKeyCount > 0) {
171+
crumb("llm metadata built", {
172+
spanId: event.spanId,
173+
runId: event.runId,
174+
responseModel,
175+
metadataKeyCount,
176+
metadataKeys: Object.keys(metadata),
177+
fromRunTags: event.runTags?.length ?? 0,
178+
fromTelemetry: metadataKeyCount - (event.runTags?.filter((t) => t.includes(":")).length ?? 0),
179+
});
180+
}
181+
// #endregion @crumbs
182+
133183
// Set _llmUsage side-channel for dual-write to llm_usage_v1
134184
const llmUsage: LlmUsageData = {
135185
genAiSystem: (props["gen_ai.system"] as string) ?? "unknown",
@@ -147,6 +197,7 @@ function enrichLlmCost(event: CreateEventInput): void {
147197
outputCost: cost.outputCost,
148198
totalCost: cost.totalCost,
149199
costDetails: cost.costDetails,
200+
metadata,
150201
};
151202

152203
event._llmUsage = llmUsage;

internal-packages/clickhouse/schema/024_create_llm_usage_v1.sql

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -27,13 +27,16 @@ CREATE TABLE IF NOT EXISTS trigger_dev.llm_usage_v1
2727
total_cost Decimal64(12) DEFAULT 0,
2828
cost_details Map(LowCardinality(String), Decimal64(12)),
2929

30+
metadata Map(LowCardinality(String), String),
31+
3032
start_time DateTime64(9) CODEC(Delta(8), ZSTD(1)),
3133
duration UInt64 DEFAULT 0 CODEC(ZSTD(1)),
3234
inserted_at DateTime64(3) DEFAULT now64(3),
3335

3436
INDEX idx_run_id run_id TYPE bloom_filter(0.001) GRANULARITY 1,
3537
INDEX idx_span_id span_id TYPE bloom_filter(0.001) GRANULARITY 1,
36-
INDEX idx_response_model response_model TYPE bloom_filter(0.01) GRANULARITY 1
38+
INDEX idx_response_model response_model TYPE bloom_filter(0.01) GRANULARITY 1,
39+
INDEX idx_metadata_keys mapKeys(metadata) TYPE bloom_filter(0.01) GRANULARITY 1
3740
)
3841
ENGINE = MergeTree
3942
PARTITION BY toDate(inserted_at)

internal-packages/clickhouse/src/llmUsage.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,8 @@ export const LlmUsageV1Input = z.object({
2828
total_cost: z.number(),
2929
cost_details: z.record(z.string(), z.number()),
3030

31+
metadata: z.record(z.string(), z.string()),
32+
3133
start_time: z.string(),
3234
duration: z.string(),
3335
});

internal-packages/tsql/src/query/printer.ts

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2370,6 +2370,21 @@ export class ClickHousePrinter {
23702370
// Try to resolve column names through table context
23712371
const resolvedChain = this.resolveFieldChain(chainWithPrefix);
23722372

2373+
// For Map columns, convert dot-notation to bracket syntax:
2374+
// metadata.user -> metadata['user']
2375+
if (resolvedChain.length > 1) {
2376+
const rootColumnSchema = this.resolveFieldToColumnSchema([node.chain[0]]);
2377+
if (rootColumnSchema?.type.startsWith("Map(")) {
2378+
const rootCol = this.printIdentifierOrIndex(resolvedChain[0]);
2379+
const mapKeys = resolvedChain.slice(1);
2380+
let result = rootCol;
2381+
for (const key of mapKeys) {
2382+
result = `${result}[${this.context.addValue(String(key))}]`;
2383+
}
2384+
return result;
2385+
}
2386+
}
2387+
23732388
// Print each chain element
23742389
let result = resolvedChain.map((part) => this.printIdentifierOrIndex(part)).join(".");
23752390

0 commit comments

Comments
 (0)