Blog
Your queue is your time-series
Every wait has a start and an end. If your workflows park each state as a row, the operations history you were about to build is already in Postgres.
October 20, 2026 · Long Tail team
Every operations team reaches the same meeting eventually. Someone puts a chart on the screen showing that throughput dipped on Thursday, and someone else asks the question that ends the meeting: where did the time go?
Nobody can answer it from what exists. So a project begins. Each service emits an event when a thing changes state. The events flow into a pipeline, the pipeline lands in a warehouse, an analyst models “state” out of event pairs, and a dashboard draws bars. It takes a quarter. It answers questions about last month. And it spends the rest of its life disagreeing slightly with the system it measures, because it is a second system, fed by a second set of writes, with its own idea of when a thing started.
We think that project is usually unnecessary. The system already holds the answer, as long as the system is a queue that waits on the world.
A wait is an interval
Look at what a durable workflow does when it hands work to the world. It says: I need a person in machine-service to sign off this machine. In Long Tail that sentence is one call:
await conditional(`${workflowId}-service`, { role: 'machine-service', metadata: { serialNumber: 'SN-0042', model: 'model-b' },});The call writes a row and pauses. The row is created the moment the machine enters service. It leaves the live set the moment a technician resolves it, or the deadline expires it, or someone cancels it. That is an interval, [created_at, ended_at), with the entity’s key on it.
Now do the same for every state the machine passes through. Idle is a wait for a job. Running is a wait for a result. Service is a wait for a technician. Write each one as a row, and the machine’s life becomes a sequence of intervals.
You did not instrument anything. You wrote the workflow the way it had to be written anyway, because each of those states really is a wait on something your code does not control. The time-series is a side effect of being honest about waiting.
Why this is the right place to measure
There are three reasons we prefer the queue to a separate event stream.
It is the source of truth. The row that measures “SN-0042 was in service for 47 minutes” is the same row the technician claimed and resolved. There is nothing to reconcile. If the row says the machine was in service, the machine was in a queue where a person could see it, claim it and act on it.
It is complete by construction. An event pipeline measures what someone remembered to emit. A queue measures everything the workflow waited on, because the workflow cannot proceed without the row. You find missing instrumentation by finding a state your workflow never modeled, and that shows up as a gap you can see.
It is cheap. In Long Tail, lt_escalations is a view over the escalation table that adds one computed column, ended_at: null while the row is pending, the terminal transition instant once it is not. A partial index on that end instant covers the history scans. Pending rows are excluded from the index, so each row costs exactly one index write, once, when it ends. The live queue pays nothing for being measurable.
Two dials, three questions
The rows know their intervals. Two settings on each role tell the analytics surface what the intervals mean.
entity_facet names the metadata key of the thing moving through the role: serialNumber, or orderId. Every role that declares the same key belongs to that entity’s system. Nobody maintains a list of which queues make up “the machine lifecycle”. It is derived from the roles that say they carry machines.
entity_state_source says how a role names states. A role like machine-fleet that holds rows with subtypes idle and running sets it to subtype. A role like machine-service, where being in the queue is the state, leaves it at role.
With those set, three questions that used to need a data team become calls.
How did the fleet spend last week? Scope to the entity, group by state, measure dwell over a window:
const { data } = await lt.escalations.aggregateByFacets({ query: { entity: 'serialNumber' }, groupBy: { state: true, facets: ['model'] }, measure: { kind: 'dwell', window: { from: '2026-10-12T00:00:00Z', to: '2026-10-19T00:00:00Z' } },});Each group comes back with dwellSeconds: open time, clipped to the window on both ends. Adding model to the grouping splits every state by model, so model-a and model-b sit side by side. Rows without the facet are a group of their own, which is usually the first thing anyone learns from the chart.
How many are in each state right now? Change the measure to membership and count distinct machines instead of rows. Pass a past asOf, and the same call reconstructs the live set at that instant. Nothing was snapshotted at 2 a.m. on Thursday. The intervals that were open at 2 a.m. are still in the table, and that is enough.
What happened to SN-0042? The timeline returns one entity’s ordered intervals across every role in its system, with durations. It keeps the gaps. If the machine left running at 9:14 and entered machine-service at 9:41, those 27 minutes appear as the space between two spans. In our experience the gaps are where the money is: the handoff nobody modeled, or the fault that sat on a screen until someone walked past.
The intervals inside the interval
A claimed row holds two more measurements. The time from created_at to the claim is wait: how long the work sat before anyone picked it up. The time from the claim to resolved_at is work: how long it took once someone did. The station metrics report both per role at p50 and p99, beside queue depth and throughput against the role’s target.
Those two numbers settle most staffing arguments. A station with long waits and short work needs more people. A station with short waits and long work needs better tools, or a better form. You can tell which without asking anyone how their week went.
What it asks of you
The surface is strict on purpose. It reads facets, ranges and existence. It rejects anything that would quietly change what a number means: a status filter on a dwell query, a window wider than the configured maximum, an entity key no role declares. A 400 that names the problem is better than a chart that answers a different question.
The real requirement is upstream, in how you write workflows. Park every state you care about as a wait, and put the entity’s key on it. If a state matters enough to measure, it usually matters enough for someone to see, claim and act on. Once it is a row, it is both.
That is the argument in one sentence: the queue that tells people what to do next is also the record of what everyone did. You only have to build it once.
The analytics guide covers the full contract, and the operations analytics use case walks through a machine fleet end to end.