Analytics
Every escalation is one interval an entity spends in one state. It opens at created_at and closes when the row leaves the live set. The queue is therefore a complete state history for every process you run. This page shows how to instrument a process with two role dials and read it back three ways: across the fleet, sliced by a facet, and for one entity.
The contract
Section titled “The contract”1. Park every state as a row
Section titled “1. Park every state as a row”role is the queue that attends the state. subtype names the state within the role when the role holds several. metadata carries the entity key and any categorical facts worth slicing by.
import { conditional } from '@hotmeshio/long-tail';
// Park the printer in the fleet queue, printing.const done = await conditional(`printer-${serialNumber}-job-${jobId}`, { role: 'printer-fleet', // the queue that attends it type: 'fleet', subtype: 'printing', // the state, within the role metadata: { serialNumber: 'PRN-001', // the entity model: 'model-a', // categorical facts, sliceable enclosed: true, },});2. Set two dials on each role
Section titled “2. Set two dials on each role”Set them on the role page’s Pace Board section, or with PATCH /api/roles/:role:
| Dial | Meaning |
|---|---|
entity_facet |
The metadata key naming what moves through this role, here serialNumber. Roles sharing an entity key form that entity’s system, derived on read. |
entity_state_source |
'role' (default): being in this queue is one state. 'subtype': the role’s subtypes are its states. |
printer-fleet entity_facet: serialNumber states from: subtype (idle, printing)printer-pickup entity_facet: serialNumber states from: roleprinter-service entity_facet: serialNumber states from: roleThat is the whole contract. Everything below reads it back.
Q1: how did every printer spend its time?
Section titled “Q1: how did every printer spend its time?”Scope to the entity’s system, group by the derived state, and measure dwell: open seconds, clipped to the window.
ltc esc aggregate-facets --entity serialNumber --group-state \ --window '{"from":"2026-08-01T00:00:00Z","to":"2026-08-01T12:00:00Z"}'const { data } = await client.escalations.aggregateByFacets({ query: { entity: 'serialNumber' }, groupBy: { state: true }, measure: { kind: 'dwell', window: { from, to } },});// groups: idle, printing, printer-pickup, printer-service, each with dwellSecondscurl -s -X POST https://lt.acme.dev/api/escalations/aggregate-by-facets \ -H "Authorization: Bearer $LT_TOKEN" -H 'Content-Type: application/json' \ -d '{"query":{"entity":"serialNumber"},"groupBy":{"state":true}, "measure":{"kind":"dwell","window":{"from":"2026-08-01T00:00:00Z","to":"2026-08-01T12:00:00Z"}}}'Membership and asOf
Section titled “Membership and asOf”Membership is the other measure: rows open at an instant. With distinctBy, it counts distinct entities instead of rows.
// How many printers are in each state right now?await client.escalations.aggregateByFacets({ query: { entity: 'serialNumber' }, groupBy: { state: true }, measure: { kind: 'membership' }, distinctBy: 'serialNumber',});Omit measure.asOf for now. A past asOf (--as-of on the CLI) reconstructs the live set at that moment.
| Measure | Answers | Inputs |
|---|---|---|
| Dwell | How long entities spent in each state over a period | window: { from, to } |
| Membership | How many rows or entities sit in each state at an instant | measure.asOf (default now), distinctBy |
Q2: the same, sliced by a facet
Section titled “Q2: the same, sliced by a facet”Add groupBy.facets. Each value gets its own state split, and rows missing the facet form a real NULL group:
ltc esc aggregate-facets --entity serialNumber --group-state --group-facets model \ --window '{"from":"2026-08-01T00:00:00Z","to":"2026-08-01T12:00:00Z"}'# model-a and model-b, each with its own idle, printing, pickup and service dwellFilter the same way: --facets '{"enclosed":true}' restricts the whole aggregate to enclosed printers.
Q3: how did one printer spend its day?
Section titled “Q3: how did one printer spend its day?”timelineByFacet returns the ordered interval sequence across the whole system:
ltc esc timeline serialNumber PRN-001 --entity serialNumberconst { data } = await client.escalations.timelineByFacet({ facet: { key: 'serialNumber', value: 'PRN-001' }, query: { entity: 'serialNumber' },});// intervals: [{ role, subtype, startedAt, endedAt, durationSeconds }, ...]curl -s -X POST https://lt.acme.dev/api/escalations/timeline-by-facet \ -H "Authorization: Bearer $LT_TOKEN" -H 'Content-Type: application/json' \ -d '{"facet":{"key":"serialNumber","value":"PRN-001"},"query":{"entity":"serialNumber"}}'Open intervals report endedAt: null. Gaps between intervals are untracked time and stay in the result: the settle time between queues is a signal in its own right.
A long history pages recent-first with order: 'desc' and the before cursor, a strict upper bound on startedAt:
ltc esc timeline serialNumber PRN-001 --entity serialNumber --order desc --limit 100ltc esc timeline serialNumber PRN-001 --entity serialNumber --order desc --limit 100 \ --before 2026-08-01T09:15:00ZThe interval
Section titled “The interval”Each row is [created_at, ended_at). ended_at comes from the terminal transition: resolved_at for resolved rows, updated_at for cancelled or expired rows, NULL while the row is live. The lt_escalations view exposes it as a computed column, and a partial index on the terminal instant keeps history scans bounded.
liveStatuses (default ['pending']) declares which statuses count as live.
Find one entity among thousands
Section titled “Find one entity among thousands”| Filter | Does |
|---|---|
prefix |
Case-insensitive prefix match on a facet value: --prefix '{"serialNumber":"PRN-00"}' |
anyOf |
Rows carrying at least one of the given facet sets, GIN-served, up to 200: { "anyOf": [{ "serialNumber": "PRN-001" }, { "serialNumber": "PRN-002" }] } |
Result groups page with orderBy, limit and offset. overflow: true means another page exists.
What the dashboard shows
Section titled “What the dashboard shows”| Question | Where |
|---|---|
| Q1 | /operations: pick an entity system in the view selector to see state dwell and membership-now counts |
| Q2 | The entity view’s Slice by control |
| Q3 | The entity table’s timeline action, or the history action on any metadata value in a list |
/operations?lens=serialNumber&entity=PRN-003 opens the fleet view focused on one printer with its timeline raised. Every surface refreshes from escalation events.
Validation
Section titled “Validation”The filter takes role or roles (or entity), facets, block, range, exists, prefix and anyOf. Anything that would quietly change what an aggregate means is a 400:
status,availableorjeopardyon the filter (liveness comes from the interval and the measure)orderBy,limitoroffseton the filter (paging applies to result groups)entitytogether withroleorroles- an entity key no role declares (the response names the gap)
groupBy.statetogether withstates[]- a window wider than
LT_ANALYTICS_MAX_WINDOW_DAYS, a futureasOf, malformed keys, unknown statuses
Result groups are capped by LT_ANALYTICS_MAX_GROUPS.
Access
Section titled “Access”Aggregates and timelines need read_all on every role in scope. A query with no role scope spans every queue and needs a global principal. With the public Pace Board flag on, counts-only aggregates (no facet keys among the group keys) are readable by any login.
| Surface | Name |
|---|---|
| SDK | client.escalations.aggregateByFacets, client.escalations.timelineByFacet |
| HTTP | POST /api/escalations/aggregate-by-facets, POST /api/escalations/timeline-by-facet |
| MCP | aggregate_by_facets, timeline_by_facet |
| CLI | ltc esc aggregate-facets, ltc esc timeline |
Cache tuning: LT_ANALYTICS_CACHE_TTL_MS, LT_ANALYTICS_PAST_CACHE_TTL_MS, LT_ANALYTICS_CACHE_MAX_ENTRIES.
