Skip to content

Add OpenTelemetry Tracing for External Services - #1542

Open
genematx wants to merge 30 commits into
bluesky:mainfrom
genematx:trace-external-services
Open

genematx wants to merge 30 commits into
bluesky:mainfrom
genematx:trace-external-services

Conversation

@genematx

@genematx genematx commented Oct 1, 2026 •

Copy link
Copy Markdown
Contributor

Where #1536 traces the request lifecycle (the FastAPI server span and internal phase spans), this PR adds spans for the external services a request touches, so a trace shows the full picture — the catalog, storage, cache, and outbound HTTP calls — and a Grafana service graph of Tiled's dependencies.

Stacked on #1536; that PR should merge first.

What's traced

Service Instrumentation Shows up as
PostgreSQL catalog & authentication opentelemetry-instrumentation-asyncpg SELECT/INSERT/… spans, db.system=postgresql, db.name=tiled_catalog / authn
SQL storage (tabular data, via ADBC) opentelemetry-instrumentation-dbapi wrapping the ADBC connection, plus an explicit span around the bulk adbc_ingest write (which bypasses the DBAPI execute path) db.system=postgresql, db.name=tiled_storage, adbc_ingest
Redis streaming cache opentelemetry-instrumentation-redis HSET/HGET/… spans, db.system=redis
Outbound HTTP (webhook delivery, OIDC auth, external policy servers) opentelemetry-instrumentation-httpx POST/GET client spans

These are client spans emitted by Tiled, so they keep service.name=tiled and are distinguished by db.system / the target host — the span correctly stays with its emitter rather than being re-labelled as another service.

Screenshot 2026-10-01 at 1 50 14 PM

Service graph

A peer.service attribute is set on each dependency so Grafana Tempo's Service Graph renders them as distinct nodes. Postgres is split by db.name; all webhook deliveries are grouped under a single webhooks node (identified by the X-Tiled-Event-ID header); other outbound calls are named by host. The example Collector / Tempo / Grafana config is wired up to generate and display it.

user → tiled → { tiled_catalog, tiled_storage, redis, webhooks, <oidc-host>, … }
Screenshot 2026-10-01 at 12 14 06 PM

Manual testing

Several integration tests are included in test_tracing.py, but it also informative to test the stack manually:

1. Start the example stack (Tiled + Postgres + Redis + Collector + Jaeger + Tempo + Grafana)

From the repo root (the two webhook env vars let the toy receiver in step 3 work; you can omit them if testing the webhook tracing is not in the scope):

TILED_WEBHOOKS_ALLOW_HTTP=true TILED_WEBHOOKS_ALLOW_PRIVATE_ADDRESSES=true \
  docker compose -f compose.dev.yml -f compose.monitoring.yml up --build
2. Exercise catalog (asyncpg), storage (ADBC), and streaming (Redis)
import numpy as np, pyarrow
from tiled.client import from_uri

c = from_uri("http://localhost:8000", api_key="secret")

# catalog (asyncpg): writes + reads
cont = c.create_container("demo")
arr = cont.write_array(np.arange(10), key="arr")
list(c)

# SQL storage (ADBC): appendable table -> adbc_ingest + DBAPI spans
z = c.create_container("demo_table", specs=["composite"])
tab = pyarrow.table({"x": [1, 2, 3], "y": [4.0, 5.0, 6.0]})
z.create_appendable_table(schema=tab.schema, key="t").append_partition(0, tab)

# Redis (streaming cache): subscribe, then push an update
sub = arr.subscribe(); sub.start_in_thread(start=0)
arr.patch(np.arange(10), offset=10, extend=True)
3. Exercise webhooks (httpx)

A minimal receiver (stdlib only), run on the host:

from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer

class H(BaseHTTPRequestHandler):
    def do_POST(self):
        body = self.rfile.read(int(self.headers.get("Content-Length", 0)))
        print(self.headers.get("X-Tiled-Event-ID"), body)
        self.send_response(200); self.end_headers()

ThreadingHTTPServer(("0.0.0.0", 9000), H).serve_forever()

Register it and fire an event (the container reaches the host via host.docker.internal):

