Skip to content

Use case

Operations analytics from the queue

Every escalation is an interval an entity spent in a state. Dwell, membership and timelines come from rows your workflows already write.

Surfaces: SDK · HTTP · CLI

The moment

Monday, 8 a.m. The plant manager has three questions. How did the machines spend last week? Do the model-b machines really spend longer in service than the model-a machines, or does it only feel that way? And what happened to SN-0042 on Thursday between nine and eleven?

The usual answer starts a project: emit events from every service, ship them to a warehouse, model the states, build the dashboards. Three weeks later the dashboards answer last month’s questions.

On Long Tail the answers are already in Postgres. A machine that spends time idle, running or in service has been parking a row in each state all along. Each row opened when the state began and closed when it ended. Those rows are the time-series.

Park every state

Write each state as a wait. role is the queue that attends the state. subtype names the state within a role that holds several. metadata carries the entity key and any categories worth slicing by.

workflows/machine-cycle.ts
import { Durable } from '@hotmeshio/hotmesh';
import { conditional, type LTEnvelope } from '@hotmeshio/long-tail';
export async function machineCycle(envelope: LTEnvelope) {
const { serialNumber, model, cycle } = envelope.data;
const { workflowId } = Durable.workflow.workflowInfo();
const facets = { serialNumber, model };
// 1. Idle until a job claims the machine
const job = await conditional<{ jobId: string }>(`${workflowId}-idle`, {
role: 'machine-fleet', subtype: 'idle', metadata: facets,
});
if (!job) return { data: { serialNumber, cycle, ended: 'idle' } };
// 2. Running until the job reports a result
const run = await conditional<{ result: 'ok' | 'fault' }>(`${workflowId}-running`, {
role: 'machine-fleet', subtype: 'running', metadata: { ...facets, jobId: job.jobId },
});
// 3. In service after a fault, until a technician signs it off
if (run && run.result === 'fault') {
await conditional(`${workflowId}-service`, { role: 'machine-service', metadata: facets });
}
return { data: { serialNumber, cycle, result: run ? run.result : 'cancelled' } };
}

Every wait is one row, and every row is one interval: [created_at, ended_at). ended_at is the instant the row left the live set, whether resolved, cancelled or expired. The interval is the measurement. Nothing else needs to be emitted.

Two dials on each role

Tell each role what moves through it and how it names states:

Terminal window
curl -X PATCH https://lt.acme.dev/api/roles/machine-fleet \
-H "Authorization: Bearer $TOKEN" -H 'Content-Type: application/json' \
-d '{ "entity_facet": "serialNumber", "entity_state_source": "subtype" }'
curl -X PATCH https://lt.acme.dev/api/roles/machine-service \
-H "Authorization: Bearer $TOKEN" -H 'Content-Type: application/json' \
-d '{ "entity_facet": "serialNumber", "entity_state_source": "role" }'
Dial Meaning
entity_facet The metadata key naming the entity: serialNumber. Roles that share the key form that entity’s system. The system is derived, never maintained
entity_state_source subtype: the role’s subtypes are states (idle, running). role: being in this queue is one state (machine-service)

That is the whole setup. Everything below reads it back.

How did the fleet spend last week?

Scope to the entity’s system, group by the derived state, and measure dwell: open seconds, clipped to the window.

const { data } = await lt.escalations.aggregateByFacets({
query: { entity: 'serialNumber' },
groupBy: { state: true },
measure: { kind: 'dwell', window: { from: '2026-10-12T00:00:00Z', to: '2026-10-19T00:00:00Z' } },
});
// groups: [{ state: 'running', dwellSeconds, sampleCount },
// { state: 'idle', ... }, { state: 'machine-service', ... }]
Terminal window
ltc esc aggregate-facets --entity serialNumber --group-state \
--window '{"from":"2026-10-12T00:00:00Z","to":"2026-10-19T00:00:00Z"}'

Model-a or model-b?

Add a facet to the grouping. Each value gets its own state split, and rows missing the facet form their own group instead of disappearing.

Terminal window
ltc esc aggregate-facets --entity serialNumber --group-state --group-facets model \
--window '{"from":"2026-10-12T00:00:00Z","to":"2026-10-19T00:00:00Z"}'

Now “model-b spends longer in service” is a number per model, from the same rows. Filter instead of grouping with --facets '{"model":"model-b"}'.

What happened to SN-0042?

The timeline is one entity’s ordered intervals across its whole system, with durations:

const { data } = await lt.escalations.timelineByFacet({
facet: { key: 'serialNumber', value: 'SN-0042' },
query: { entity: 'serialNumber' },
window: { from: '2026-10-16T09:00:00Z', to: '2026-10-16T11:00:00Z' },
});
// intervals: [{ role, subtype, status, startedAt, endedAt, durationSeconds }, ...]

The gaps between intervals are kept, not filled. A gap is time the machine spent in no tracked state: the minutes between a fault being reported and the service row being picked up, or a handoff nobody modeled. Gaps are often the most useful part of the answer.

Dwell and membership

The same rows answer two different questions:

Measure Question Reads
Dwell How long did the entities spend in each state over a window? Open seconds per group within [from, to)
Membership How many are in each state at one instant? Rows open at that instant, or distinct entities with distinctBy
// How many machines are in each state right now?
await lt.escalations.aggregateByFacets({
query: { entity: 'serialNumber' },
groupBy: { state: true },
measure: { kind: 'membership' },
distinctBy: 'serialNumber',
});

Any moment, reconstructed

Pass a past asOf and membership reconstructs the live set at that instant. “How many machines were in service at 2 a.m. on Thursday?” is the same call with measure: { kind: 'membership', asOf: '2026-10-16T02:00:00Z' }. Nothing was snapshotted at 2 a.m. The intervals contain the answer.

Wait and work

Every claimed row has two more intervals inside it. Wait runs from created_at to the claim. Work runs from the claim to resolved_at. The station metrics report both at p50 and p99 per role, with queue depth and throughput against the role’s target, for the Pace Board.

Where the answers appear

The /operations view offers every entity system beside the station view: state dwell over the selected period, membership counts now, and a Slice by control for any facet. Every entity row opens its timeline. Each panel updates from escalation events within seconds of a resolve.

Aggregates and timelines require read_all on every role in scope, and invalid queries are rejected with a 400 instead of quietly answering a different question.