Skip to content

Commit 1fe39e1

Browse files
feat: add OTLP export and W3C trace context propagation to tracing (#9414)
Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>
1 parent 6783a8d commit 1fe39e1

6 files changed

Lines changed: 714 additions & 37 deletions

File tree

development_docs/traces.md

Lines changed: 73 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -2,22 +2,90 @@
22

33
## Server traces
44

5-
For debugging purposes, we emit OpenTelemetry traces from the server. We emit traces to `~/.marimo/traces/spans.jsonl`. We don't emit any sensitive information in the traces, and these traces stay local to your machine. The traces get wiped on each sever restart.
5+
For debugging purposes, we emit OpenTelemetry traces from the server. By
6+
default, traces are written to a local JSONL file. When an OTLP endpoint is
7+
configured, traces are exported via OTLP instead, letting marimo participate in
8+
distributed tracing stacks such as Jaeger, Grafana Tempo, or GCP Cloud Trace.
69

7-
You can analyze the traces using tools like Jaeger or Zipkin, or our marimo notebook:
10+
### Prerequisites
11+
12+
Tracing requires the `otel` extra (or a development install, which includes
13+
the same packages):
814

915
```bash
10-
marimo edit scripts/analyze_traces.py
16+
pip install "marimo[otel]"
1117
```
1218

1319
### Enable Traces
1420

15-
To enable traces, set the `MARIMO_TRACING` environment variable to `true`:
21+
Set `MARIMO_TRACING=true` to turn tracing on:
22+
23+
```bash
24+
MARIMO_TRACING=true marimo run notebook.py
25+
```
26+
27+
### Local file export (default)
28+
29+
With no additional configuration, spans are written to
30+
`~/.marimo/traces/spans.jsonl` (the exact path depends on your platform's
31+
XDG state directory). The file is cleared on each server restart and never
32+
leaves your machine.
33+
34+
You can analyze local traces with Jaeger, Zipkin, or the bundled notebook:
1635

1736
```bash
18-
MARIMO_TRACING=true ./your_server_command
37+
marimo edit scripts/analyze_traces.py
1938
```
2039

40+
### OTLP export
41+
42+
To export traces to a remote collector, set the standard OpenTelemetry
43+
environment variables:
44+
45+
```bash
46+
MARIMO_TRACING=true \
47+
OTEL_EXPORTER_OTLP_ENDPOINT=http://localhost:4318 \
48+
OTEL_SERVICE_NAME=marimo \
49+
marimo run notebook.py
50+
```
51+
52+
| Variable | Purpose | Default |
53+
|---|---|---|
54+
| `OTEL_EXPORTER_OTLP_ENDPOINT` | Endpoint of an OTLP collector for all signals | _(unset — file export)_ |
55+
| `OTEL_EXPORTER_OTLP_TRACES_ENDPOINT` | Trace-specific OTLP endpoint; takes precedence over `OTEL_EXPORTER_OTLP_ENDPOINT` | _(unset)_ |
56+
| `OTEL_EXPORTER_OTLP_PROTOCOL` | OTLP protocol for all signals: `http/protobuf` or `grpc` | `http/protobuf` |
57+
| `OTEL_EXPORTER_OTLP_TRACES_PROTOCOL` | Trace-specific OTLP protocol; takes precedence over `OTEL_EXPORTER_OTLP_PROTOCOL` | _(unset)_ |
58+
| `OTEL_SERVICE_NAME` | `service.name` resource attribute | `marimo` |
59+
| `OTEL_RESOURCE_ATTRIBUTES` | Comma-separated `key=value` pairs added to the resource | _(empty)_ |
60+
61+
With the default `http/protobuf` protocol, a generic
62+
`OTEL_EXPORTER_OTLP_ENDPOINT` is treated by the OpenTelemetry exporter as the
63+
collector base URL and traces are sent to `/v1/traces`. For example,
64+
`http://localhost:4318` exports traces to `http://localhost:4318/v1/traces`.
65+
If you set `OTEL_EXPORTER_OTLP_TRACES_ENDPOINT`, include the full traces path,
66+
for example `http://localhost:4318/v1/traces`.
67+
68+
For gRPC collectors, set the protocol explicitly:
69+
70+
```bash
71+
MARIMO_TRACING=true \
72+
OTEL_EXPORTER_OTLP_PROTOCOL=grpc \
73+
OTEL_EXPORTER_OTLP_ENDPOINT=http://localhost:4317 \
74+
marimo run notebook.py
75+
```
76+
77+
If the selected OTLP exporter package is not installed, or the configured
78+
protocol is unsupported, marimo logs a warning and falls back to the local file
79+
exporter.
80+
81+
### Distributed trace propagation
82+
83+
The `OpenTelemetryMiddleware` extracts incoming W3C `traceparent` headers, so
84+
when another service calls marimo (e.g., via the MCP HTTP endpoint), the
85+
resulting spans are linked as children of the caller's trace. No extra
86+
configuration is needed — propagation works automatically whenever tracing is
87+
enabled.
88+
2189
## Profiling the kernel
2290

2391
You can generate profiling statistics of the kernel in edit mode using the

marimo/_server/api/middleware.py

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -202,9 +202,14 @@ async def dispatch(
202202
if not GLOBAL_SETTINGS.TRACING:
203203
return await call_next(request)
204204

205+
from opentelemetry.propagate import extract
206+
207+
ctx = extract(carrier=request.headers)
208+
205209
with server_tracer.start_as_current_span(
206210
f"{request.method} {request.url.path}",
207211
kind=self.trace.SpanKind.SERVER,
212+
context=ctx,
208213
attributes={
209214
"http.method": request.method,
210215
"http.target": request.url.path or "",

marimo/_tracer.py

Lines changed: 107 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@
33

44
import os
55
from contextlib import contextmanager
6-
from typing import TYPE_CHECKING, Any, cast
6+
from typing import TYPE_CHECKING, Any, Literal, cast
77

88
from marimo import _loggers
99
from marimo._config.settings import GLOBAL_SETTINGS
@@ -81,6 +81,37 @@ def start_as_current_span(self, *args: Any, **kwargs: Any) -> Any:
8181

8282

8383
TRACE_FILENAME = os.path.join("traces", "spans.jsonl")
84+
OTLPProtocol = Literal["grpc", "http/protobuf"]
85+
86+
87+
def _otlp_endpoint_configured() -> bool:
88+
return bool(
89+
os.environ.get("OTEL_EXPORTER_OTLP_TRACES_ENDPOINT")
90+
or os.environ.get("OTEL_EXPORTER_OTLP_ENDPOINT")
91+
)
92+
93+
94+
def _otlp_protocol() -> OTLPProtocol | None:
95+
protocol = (
96+
(
97+
os.environ.get("OTEL_EXPORTER_OTLP_TRACES_PROTOCOL")
98+
or os.environ.get("OTEL_EXPORTER_OTLP_PROTOCOL")
99+
or "http/protobuf"
100+
)
101+
.strip()
102+
.lower()
103+
)
104+
protocol = protocol or "http/protobuf"
105+
106+
if protocol in ("grpc", "http/protobuf"):
107+
return cast(OTLPProtocol, protocol)
108+
109+
LOGGER.warning(
110+
"Unsupported OTLP protocol %r; expected 'grpc' or "
111+
"'http/protobuf'. Falling back to file export.",
112+
protocol,
113+
)
114+
return None
84115

85116

86117
def _set_tracer_provider() -> None:
@@ -90,6 +121,7 @@ def _set_tracer_provider() -> None:
90121
DependencyManager.opentelemetry.require("for tracing.")
91122

92123
from opentelemetry import trace
124+
from opentelemetry.sdk.resources import Resource
93125
from opentelemetry.sdk.trace import ReadableSpan, TracerProvider
94126
from opentelemetry.sdk.trace.export import (
95127
BatchSpanProcessor,
@@ -103,37 +135,80 @@ def _set_tracer_provider() -> None:
103135
except Exception:
104136
return
105137

106-
class FileExporter(SpanExporter):
107-
def __init__(self, file_path: Path) -> None:
108-
self.file_path = file_path
109-
# Clear file
110-
self.file_path.write_bytes(b"")
111-
112-
def export(self, spans: Sequence[ReadableSpan]) -> SpanExportResult:
113-
try:
114-
with self.file_path.open("a", encoding="utf-8") as f:
115-
for span in spans:
116-
f.write(span.to_json(cast(Any, None)))
117-
f.write("\n")
118-
return SpanExportResult.SUCCESS
119-
except Exception as e:
120-
LOGGER.exception(e)
121-
return SpanExportResult.FAILURE
122-
123-
def shutdown(self) -> None:
124-
pass
125-
126-
# Create a directory for logs if it doesn't exist
127-
config_ready = ConfigReader.for_filename(TRACE_FILENAME)
128-
filepath = config_ready.filepath
129-
filepath.parent.mkdir(parents=True, exist_ok=True)
130-
131-
# Create a file exporter
132-
file_exporter: FileExporter = FileExporter(filepath)
133-
134-
provider = TracerProvider()
135-
processor = BatchSpanProcessor(file_exporter)
136-
provider.add_span_processor(processor)
138+
otlp_protocol = _otlp_protocol() if _otlp_endpoint_configured() else None
139+
OTLPSpanExporter: Any | None = None
140+
if otlp_protocol == "grpc":
141+
try:
142+
from opentelemetry.exporter.otlp.proto.grpc.trace_exporter import (
143+
OTLPSpanExporter as GrpcOTLPSpanExporter,
144+
)
145+
146+
OTLPSpanExporter = GrpcOTLPSpanExporter
147+
except ImportError:
148+
LOGGER.warning(
149+
"opentelemetry-exporter-otlp-proto-grpc not installed; "
150+
"install marimo[otel] for OTLP export. Falling back to file export.",
151+
)
152+
elif otlp_protocol == "http/protobuf":
153+
try:
154+
from opentelemetry.exporter.otlp.proto.http.trace_exporter import (
155+
OTLPSpanExporter as HttpOTLPSpanExporter,
156+
)
157+
158+
OTLPSpanExporter = HttpOTLPSpanExporter
159+
except ImportError:
160+
LOGGER.warning(
161+
"opentelemetry-exporter-otlp-proto-http not installed; "
162+
"install marimo[otel] for OTLP export. Falling back to file export.",
163+
)
164+
165+
if OTLPSpanExporter is not None:
166+
resource = Resource.create(
167+
{
168+
"service.name": "marimo",
169+
},
170+
)
171+
provider = TracerProvider(resource=resource)
172+
provider.add_span_processor(
173+
BatchSpanProcessor(
174+
OTLPSpanExporter(),
175+
),
176+
)
177+
LOGGER.debug(
178+
"OTel tracer: OTLP export via %s",
179+
otlp_protocol,
180+
)
181+
else:
182+
183+
class FileExporter(SpanExporter):
184+
def __init__(self, file_path: Path) -> None:
185+
self.file_path = file_path
186+
self.file_path.write_bytes(b"")
187+
188+
def export(
189+
self,
190+
spans: Sequence[ReadableSpan],
191+
) -> SpanExportResult:
192+
try:
193+
with self.file_path.open("a", encoding="utf-8") as f:
194+
for span in spans:
195+
f.write(span.to_json(cast(Any, None)))
196+
f.write("\n")
197+
return SpanExportResult.SUCCESS
198+
except Exception as e:
199+
LOGGER.exception(e)
200+
return SpanExportResult.FAILURE
201+
202+
def shutdown(self) -> None:
203+
pass
204+
205+
config_ready = ConfigReader.for_filename(TRACE_FILENAME)
206+
filepath = config_ready.filepath
207+
filepath.parent.mkdir(parents=True, exist_ok=True)
208+
209+
provider = TracerProvider()
210+
provider.add_span_processor(BatchSpanProcessor(FileExporter(filepath)))
211+
LOGGER.debug("OTel tracer: file export to %s", filepath)
137212

138213
# Sets the global default tracer provider
139214
trace.set_tracer_provider(provider)

pyproject.toml

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -116,13 +116,22 @@ mcp = [
116116
"pydantic>2",
117117
]
118118

119+
otel = [
120+
"opentelemetry-api~=1.28.0",
121+
"opentelemetry-sdk~=1.28.0",
122+
"opentelemetry-exporter-otlp-proto-http~=1.28.0",
123+
"opentelemetry-exporter-otlp-proto-grpc~=1.28.0",
124+
]
125+
119126
[dependency-groups]
120127
dev = [
121128
# Typo checking
122129
"typos~=1.23.6",
123130
# For tracing debugging
124131
"opentelemetry-api~=1.28.0",
125132
"opentelemetry-sdk~=1.28.0",
133+
"opentelemetry-exporter-otlp-proto-http~=1.28.0",
134+
"opentelemetry-exporter-otlp-proto-grpc~=1.28.0",
126135
# For SQL
127136
"duckdb>=1.0.0",
128137
"sqlglot[c]>=26.8.0",

0 commit comments

Comments
 (0)