import httpx
httpx.post(
    "http://localhost:8000/api/v1/webhooks/target/",
    headers={"Authorization": "Apikey secret"},
    json={"url": "http://host.docker.internal:9000/hook"},
).raise_for_status()
c.create_container("fires_webhook")   # delivered via httpx -> a 'webhooks' client span
4. What you should see
  • Jaeger (service tiled): request traces now contain child spans for SELECT/INSERT (db.system=postgresql), HSET/HGET (db.system=redis), adbc_ingest, and POST (webhook delivery). Startup / new-connection queries that run outside a request appear as their own short SELECT/select/show traces.
  • Grafana → Tempo → Service Graph: tiled with edges to tiled_catalog, tiled_storage, redis, and webhooks.

The automated coverage lives in tests/test_tracing.py (extended with asyncpg / ADBC / Redis / httpx assertions); those tests run against live Postgres/Redis and skip when TILED_TEST_POSTGRESQL_URI / TILED_TEST_REDIS are unset.

Caveats

  • Database tracing covers PostgreSQL (asyncpg + ADBC), not SQLite — the SQLAlchemy instrumentation that would cover SQLite does not yet support Tiled's SQLAlchemy version. SQLite / in-memory setups still get the request and phase spans from Add Basic OpenTelemetry Tracing #1536.

Follow up on: PR #1536
Related Issue: #1000

Checklist

  • Add a Changelog entry
  • Add the ticket number which this PR closes to the comment section

Instrument the server with OpenTelemetry tracing, exported over OTLP and
disabled unless OTEL_EXPORTER_OTLP_ENDPOINT is set. Health checks and
metrics scrapes can be excluded via OTEL_PYTHON_FASTAPI_EXCLUDED_URLS.

Add an OpenTelemetry Collector and Jaeger to the example monitoring
stack. The Collector receives traces and forwards them to Jaeger, and
also scrapes and re-exposes Tiled's Prometheus metrics.

Add a 'Distributed Tracing' user-guide page.
Send traces to both Jaeger and Grafana Tempo: the OpenTelemetry Collector
now fans traces out to a Tempo service in addition to Jaeger, and Tempo is
added as a Grafana datasource so traces can be explored in Grafana with
TraceQL. Bump Grafana to a version that supports TraceQL, and add the
required apiVersion to the datasource provisioning files.

Illustrate the telemetry flow in the tracing docs with a diagram.
Wrap record_timing in an OpenTelemetry span so the phases it already times
(access control, read, tokenize, pack) appear as child spans in a request's
trace, giving a per-request breakdown of where time is spent. The span is a
no-op when OpenTelemetry is not installed or no tracer provider is
configured.
record_timing opened a phase span unconditionally, so requests that are
excluded from tracing (health checks, metrics scrapes) produced orphaned
single-span traces that cluttered the trace UI. Only open a phase span
when there is an active recording span.
FastAPI >=0.142 ships built-in OpenTelemetry support that, when
OTEL_EXPORTER_OTLP_ENDPOINT is set, registers its own OTLP export
pipeline on the global tracer provider. Combined with the tracing
pipeline Tiled configures, this exported every span twice. Pass
telemetry={"auto_configure": False} to FastAPI() so Tiled remains the
sole exporter.
Use an in-memory span exporter to verify, without a running collector or
backend: a traced request emits the FastAPI server span and child phase
spans (single trace, no duplicate span IDs); excluded endpoints emit no
spans; tracing stays off when OTEL_EXPORTER_OTLP_ENDPOINT is unset; and
FastAPI's built-in telemetry does not register a second export pipeline.
FastAPI >=0.142 ships its own OpenTelemetry integration that emits request
spans on any globally installed tracer provider when the app is not
instrumented by opentelemetry-instrumentation-fastapi. The in-memory
provider the tests install made test_tracing_disabled_by_default capture
those spans and fail on CI. Assert instead that the tracing hook did not
instrument the app (its documented off-by-default behavior). Also use
single backticks in comments/docstrings.
The base compose.yml and compose.dev.yml set OTEL_EXPORTER_OTLP_ENDPOINT
pointing at otel-collector, which is only defined in compose.monitoring.yml.
Running the base files on their own therefore enabled tracing against an
unreachable host, causing continuous export failures. Move the OTEL_*
variables into compose.monitoring.yml, next to the collector they target, so
tracing is off unless that overlay is used.

