Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
30 commits
Select commit Hold shift + click to select a range
8944f6b
feat: add automatic LLM cost tracking for GenAI spans
ericallam Mar 11, 2026
3f77222
feat: add friendlyId to LlmModel, TRQL llm_usage integration, and see…
ericallam Mar 11, 2026
9d5c204
feat: add metadata map to llm_usage_v1 for cost attribution by user/t…
ericallam Mar 11, 2026
72c1879
New AI streamText span sidebar
ericallam Mar 12, 2026
a5b84cf
A bunch of improvements around extracting AI data
ericallam Mar 13, 2026
74fa6cb
Fixed a bunch of coderabbit issues
ericallam Mar 13, 2026
8030da7
fix some typescript errors
ericallam Mar 13, 2026
2468dbc
some fixes
ericallam Mar 13, 2026
9bceee3
A bunch of fixes
ericallam Mar 13, 2026
7db982a
Copy raw properties footer behind isAdmin
samejr Mar 13, 2026
83c4190
Nicer Tools tab icon and tabs scroll behaviour
samejr Mar 13, 2026
afdd619
Display details as a property table instead of pills
samejr Mar 13, 2026
4679645
Use proper paragraph component
samejr Mar 13, 2026
aa83167
Style improvements to the messages
samejr Mar 13, 2026
e9f13b9
Renamed llm_usage_v1 to llm_metrics_v1, added some additional fields …
ericallam Mar 14, 2026
5c7daeb
Create AI/LLM metrics dashboard, with support for a model filter
ericallam Mar 15, 2026
4092b70
Lots more example content in the spans seed
samejr Mar 17, 2026
b5d7994
New model logos
samejr Mar 17, 2026
3a49af8
Default wider side panel to better view markdown
samejr Mar 17, 2026
c322ee7
Render JSON object output in our code block component rather than as …
samejr Mar 17, 2026
a262c7a
Use streamdown for system prompts
samejr Mar 17, 2026
e372fa0
toolCall doesn’t get wrapped in chat div
samejr Mar 17, 2026
e4ae511
fixed failing tests because of icon name changes
ericallam Mar 17, 2026
d5a32be
strip crumbs
ericallam Mar 17, 2026
9e2b578
give the llm model registry a short window to become ready when the s…
ericallam Mar 17, 2026
c7d2706
A few comments
ericallam Mar 17, 2026
6e230f5
Add env vars for configuring the llm_metrics_v1 flush scheudler settings
ericallam Mar 17, 2026
430c0e0
as string bad
ericallam Mar 17, 2026
0ea16d4
register default model prices
ericallam Mar 17, 2026
2af8a1b
add llm_metrics to the get_query_schema tool desc
ericallam Mar 17, 2026
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
5 changes: 5 additions & 0 deletions .changeset/llm-metadata-run-tags.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@trigger.dev/core": patch
---

Propagate run tags to span attributes so they can be extracted server-side for LLM cost attribution metadata.
6 changes: 6 additions & 0 deletions .server-changes/llm-cost-tracking.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
---
area: webapp
type: feature
---

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.
177 changes: 177 additions & 0 deletions apps/webapp/app/assets/icons/AiProviderIcons.tsx

Large diffs are not rendered by default.

12 changes: 12 additions & 0 deletions apps/webapp/app/assets/icons/AnthropicLogoIcon.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
export function AnthropicLogoIcon({ className }: { className?: string }) {
return (
<svg
className={className}
viewBox="0 0 24 24"
fill="currentColor"
xmlns="http://www.w3.org/2000/svg"
>
<path d="M17.3041 3.541h-3.6718l6.696 16.918H24Zm-10.6082 0L0 20.459h3.7442l1.3693-3.5527h7.0052l1.3693 3.5528h3.7442L10.5363 3.5409Zm-.3712 10.2232 2.2914-5.9456 2.2914 5.9456Z" />
</svg>
);
}
10 changes: 10 additions & 0 deletions apps/webapp/app/components/code/QueryResultsChart.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -1209,9 +1209,19 @@ function createYAxisFormatter(
formatDurationMilliseconds(value * 1000, { style: "short" });
}

if (format === "durationNs") {
return (value: number): string =>
formatDurationMilliseconds(value / 1_000_000, { style: "short" });
}

