mirror of
https://github.com/simonw/datasette.git
synced 2026-09-03 15:14:08 +02:00
Add a pure-Python OTLP demo: receiver, Jaeger recipe, no Docker
demos/otel/ shows the whole export path end to end with uv alone. The ~150 line otlp_receiver.py decodes the real OTLP/HTTP protobuf wire format and prints a summary on Ctrl-C; a Justfile wraps the receiver, an instrumented `datasette` under opentelemetry-instrument, a generated 200-row demo database, and Jaeger from its own binary. Jaeger and the receiver both listen on 4318, so the Datasette side is one identical `just serve` either way. Verified against this branch: startup plus one request against the demo table exports 93 spans with exactly two roots - the request span and datasette.startup - and in Jaeger the same run lands as two traces, 67 spans nested under `GET <route>` and 26 under startup. That "no orphans" shape is new since the earlier demo iteration: the per-request server span and the startup parent are in core now, so the old asgi_wrapper plugin and its "ignore the ~24 tiny traces" caveats are gone rather than ported. serve_forever() runs on a worker thread with the main thread waiting on an event, and SIGINT/SIGTERM are handled explicitly, because KeyboardInterrupt alone does not reliably reach the script through the `uv run` wrapper - the summary was silently never printed. The per-batch print flushes explicitly so live feedback survives a pipe. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012U7coQfVu8nK2R4q2mCULA
This commit is contained in:
parent
745b7872b8
commit
4a97e577b8
3 changed files with 334 additions and 0 deletions
30
demos/otel/Justfile
Normal file
30
demos/otel/Justfile
Normal file
|
|
@ -0,0 +1,30 @@
|
|||
# OpenTelemetry demo - see README.md in this directory.
|
||||
#
|
||||
# just receiver + just serve -> span summary in the terminal
|
||||
# just jaeger + just serve -> real trace UI at http://localhost:16686
|
||||
#
|
||||
# Both listen for OTLP/HTTP on port 4318, so `just serve` works with either
|
||||
# (but not both at once).
|
||||
|
||||
otel_env := "OTEL_TRACES_EXPORTER=otlp OTEL_METRICS_EXPORTER=none OTEL_LOGS_EXPORTER=none OTEL_EXPORTER_OTLP_PROTOCOL=http/protobuf OTEL_EXPORTER_OTLP_ENDPOINT=http://localhost:4318 OTEL_SERVICE_NAME=datasette OTEL_BSP_SCHEDULE_DELAY=1000"
|
||||
|
||||
default:
|
||||
@just --list --unsorted
|
||||
|
||||
# Terminal 1, option A: the ~150 line pure-Python receiver. Ctrl-C for a summary
|
||||
receiver:
|
||||
uv run --with opentelemetry-proto python otlp_receiver.py
|
||||
|
||||
# Terminal 1, option B: Jaeger from its own binary - no Docker. UI on :16686
|
||||
jaeger:
|
||||
@command -v jaeger >/dev/null || { echo "No jaeger binary on PATH. Grab one from https://www.jaegertracing.io/download/"; exit 1; }
|
||||
jaeger
|
||||
|
||||
# Terminal 2: Datasette under the OpenTelemetry agent, exporting to :4318
|
||||
serve db="demo.db":
|
||||
@[ "{{ db }}" != "demo.db" ] || [ -e demo.db ] || sqlite3 demo.db "create table plants(id integer primary key, name text, height_cm real); with recursive n(i) as (select 1 union all select i + 1 from n where i < 200) insert into plants select i, 'plant ' || i, abs(random() % 300) from n;"
|
||||
{{ otel_env }} uv run --with opentelemetry-distro --with opentelemetry-exporter-otlp-proto-http opentelemetry-instrument datasette {{ db }} -p 8001
|
||||
|
||||
# Terminal 3: make a traced request (defaults to the generated demo table)
|
||||
request path="/demo/plants":
|
||||
curl -s -o /dev/null -w "%{http_code}\n" http://localhost:8001{{ path }}
|
||||
121
demos/otel/README.md
Normal file
121
demos/otel/README.md
Normal file
|
|
@ -0,0 +1,121 @@
|
|||
# OpenTelemetry demo
|
||||
|
||||
Datasette core depends on `opentelemetry-api` only. It emits spans and nothing else — it never
|
||||
creates a `TracerProvider`, never configures an exporter, and never sets a sampler. With no SDK
|
||||
installed every span is a no-op and costs approximately nothing.
|
||||
|
||||
That means "turning tracing on" is entirely the job of whoever runs Datasette. This directory
|
||||
shows two ways to do it, **neither of which needs Docker**:
|
||||
|
||||
| | |
|
||||
|---|---|
|
||||
| `otlp_receiver.py` | A ~150 line pure-Python OTLP/HTTP receiver — a real protobuf export, summarized in your terminal |
|
||||
| `just jaeger` | The same export into Jaeger's own binary, for a real trace UI |
|
||||
|
||||
Both listen for OTLP/HTTP on port 4318, so the Datasette side is identical — run one or the
|
||||
other, not both. The `Justfile` in this directory wraps every command below; bare `just` lists
|
||||
the recipes.
|
||||
|
||||
## 1. A real OTLP export, pure Python
|
||||
|
||||
Terminal 1 — the receiver (`just receiver`):
|
||||
|
||||
```bash
|
||||
uv run --with opentelemetry-proto python demos/otel/otlp_receiver.py
|
||||
```
|
||||
|
||||
Terminal 2 — Datasette under the OpenTelemetry agent (`just serve`, which also generates a
|
||||
200-row `demo.db` on first run):
|
||||
|
||||
```bash
|
||||
OTEL_TRACES_EXPORTER=otlp \
|
||||
OTEL_METRICS_EXPORTER=none \
|
||||
OTEL_LOGS_EXPORTER=none \
|
||||
OTEL_EXPORTER_OTLP_PROTOCOL=http/protobuf \
|
||||
OTEL_EXPORTER_OTLP_ENDPOINT=http://localhost:4318 \
|
||||
OTEL_SERVICE_NAME=datasette \
|
||||
OTEL_BSP_SCHEDULE_DELAY=1000 \
|
||||
uv run --with opentelemetry-distro \
|
||||
--with opentelemetry-exporter-otlp-proto-http \
|
||||
opentelemetry-instrument datasette demo.db -p 8001
|
||||
```
|
||||
|
||||
Load a page (`just request`), wait a second for the batch flush, then Ctrl-C the receiver.
|
||||
Real output from startup plus one request against the 200-row demo table:
|
||||
|
||||
```
|
||||
received 93 spans (93 total)
|
||||
|
||||
=== 93 spans ===
|
||||
db.query 43 18.55ms total
|
||||
db.query.execute 42 7.60ms total
|
||||
db.write.queue_wait 3 0.40ms total
|
||||
db.write.execute 3 4.99ms total
|
||||
datasette.startup 1 7.17ms total
|
||||
GET /(?P<database>[^\/\.]+)/(?P<table>[^\/\.]+)(\.(?P<format>\w+))?$ 1 59.46ms total
|
||||
|
||||
slowest spans:
|
||||
59.46ms GET /(?P<database>[^\/\.]+)/(?P<table>[^\/\.]+)(\.(?P<format>\w+))?$ -> 200
|
||||
7.17ms datasette.startup
|
||||
4.58ms db.write.execute
|
||||
1.81ms db.query with limited as (select * from (select id, name, height_cm f
|
||||
|
||||
2 root spans:
|
||||
datasette.startup x1
|
||||
GET /(?P<database>[^\/\.]+)/(?P<table>[^\/\.]+)(\.(?P<format>\w+))?$ x1
|
||||
```
|
||||
|
||||
Things worth noticing:
|
||||
|
||||
- **Exactly two root spans.** Every span belongs to either the request that caused it or to
|
||||
`datasette.startup` — there are no orphans. A `block=False` write would be a third kind of
|
||||
root, carrying a *link* back to the request that enqueued it rather than a parent, because
|
||||
the write can outlive that request.
|
||||
- **Request spans are named after the matched route**, which in Datasette is a regex — that
|
||||
string is ugly but it is the honest low-cardinality name. The pretty path is in the
|
||||
`url.path` attribute.
|
||||
- **`db.query` vs `db.query.execute`.** The gap between the two is time spent waiting for a
|
||||
thread. Likewise `db.write.queue_wait` vs `db.write.execute` is how you tell "the write was
|
||||
slow" apart from "the write waited behind another writer".
|
||||
|
||||
The receiver is a debugging aid, not a backend: nothing is persisted, it speaks OTLP/HTTP only
|
||||
(not gRPC), and it ignores metrics and logs.
|
||||
|
||||
## 2. The same thing with a real UI: Jaeger, no Docker
|
||||
|
||||
Jaeger ingests OTLP directly on the same port 4318, so the Datasette command does not change.
|
||||
With the `jaeger` binary on your PATH (<https://www.jaegertracing.io/download/>):
|
||||
|
||||
```bash
|
||||
just jaeger # terminal 1 — UI on http://localhost:16686
|
||||
just serve # terminal 2 — identical to above
|
||||
just request # terminal 3
|
||||
```
|
||||
|
||||
Then open <http://localhost:16686>, pick service `datasette`, and Find Traces. Verified: one
|
||||
request produces exactly two traces — the request trace (~67 spans, rooted at the `GET ...`
|
||||
span, with every `db.query` nested inside it across thread boundaries) and the
|
||||
`datasette.startup` trace (~26 spans of catalog queries and connection warm-up).
|
||||
|
||||
## Notes on the environment variables
|
||||
|
||||
- **`opentelemetry-instrument` is required.** Setting `OTEL_TRACES_EXPORTER` and running plain
|
||||
`datasette` produces nothing at all: that variable is read by the SDK's auto-configuration,
|
||||
which only runs under the agent — core never installs a provider itself.
|
||||
- **`OTEL_SERVICE_NAME=datasette`** is what you pick from Jaeger's Service dropdown. Leave it
|
||||
out and the SDK defaults to `unknown_service:<executable>`.
|
||||
- **`OTEL_BSP_SCHEDULE_DELAY=1000`** drops the batch flush from its ~10 second default to ~1
|
||||
second. For a demo this is the difference between "it works" and "it looks broken". Do not
|
||||
use it in production — it trades export efficiency for latency.
|
||||
- **`OTEL_METRICS_EXPORTER=none OTEL_LOGS_EXPORTER=none`** because `opentelemetry-distro`
|
||||
defaults every signal to OTLP, and a traces-only backend like Jaeger answers the metrics
|
||||
and logs exports with a stream of `StatusCode.UNIMPLEMENTED` noise.
|
||||
|
||||
## Privacy
|
||||
|
||||
`db.query.text` **is** recorded, truncated. SQL **parameter values are never recorded** — only
|
||||
a parameter count. On a public Datasette instance the SQL text is user-supplied; if you export
|
||||
to a third-party vendor, that text leaves your infrastructure.
|
||||
|
||||
See the telemetry section of the Datasette documentation for the full span and attribute
|
||||
reference.
|
||||
183
demos/otel/otlp_receiver.py
Normal file
183
demos/otel/otlp_receiver.py
Normal file
|
|
@ -0,0 +1,183 @@
|
|||
"""
|
||||
A minimal OTLP/HTTP trace receiver, in about a hundred lines of Python.
|
||||
|
||||
Run it, point Datasette's OpenTelemetry agent at it, and get a real end-to-end
|
||||
export - over the wire, in the real protobuf wire format - without Docker, a
|
||||
collector, or Jaeger:
|
||||
|
||||
# terminal 1
|
||||
uv run --with opentelemetry-proto python demos/otel/otlp_receiver.py
|
||||
|
||||
# terminal 2
|
||||
OTEL_TRACES_EXPORTER=otlp \\
|
||||
OTEL_METRICS_EXPORTER=none \\
|
||||
OTEL_LOGS_EXPORTER=none \\
|
||||
OTEL_EXPORTER_OTLP_PROTOCOL=http/protobuf \\
|
||||
OTEL_EXPORTER_OTLP_ENDPOINT=http://localhost:4318 \\
|
||||
OTEL_SERVICE_NAME=datasette \\
|
||||
OTEL_BSP_SCHEDULE_DELAY=1000 \\
|
||||
uv run --with opentelemetry-distro \\
|
||||
--with opentelemetry-exporter-otlp-proto-http \\
|
||||
opentelemetry-instrument datasette mydb.db
|
||||
|
||||
(or `just receiver` and `just serve mydb.db` from this directory.)
|
||||
|
||||
Load a page, wait a second for the batch processor to flush, then press
|
||||
Ctrl-C here for a summary.
|
||||
|
||||
This is a debugging aid, not a tracing backend: it does not persist anything,
|
||||
speaks only OTLP/HTTP (not gRPC), and ignores metrics and logs. For anything
|
||||
real, export to an actual backend instead.
|
||||
"""
|
||||
|
||||
import gzip
|
||||
import signal
|
||||
import sys
|
||||
import threading
|
||||
from collections import Counter
|
||||
from http.server import BaseHTTPRequestHandler, HTTPServer
|
||||
|
||||
try:
|
||||
from opentelemetry.proto.collector.trace.v1.trace_service_pb2 import (
|
||||
ExportTraceServiceRequest,
|
||||
ExportTraceServiceResponse,
|
||||
)
|
||||
except ImportError:
|
||||
sys.exit("This receiver needs the OpenTelemetry protobuf definitions:\n uv run --with opentelemetry-proto python demos/otel/otlp_receiver.py")
|
||||
|
||||
HOST = "127.0.0.1"
|
||||
PORT = 4318
|
||||
|
||||
received = []
|
||||
|
||||
|
||||
def attribute_value(value):
|
||||
for field in ("string_value", "int_value", "double_value", "bool_value"):
|
||||
if value.HasField(field):
|
||||
return getattr(value, field)
|
||||
return None
|
||||
|
||||
|
||||
class OTLPHandler(BaseHTTPRequestHandler):
|
||||
def log_message(self, *args):
|
||||
pass # the default handler logs every request to stderr
|
||||
|
||||
def do_GET(self):
|
||||
# For the person who opens http://localhost:4318 in a browser
|
||||
# expecting a UI: there isn't one here, on Jaeger either - 4318 is
|
||||
# where exporters POST protobuf. Jaeger's UI lives on :16686.
|
||||
body = (
|
||||
f"This is an OTLP/HTTP ingestion endpoint ({len(received)} spans "
|
||||
"received so far).\n\n"
|
||||
"There is no UI on this port - OpenTelemetry exporters POST "
|
||||
"protobuf to /v1/traces here.\nThe span summary appears in the "
|
||||
"terminal running this receiver when you Ctrl-C it.\n"
|
||||
"For a real UI, run Jaeger instead (`just jaeger`) and open "
|
||||
"http://localhost:16686\n"
|
||||
).encode()
|
||||
self.send_response(200)
|
||||
self.send_header("Content-Type", "text/plain")
|
||||
self.send_header("Content-Length", str(len(body)))
|
||||
self.end_headers()
|
||||
self.wfile.write(body)
|
||||
|
||||
def do_POST(self):
|
||||
body = self.rfile.read(int(self.headers.get("Content-Length", 0)))
|
||||
if self.headers.get("Content-Encoding") == "gzip":
|
||||
body = gzip.decompress(body)
|
||||
|
||||
request = ExportTraceServiceRequest()
|
||||
request.ParseFromString(body)
|
||||
batch = 0
|
||||
for resource_spans in request.resource_spans:
|
||||
for scope_spans in resource_spans.scope_spans:
|
||||
for span in scope_spans.spans:
|
||||
batch += 1
|
||||
received.append(
|
||||
{
|
||||
"name": span.name,
|
||||
"parent_id": span.parent_span_id.hex() or None,
|
||||
"duration_ms": (span.end_time_unix_nano - span.start_time_unix_nano) / 1e6,
|
||||
"attributes": {a.key: attribute_value(a.value) for a in span.attributes},
|
||||
}
|
||||
)
|
||||
# flush=True so the live feedback survives being piped or redirected
|
||||
print(f"received {batch} spans ({len(received)} total)", flush=True)
|
||||
|
||||
payload = ExportTraceServiceResponse().SerializeToString()
|
||||
self.send_response(200)
|
||||
self.send_header("Content-Type", "application/x-protobuf")
|
||||
self.send_header("Content-Length", str(len(payload)))
|
||||
self.end_headers()
|
||||
self.wfile.write(payload)
|
||||
|
||||
|
||||
def span_detail(span):
|
||||
"The one attribute most worth showing next to a span name."
|
||||
attributes = span["attributes"]
|
||||
query = attributes.get("db.query.text")
|
||||
if query:
|
||||
return f" {query[:60]}"
|
||||
status = attributes.get("http.response.status_code")
|
||||
if status is not None:
|
||||
return f" -> {status}"
|
||||
return ""
|
||||
|
||||
|
||||
def summarise():
|
||||
if not received:
|
||||
print("\nNo spans received.")
|
||||
print("Remember: `opentelemetry-instrument` is required - Datasette core")
|
||||
print("installs no provider - and the BatchSpanProcessor flushes about")
|
||||
print("every 10s unless OTEL_BSP_SCHEDULE_DELAY says otherwise.")
|
||||
return
|
||||
|
||||
print(f"\n=== {len(received)} spans ===")
|
||||
for name, count in Counter(span["name"] for span in received).most_common():
|
||||
total_ms = sum(s["duration_ms"] for s in received if s["name"] == name)
|
||||
print(f" {name:<45}{count:>5} {total_ms:>9.2f}ms total")
|
||||
|
||||
slowest = sorted(received, key=lambda s: -s["duration_ms"])[:5]
|
||||
print("\nslowest spans:")
|
||||
for span in slowest:
|
||||
print(f" {span['duration_ms']:>9.2f}ms {span['name']}{span_detail(span)}")
|
||||
|
||||
# Roots are one span per request (named "GET <route>"), datasette.startup,
|
||||
# and any block=False write - those link back to their enqueuer rather than
|
||||
# nesting under it, because the write can outlive the request that queued it.
|
||||
roots = Counter(span["name"] for span in received if not span["parent_id"])
|
||||
print(f"\n{sum(roots.values())} root spans:")
|
||||
for name, count in roots.most_common():
|
||||
print(f" {name} x{count}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
try:
|
||||
server = HTTPServer((HOST, PORT), OTLPHandler)
|
||||
except OSError as error:
|
||||
sys.exit(
|
||||
f"Could not listen on {HOST}:{PORT} ({error}).\n"
|
||||
"Something else is already using the OTLP port - most likely an "
|
||||
"earlier copy of this receiver, or Jaeger, still running."
|
||||
)
|
||||
print(f"OTLP/HTTP receiver listening on http://{HOST}:{PORT}")
|
||||
print("Press Ctrl-C for a summary.")
|
||||
|
||||
# serve_forever() runs on a worker thread and the main thread just waits,
|
||||
# so the summary still prints when this is launched through a wrapper such
|
||||
# as `uv run`, where relying on KeyboardInterrupt alone is unreliable.
|
||||
stop = threading.Event()
|
||||
threading.Thread(target=server.serve_forever, daemon=True).start()
|
||||
|
||||
def request_stop(signum, frame):
|
||||
stop.set()
|
||||
|
||||
signal.signal(signal.SIGINT, request_stop)
|
||||
signal.signal(signal.SIGTERM, request_stop)
|
||||
|
||||
try:
|
||||
stop.wait()
|
||||
except KeyboardInterrupt:
|
||||
pass
|
||||
server.shutdown()
|
||||
summarise()
|
||||
Loading…
Add table
Add a link
Reference in a new issue