Skip to main content

☰ 🔭 Datalayer OTEL

Datalayer OTEL service provides long-term observability storage and querying for the Datalayer Platform. It collects traces, metrics and logs via Apache Pulsar, stores them as Parquet files using Apache DataFusion, and exposes a REST + WebSocket API for querying.

The datalayer-otel Helm chart deploys two components into the datalayer-otel namespace:

  1. OTEL Collector – An OpenTelemetry Collector Contrib instance that receives OTLP data (gRPC :4317, HTTP :4318) from instrumented services and exports it to Apache Pulsar topics.
  2. FastAPI Query Service – A Python FastAPI application that consumes OTLP data from Pulsar, stores it in Parquet files (via DataFusion), and serves a REST API for querying traces, metrics and logs.
┌──────────────┐ OTLP gRPC/HTTP ┌────────────────────┐
│ Services │ ──────────────────► │ OTEL Collector │
│ (13 svcs) │ │ (datalayer-otel) │
└──────────────┘ └────────┬──────────┘
│ Pulsar
┌─────────▼──────────┐
│ Apache Pulsar │
│ otel-traces │
│ otel-metrics │
│ otel-logs │
└─────────┬──────────┘
│ consume
┌─────────▼──────────┐
│ FastAPI Service │
│ DataFusion Store │
│ (Parquet files) │
└─────────┬──────────┘
│ REST / WS
┌─────────▼──────────┐
│ UI / CLI clients │
└────────────────────┘
info

This is separate from the datalayer-observer stack (deployed in the datalayer-observer namespace) which runs Tempo, Prometheus and Loki for Grafana-based dashboards. The datalayer-otel service provides a dedicated Datalayer-native API for observability data, with SQL query capabilities via DataFusion.

Deploy Datalayer OTEL​

plane up datalayer-otel

Check the availability of the OTEL Pods.

kubectl get pods -n datalayer-otel

API Endpoints​

The FastAPI query service exposes the following endpoints under /api/otel/v1:

EndpointMethodAuthDescription
/pingGETNoHealth check
/traces/GETJWTList / query traces
/metrics/GETJWTQuery metrics
/logs/GETJWTQuery logs
/query/sqlPOSTJWTRun ad-hoc SQL via DataFusion
/system/statsGETAdminStorage statistics
/wsWSTokenReal-time WebSocket stream
/dashboards/GET, POSTJWTThe built-in MCP and orchestration dashboards, then the account's own
/dashboards/{id}GET, PUT, DELETEJWTOne dashboard; built-ins are read-only (409)
/dashboards/{id}/dataGETJWTEvery panel, answered under the caller's account scope

API docs are available at https://<RUN_HOST>/api/otel/v1/docs (Swagger) and /api/otel/v1/redoc (ReDoc).

Dashboards​

The MCP gateway keeps its metrics catalogue and serves no telemetry route: this service is where its signals are read, so its dashboards live here, beside the platform's others. Seven are built in. Six are the gateway's — service levels, the four SLIs the app's Observability view and datalayer mcp metrics read, and one per marketed MCP area: calls and refusals, runs, sandboxes by provider, data access, agents by organization — generated from a copy of the gateway's catalogue (datalayer_otel/dashboards.py) that a contract test holds equal to the gateway's telemetry.CATALOGUE. The seventh, orchestration, is generated the same way from a copy of agent-runtimes' orchestration_measures.CATALOGUE: the measures the execution store records wherever executions are held, in the agents service and in durable. An instrument a writer adds without a panel fails a test rather than going undrawn; a panel naming a label its writer does not emit fails at import.

A dashboard is a list of panels. A panel is one metric, optionally split by one of its labels (by) and narrowed to some label values (where, as {"recovery": "disconnected"}), summarised one way: sum, last, avg, max or count. The orchestration dashboard narrows its settled executions to those that lost sight of their worker and to those whose worker was lost, each split by how they ended, which is what the share that completed is read from.

How a panel reads its series​

