Skip to content
Merged
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
78 changes: 73 additions & 5 deletions development_docs/traces.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,22 +2,90 @@

## Server traces

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.
For debugging purposes, we emit OpenTelemetry traces from the server. By
default, traces are written to a local JSONL file. When an OTLP endpoint is
configured, traces are exported via OTLP instead, letting marimo participate in
distributed tracing stacks such as Jaeger, Grafana Tempo, or GCP Cloud Trace.

You can analyze the traces using tools like Jaeger or Zipkin, or our marimo notebook:
### Prerequisites

Tracing requires the `otel` extra (or a development install, which includes
the same packages):

```bash
marimo edit scripts/analyze_traces.py
pip install "marimo[otel]"
```

### Enable Traces

To enable traces, set the `MARIMO_TRACING` environment variable to `true`:
Set `MARIMO_TRACING=true` to turn tracing on:

```bash
MARIMO_TRACING=true marimo run notebook.py
```

### Local file export (default)

With no additional configuration, spans are written to
`~/.marimo/traces/spans.jsonl` (the exact path depends on your platform's
XDG state directory). The file is cleared on each server restart and never
leaves your machine.

You can analyze local traces with Jaeger, Zipkin, or the bundled notebook:

```bash
MARIMO_TRACING=true ./your_server_command
marimo edit scripts/analyze_traces.py
```

### OTLP export

To export traces to a remote collector, set the standard OpenTelemetry
environment variables:

```bash
MARIMO_TRACING=true \
OTEL_EXPORTER_OTLP_ENDPOINT=http://localhost:4318 \
OTEL_SERVICE_NAME=marimo \
marimo run notebook.py
```

| Variable | Purpose | Default |
|---|---|---|
| `OTEL_EXPORTER_OTLP_ENDPOINT` | Endpoint of an OTLP collector for all signals | _(unset — file export)_ |
| `OTEL_EXPORTER_OTLP_TRACES_ENDPOINT` | Trace-specific OTLP endpoint; takes precedence over `OTEL_EXPORTER_OTLP_ENDPOINT` | _(unset)_ |
| `OTEL_EXPORTER_OTLP_PROTOCOL` | OTLP protocol for all signals: `http/protobuf` or `grpc` | `http/protobuf` |
| `OTEL_EXPORTER_OTLP_TRACES_PROTOCOL` | Trace-specific OTLP protocol; takes precedence over `OTEL_EXPORTER_OTLP_PROTOCOL` | _(unset)_ |
| `OTEL_SERVICE_NAME` | `service.name` resource attribute | `marimo` |
| `OTEL_RESOURCE_ATTRIBUTES` | Comma-separated `key=value` pairs added to the resource | _(empty)_ |

With the default `http/protobuf` protocol, a generic
`OTEL_EXPORTER_OTLP_ENDPOINT` is treated by the OpenTelemetry exporter as the
collector base URL and traces are sent to `/v1/traces`. For example,
`http://localhost:4318` exports traces to `http://localhost:4318/v1/traces`.
If you set `OTEL_EXPORTER_OTLP_TRACES_ENDPOINT`, include the full traces path,
for example `http://localhost:4318/v1/traces`.

For gRPC collectors, set the protocol explicitly:

```bash
MARIMO_TRACING=true \
OTEL_EXPORTER_OTLP_PROTOCOL=grpc \
OTEL_EXPORTER_OTLP_ENDPOINT=http://localhost:4317 \
marimo run notebook.py
```

If the selected OTLP exporter package is not installed, or the configured
protocol is unsupported, marimo logs a warning and falls back to the local file
exporter.

### Distributed trace propagation

The `OpenTelemetryMiddleware` extracts incoming W3C `traceparent` headers, so
when another service calls marimo (e.g., via the MCP HTTP endpoint), the
resulting spans are linked as children of the caller's trace. No extra
configuration is needed — propagation works automatically whenever tracing is
enabled.

