I can finally watch Askr think — and it speaks HTTP/3 now
A field journal through Askr 0.9.4 → 0.9.6: agentless SQL logging, a leader-elected metrics rollup, per-request OpenTelemetry traces, HTTP/3 over QUIC, a -full build, and askr-laravel on Packagist.
The last time I wrote here, Askr had learned to not run PHP when it didn't have to (stale-while-revalidate), to recycle a leaking worker before it crashed, and to grow a durable second tier. Useful, invisible plumbing.
But there was a nagging gap. I'd been making claims — "PHP is 99.5% of a request," "the cache saved a database round-trip," "the worker pool scaled to the backlog" — and my evidence was a /metrics gauge and a warm feeling. I couldn't watch a single request and see where its milliseconds actually went. And on the transport side, Askr spoke HTTP/1.1 and HTTP/2, but the modern web had moved on to QUIC.
So these three releases are about two things: seeing clearly, and speaking the current transport. Here's the why and the how.
1. Logs, without an agent (0.9.4)
Every request already builds a structured record inside Askr — method, path, status, latency, peer. It was going to a file or /dev/null. Why not stream it somewhere I can query?
ASKR_OBSERV_DSN=mysql://user:pass@telemetry-host:3306/askr_logs
That's the whole setup (build with --features observ). Now every request lands in a logs table — created for you — and you ask questions in plain SQL:
-- slowest endpoints in the last 15 minutes
SELECT path, PERCENTILE(latency_ms, 0.95) AS p95, COUNT(*) AS n
FROM logs
WHERE ts >= NOW() - INTERVAL 15 MINUTE
GROUP BY path ORDER BY p95 DESC LIMIT 10;
The important design constraint: telemetry must never touch a request. The hot path does one non-blocking try_send into a bounded queue and moves on; a background task batches rows into one multi-row INSERT. If the database is slow or down, rows are dropped (with a counted warning) — availability over completeness. Your users never wait for your logging.
A field lesson I'll be honest about: this speaks the MySQL wire protocol, and its home is ElyraSQL (which uses
mysql_native_password). MySQL 8 / MariaDB 11 default tocaching_sha2_password, which the driver only does over TLS — so there's anASKR_OBSERV_TLS=1for those. I spent an embarrassing evening discovering that the "server keeps sendingsha256_password" was never a server problem. Debugging is humbling.
2. A rollup, so dashboards don't scan everything (0.9.6)
Raw logs are great for forensics, terrible for a "requests per minute" panel that has to scan a million rows. So the sink also writes a periodic rollup:
SELECT ts, requests, errors, p50_ms, p95_ms, p99_ms, inflight
FROM metrics ORDER BY id; -- one row every 10s
The fun part is a distributed-systems gotcha. Askr's metrics live in shared memory, written by every worker on the box. If all N workers snapshotted them, you'd get N duplicate rows with wrong deltas. So exactly one worker writes the rollup — elected via a PID stored in the shared metrics region, and re-elected if that worker dies. No coordinator, no config: the election is a compare-and-swap on a shared integer.
3. Watching a request think: OpenTelemetry traces (0.9.6)
This is the one I'd wanted for months. Because Askr owns the entire request boundary — it's not shouting at PHP across a FastCGI socket — it can emit a trace that FPM and Octane are structurally blind to.
ASKR_OTEL_ENDPOINT=http://127.0.0.1:4317 # OTLP/gRPC → Jaeger, Tempo, …
Every PHP request becomes a small flamegraph:
http.request GET /orders/42 · 200 · cache=MISS · 15.4 ms
├─ php.execute 15.2 ms ← there's your 99.5%
└─ response.build 0.2 ms ← and there's where the rest went (compression)
That nesting is the whole thesis, made visible per request. Spin up Jaeger and see it yourself in about thirty seconds:
$ docker run -d --name jaeger -e COLLECTOR_OTLP_ENABLED=true \
-p 16686:16686 -p 4317:4317 jaegertracing/all-in-one
$ ASKR_OTEL_ENDPOINT=http://127.0.0.1:4317 askr serve --root public …
# open http://localhost:16686, pick service "askr"
The first time I saw php.execute sitting at 15.2 of 15.4 milliseconds inside http.request, I actually laughed. Every performance argument I'd made for a year, now a picture. And it's exported on a background batch processor, so — like the logs — tracing never adds latency to the request it's describing.
4. HTTP/3, and the handler that didn't care (0.9.6)
The last big transport gap. Modern clients want QUIC — 0-RTT, no head-of-line blocking, graceful on flaky mobile networks. FrankenPHP had it; Askr didn't.
askr serve --root public \
--tls-cert cert.pem --tls-key key.pem --http3 \
--listen 0.0.0.0:443
Ask a real HTTP/3 client:
$ curl --http3-only https://your-host/orders/42
hello over HTTP/3.0
[HTTP-version=3]
The part I'm quietly proud of: the PHP app doesn't know or care. It sees SERVER_PROTOCOL=HTTP/3.0 and serves the request exactly as it would over HTTP/2. That's because I made the request handler generic over the body type, so the QUIC path and the TCP path funnel into the same function. HTTP/3 became an adapter, not a fork of the whole server.
Under the hood it's quinn + h3, sharing the same rustls certificate as the TCP listener, with a SO_REUSEPORT UDP socket per prefork worker (the kernel steers each QUIC connection to one worker). TCP responses advertise it so clients upgrade themselves:
alt-svc: h3=":443"; ma=86400
Three risks going in — the rustls provider clash (ring vs aws-lc-rs), the notoriously fiddly quinn/h3 version alignment, and binding UDP across prefork workers. All three turned out to be already-solved or a one-line fix. Sometimes the scary feature is scarier in the planning than the doing.
Honest caveat: this first cut buffers streaming responses (SSE) over h3, and you need to open a UDP port. Both are on the list.
5. Making all of it actually usable (0.9.5)
Here's a thing I'd gotten wrong for several releases: every one of these features — the L2 SQL Anywhere tiers, observability, OTel, now HTTP/3 — is opt-in behind a build feature, so the default binary stays lean. Which is lovely, except nobody can use them without compiling from source.
So there's now a -full build, published every release:
docker pull ghcr.io/kwhorne/askr:full
# or the askr-<ver>-linux-<arch>-full.tar.gz tarball
Same server, same size class — but with the durable backends, observability, OTel, and HTTP/3 compiled in. The extra features stay inert until you set the matching env var or flag. The default image is untouched. You choose.
6. And Laravel got a real composer require
Small but satisfying: the askr-laravel package is on Packagist now.
composer require kwhorne/askr-laravel
SESSION_DRIVER=askr
CACHE_STORE=askr
QUEUE_CONNECTION=askr
BROADCAST_CONNECTION=askr
Session, cache, locks, queue, broadcasting — the whole Redis-shaped hole — filled from one binary. (Getting a monorepo package onto Packagist, with a subtree-split mirror and a webhook, was its own little yak-shave. The important thing is that the bare composer require just works now.)
Where this leaves things
Two releases ago, Askr's story was "run PHP less, and survive when it leaks." Now it can show you exactly that — logs you query in SQL, a per-minute rollup, and a trace where you can literally point at the 99.5%. And it meets clients on the transport they actually use, with the app none the wiser.
The pattern underneath all of it hasn't changed: own the whole request lifecycle, and your observability and your transports become facts you already have, not integrations you bolt on. The store and the runtime are one binary — so the trace boundary is right there, and a new transport is just another door into the same room.
Askr is v0.9.6 today: ghcr.io/kwhorne/askr:0.9.6 (and :0.9.6-full), multi-arch, PHP 8.5, Laravel 13, now speaking h1 / h2 / h3.
Still pre-1.0. Still the most fun I've had with a side project in years. The road to 1.0 from here is mostly sanding: streaming responses over h3, finer trace spans, and the honest, unglamorous work of API stability.
See you at 1.0. 🌳