Services export counters as running totals, the OpenTelemetry default: every export repeats everything counted since the process started, so a service handling one call a minute reports 1, 2, 3, 4 and 5 over five minutes. Adding those reports up says 15; the answer is 5. A panel therefore reads each series — one process's running total for one set of labels — and only then combines the series:

  • a counter is how much its running total went up over the window: its last reading minus its reading when the window opened, and all of it when its process started inside the window;
  • a change reported as a delta adds up;
  • a gauge, or an up-down counter, is its latest reading;
  • a duration is its total over its count.

sum adds the series' values; avg and max are over series, except for a duration, whose avg is its total time over its total count; count is how many things were counted, or how many series a gauge has. p50, p95 and p99 read a percentile from a histogram's buckets: each series' bucket increases over the window, added across processes, and the rank interpolated within the bucket it falls in, as Prometheus's histogram_quantile does. A rank in the last bucket, which has no upper bound, reads as that bucket's lower bound. A panel whose aggregation is not its metric's default names it in its id (mcp-call-duration-p95).

The consumer keeps what makes a point part of a series: start_time_unix_nano (when its running total started, so a restarted process is a new series rather than a counter that went down), aggregation_temporality (cumulative or delta), is_monotonic (a sum that only goes up), and for an explicit-bucket histogram its bucket_counts and explicit_bounds. Points stored before those columns existed read as running totals from an unknown start. Each panel is two aggregate queries, one row per series, so a window of any length is read whole; the points a chart draws — each export's step — are what limit can cut, and points_truncated says when it did.

GET /dashboards/{id}/data?start=&end=&account_uid= answers every panel under the caller's resolved account scope, the same scope every other query here gets. That is what "agents by organization" means: the same panels, read as one organization by passing its account_uid. A panel whose query fails answers with its error and the other panels still answer.

Whose account a point is under decides who sees it. The gateway exports with a service credential, so its platform-level points — workers, dependencies, jobs — are filed under the gateway's own account, and a platform administrator asking for a built-in MCP dashboard with no account_uid reads that account rather than their personal one, where the gateway has never written a point. The orchestration dashboard is never redirected: its points carry usage_account_uid, the account whose executions they count. The account is found in the store, from the instruments only the gateway writes about itself, not configured. A call made in an organization carries usage_account_uid as a label, and the consumer files that point under the organization instead: that is what "agents by organization" reads when an owner passes their organization's account_uid, or opens it from the console. Naming an account always means that account, and a custom dashboard is never redirected.

People's own dashboards are JSON files under the data directory, one per dashboard per account ({data_dir}/dashboards/{account}/{id}.json); dashboards are few, small and edited by hand, so there is no table to migrate. A custom panel may chart any metric this service holds — the other services report here too. Built-in ids cannot be taken, updated or deleted.

CLI​

The datalayer-otel package also ships a CLI for operating and querying the service:

datalayer-otel serve # Start the FastAPI server
datalayer-otel traces # List / get traces
datalayer-otel metrics # Query metrics
datalayer-otel logs # Query logs
datalayer-otel query # Run ad-hoc SQL via DataFusion
datalayer-otel stats # Show storage statistics
datalayer-otel smoke-test # Send traces/metrics/logs and query them back
datalayer-otel logfire # Send test spans/logs to Logfire
datalayer-otel flush # Force-flush buffered data
datalayer-otel services # List observed service names

All query commands authenticate via DATALAYER_API_KEY (Bearer token).

Environment Variables​

FastAPI Query Service​

Authentication (via datalayer_common)​