## Profiling the kernel

You can generate profiling statistics of the kernel in edit mode using the
Expand Down
5 changes: 5 additions & 0 deletions marimo/_server/api/middleware.py
Original file line number Diff line number Diff line change
Expand Up @@ -202,9 +202,14 @@ async def dispatch(
if not GLOBAL_SETTINGS.TRACING:
return await call_next(request)

from opentelemetry.propagate import extract

ctx = extract(carrier=request.headers)

with server_tracer.start_as_current_span(
f"{request.method} {request.url.path}",
kind=self.trace.SpanKind.SERVER,
context=ctx,
attributes={
"http.method": request.method,
"http.target": request.url.path or "",
Expand Down
139 changes: 107 additions & 32 deletions marimo/_tracer.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@

import os
from contextlib import contextmanager
from typing import TYPE_CHECKING, Any, cast
from typing import TYPE_CHECKING, Any, Literal, cast

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


TRACE_FILENAME = os.path.join("traces", "spans.jsonl")
OTLPProtocol = Literal["grpc", "http/protobuf"]


def _otlp_endpoint_configured() -> bool:
return bool(
os.environ.get("OTEL_EXPORTER_OTLP_TRACES_ENDPOINT")
or os.environ.get("OTEL_EXPORTER_OTLP_ENDPOINT")
)


def _otlp_protocol() -> OTLPProtocol | None:
protocol = (
(
os.environ.get("OTEL_EXPORTER_OTLP_TRACES_PROTOCOL")
or os.environ.get("OTEL_EXPORTER_OTLP_PROTOCOL")
or "http/protobuf"
)
.strip()
.lower()
)
protocol = protocol or "http/protobuf"

if protocol in ("grpc", "http/protobuf"):
return cast(OTLPProtocol, protocol)

LOGGER.warning(
"Unsupported OTLP protocol %r; expected 'grpc' or "
"'http/protobuf'. Falling back to file export.",
protocol,
)
return None


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

from opentelemetry import trace
from opentelemetry.sdk.resources import Resource
from opentelemetry.sdk.trace import ReadableSpan, TracerProvider
from opentelemetry.sdk.trace.export import (
BatchSpanProcessor,
Expand All @@ -103,37 +135,80 @@ def _set_tracer_provider() -> None:
except Exception:
return

class FileExporter(SpanExporter):
def __init__(self, file_path: Path) -> None:
self.file_path = file_path
# Clear file
self.file_path.write_bytes(b"")

def export(self, spans: Sequence[ReadableSpan]) -> SpanExportResult:
try:
with self.file_path.open("a", encoding="utf-8") as f:
for span in spans:
f.write(span.to_json(cast(Any, None)))
f.write("\n")
return SpanExportResult.SUCCESS
except Exception as e:
LOGGER.exception(e)
return SpanExportResult.FAILURE

def shutdown(self) -> None:
pass

# Create a directory for logs if it doesn't exist
config_ready = ConfigReader.for_filename(TRACE_FILENAME)
filepath = config_ready.filepath
filepath.parent.mkdir(parents=True, exist_ok=True)

# Create a file exporter
file_exporter: FileExporter = FileExporter(filepath)

provider = TracerProvider()
processor = BatchSpanProcessor(file_exporter)
provider.add_span_processor(processor)
otlp_protocol = _otlp_protocol() if _otlp_endpoint_configured() else None
OTLPSpanExporter: Any | None = None
if otlp_protocol == "grpc":
try:
from opentelemetry.exporter.otlp.proto.grpc.trace_exporter import (
Comment thread
tigretigre marked this conversation as resolved.
OTLPSpanExporter as GrpcOTLPSpanExporter,
)

OTLPSpanExporter = GrpcOTLPSpanExporter
except ImportError:
LOGGER.warning(
"opentelemetry-exporter-otlp-proto-grpc not installed; "
"install marimo[otel] for OTLP export. Falling back to file export.",
)
elif otlp_protocol == "http/protobuf":
try:
from opentelemetry.exporter.otlp.proto.http.trace_exporter import (
OTLPSpanExporter as HttpOTLPSpanExporter,
)

OTLPSpanExporter = HttpOTLPSpanExporter
except ImportError:
LOGGER.warning(
"opentelemetry-exporter-otlp-proto-http not installed; "
"install marimo[otel] for OTLP export. Falling back to file export.",
)

if OTLPSpanExporter is not None:
resource = Resource.create(
{
"service.name": "marimo",
},
)
Comment on lines +166 to +170
Copy link
Copy Markdown
Contributor

@cubic-dev-ai cubic-dev-ai Bot Apr 30, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2: Explicitly passing service.name to Resource.create() silently overrides any user-provided OTEL_SERVICE_NAME or OTEL_RESOURCE_ATTRIBUTES environment variables. Merge the fallback resource instead.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At marimo/_tracer.py, line 166:

<comment>Explicitly passing `service.name` to `Resource.create()` silently overrides any user-provided `OTEL_SERVICE_NAME` or `OTEL_RESOURCE_ATTRIBUTES` environment variables. Merge the fallback resource instead.</comment>

<file context>
@@ -103,37 +135,80 @@ def _set_tracer_provider() -> None:
+            )
+
+    if OTLPSpanExporter is not None:
+        resource = Resource.create(
+            {
+                "service.name": "marimo",
</file context>
Suggested change
resource = Resource.create(
{
"service.name": "marimo",
},
)
resource = Resource.create()
if str(resource.attributes.get("service.name", "")).startswith("unknown_service"):
resource = resource.merge(Resource({"service.name": "marimo"}))
Fix with Cubic

provider = TracerProvider(resource=resource)
provider.add_span_processor(
BatchSpanProcessor(
OTLPSpanExporter(),
),
)
LOGGER.debug(
"OTel tracer: OTLP export via %s",
otlp_protocol,
)
else:

class FileExporter(SpanExporter):
def __init__(self, file_path: Path) -> None:
self.file_path = file_path
self.file_path.write_bytes(b"")

def export(
self,
spans: Sequence[ReadableSpan],
) -> SpanExportResult:
try:
with self.file_path.open("a", encoding="utf-8") as f:
for span in spans:
f.write(span.to_json(cast(Any, None)))
f.write("\n")
return SpanExportResult.SUCCESS
except Exception as e:
LOGGER.exception(e)
return SpanExportResult.FAILURE

def shutdown(self) -> None:
pass

config_ready = ConfigReader.for_filename(TRACE_FILENAME)
filepath = config_ready.filepath
filepath.parent.mkdir(parents=True, exist_ok=True)

provider = TracerProvider()
provider.add_span_processor(BatchSpanProcessor(FileExporter(filepath)))
LOGGER.debug("OTel tracer: file export to %s", filepath)

# Sets the global default tracer provider
trace.set_tracer_provider(provider)
Expand Down
9 changes: 9 additions & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -116,13 +116,22 @@ mcp = [
"pydantic>2",
]

otel = [
"opentelemetry-api~=1.28.0",
"opentelemetry-sdk~=1.28.0",
"opentelemetry-exporter-otlp-proto-http~=1.28.0",
"opentelemetry-exporter-otlp-proto-grpc~=1.28.0",
]

[dependency-groups]
dev = [
# Typo checking
"typos~=1.23.6",
# For tracing debugging
"opentelemetry-api~=1.28.0",
"opentelemetry-sdk~=1.28.0",
"opentelemetry-exporter-otlp-proto-http~=1.28.0",
"opentelemetry-exporter-otlp-proto-grpc~=1.28.0",
# For SQL
"duckdb>=1.0.0",
"sqlglot[c]>=26.8.0",
Expand Down
Loading
Loading