Also update the tracing user guide to launch the example with compose.dev.yml
(which builds the image from this checkout) instead of compose.yml (whose
pinned published image may not include tracing yet).
The storage database is accessed via ADBC, which the asyncpg instrumentation
does not cover, so its queries would otherwise be invisible in traces. Wrap the
ADBC connection factory with opentelemetry-instrumentation-dbapi (gated on
OTEL_EXPORTER_OTLP_ENDPOINT) to emit a span per query, and emit an explicit
span around the bulk adbc_ingest write, which bypasses the DBAPI execute path.
Trace Tiled's outbound HTTP calls (webhook deliveries, OIDC authentication,
external policy servers) with opentelemetry-instrumentation-httpx. A request
hook sets peer.service so each dependency becomes its own node in the Tempo
service graph: all webhook deliveries (identified by the X-Tiled-Event-ID
header) group under a single 'webhooks' node, while other calls are named by
host.
Set peer.service on database and cache client spans in the Collector (the
database name for Postgres, db.system otherwise) and drop noisy
transaction-control statements. Have Tempo's metrics generator build service
graph metrics from the peer attributes and remote-write them to Prometheus,
and wire Grafana's Tempo datasource to that Prometheus for the Service Graph.
Extend the in-process tracing tests to assert the external-service spans:
asyncpg (catalog Postgres), ADBC (SQL storage), Redis (streaming cache), and
httpx (webhook delivery). These run against live backends and skip when
TILED_TEST_POSTGRESQL_URI / TILED_TEST_REDIS are not configured.
Describe the asyncpg/ADBC/Redis/httpx spans, note that database tracing covers
PostgreSQL (not SQLite), add a sampling section, and explain the Grafana
service graph of Tiled's dependencies.
@genematx
genematx marked this pull request as ready for review October 1, 2026 19:03

@danielballan danielballan left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Looks good. Other than my comments already in #1536, I just have one question here.

Comment thread tiled/storage.py Outdated
"tiled.storage",
creator(),
dialect,
connection_attributes={"database": "adbc_current_catalog"},

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

I feel confused by the name adbc_current_catalog because I don't except to see "ADBC" and "catalog" together. ADBC is for tabular storage, not catalog. It seems possible (or likely!) that I'm missing something though.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Great question, I was a bit confused too at first. adbc_current_catalog is an internal attribute that tells OpenTelemetry which name to use to mark the connection (in this case, the database name, which would resolve to tiled_storage).
I've added a comment to make it clear (and while doing so, fixed a small bug too).

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Added tests for tracing with DuckDB as well.

`opentelemetry` is a namespace package shared by all `opentelemetry-*`
distributions, so `find_spec("opentelemetry")` succeeds even when the
API package (which provides `opentelemetry.trace`) is not installed, and
importing `tiled.server.utils` then fails. Check for `opentelemetry.trace`
itself.

Import `importlib.metadata` explicitly: `import importlib` does not load
the submodule, and `app.py` only worked because another import loaded it.
The DBAPI instrumentation of the ADBC storage connections reads
`adbc_current_catalog` for `db.name`. DuckDB's ADBC driver raises instead
of returning a value, and the instrumentation only tolerates a missing
attribute, so with tracing on every new DuckDB storage connection failed.
Read it once and leave `db.name` unset if the driver cannot provide it.

Also explain the name: ADBC uses SQL-standard terms, where a "catalog" is
a database, unrelated to Tiled's catalog.

Add a test writing to SQLite and DuckDB storage with tracing on.
Fold the embedded-storage test into the Postgres storage test using the existing `sql_storage_uri` fixture. `db.name` is not checked on DuckDB, whose ADBC driver does not implement `adbc_current_catalog`.
`batch: {}` has no size cap. Jaeger and Tempo reject OTLP/gRPC messages
larger than 4 MiB by default and the collector drops such batches, so a
burst of traces was partly lost: of 10000 spans sent in a burst, Jaeger
received 1808. Cap batches at 1024 spans (send 512), which fits spans
averaging under ~4 KiB; with the cap, all spans arrive in both backends.

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants