carlos / platform Public

Timing at the platform boundary: activator wake and edge round-trip are invisible to an app Feature Request open

Tito Go is adopting a per-screen performance budget: 150ms warm, 500ms cold, enforced in CI and reported from production. Building it has made clear that an app can only measure the middle of a request, and both ends belong to CARLOS.

What we can measure

Server TTFB inside the instance process, per screen. That works and we are shipping it: a middleware records entry to first byte, and CI gates every screen against a table of budgets.

What we cannot measure, and would like to

1. Activator wake to first byte. Our "cold" budget is a hibernated instance being woken and answering. Almost all of that happens before our code can observe anything: the activator execs the binary, the process starts, opens SQLite and runs ~400 DDL migration statements (207ms measured), parses templates per locale, and only then serves. An instance cannot time its own wake, so the number we most want is the one we structurally cannot produce.

Ask: can the activator record wake to first-byte-served per instance, and expose it — a header on the first response after a wake, a control-socket read, or a log line we can scrape? Even a coarse figure would separate "our boot is slow" from "the wake path is slow", which today we cannot do at all.

2. Edge round-trip versus instance TTFB. Requests cross carlos-edge before reaching the instance socket. Our TTFB excludes that hop, so an instance can be inside 150ms while the person waits materially longer and we never know.

Ask: can carlos-edge stamp a timing header (Server-Timing or comparable) carrying its own view of the request, so an app can compare its internal number against the real round trip? Per-request precision matters less than being able to detect a systematic gap.

Why this is a platform question

Any app on CARLOS that makes a latency promise meets the same wall. The activator and the edge are shared infrastructure, so measuring them once at the platform level seems better than every app inventing its own approximation. A budget an app cannot verify end to end is a budget it will quietly stop trusting.

What we will do meanwhile

Ship the warm half — instance TTFB, per screen, CI-gated and reported on our fleet page — and hold the cold budget as explicitly unverified rather than approximate it from inside the process.

Half of this is built, on branch activator-wake-timing. The other half I have deliberately not built, and the reason is worth more than the code would have been.

Built: wake to first byte, split

The wake event carlos logs already carries now reports its own timing:

instance woke in 1.4s (spawn 1.2s) (version 7507a33)

Two figures rather than one, because a single total would not settle what your budget is actually asking. spawn is Backend.Start alone — exec to the socket accepting, which is your process opening SQLite, running its ~400 DDL statements and parsing its templates, and none of the platform's work. The rest of the total is the lease, the restore, the seed and starting replication.

So "woke in 1.4s (spawn 1.2s)" says your boot is the problem, and "woke in 1.4s (spawn 200ms)" says it is not. That distinction is the thing your write-up says is unavailable today, and it is the one a cold budget lives or dies on.

You offered three shapes — a header on the first response after a wake, a control-socket read, or a log line to scrape. This is the third, chosen because it costs nothing in the request path. Unmeasured stays unmeasured: a wake with no timings reads exactly as the line did before, rather than claiming an instant wake.

Not built: the edge's Server-Timing header, and why

Your second ask is the right idea and it is a design question, not a patch. This repo has a rule it paid for: a ResponseWriter wrapper that edits headers needs its own header map, because the wrappers in internal/edge embed the writer below, so Header() is ONE shared map from handler to connection — and maybeStore caches rec.Header().Clone().

A timing header added the obvious way would therefore be cached with the response and served to every later visitor, describing a request that happened to somebody else, minutes ago. The gzip wrapper already has a private map for exactly this reason. There are two more traps behind it: a header set after WriteHeader is a TRAILER, so late timing has to be synced down explicitly; and the wrapper order is load-bearing (recorder above the compressor, usage counter below it), so where the timing wrapper sits decides what it can measure.

None of that is hard. It is just a decision about which responses may carry the header and where it goes in the order — and getting it wrong poisons the edge cache with other people's timings, which is a worse bug than the one it fixes. It wants your eye, not an overnight branch.

The per-request half

Worth stating plainly since it follows from the above: the figures on the branch are per WAKE, on the platform's own log stream, not per request. Tying a wake to the request that triggered it is the header question by another name.

Leaving this open on the header.

Triage, 2026-09-05. activator-wake-timing reviewed. CI green. Verdict: merge, with one coverage gap worth closing knowingly.

The claims hold. The "unmeasured stays unmeasured" property is real — wakeEventMessage gates on wakeTook > 0, so an untimed wake prints the bare line rather than claiming an instant one, which was the right instinct given what your budget is for. Nothing was added to the warm request path: both timestamps are taken inside wake()/doWake(), the cold path only. Durations cannot go negative across a clock step, because a.now defaults to time.Now and Sub uses the monotonic reading. And no consumer pins the old string with == — every reference uses Contains or builds its own fixture — so the format change breaks no parser.

The gap: every timing test calls wakeEventMessage directly with hand-built structs. The one test that exercises the real path uses a frozen fake clock that is never advanced, so wakeTook and spawnTook come out as 0 and its Contains(msg, "instance woke") assertion passes whether the feature is wired up or not. So a refactor that dropped the assignment in wake() would go unnoticed. One test that advances the fake clock and asserts the duration reaching the sink closes it.

Minor: a genuinely-measured sub-500µs value renders as "0s" via Go's zero-Duration case, which is visually identical to the unmeasured line the design deliberately distinguishes. Unreachable in production — every doWake step does real I/O — but a near-instant mock backend in CI would hit it.

On the header, which is the half you are actually still waiting for. The reasoning for not building it overnight is right, and I would add one thing to the trap list rather than argue with it: the wrapper order is load-bearing in a way that decides the semantics, not just the safety. Above the recorder, the header describes work that excludes compression; below it, it includes it — so "where it goes in the order" and "what the number means" are the same question, and an app comparing its internal TTFB against it needs to know which was chosen. That is an argument for the decision being written down with the answer, not just made.

Worth noting the per-request half you flagged has a sibling now: #11 wants an AWAKE column and #13 wants a running-build signal, and both run into the same wall — the activator holds per-child state (spawnVersion, spawn time) with no accessor, no status field and no API. Publishing that once would serve all three. Not the header, but adjacent enough that it should probably be decided in the same sitting.

Leaving open on the header, as you had it.