Skip to content

Commit d07b29f

Browse files
committed
Add OTLP trace ingestion MVP
1 parent e3642f6 commit d07b29f

6 files changed

Lines changed: 494 additions & 1 deletion

File tree

docs/source/_toctree.yml

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,8 @@
2929
title: API and MCP Server
3030
- local: alerts
3131
title: Alerts
32+
- local: otel_tracing
33+
title: OpenTelemetry Traces
3234
- local: ml_agents
3335
title: ML Agents
3436
- local: environment_variables

docs/source/otel_tracing.md

Lines changed: 90 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,90 @@
1+
# OpenTelemetry Traces
2+
3+
Trackio can receive OpenTelemetry trace data over OTLP/HTTP and show it in the
4+
dashboard's **Traces** tab. This is useful for agent and LLM frameworks that
5+
already emit OpenTelemetry or OpenInference spans, such as smolagents through
6+
`openinference-instrumentation-smolagents`.
7+
8+
This is a minimal v1 receiver:
9+
10+
- supported endpoint: `POST /otel/v1/traces`
11+
- supported encoding: OTLP protobuf (`application/x-protobuf`)
12+
- supported signal: traces
13+
- storage: each incoming span is converted to a `trackio.Trace` log entry
14+
15+
## Start Trackio
16+
17+
Launch a writable Trackio dashboard:
18+
19+
```bash
20+
trackio show
21+
```
22+
23+
Use the write-access URL or token printed by the server. For a local server,
24+
OTLP requests must include the same write token as normal remote logging.
25+
26+
## Configure an OTLP Exporter
27+
28+
Point your OpenTelemetry exporter at Trackio:
29+
30+
```bash
31+
export OTEL_EXPORTER_OTLP_TRACES_ENDPOINT="http://localhost:7860/otel/v1/traces"
32+
export OTEL_EXPORTER_OTLP_TRACES_PROTOCOL="http/protobuf"
33+
export OTEL_EXPORTER_OTLP_TRACES_HEADERS="x-trackio-write-token=<write-token>"
34+
```
35+
36+
Set the Trackio project and run using OpenTelemetry resource attributes:
37+
38+
```python
39+
from opentelemetry.sdk.resources import Resource
40+
from opentelemetry.sdk.trace import TracerProvider
41+
42+
provider = TracerProvider(
43+
resource=Resource(
44+
{
45+
"trackio.project": "my-project",
46+
"trackio.run": "agent-run",
47+
"service.name": "smolagents",
48+
}
49+
)
50+
)
51+
```
52+
53+
If `trackio.project` is not set, Trackio uses the `project` query parameter if
54+
present, then falls back to `otel-traces`. If `trackio.run` is not set, Trackio
55+
uses `service.name`, then falls back to `otel`.
56+
57+
## Example: smolagents with OpenInference
58+
59+
```bash
60+
pip install smolagents opentelemetry-sdk opentelemetry-exporter-otlp-proto-http openinference-instrumentation-smolagents
61+
```
62+
63+
```python
64+
from opentelemetry.exporter.otlp.proto.http.trace_exporter import OTLPSpanExporter
65+
from opentelemetry.sdk.resources import Resource
66+
from opentelemetry.sdk.trace import TracerProvider
67+
from opentelemetry.sdk.trace.export import BatchSpanProcessor
68+
from openinference.instrumentation.smolagents import SmolagentsInstrumentor
69+
70+
provider = TracerProvider(
71+
resource=Resource(
72+
{
73+
"trackio.project": "agent-debugging",
74+
"trackio.run": "smolagents",
75+
"service.name": "smolagents",
76+
}
77+
)
78+
)
79+
provider.add_span_processor(BatchSpanProcessor(OTLPSpanExporter()))
80+
SmolagentsInstrumentor().instrument(tracer_provider=provider)
81+
82+
# Run your smolagents application normally. Spans will appear in Trackio.
83+
```
84+
85+
## Notes
86+
87+
Trackio preserves the raw span attributes in trace metadata and maps common
88+
OpenInference fields such as `input.value`, `output.value`, and indexed
89+
`llm.input_messages.*` / `llm.output_messages.*` attributes into conversational
90+
messages for the Traces tab.

