Skip to content
Open
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
62 changes: 49 additions & 13 deletions apps/web/src/components/attributes/commit-sha-attribute.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import type { ReactNode } from "react"

import { CommitShaHoverCard } from "@/components/vcs/commit-sha-hover-card"
import { formatGenAiCost, formatTokens } from "@maple/ui/lib/gen-ai"

// Resource/span attribute keys whose value is a git commit SHA. Wrapping these
// in the hover card mirrors the trace header (which enriches
Expand All @@ -10,20 +11,55 @@ import { CommitShaHoverCard } from "@/components/vcs/commit-sha-hover-card"
// (still copyable), so listing a key here is always safe.
const COMMIT_SHA_KEYS = new Set(["deployment.commit_sha", "vcs.ref.head.revision"])

// GenAI (`gen_ai.*`) usage keys carry numbers stringified by the ingest encoder
// ("42"), so the raw attribute table shows bare digits. Format them inline:
// model ids as a chip, token counts with thousands separators, cost as currency.
const GEN_AI_MODEL_KEYS = new Set(["gen_ai.request.model", "gen_ai.response.model"])
const GEN_AI_TOKEN_KEYS = new Set([
"gen_ai.usage.input_tokens",
"gen_ai.usage.output_tokens",
"gen_ai.usage.total_tokens",
"gen_ai.usage.prompt_tokens",
"gen_ai.usage.completion_tokens",
])

/**
* `AttributesConfig.renderValue` implementation. Returns a hover-card-wrapped
* value for known commit-SHA keys, or `null` to fall back to the default
* copyable text.
* `AttributesConfig.renderValue` implementation. Returns a specialized node for
* known attribute keys (commit SHAs, GenAI model/token/cost), or `null` to fall
* back to the default copyable text.
*/
export function renderAttributeValue(attrKey: string, value: string): ReactNode | null {
if (!COMMIT_SHA_KEYS.has(attrKey)) return null
return (
<CommitShaHoverCard
sha={value}
copy={{ value, label: "commit SHA" }}
className="-mx-0.5 cursor-pointer break-all rounded px-0.5 transition-colors hover:bg-muted/50"
>
{value}
</CommitShaHoverCard>
)
if (COMMIT_SHA_KEYS.has(attrKey)) {
return (
<CommitShaHoverCard
sha={value}
copy={{ value, label: "commit SHA" }}
className="-mx-0.5 cursor-pointer break-all rounded px-0.5 transition-colors hover:bg-muted/50"
>
{value}
</CommitShaHoverCard>
)
}

if (GEN_AI_MODEL_KEYS.has(attrKey)) {
return (
<span className="inline-flex items-center rounded border bg-muted/40 px-1.5 py-0.5 font-mono text-[11px]">
{value}
</span>
)
}

if (GEN_AI_TOKEN_KEYS.has(attrKey)) {
const n = Number(value)
if (!Number.isFinite(n)) return null
return <span className="font-mono tabular-nums">{formatTokens(n)}</span>
}

if (attrKey === "gen_ai.usage.cost") {
const n = Number(value)
if (!Number.isFinite(n)) return null
return <span className="font-mono tabular-nums">{formatGenAiCost(n)}</span>
}

return null
}
4 changes: 4 additions & 0 deletions docs/openrouter-tracing.md
Original file line number Diff line number Diff line change
Expand Up @@ -67,13 +67,17 @@ OpenRouter only emits Broadcast traces for traffic under the OpenRouter account

OpenRouter Broadcast traces use standard GenAI semantic convention attributes such as `gen_ai.*` for model, usage, and cost data. Maple also receives the custom metadata above under OpenRouter's `trace.metadata.*` namespace.

Maple renders these as first-class LLM calls: any span carrying `gen_ai.*` attributes is badged with a sparkle/provider icon in the waterfall, timeline, and flow views, and the span-detail panel shows a summary block with the provider, operation, model, input/output tokens, cost, request params, and finish reason. In the attribute table, `gen_ai.request.model` / `gen_ai.response.model` render as model chips, token counts are grouped, and `gen_ai.usage.cost` renders as currency. Because OTel renamed `gen_ai.system` → `gen_ai.provider.name`, a provider filter matches spans using either spelling.

Useful filters:

```text
trace.metadata.orgId = "<orgId>"
trace.metadata.operation = "chat.turn"
trace.metadata.mode = "dashboard_builder"
session.id = "<orgId>:<tabId>"
gen_ai.provider.name = "openai" # also matches the legacy gen_ai.system spelling
gen_ai.request.model = "openai/gpt-4o"
```

If prompt or completion content should not leave OpenRouter, enable Privacy Mode for the OpenRouter observability destination. OpenRouter's docs state that Privacy Mode excludes prompt and completion content while still sending timing, model, token usage, cost, and metadata.
Expand Down
11 changes: 9 additions & 2 deletions docs/otel-coverage-roadmap.md
Original file line number Diff line number Diff line change
Expand Up @@ -59,8 +59,15 @@ data exists:
Histogram datasource has bounds + bucket counts; registry has a generic histogram/heatmap but nothing wired to OTel histogram buckets.
- [ ] **12. trace_state / sampling filter** — _S, trace filtering_
`TraceState` + `SampleRate` stored; useful for "show only head-sampled" debugging. Lower demand.
- [ ] **13. GenAI / LLM semantic conventions** (`gen_ai.*`) — _M, filtering + visuals_
Token usage, model, system land in the generic attrs map — no dedicated facets or token/cost visuals. Emerging, high-interest namespace.
- [~] **13. GenAI / LLM semantic conventions** (`gen_ai.*`) — _M, filtering + visuals_
**Trace-level enrichment shipped:** LLM spans (OpenRouter Broadcast et al.) are detected via
`getGenAiInfo` (`packages/ui/src/lib/gen-ai.ts`) + a `genAiAdapter`
(`packages/ui/src/lib/cloud-platforms/gen-ai.ts`), so the waterfall/flame/flow views badge
them and the span-detail panel shows a model/provider/token/cost summary; `gen_ai.*` keys are
projected into the trace views (`TREE_SPAN_ATTR_KEYS`), `gen_ai.system`↔`gen_ai.provider.name`
coalesce in attribute filters (`traces-shared.ts`), and model/token/cost render formatted in
the attribute table. **Deferred:** dedicated token/cost aggregation + AI page (needs the
`gen_ai_usage_hourly` rollup and the numeric-attribute aggregation wiring — see below).

## Tier 3 — Needs ingest-encoder changes (`apps/ingest/src/telemetry.rs`)

Expand Down
21 changes: 21 additions & 0 deletions packages/query-engine/src/ch/ch.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -234,6 +234,23 @@ describe("tracesTimeseriesQuery", () => {
expect(sql).toContain("0 AS p50Duration")
})

it("coalesces gen_ai.provider.name and legacy gen_ai.system in an attribute filter", () => {
// An org filtering LLM spans by provider must match whichever spelling the
// span carries — OTel renamed gen_ai.system → gen_ai.provider.name.
const q = tracesTimeseriesQuery({
metric: "count",
needsSampling: false,
attributeFilters: [{ key: "gen_ai.provider.name", value: "openai", mode: "equals" }],
})
const { sql } = compileCH(q, baseParams)
// A span-level attribute filter reads the raw traces table (not the MV)...
expect(sql).toContain("FROM traces")
// ...and coalesces both spellings to the same value.
expect(sql).toContain("SpanAttributes['gen_ai.provider.name']")
expect(sql).toContain("SpanAttributes['gen_ai.system']")
expect(sql).toContain("= 'openai'")
})