if (format === "costInDollars" || format === "cost") {
return (value: number): string => {
const dollars = format === "cost" ? value / 100 : value;
if (dollars === 0) return "$0";
if (Math.abs(dollars) >= 1000) return `$${(dollars / 1000).toFixed(1)}K`;
if (Math.abs(dollars) >= 1) return `$${dollars.toFixed(2)}`;
if (Math.abs(dollars) >= 0.01) return `$${dollars.toFixed(4)}`;
if (Math.abs(dollars) >= 0.0001) return `$${dollars.toFixed(6)}`;
return formatCurrencyAccurate(dollars);
};
}
Expand Down
20 changes: 20 additions & 0 deletions apps/webapp/app/components/code/TSQLResultsTable.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -81,6 +81,11 @@ function getFormattedValue(value: unknown, column: OutputColumnMetadata): string
return formatDurationMilliseconds(value * 1000, { style: "short" });
}
break;
case "durationNs":
if (typeof value === "number") {
return formatDurationMilliseconds(value / 1_000_000, { style: "short" });
}
break;
case "cost":
if (typeof value === "number") {
return formatCurrencyAccurate(value / 100);
Expand Down Expand Up @@ -282,6 +287,12 @@ function getDisplayLength(value: unknown, column: OutputColumnMetadata): number
return formatted.length;
}
return 10;
case "durationNs":
if (typeof value === "number") {
const formatted = formatDurationMilliseconds(value / 1_000_000, { style: "short" });
return formatted.length;
}
return 10;
case "cost":
case "costInDollars":
// Currency format: "$1,234.56"
Expand Down Expand Up @@ -598,6 +609,15 @@ function CellValue({
);
}
return <span>{String(value)}</span>;
case "durationNs":
if (typeof value === "number") {
return (
<span className="tabular-nums">
{formatDurationMilliseconds(value / 1_000_000, { style: "short" })}
</span>
);
}
return <span>{String(value)}</span>;
case "cost":
if (typeof value === "number") {
return <span className="tabular-nums">{formatCurrencyAccurate(value / 100)}</span>;
Expand Down
159 changes: 159 additions & 0 deletions apps/webapp/app/components/metrics/ModelsFilter.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,159 @@
import { CubeIcon } from "@heroicons/react/20/solid";
import * as Ariakit from "@ariakit/react";
import { type ReactNode, useMemo } from "react";
import { AppliedFilter } from "~/components/primitives/AppliedFilter";
import {
ComboBox,
SelectItem,
SelectList,
SelectPopover,
SelectProvider,
SelectTrigger,
} from "~/components/primitives/Select";
import { useSearchParams } from "~/hooks/useSearchParam";
import { appliedSummary, FilterMenuProvider } from "~/components/runs/v3/SharedFilters";
import { tablerIcons } from "~/utils/tablerIcons";
import tablerSpritePath from "~/components/primitives/tabler-sprite.svg";
import { AnthropicLogoIcon } from "~/assets/icons/AnthropicLogoIcon";

const shortcut = { key: "m" };

export type ModelOption = {
model: string;
system: string;
};

interface ModelsFilterProps {
possibleModels: ModelOption[];
}

function modelIcon(system: string, model: string): ReactNode {
// For gateway/openrouter, derive provider from model prefix
let provider = system.split(".")[0];
if (provider === "gateway" || provider === "openrouter") {
if (model.includes("/")) {
provider = model.split("/")[0].replace(/-/g, "");
}
}

// Special case: Anthropic uses a custom SVG icon
if (provider === "anthropic") {
return <AnthropicLogoIcon className="size-4 shrink-0" />;
}

const iconName = `tabler-brand-${provider}`;
if (tablerIcons.has(iconName)) {
return (
<svg className="size-4 shrink-0 stroke-[1.5]">
<use xlinkHref={`${tablerSpritePath}#${iconName}`} />
</svg>
);
}

return <CubeIcon className="size-4 shrink-0" />;
}

export function ModelsFilter({ possibleModels }: ModelsFilterProps) {
const { values, replace, del } = useSearchParams();
const selectedModels = values("models");

if (selectedModels.length === 0 || selectedModels.every((v) => v === "")) {
return (
<FilterMenuProvider>
{(search, setSearch) => (
<ModelsDropdown
trigger={
<SelectTrigger
icon={<CubeIcon className="size-4" />}
variant="secondary/small"
shortcut={shortcut}
tooltipTitle="Filter by model"
>
<span className="ml-0.5">Models</span>
</SelectTrigger>
}
searchValue={search}
clearSearchValue={() => setSearch("")}
possibleModels={possibleModels}
/>
)}
</FilterMenuProvider>
);
}

return (
<FilterMenuProvider>
{(search, setSearch) => (
<ModelsDropdown
trigger={
<Ariakit.Select render={<div className="group cursor-pointer focus-custom" />}>
<AppliedFilter
label="Model"
icon={<CubeIcon className="size-4" />}
value={appliedSummary(selectedModels)}
onRemove={() => del(["models"])}
variant="secondary/small"
/>
</Ariakit.Select>
}
searchValue={search}
clearSearchValue={() => setSearch("")}
possibleModels={possibleModels}
/>
)}
</FilterMenuProvider>
);
}

function ModelsDropdown({
trigger,
clearSearchValue,
searchValue,
onClose,
possibleModels,
}: {
trigger: ReactNode;
clearSearchValue: () => void;
searchValue: string;
onClose?: () => void;
possibleModels: ModelOption[];
}) {
const { values, replace } = useSearchParams();

const handleChange = (values: string[]) => {
clearSearchValue();
replace({ models: values });
};

const filtered = useMemo(() => {
return possibleModels.filter((m) => {
return m.model?.toLowerCase().includes(searchValue.toLowerCase());
});
}, [searchValue, possibleModels]);

return (
<SelectProvider value={values("models")} setValue={handleChange} virtualFocus={true}>
{trigger}
<SelectPopover
className="min-w-0 max-w-[min(360px,var(--popover-available-width))]"
hideOnEscape={() => {
if (onClose) {
onClose();
return false;
}
return true;
}}
>
<ComboBox placeholder="Filter by model..." value={searchValue} />
<SelectList>
{filtered.map((m) => (
<SelectItem key={m.model} value={m.model} icon={modelIcon(m.system, m.model)}>
{m.model}
</SelectItem>
))}
{filtered.length === 0 && <SelectItem disabled>No models found</SelectItem>}
</SelectList>
</SelectPopover>
</SelectProvider>
);
}
40 changes: 40 additions & 0 deletions apps/webapp/app/components/runs/v3/RunIcon.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,10 +3,25 @@ import {
HandRaisedIcon,
InformationCircleIcon,
RectangleStackIcon,
SparklesIcon,
Squares2X2Icon,
TableCellsIcon,
TagIcon,
WrenchIcon,
} from "@heroicons/react/20/solid";
import { AnthropicLogoIcon } from "~/assets/icons/AnthropicLogoIcon";
import {
AnthropicIcon,
AzureIcon,
CerebrasIcon,
DeepseekIcon,
GeminiIcon,
LlamaIcon,
MistralIcon,
OpenAIIcon,
PerplexityIcon,
XAIIcon,
} from "~/assets/icons/AiProviderIcons";
import { AttemptIcon } from "~/assets/icons/AttemptIcon";
import { TaskIcon } from "~/assets/icons/TaskIcon";
import { cn } from "~/utils/cn";
Expand Down Expand Up @@ -112,6 +127,31 @@ export function RunIcon({ name, className, spanName }: TaskIconProps) {
return <FunctionIcon className={cn(className, "text-error")} />;
case "streams":
return <StreamsIcon className={cn(className, "text-text-dimmed")} />;
case "hero-sparkles":
return <SparklesIcon className={cn(className, "text-text-dimmed")} />;
case "hero-wrench":
return <WrenchIcon className={cn(className, "text-text-dimmed")} />;
case "tabler-brand-anthropic":
case "ai-provider-anthropic":
return <AnthropicIcon className={cn(className, "text-text-dimmed")} />;
case "ai-provider-openai":
return <OpenAIIcon className={cn(className, "text-text-dimmed")} />;
case "ai-provider-gemini":
return <GeminiIcon className={cn(className, "text-text-dimmed")} />;
case "ai-provider-llama":
return <LlamaIcon className={cn(className, "text-text-dimmed")} />;
case "ai-provider-deepseek":
return <DeepseekIcon className={cn(className, "text-text-dimmed")} />;
case "ai-provider-xai":
return <XAIIcon className={cn(className, "text-text-dimmed")} />;
case "ai-provider-perplexity":
return <PerplexityIcon className={cn(className, "text-text-dimmed")} />;
case "ai-provider-cerebras":
return <CerebrasIcon className={cn(className, "text-text-dimmed")} />;
case "ai-provider-mistral":
return <MistralIcon className={cn(className, "text-text-dimmed")} />;
case "ai-provider-azure":
return <AzureIcon className={cn(className, "text-text-dimmed")} />;
}

return <InformationCircleIcon className={cn(className, "text-text-dimmed")} />;
Expand Down
26 changes: 26 additions & 0 deletions apps/webapp/app/components/runs/v3/SpanTitle.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,8 @@ import { TaskEventStyle } from "@trigger.dev/core/v3";
import type { TaskEventLevel } from "@trigger.dev/database";
import { Fragment } from "react";
import { cn } from "~/utils/cn";
import { tablerIcons } from "~/utils/tablerIcons";
import tablerSpritePath from "~/components/primitives/tabler-sprite.svg";

type SpanTitleProps = {
message: string;
Expand Down Expand Up @@ -45,6 +47,15 @@ function SpanAccessory({
/>
);
}
case "pills": {
return (
<div className="flex items-center gap-1">
{accessory.items.map((item, index) => (
<SpanPill key={index} text={item.text} icon={item.icon} />
))}
</div>
);
}
default: {
return (
<div className={cn("flex gap-1")}>
Expand All @@ -59,6 +70,21 @@ function SpanAccessory({
}
}

function SpanPill({ text, icon }: { text: string; icon?: string }) {
const hasIcon = icon && tablerIcons.has(icon);

return (
<span className="inline-flex items-center gap-0.5 rounded-full border border-charcoal-700 bg-charcoal-850 px-1.5 py-px text-xxs text-text-dimmed">
{hasIcon && (
<svg className="size-3 stroke-[1.5] text-text-dimmed/70">
<use xlinkHref={`${tablerSpritePath}#${icon}`} />
</svg>
)}
<span className="truncate">{text}</span>
</span>
);
}

export function SpanCodePathAccessory({
accessory,
className,
Expand Down
Loading
Loading