Repository navigation
Conversation
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.
The FastAPI auto_configure opt-out entry was lost while resolving a CHANGELOG conflict when merging main.
# Conflicts: # CHANGELOG.md
danielballan
left a comment
There was a problem hiding this comment.
Looks good. Other than my comments already in #1536, I just have one question here.
| "tiled.storage", | ||
| creator(), | ||
| dialect, | ||
| connection_attributes={"database": "adbc_current_catalog"}, |
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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).
There was a problem hiding this comment.
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.
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.
What's traced
opentelemetry-instrumentation-asyncpgSELECT/INSERT/… spans,db.system=postgresql,db.name=tiled_catalog/ authnopentelemetry-instrumentation-dbapiwrapping the ADBC connection, plus an explicit span around the bulkadbc_ingestwrite (which bypasses the DBAPIexecutepath)db.system=postgresql,db.name=tiled_storage,adbc_ingestopentelemetry-instrumentation-redisHSET/HGET/… spans,db.system=redisopentelemetry-instrumentation-httpxPOST/GETclient spansThese are client spans emitted by Tiled, so they keep
service.name=tiledand are distinguished bydb.system/ the target host — the span correctly stays with its emitter rather than being re-labelled as another service.Service graph
A
peer.serviceattribute is set on each dependency so Grafana Tempo's Service Graph renders them as distinct nodes. Postgres is split bydb.name; all webhook deliveries are grouped under a singlewebhooksnode (identified by theX-Tiled-Event-IDheader); other outbound calls are named by host. The example Collector / Tempo / Grafana config is wired up to generate and display it.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)2. Exercise catalog (asyncpg), storage (ADBC), and streaming (Redis)
3. Exercise webhooks (httpx)
A minimal receiver (stdlib only), run on the host:
Register it and fire an event (the container reaches the host via
host.docker.internal):4. What you should see
tiled): request traces now contain child spans forSELECT/INSERT(db.system=postgresql),HSET/HGET(db.system=redis),adbc_ingest, andPOST(webhook delivery). Startup / new-connection queries that run outside a request appear as their own shortSELECT/select/showtraces.tiledwith edges totiled_catalog,tiled_storage,redis, andwebhooks.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 whenTILED_TEST_POSTGRESQL_URI/TILED_TEST_REDISare unset.Caveats
Follow up on: PR #1536
Related Issue: #1000
Checklist