it("builds apdex timeseries with threshold", () => {
const q = tracesTimeseriesQuery({ metric: "apdex", needsSampling: false, apdexThresholdMs: 250 })
const { sql } = compileCH(q, baseParams)
Expand Down Expand Up @@ -1194,6 +1211,10 @@ describe("converted queries", () => {
expect(sql).not.toContain("toJSONString(ResourceAttributes)")
expect(sql).toContain("'http.route', SpanAttributes['http.route']")
expect(sql).toContain("'cache.result', SpanAttributes['cache.result']")
// GenAI/LLM keys are projected so the trace views can badge LLM spans.
expect(sql).toContain("'gen_ai.operation.name', SpanAttributes['gen_ai.operation.name']")
expect(sql).toContain("'gen_ai.request.model', SpanAttributes['gen_ai.request.model']")
expect(sql).toContain("'gen_ai.usage.cost', SpanAttributes['gen_ai.usage.cost']")
expect(sql).toContain("'deployment.environment', ResourceAttributes['deployment.environment']")
expect(sql).toContain("AS spanAttributes")
expect(sql).toContain("AS resourceAttributes")
Expand Down
18 changes: 17 additions & 1 deletion packages/query-engine/src/ch/queries/errors.ts
Original file line number Diff line number Diff line change
Expand Up @@ -166,6 +166,21 @@ const TREE_SPAN_ATTR_KEYS = [
"cloudflare.colo",
"faas.invoked_region",
"cloudflare.outcome",
// GenAI / LLM client spans (OpenRouter Broadcast + OTel `gen_ai.*` semconv) —
// the `gen_ai.operation.name` value (with provider + model + token/cost usage)
// lets the trace views detect an LLM call and render its badge without waiting
// for the per-span lazy detail fetch. The full set (finish reasons, request
// params, prompt/completion) is loaded lazily by `spanDetailQuery` for the
// detail panel. Both the new `gen_ai.provider.name` and the legacy
// `gen_ai.system` spellings are projected so the detector coalesces either.
"gen_ai.operation.name",
"gen_ai.provider.name",
"gen_ai.system",
"gen_ai.request.model",
"gen_ai.response.model",
"gen_ai.usage.input_tokens",
"gen_ai.usage.output_tokens",
"gen_ai.usage.cost",
] as const

/**
Expand Down Expand Up @@ -568,7 +583,8 @@ export function tracesFacetsQuery(opts: TracesFacetsOpts): CHUnionQuery<TracesFa
spanName: () => makeFacetQuery("SpanName", "spanName", ($) => $.SpanName.neq(""), 20),
httpMethod: () => makeFacetQuery("HttpMethod", "httpMethod", ($) => $.HttpMethod.neq(""), 20),
httpStatus: () => makeFacetQuery("HttpStatusCode", "httpStatus", ($) => $.HttpStatusCode.neq(""), 20),
deploymentEnv: () => makeFacetQuery("DeploymentEnv", "deploymentEnv", ($) => $.DeploymentEnv.neq(""), 20),
deploymentEnv: () =>
makeFacetQuery("DeploymentEnv", "deploymentEnv", ($) => $.DeploymentEnv.neq(""), 20),
serviceNamespace: () =>
makeFacetQuery("ServiceNamespace", "serviceNamespace", ($) => $.ServiceNamespace.neq(""), 20),
}
Expand Down
28 changes: 17 additions & 11 deletions packages/query-engine/src/traces-shared.ts
Original file line number Diff line number Diff line change
Expand Up @@ -45,23 +45,29 @@ export const TRACE_LIST_MV_RESOURCE_MAP: Record<string, string> = {
import * as CH from "@maple-dev/clickhouse-builder/expr"

// ---------------------------------------------------------------------------
// HTTP semconv coalescing
// Semconv coalescing
//
// OpenTelemetry renamed several HTTP span attributes in the stable semconv:
// http.method → http.request.method
// http.status_code → http.response.status_code
// `trace_list_mv` coalesces both spellings when it pre-extracts its columns
// (see materializations.ts), so the quick-filter facet counts cover spans that
// use *either* key. Filters that read the raw `traces` table must coalesce the
// same way — otherwise a facet shows a count while applying it matches zero
// rows (the data carries the new key, the filter looked up the old one).
// OpenTelemetry renamed several span attributes across semconv versions:
// http.method → http.request.method
// http.status_code → http.response.status_code
// gen_ai.system → gen_ai.provider.name (GenAI/LLM spans)
// Emitters in the wild still send either spelling. `trace_list_mv` coalesces the
// HTTP pair when it pre-extracts its columns (see materializations.ts), so the
// quick-filter facet counts cover spans that use *either* key. Filters that read
// the raw `traces` table must coalesce the same way — otherwise a facet shows a
// count while applying it matches zero rows (the data carries the new key, the
// filter looked up the old one). The GenAI pair has no MV column yet; coalescing
// it here still makes provider filters match regardless of which name a span uses.
// ---------------------------------------------------------------------------

const HTTP_SEMCONV_ALIASES: Record<string, readonly string[]> = {
const SEMCONV_ALIASES: Record<string, readonly string[]> = {
"http.method": ["http.method", "http.request.method"],
"http.request.method": ["http.method", "http.request.method"],
"http.status_code": ["http.status_code", "http.response.status_code"],
"http.response.status_code": ["http.status_code", "http.response.status_code"],
// Prefer the new `gen_ai.provider.name` spelling first (first non-empty wins).
"gen_ai.provider.name": ["gen_ai.provider.name", "gen_ai.system"],
"gen_ai.system": ["gen_ai.provider.name", "gen_ai.system"],
}

/** `if(map[k0] != '', map[k0], if(map[k1] != '', …))` — first non-empty alias. */
Expand Down Expand Up @@ -115,7 +121,7 @@ export function buildAttrFilterCondition(
const mapExpr = CH.dynamicColumn<Record<string, string>>(mapName)
// Span attributes renamed across OTel semconv versions match either spelling,
// mirroring trace_list_mv. Resource attributes have no such aliases.
const keys = mapName === "SpanAttributes" ? (HTTP_SEMCONV_ALIASES[af.key] ?? [af.key]) : [af.key]
const keys = mapName === "SpanAttributes" ? (SEMCONV_ALIASES[af.key] ?? [af.key]) : [af.key]
const colExpr: CH.Expr<string> = coalescedMapGet(mapExpr, keys)
const value = af.value ?? ""

Expand Down
1 change: 1 addition & 0 deletions packages/ui/src/components/icons/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@ export { NetworkNodesIcon } from "./network-nodes"
export { PulseIcon } from "./pulse"
export { RadioCheckedIcon } from "./radio-checked"
export { SidebarLeftIcon } from "./sidebar-left"
export { SparklesIcon } from "./sparkles"
export { TextWrapIcon } from "./text-wrap"
export { ThumbtackIcon } from "./thumbtack"
export { XmarkIcon } from "./xmark"
35 changes: 35 additions & 0 deletions packages/ui/src/components/icons/sparkles.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
import type { IconProps } from "./icon"

// A two-star "sparkles" glyph — the conventional mark for AI / LLM features.
// Used to badge GenAI (`gen_ai.*`) client spans across the trace views.
const paths: ReadonlyArray<string> = [
"M9.5 6.5 L11 11.5 L16 13 L11 14.5 L9.5 19.5 L8 14.5 L3 13 L8 11.5 Z",
"M17.5 3 L18.3 5.2 L20.5 6 L18.3 6.8 L17.5 9 L16.7 6.8 L14.5 6 L16.7 5.2 Z",
]

function SparklesIcon({ size = 24, className, ...props }: IconProps) {
return (
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
width={size}
height={size}
className={className}
fill="none"
aria-hidden="true"
{...props}
>
{paths.map((d, i) => (
<path
key={i}
d={d}
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
/>
))}
</svg>
)
}
export { SparklesIcon }
Loading
Loading