pyproject.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ dependencies = [
2121
"numpy<3.0.0",
2222
"pillow<13.0.0",
2323
"orjson>=3.0,<4.0.0",
24+
"opentelemetry-proto>=1.25.0,<2.0.0",
2425
"tomli>=2.0.0; python_version < '3.11'",
2526
]
2627
classifiers = [

tests/unit/test_otel_ingest.py

Lines changed: 92 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,92 @@
1+
from opentelemetry.proto.collector.trace.v1.trace_service_pb2 import (
2+
ExportTraceServiceRequest,
3+
)
4+
from opentelemetry.proto.common.v1.common_pb2 import AnyValue, KeyValue
5+
from opentelemetry.proto.resource.v1.resource_pb2 import Resource
6+
from opentelemetry.proto.trace.v1.trace_pb2 import ResourceSpans, ScopeSpans, Span
7+
from starlette.testclient import TestClient
8+
9+
from trackio.otel import ingest_otlp_trace_bytes
10+
from trackio.server import build_starlette_app_only
11+
from trackio.sqlite_storage import SQLiteStorage
12+
13+
14+
def _kv(key, value):
15+
any_value = AnyValue()
16+
if isinstance(value, bool):
17+
any_value.bool_value = value
18+
elif isinstance(value, int):
19+
any_value.int_value = value
20+
elif isinstance(value, float):
21+
any_value.double_value = value
22+
else:
23+
any_value.string_value = value
24+
return KeyValue(key=key, value=any_value)
25+
26+
27+
def _request_bytes():
28+
span = Span(
29+
trace_id=bytes.fromhex("0" * 31 + "1"),
30+
span_id=bytes.fromhex("0" * 15 + "2"),
31+
name="agent run",
32+
kind=Span.SPAN_KIND_INTERNAL,
33+
start_time_unix_nano=1_700_000_000_000_000_000,
34+
end_time_unix_nano=1_700_000_001_000_000_000,
35+
attributes=[
36+
_kv("input.value", "hello"),
37+
_kv("output.value", "world"),
38+
_kv("openinference.span.kind", "agent"),
39+
],
40+
)
41+
resource_spans = ResourceSpans(
42+
resource=Resource(
43+
attributes=[
44+
_kv("trackio.project", "otel-project"),
45+
_kv("service.name", "smolagents"),
46+
_kv("service.instance.id", "service-1"),
47+
]
48+
),
49+
scope_spans=[ScopeSpans(spans=[span])],
50+
)
51+
return ExportTraceServiceRequest(resource_spans=[resource_spans]).SerializeToString()
52+
53+
54+
def test_ingest_otlp_trace_bytes_logs_trackio_trace(temp_dir):
55+
result = ingest_otlp_trace_bytes(_request_bytes())
56+
57+
assert result["accepted_spans"] == 1
58+
traces = SQLiteStorage.get_traces(
59+
"otel-project", run="smolagents", run_id="service-1"
60+
)
61+
assert len(traces) == 1
62+
assert traces[0]["run"] == "smolagents"
63+
assert traces[0]["messages"] == [
64+
{"role": "user", "content": "hello"},
65+
{"role": "assistant", "content": "world"},
66+
]
67+
assert traces[0]["metadata"]["source"] == "opentelemetry"
68+
assert traces[0]["metadata"]["attributes"]["openinference.span.kind"] == "agent"
69+
70+
71+
def test_otel_route_requires_write_token_and_accepts_protobuf(temp_dir):
72+
app, write_token = build_starlette_app_only()
73+
client = TestClient(app)
74+
75+
unauthorized = client.post("/otel/v1/traces", content=_request_bytes())
76+
assert unauthorized.status_code == 400
77+
78+
response = client.post(
79+
"/otel/v1/traces",
80+
content=_request_bytes(),
81+
headers={
82+
"content-type": "application/x-protobuf",
83+
"x-trackio-write-token": write_token,
84+
},
85+
)
86+
87+
assert response.status_code == 200
88+
assert response.headers["content-type"].startswith("application/x-protobuf")
89+
assert (
90+
len(SQLiteStorage.get_traces("otel-project", run="smolagents", run_id="service-1"))
91+
== 1
92+
)

0 commit comments

Comments
 (0)