diff --git a/backend/db/clickhouse/client.py b/backend/db/clickhouse/client.py
index aec2aeb7..48cef7c7 100644
--- a/backend/db/clickhouse/client.py
+++ b/backend/db/clickhouse/client.py
@@ -48,6 +48,7 @@ def insert_traces_batch(self, traces: list[dict[str, Any]]) -> None:
t.get("release"),
t.get("input"),
t.get("output"),
+ t.get("metadata"),
now, # ch_create_time
now, # ch_update_time
]
@@ -67,6 +68,7 @@ def insert_traces_batch(self, traces: list[dict[str, Any]]) -> None:
"release",
"input",
"output",
+ "metadata",
"ch_create_time",
"ch_update_time",
],
@@ -100,6 +102,7 @@ def insert_spans_batch(self, spans: list[dict[str, Any]]) -> None:
s.get("input"),
s.get("output"),
s.get("environment", "default"),
+ s.get("metadata"),
now, # ch_create_time
now, # ch_update_time
]
@@ -127,6 +130,7 @@ def insert_spans_batch(self, spans: list[dict[str, Any]]) -> None:
"input",
"output",
"environment",
+ "metadata",
"ch_create_time",
"ch_update_time",
],
diff --git a/backend/db/clickhouse/migrations/001_create_traces.sql b/backend/db/clickhouse/migrations/001_create_traces.sql
index 86c88e76..0972fd38 100644
--- a/backend/db/clickhouse/migrations/001_create_traces.sql
+++ b/backend/db/clickhouse/migrations/001_create_traces.sql
@@ -11,6 +11,7 @@ CREATE TABLE IF NOT EXISTS traces
release Nullable(String),
input Nullable(String) CODEC(ZSTD(3)),
output Nullable(String) CODEC(ZSTD(3)),
+ metadata Nullable(String) CODEC(ZSTD(3)),
ch_create_time DateTime64(3) DEFAULT now64(3),
ch_update_time DateTime64(3) DEFAULT now64(3)
)
diff --git a/backend/db/clickhouse/migrations/002_create_spans.sql b/backend/db/clickhouse/migrations/002_create_spans.sql
index 81aa302a..f9e8e3fe 100644
--- a/backend/db/clickhouse/migrations/002_create_spans.sql
+++ b/backend/db/clickhouse/migrations/002_create_spans.sql
@@ -19,6 +19,7 @@ CREATE TABLE IF NOT EXISTS spans
input Nullable(String) CODEC(ZSTD(3)),
output Nullable(String) CODEC(ZSTD(3)),
environment String DEFAULT 'default',
+ metadata Nullable(String) CODEC(ZSTD(3)),
ch_create_time DateTime64(3) DEFAULT now64(3),
ch_update_time DateTime64(3) DEFAULT now64(3)
)
diff --git a/backend/rest/config/traces.py b/backend/rest/config/traces.py
index 9093191e..e89aa65b 100644
--- a/backend/rest/config/traces.py
+++ b/backend/rest/config/traces.py
@@ -24,6 +24,7 @@ class SpanResponse(BaseModel):
total_tokens: int | None
input: str | None
output: str | None
+ metadata: str | None
class TraceListItem(BaseModel):
@@ -70,4 +71,5 @@ class TraceDetailResponse(BaseModel):
release: str | None
input: str | None
output: str | None
+ metadata: str | None
spans: list[SpanResponse]
diff --git a/backend/rest/services/trace_reader.py b/backend/rest/services/trace_reader.py
index 08f8f21d..2755b6ec 100644
--- a/backend/rest/services/trace_reader.py
+++ b/backend/rest/services/trace_reader.py
@@ -135,7 +135,7 @@ def get_trace(self, project_id: str, trace_id: str) -> dict | None:
trace_query = """
SELECT
trace_id, project_id, name, trace_start_time,
- user_id, session_id, environment, release, input, output
+ user_id, session_id, environment, release, input, output, metadata
FROM traces FINAL
WHERE project_id = {project_id:String} AND trace_id = {trace_id:String}
LIMIT 1
@@ -160,6 +160,7 @@ def get_trace(self, project_id: str, trace_id: str) -> dict | None:
"release": row[7],
"input": row[8],
"output": row[9],
+ "metadata": row[10],
}
# Fetch spans
@@ -168,7 +169,7 @@ def get_trace(self, project_id: str, trace_id: str) -> dict | None:
span_id, trace_id, parent_span_id, name, span_kind,
span_start_time, span_end_time, status, status_message,
model_name, cost, input_tokens, output_tokens, total_tokens,
- input, output
+ input, output, metadata
FROM spans FINAL
WHERE project_id = {project_id:String} AND trace_id = {trace_id:String}
ORDER BY span_start_time ASC
@@ -198,6 +199,7 @@ def get_trace(self, project_id: str, trace_id: str) -> dict | None:
"total_tokens": int(row[13]) if row[13] is not None else None,
"input": row[14],
"output": row[15],
+ "metadata": row[16],
}
)
diff --git a/backend/worker/otel_transform.py b/backend/worker/otel_transform.py
index f460c519..ff5b57fe 100644
--- a/backend/worker/otel_transform.py
+++ b/backend/worker/otel_transform.py
@@ -41,6 +41,35 @@
logger = logging.getLogger(__name__)
+# Attributes that are already extracted into dedicated fields
+_KNOWN_ATTRIBUTE_PREFIXES = {
+ "traceroot.span.input",
+ "traceroot.span.output",
+ "traceroot.span.type",
+ "traceroot.span.metadata",
+ "traceroot.span.tags",
+ "traceroot.llm.",
+ "traceroot.trace.",
+ "traceroot.environment",
+ "traceroot.version",
+ "openinference.span.kind",
+ "session.id",
+ "session.user_id",
+ "user.id",
+ "input.value",
+ "output.value",
+ "gen_ai.",
+ "llm.token_count.",
+ "llm.model_name",
+ "llm.input_messages",
+ "llm.output_messages",
+}
+
+
+def _is_known_attribute(key: str) -> bool:
+ """Check if an attribute key is already extracted into a dedicated field."""
+ return any(key == prefix or key.startswith(prefix) for prefix in _KNOWN_ATTRIBUTE_PREFIXES)
+
def decode_otel_id(b64_value: str | None) -> str | None:
"""Decode base64-encoded OTEL trace/span ID to hex string.
@@ -367,6 +396,24 @@ def transform_otel_to_clickhouse(
if usage["cost"] is not None:
span_record["cost"] = usage["cost"]
+ # Extract metadata
+ # Priority: explicit traceroot.span.metadata > remaining attributes
+ explicit_metadata = span_attrs.get("traceroot.span.metadata")
+ if explicit_metadata is not None:
+ if isinstance(explicit_metadata, str):
+ span_record["metadata"] = explicit_metadata
+ else:
+ span_record["metadata"] = json.dumps(explicit_metadata)
+ else:
+ # Collect non-internal attributes as metadata
+ extra_attrs = {
+ k: v
+ for k, v in span_attrs.items()
+ if not _is_known_attribute(k) and v is not None
+ }
+ if extra_attrs:
+ span_record["metadata"] = json.dumps(extra_attrs)
+
# Check span status for errors
status = otel_span.get("status", {})
status_code = status.get("code", 0)
@@ -416,6 +463,15 @@ def transform_otel_to_clickhouse(
"environment": environment,
}
+ # Extract trace-level metadata
+ trace_metadata = span_attrs.get("traceroot.trace.metadata")
+ if trace_metadata is not None:
+ traces[trace_id]["metadata"] = (
+ json.dumps(trace_metadata)
+ if not isinstance(trace_metadata, str)
+ else trace_metadata
+ )
+
# Root span input/output becomes trace input/output
if span_input is not None:
traces[trace_id]["input"] = (
diff --git a/backend/worker/tests/__init__.py b/backend/worker/tests/__init__.py
new file mode 100644
index 00000000..e69de29b
diff --git a/backend/worker/tests/test_otel_transform_metadata.py b/backend/worker/tests/test_otel_transform_metadata.py
new file mode 100644
index 00000000..79408ef3
--- /dev/null
+++ b/backend/worker/tests/test_otel_transform_metadata.py
@@ -0,0 +1,162 @@
+"""Tests for metadata extraction in otel_transform."""
+
+import base64
+import json
+
+from worker.otel_transform import transform_otel_to_clickhouse
+
+
+def _make_trace_id() -> str:
+ """Return a base64-encoded 16-byte trace ID."""
+ return base64.b64encode(b"\x01" * 16).decode()
+
+
+def _make_span_id(byte: int = 0x02) -> str:
+ """Return a base64-encoded 8-byte span ID."""
+ return base64.b64encode(bytes([byte] * 8)).decode()
+
+
+def _attr(key: str, value) -> dict:
+ """Build an OTEL attribute entry."""
+ if isinstance(value, str):
+ return {"key": key, "value": {"stringValue": value}}
+ if isinstance(value, bool):
+ return {"key": key, "value": {"boolValue": value}}
+ if isinstance(value, int):
+ return {"key": key, "value": {"intValue": str(value)}}
+ if isinstance(value, float):
+ return {"key": key, "value": {"doubleValue": value}}
+ # Fall back to stringValue for dicts serialised as JSON
+ return {
+ "key": key,
+ "value": {"stringValue": json.dumps(value) if not isinstance(value, str) else value},
+ }
+
+
+def _otel_payload(span_attributes: list[dict], *, parent_span_id: str | None = None) -> dict:
+ """Build a minimal OTEL payload with one resource span containing one span."""
+ span = {
+ "traceId": _make_trace_id(),
+ "spanId": _make_span_id(),
+ "name": "test-span",
+ "kind": "SPAN_KIND_INTERNAL",
+ "startTimeUnixNano": "1700000000000000000",
+ "endTimeUnixNano": "1700000001000000000",
+ "attributes": span_attributes,
+ "status": {},
+ }
+ if parent_span_id is not None:
+ span["parentSpanId"] = parent_span_id
+ return {
+ "resourceSpans": [
+ {
+ "resource": {"attributes": []},
+ "scopeSpans": [{"scope": {"name": "test"}, "spans": [span]}],
+ }
+ ]
+ }
+
+
+# ── Tests ──────────────────────────────────────────────────────────
+
+
+def test_explicit_metadata_extracted():
+ """traceroot.span.metadata attribute is captured as span metadata."""
+ meta = {"custom_key": "custom_value", "run_id": 42}
+ payload = _otel_payload([_attr("traceroot.span.metadata", json.dumps(meta))])
+
+ _traces, spans = transform_otel_to_clickhouse(payload, project_id="proj-1")
+
+ assert len(spans) == 1
+ assert "metadata" in spans[0]
+ assert json.loads(spans[0]["metadata"]) == meta
+
+
+def test_extra_attributes_become_metadata():
+ """Custom attributes not in the known set appear as metadata."""
+ payload = _otel_payload(
+ [
+ _attr("my.custom.attr", "hello"),
+ _attr("another.thing", "world"),
+ ]
+ )
+
+ _traces, spans = transform_otel_to_clickhouse(payload, project_id="proj-1")
+
+ assert len(spans) == 1
+ meta = json.loads(spans[0]["metadata"])
+ assert meta["my.custom.attr"] == "hello"
+ assert meta["another.thing"] == "world"
+
+
+def test_known_attributes_excluded_from_metadata():
+ """Known attributes (traceroot.span.input, gen_ai.*, etc.) do NOT leak into metadata."""
+ payload = _otel_payload(
+ [
+ _attr("traceroot.span.input", "some input"),
+ _attr("gen_ai.system", "openai"),
+ _attr("llm.model_name", "gpt-4"),
+ _attr("input.value", "hi"),
+ _attr("openinference.span.kind", "LLM"),
+ _attr("session.id", "s-1"),
+ _attr("user.id", "u-1"),
+ _attr("llm.input_messages.0.message.role", "user"),
+ _attr("llm.input_messages.0.message.content", "hello"),
+ _attr("llm.output_messages.0.message.role", "assistant"),
+ _attr("llm.output_messages.0.message.content", "hi there"),
+ # One unknown attribute so we can verify metadata dict exists but excludes known keys
+ _attr("my.custom.flag", "yes"),
+ ]
+ )
+
+ _traces, spans = transform_otel_to_clickhouse(payload, project_id="proj-1")
+
+ meta = json.loads(spans[0]["metadata"])
+ assert "my.custom.flag" in meta
+ # None of the known keys should be present
+ for key in (
+ "traceroot.span.input",
+ "gen_ai.system",
+ "llm.model_name",
+ "input.value",
+ "openinference.span.kind",
+ "session.id",
+ "user.id",
+ "llm.input_messages.0.message.role",
+ "llm.input_messages.0.message.content",
+ "llm.output_messages.0.message.role",
+ "llm.output_messages.0.message.content",
+ ):
+ assert key not in meta, f"{key} should not appear in metadata"
+
+
+def test_trace_metadata_extracted():
+ """traceroot.trace.metadata on root span populates the trace record."""
+ trace_meta = {"experiment": "v2", "dataset": "eval-100"}
+ payload = _otel_payload(
+ [
+ _attr("traceroot.trace.metadata", json.dumps(trace_meta)),
+ ]
+ )
+
+ traces, _spans = transform_otel_to_clickhouse(payload, project_id="proj-1")
+
+ assert len(traces) == 1
+ assert "metadata" in traces[0]
+ assert json.loads(traces[0]["metadata"]) == trace_meta
+
+
+def test_no_metadata_when_no_extra_attributes():
+ """When only known attributes exist, metadata is not set on the span."""
+ payload = _otel_payload(
+ [
+ _attr("traceroot.span.input", "hello"),
+ _attr("traceroot.span.output", "world"),
+ _attr("traceroot.span.type", "LLM"),
+ ]
+ )
+
+ _traces, spans = transform_otel_to_clickhouse(payload, project_id="proj-1")
+
+ assert len(spans) == 1
+ assert "metadata" not in spans[0]
diff --git a/frontend/ui/src/features/traces/components/JsonRenderer.tsx b/frontend/ui/src/features/traces/components/JsonRenderer.tsx
index 8979c998..856fbb33 100644
--- a/frontend/ui/src/features/traces/components/JsonRenderer.tsx
+++ b/frontend/ui/src/features/traces/components/JsonRenderer.tsx
@@ -22,6 +22,17 @@ export function JsonRenderer({ value, depth = 0 }: JsonRendererProps) {
}
if (typeof value === "string") {
+ // Try to parse JSON strings and render them as structured objects
+ if (value.startsWith("{") || value.startsWith("[")) {
+ try {
+ const parsed = JSON.parse(value);
+ if (typeof parsed === "object" && parsed !== null && depth < 10) {
+ return ;
+ }
+ } catch {
+ // Not valid JSON, render as plain string
+ }
+ }
return (
"{value}"
diff --git a/frontend/ui/src/features/traces/components/SpanInfoPanel.tsx b/frontend/ui/src/features/traces/components/SpanInfoPanel.tsx
index f287b294..586b2c56 100644
--- a/frontend/ui/src/features/traces/components/SpanInfoPanel.tsx
+++ b/frontend/ui/src/features/traces/components/SpanInfoPanel.tsx
@@ -39,6 +39,7 @@ export function SpanInfoPanel({ projectId, trace, selection, onClose }: SpanInfo
const timestamp = isTrace ? trace.trace_start_time : selection.span.span_start_time;
const input = isTrace ? trace.input : selection.span.input;
const output = isTrace ? trace.output : selection.span.output;
+ const metadata = isTrace ? trace.metadata : selection.span.metadata;
// Trace-level aggregates
const traceTotalCost = isTrace ? getTraceTotalCost(trace) : null;
@@ -198,6 +199,15 @@ export function SpanInfoPanel({ projectId, trace, selection, onClose }: SpanInfo
>
+
+ {/* Metadata */}
+ copyToClipboard(metadata) : undefined}
+ >
+
+
);
diff --git a/frontend/ui/src/types/api.ts b/frontend/ui/src/types/api.ts
index 08aff53a..bf694630 100644
--- a/frontend/ui/src/types/api.ts
+++ b/frontend/ui/src/types/api.ts
@@ -108,6 +108,7 @@ export interface Span {
total_tokens: number | null;
input: string | null;
output: string | null;
+ metadata: string | null;
}
export interface TraceDetail {
@@ -121,6 +122,7 @@ export interface TraceDetail {
release: string | null;
input: string | null;
output: string | null;
+ metadata: string | null;
spans: Span[];
}
diff --git a/tests/db/test_client.py b/tests/db/test_client.py
index 9a284c93..34e465d6 100644
--- a/tests/db/test_client.py
+++ b/tests/db/test_client.py
@@ -49,10 +49,11 @@ def test_builds_correct_rows(self):
assert row[7] == "v1.0" # release
assert row[8] == "hello" # input
assert row[9] == "world" # output
+ assert row[10] is None # metadata
# ch_create_time and ch_update_time are auto-set
- assert isinstance(row[10], datetime)
assert isinstance(row[11], datetime)
- assert len(columns) == 12
+ assert isinstance(row[12], datetime)
+ assert len(columns) == 13
def test_empty_batch_no_insert(self):
"""Empty list -> no _client.insert() call."""
@@ -112,7 +113,7 @@ def test_builds_correct_rows(self):
assert row[12] == 100 # input_tokens
assert row[13] == 50 # output_tokens
assert row[14] == 150 # total_tokens
- assert len(columns) == 20
+ assert len(columns) == 21
def test_optional_fields_none(self):
"""None values for optional fields (cost, tokens)."""
diff --git a/tests/rest/test_traces_router.py b/tests/rest/test_traces_router.py
index a5b7de7a..8a2b5ecd 100644
--- a/tests/rest/test_traces_router.py
+++ b/tests/rest/test_traces_router.py
@@ -37,6 +37,7 @@
"release": None,
"input": None,
"output": None,
+ "metadata": None,
"spans": [
{
"span_id": "span-1",
@@ -55,6 +56,7 @@
"total_tokens": None,
"input": None,
"output": None,
+ "metadata": None,
}
],
}
diff --git a/traceroot-py/tests/test_otel.py b/traceroot-py/tests/test_otel.py
index bdeb4355..d741c8f5 100644
--- a/traceroot-py/tests/test_otel.py
+++ b/traceroot-py/tests/test_otel.py
@@ -297,6 +297,7 @@ def failing(x):
def test_metadata_and_tags(memory_exporter):
"""Test metadata and tags are captured in attributes."""
+ import json
@observe(
name="tagged-op",
@@ -314,6 +315,58 @@ def tagged_op():
# Check tags are set
tags = get_span_attribute(span, SpanAttributes.SPAN_TAGS)
assert tags is not None
+ assert "production" in tags
+ assert "critical" in tags
+
+ # Check metadata is set and contains expected data
+ metadata_raw = get_span_attribute(span, SpanAttributes.SPAN_METADATA)
+ assert metadata_raw is not None
+ metadata = json.loads(metadata_raw)
+ assert metadata["version"] == "1.0"
+ assert metadata["env"] == "test"
+
+
+def test_update_current_span_metadata(memory_exporter):
+ """Test update_current_span sets metadata on the active span."""
+ import json
+
+ @observe(name="span-with-metadata")
+ def func_with_metadata():
+ traceroot.update_current_span(metadata={"custom_key": "custom_value", "score": 0.95})
+ return "done"
+
+ func_with_metadata()
+
+ spans = memory_exporter.get_finished_spans()
+ assert len(spans) == 1
+
+ span = spans[0]
+ metadata_raw = get_span_attribute(span, SpanAttributes.SPAN_METADATA)
+ assert metadata_raw is not None
+ metadata = json.loads(metadata_raw)
+ assert metadata["custom_key"] == "custom_value"
+ assert metadata["score"] == 0.95
+
+
+def test_update_current_trace_metadata(memory_exporter):
+ """Test update_current_trace sets trace-level metadata on the active span."""
+ import json
+
+ @observe(name="trace-with-metadata")
+ def func_with_trace_metadata():
+ traceroot.update_current_trace(metadata={"trace_level": "info"})
+ return "done"
+
+ func_with_trace_metadata()
+
+ spans = memory_exporter.get_finished_spans()
+ assert len(spans) == 1
+
+ span = spans[0]
+ metadata_raw = get_span_attribute(span, SpanAttributes.TRACE_METADATA)
+ assert metadata_raw is not None
+ metadata = json.loads(metadata_raw)
+ assert metadata["trace_level"] == "info"
def test_function_name_as_default(memory_exporter):