<p>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.</p><p>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 <code>/metrics</code> 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.</p><p>So these three releases are about two things: seeing clearly, and speaking the current transport. Here's the why and the how.</p><h2>1. Logs, without an agent (0.9.4)</h2><p>Every request already builds a structured record inside Askr — method, path, status, latency, peer. It was going to a file or <code>/dev/null</code>. Why not stream it somewhere I can query?</p><pre><code class="language-dotenv">ASKR_OBSERV_DSN=mysql://user:pass@telemetry-host:3306/askr_logs
</code></pre><p>That's the whole setup (build with <code>--features observ</code>). Now every request lands in a <code>logs</code> table — created for you — and you ask questions in plain SQL:</p><pre><code class="language-sql">-- slowest endpoints in the last 15 minutes
SELECT path, PERCENTILE(latency_ms, 0.95) AS p95, COUNT(*) AS n
FROM logs
WHERE ts &gt;= NOW() - INTERVAL 15 MINUTE
GROUP BY path ORDER BY p95 DESC LIMIT 10;
</code></pre><p>The important design constraint: telemetry must never touch a request. The hot path does one non-blocking <code>try_send</code> into a bounded queue and moves on; a background task batches rows into one multi-row <code>INSERT</code>. If the database is slow or down, rows are dropped (with a counted warning) — availability over completeness. Your users never wait for your logging.</p><blockquote><p>A field lesson I'll be honest about: this speaks the MySQL wire protocol, and its home is ElyraSQL (which uses <code>mysql_native_password</code>). MySQL 8 / MariaDB 11 default to <code>caching_sha2_password</code>, which the driver only does over TLS — so there's an <code>ASKR_OBSERV_TLS=1</code> for those. I spent an embarrassing evening discovering that the "server keeps sending <code>sha256_password</code>" was never a server problem. Debugging is humbling.</p></blockquote><h2>2. A rollup, so dashboards don't scan everything (0.9.6)</h2><p>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:</p><pre><code class="language-sql">SELECT ts, requests, errors, p50_ms, p95_ms, p99_ms, inflight
FROM metrics ORDER BY id;   -- one row every 10s
</code></pre><p>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.</p><h2>3. Watching a request think: OpenTelemetry traces (0.9.6)</h2><p>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.</p><pre><code class="language-dotenv">ASKR_OTEL_ENDPOINT=http://127.0.0.1:4317   # OTLP/gRPC → Jaeger, Tempo, …
</code></pre><p>Every PHP request becomes a small flamegraph:</p><pre><code class="language-text">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)
</code></pre><p>That nesting is the whole thesis, made visible per request. Spin up Jaeger and see it yourself in about thirty seconds:</p><pre><code class="language-text">$ 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"
</code></pre><p>The first time I saw <code>php.execute</code> sitting at 15.2 of 15.4 milliseconds inside <code>http.request</code>, 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.</p><h2>4. HTTP/3, and the handler that didn't care (0.9.6)</h2><p>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.</p><pre><code class="language-bash">askr serve --root public \
  --tls-cert cert.pem --tls-key key.pem --http3 \
  --listen 0.0.0.0:443
</code></pre><p>Ask a real HTTP/3 client:</p><pre><code class="language-text">$ curl --http3-only https://your-host/orders/42
hello over HTTP/3.0
[HTTP-version=3]
</code></pre><p>The part I'm quietly proud of: the PHP app doesn't know or care. It sees <code>SERVER_PROTOCOL=HTTP/3.0</code> 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.</p><p>Under the hood it's <code>quinn</code> + <code>h3</code>, sharing the same <code>rustls</code> certificate as the TCP listener, with a <code>SO_REUSEPORT</code> UDP socket per prefork worker (the kernel steers each QUIC connection to one worker). TCP responses advertise it so clients upgrade themselves:</p><pre><code class="language-text">alt-svc: h3=":443"; ma=86400
</code></pre><p>Three risks going in — the <code>rustls</code> provider clash (<code>ring</code> vs <code>aws-lc-rs</code>), the notoriously fiddly <code>quinn</code>/<code>h3</code> 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.</p><p>Honest caveat: this first cut buffers streaming responses (SSE) over h3, and you need to open a UDP port. Both are on the list.</p><h2>5. Making all of it actually usable (0.9.5)</h2><p>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.</p><p>So there's now a <code>-full</code> build, published every release:</p><pre><code class="language-bash">docker pull ghcr.io/kwhorne/askr:full
# or the askr-&lt;ver&gt;-linux-&lt;arch&gt;-full.tar.gz tarball
</code></pre><p>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.</p><h2>6. And Laravel got a real <code>composer require</code></h2><p>Small but satisfying: the <code>askr-laravel</code> package is on Packagist now.</p><pre><code class="language-bash">composer require kwhorne/askr-laravel
</code></pre><pre><code class="language-dotenv">SESSION_DRIVER=askr
CACHE_STORE=askr
QUEUE_CONNECTION=askr
BROADCAST_CONNECTION=askr
</code></pre><p>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 <code>composer require</code> just works now.)</p><h2>Where this leaves things</h2><p>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.</p><p>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.</p><p>Askr is v0.9.6 today: <code>ghcr.io/kwhorne/askr:0.9.6</code> (and <code>:0.9.6-full</code>), multi-arch, PHP 8.5, Laravel 13, now speaking h1 / h2 / h3.</p><p>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.</p><p>See you at 1.0. 🌳</p>