VariableRequiredDefaultDescription
DATALAYER_JWT_SECRETYes–Shared secret for JWT token validation
DATALAYER_JWT_ISSUERYes–Expected JWT token issuer (e.g. https://id.datalayer.run)
DATALAYER_JWT_ALGORITHMNoHS256JWT signing algorithm
DATALAYER_JWT_CACHE_VALIDATENofalseCache JWT validation results
DATALAYER_IAM_API_KEYYes–Internal service-to-service API key

Service Configuration​

VariableRequiredDefaultDescription
DATALAYER_OTEL_PORTNo7800Port the FastAPI server listens on
DATALAYER_CORS_ORIGINNo*Allowed CORS origin (used by datalayer_common)

DataFusion / Storage​

VariableRequiredDefaultDescription
DATALAYER_OTEL_DATAFUSION_DATA_DIRNo/var/lib/datalayer-otel/dataPath to Parquet data directory
DATALAYER_OTEL_DATAFUSION_MAX_ROWS_PER_FILENo100000Max rows per Parquet file before rotation
DATALAYER_OTEL_RETENTION_DAYSNo30Number of days to retain Parquet files

Apache Pulsar​

VariableRequiredDefaultDescription
DATALAYER_OTEL_PULSAR_URLNopulsar://pulsar-broker:6650Pulsar broker URL
DATALAYER_OTEL_PULSAR_TRACES_TOPICNopersistent://public/default/otel-tracesPulsar topic for traces
DATALAYER_OTEL_PULSAR_METRICS_TOPICNopersistent://public/default/otel-metricsPulsar topic for metrics
DATALAYER_OTEL_PULSAR_LOGS_TOPICNopersistent://public/default/otel-logsPulsar topic for logs
DATALAYER_OTEL_PULSAR_SUBSCRIPTIONNodatalayer-otel-consumerPulsar subscription name
DATALAYER_OTEL_PULSAR_BATCH_SIZENo1000Number of messages per batch
DATALAYER_OTEL_PULSAR_BATCH_TIMEOUT_SECONDSNo3Max seconds before a partial batch is flushed

Logfire (optional)​

VariableRequiredDefaultDescription
DATALAYER_LOGFIRE_API_KEYNo""Logfire write token
DATALAYER_LOGFIRE_PROJECTNostarter-projectLogfire project name
DATALAYER_LOGFIRE_URLNohttps://logfire-us.pydantic.devLogfire base URL
DATALAYER_LOGFIRE_SEND_TO_LOGFIRENotrueWhether to send data to Logfire cloud

OTEL Collector​

The collector is configured via the Helm chart ConfigMap. The following Helm values control its behavior:

Helm ValueDefaultDescription
collector.imageotel/opentelemetry-collector-contrib:0.117.0Collector container image
collector.otlp.grpcPort4317OTLP gRPC listen port
collector.otlp.httpPort4318OTLP HTTP listen port
collector.pulsar.endpointpulsar://pulsar-broker:6650Pulsar broker endpoint
collector.pulsar.topics.tracespersistent://public/default/otel-tracesPulsar traces topic
collector.pulsar.topics.metricspersistent://public/default/otel-metricsPulsar metrics topic
collector.pulsar.topics.logspersistent://public/default/otel-logsPulsar logs topic

Instrumented Services​

The platform services that send telemetry to the OTEL Collector use these environment variables (set via up.sh and the datalayer_common.instrumentation module):

VariableRequiredDefaultDescription
OTEL_EXPORTER_OTLP_TRACES_ENDPOINTYeshttp://datalayer-otel-otel-collector-svc.datalayer-otel.svc.cluster.local:4317Collector gRPC endpoint for traces
OTEL_EXPORTER_OTLP_METRICS_ENDPOINTYeshttp://datalayer-otel-otel-collector-svc.datalayer-otel.svc.cluster.local:4317Collector gRPC endpoint for metrics
OTEL_EXPORTER_OTLP_LOGS_ENDPOINTYeshttp://datalayer-otel-otel-collector-svc.datalayer-otel.svc.cluster.local:4317Collector gRPC endpoint for logs
DATALAYER_OTEL_API_KEYNo""Bearer token attached to OTLP export requests
OTEL_PYTHON_LOG_LEVELNoinfoPython OTEL SDK log level
OTEL_SDK_DISABLEDNofalseSet to true to disable the OTEL SDK entirely
Endpoint Values

For a standard deployment, all three OTEL_EXPORTER_OTLP_*_ENDPOINT variables point to the same collector:

# The datalayer-otel collector in the datalayer-otel namespace
export OTEL_EXPORTER_OTLP_TRACES_ENDPOINT="http://datalayer-otel-otel-collector-svc.datalayer-otel.svc.cluster.local:4317"
export OTEL_EXPORTER_OTLP_METRICS_ENDPOINT="http://datalayer-otel-otel-collector-svc.datalayer-otel.svc.cluster.local:4317"
export OTEL_EXPORTER_OTLP_LOGS_ENDPOINT="http://datalayer-otel-otel-collector-svc.datalayer-otel.svc.cluster.local:4317"

If the datalayer-observer stack is also deployed, services can point to the observer collector instead (or in addition) — that collector forwards to Tempo, Prometheus and Loki for Grafana dashboards:

# The datalayer-observer collector in the datalayer-observer namespace
export OTEL_EXPORTER_OTLP_TRACES_ENDPOINT="http://datalayer-collector-collector.datalayer-observer.svc.cluster.local:4317"
export OTEL_EXPORTER_OTLP_METRICS_ENDPOINT="http://datalayer-collector-collector.datalayer-observer.svc.cluster.local:4317"
export OTEL_EXPORTER_OTLP_LOGS_ENDPOINT="http://datalayer-collector-collector.datalayer-observer.svc.cluster.local:4317"

Service names​

Each service names itself when it installs the providers (datalayer_common.instrumentation.instrument(app, "<name>")), and that name becomes the service.name resource attribute every span, metric and log record carries — it is what datalayer-otel services lists and what the SQL examples group by:

ai-agents, ai_inference, contents, growth, iam, inbounds, library, manager, runtimes, scheduler, spacer, status, success, support. The Operator and the Runtimes companion pass their name in from their configuration rather than hard-coding it.

MCP Server gateway and workers​

The MCP Server gateway is addressed at the collector by the same OTEL_* variables as the other services — its chart sets all three endpoints, DATALAYER_OTEL_API_KEY, OTEL_PYTHON_LOG_LEVEL and OTEL_SDK_DISABLED, and the gateway validates the endpoint at startup and reports it on GET /api/mcp/readyz. Telemetry is listed there and never probed: no user operation waits on the collector, and its availability is never a readiness condition for the gateway.

Two service.names are reserved for it: datalayer-mcp-server for the gateway replicas, and datalayer-mcp-worker for the per-user jupyter-mcp-server processes a replica runs — a worker's last lines are worth querying after the process is gone, which is why it gets a name of its own rather than being folded into the gateway's.

Nothing arrives from the gateway yet

The gateway does not install an OpenTelemetry SDK provider: it neither calls datalayer_common.instrumentation.instrument nor runs under opentelemetry-instrument. Its instruments are therefore created against the global no-op meter, no service.name is set, and no span, metric or log record from the gateway or its workers reaches this collector. The endpoints are configured so the addressing is right the day the provider is installed. Until then the gateway is read through its own logs, /api/mcp/readyz and GET /api/mcp/v1/operations/workers.

The MCP metric catalog​

The names are declared in one place — datalayer_mcp_server/telemetry.py — so a dashboard and an alert can be written against names that do not drift. Labels are closed and bounded: a tool, an outcome, a dependency, a refusal reason. A user, an agent, an organization or a sandbox is never a metric label.

InstrumentKindLabels
mcp.callscountertool, outcome — declared, not written yet
mcp.refusalscounterreason (scope, item, attribution_failed, header_body_mismatch, access_check_failed, unknown_handle, sandbox_lost, runtimes_unavailable, rate_limited:user, rate_limited:agent)
mcp.workersgaugestate=running
mcp.worker_start_secondshistogramprovider=process
mcp.forwardscounteroutcome (ok, unreachable) — cross-replica hops
mcp.dependency.durationhistogramdependency (iam, store, spacer, runtimes, contents), outcome
mcp.dependency.timeoutscounterdependency
mcp.readiness.failurescounterdependency
mcp.sandbox.lostcounterprovider, then (reported, relaunched)

The durable.* instruments are written by the durable execution service under service.name datalayer-durable, and sandbox.launch_seconds by the Runtimes path; both are listed, with their alert rules, on the durable service page. This sentence used to say they belonged to a service that was not deployed, which stopped being true with the durable chart and was left standing.

Correlation​

Spans are not emitted by the gateway yet, so none of the fields below can be queried today. They are the set the gateway will carry, and the set to write a dashboard or a support query against once it does — recorded here so a name is agreed before it is used:

FieldOnMeaning
mcp.method / mcp.toolspans, and mcp.callsThe JSON-RPC method and the tool name
mcp.binding.uidspans, logsThe handle the call resolved: nb_… a notebook, sb_… a sandbox session, ts_… a toolset
mcp.task.idspans, logsThe run. A task outliving its request links its later spans to the initiating trace, so "show me this run" is a query by task id rather than by one trace id
sandbox.uid / sandbox.providerspansThe Runtimes pod_name of the session's sandbox, and which provider holds it
workflow.id / workflow.enginespansThe durable run behind a task
evals.launch_uid / evals.run_uid / evals.case_id / evals.subjectthe evals.task spanOne benchmark task a team worked, and the root of the trace its whole team tree runs in; the task result's trace_id is this trace. Written by durable, its page says more
contents.source.uidspansThe content source a call reached
user.uid, client.id, org.uidspans onlyIdentity, on access-controlled spans — never on a metric, where the label set must stay bounded

The client's traceparent (SEP-414, sent in _meta) is meant to be continued through gateway, worker, Runtimes and Contents; the gateway does not read it today. IAM already reads a request's traceparent into the trace_id of the audit rows it writes, so its security decisions can be joined to a run once the gateway carries the trace.

Dashboards and alerts​

There are none for MCP yet: no dashboard on this service, no alert rules on the SLOs, and no PrometheusRule in the gateway's chart. A gateway outage is caught today by the ordinary Kubernetes deployment, ingress and certificate alerts. The SLIs the alerts will read — availability of POST /mcp, p95 call duration for the synchronous tools, task success rate, and p95 sandbox launch time per provider — need mcp.calls, mcp.call.duration and sandbox.launch_seconds, none of which is written yet.

Connecting from the Internet (Public Endpoints)​

When the datalayer-otel service is deployed behind a public ingress (e.g. https://prod1.datalayer.run), external clients can send OTLP data and query the REST API over the internet.

Environment Variables (Public Internet)​

When sending telemetry or querying the OTEL service from outside the cluster, use these environment variables:

VariableRequiredDefaultDescription
DATALAYER_RUN_URLYeshttps://prod1.datalayer.runBase URL of the Datalayer platform
DATALAYER_API_KEYYes–JWT or API key for authentication (used as Bearer token)
DATALAYER_OTLP_URLNo${DATALAYER_RUN_URL}/api/otel/v1/otlpOTLP/HTTP collector endpoint for sending signals
DATALAYER_OTEL_URLNo${DATALAYER_RUN_URL}REST API base URL for querying traces/metrics/logs
OTEL_EXPORTER_OTLP_TRACES_ENDPOINTNo–Standard OTEL SDK env var (set to ${DATALAYER_OTLP_URL} for external use)
OTEL_EXPORTER_OTLP_METRICS_ENDPOINTNo–Standard OTEL SDK env var (set to ${DATALAYER_OTLP_URL} for external use)
OTEL_EXPORTER_OTLP_LOGS_ENDPOINTNo–Standard OTEL SDK env var (set to ${DATALAYER_OTLP_URL} for external use)
Internal vs. Public Endpoints

Internal services (inside the cluster) use gRPC on port 4317 via the cluster-local service name. External clients use OTLP/HTTP via the public ingress — gRPC is not available over the public endpoint.

Sending OTLP Signals (OTLP/HTTP)​

The OTEL Collector is exposed at https://<RUN_HOST>/api/otel/v1/otlp.
External clients use OTLP/HTTP (not gRPC) and authenticate with a Bearer token:

# Public OTLP endpoint for external / internet clients
export DATALAYER_RUN_URL="https://prod1.datalayer.run"
export DATALAYER_API_KEY="<your-jwt-or-api-key>"

# OTLP/HTTP endpoints (used by the core otel example generator)
export DATALAYER_OTLP_URL="${DATALAYER_RUN_URL}/api/otel/v1/otlp"
# Individual signal endpoints:
# POST ${DATALAYER_OTLP_URL}/v1/traces
# POST ${DATALAYER_OTLP_URL}/v1/logs
# POST ${DATALAYER_OTLP_URL}/v1/metrics

Querying the REST API​

export DATALAYER_RUN_URL="https://prod1.datalayer.run"
export DATALAYER_API_KEY="<your-jwt-or-api-key>"

# Query traces
curl -H "Authorization: Bearer ${DATALAYER_API_KEY}" \
"${DATALAYER_RUN_URL}/api/otel/v1/traces/?limit=10"

# Query logs
curl -H "Authorization: Bearer ${DATALAYER_API_KEY}" \
"${DATALAYER_RUN_URL}/api/otel/v1/logs/?limit=10"

# Query metrics
curl -H "Authorization: Bearer ${DATALAYER_API_KEY}" \
"${DATALAYER_RUN_URL}/api/otel/v1/metrics/?limit=10"

# Run SQL on DataFusion
curl -X POST -H "Authorization: Bearer ${DATALAYER_API_KEY}" \
-H "Content-Type: application/json" \
-d '{"sql": "SELECT service_name, COUNT(*) as cnt FROM spans GROUP BY service_name ORDER BY cnt DESC LIMIT 10"}' \
"${DATALAYER_RUN_URL}/api/otel/v1/query/sql"

WebSocket (Real-Time Stream)​

# Connect to the WebSocket stream (authenticates via query param)
wscat -c "wss://prod1.datalayer.run/api/otel/v1/ws?token=${DATALAYER_API_KEY}"

Core OTEL Example​

The core otel example uses these environment variables to connect to a public deployment:

# Point the example at the public Datalayer platform
export DATALAYER_RUN_URL="https://prod1.datalayer.run"
export DATALAYER_API_KEY="<your-jwt-or-api-key>"

# Optional: override the OTLP target (defaults to ${DATALAYER_RUN_URL}/api/otel/v1/otlp)
# export DATALAYER_OTLP_URL="https://prod1.datalayer.run/api/otel/v1/otlp"

# Optional: override the OTEL REST query URL (defaults to DATALAYER_RUN_URL)
# export DATALAYER_OTEL_URL="https://prod1.datalayer.run"

# Start the example
cd examples/otel
uvicorn app.main:app --reload --port 8600

The generator (generator.py) resolves the OTLP endpoint in this order:

  1. DATALAYER_OTLP_URL — explicit OTLP collector URL
  2. DATALAYER_OTEL_RUN_URL or DATALAYER_RUN_URL + /api/otel/v1/otlp
  3. https://prod1.datalayer.run/api/otel/v1/otlp — production fallback

The UI (vite.config.ts) resolves the REST + WebSocket URLs from DATALAYER_RUN_URL (default https://prod1.datalayer.run).

Smoke Test​

You can verify the full pipeline end-to-end (send → Pulsar → DataFusion → query) using:

# Via the datalayer CLI (from datalayer-core)
datalayer otel smoke-test --url https://prod1.datalayer.run --token $DATALAYER_API_KEY

# Via the datalayer-otel CLI (from the otel service itself)
datalayer-otel smoke-test --url https://prod1.datalayer.run --token $DATALAYER_API_KEY

This sends test traces, metrics and logs, waits for ingestion, then queries them back and runs SQL queries on the DataFusion tables.