Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions changelog.d/timeline-health.added.md
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
The Timeline plugin can now report team health: `timeline/health` reads the same window and returns, per Bot, its turns, error rate, how busy it was, how many of its requests are unanswered, and short flags (`errors`, `busy`, `idle`, `waiting`), plus a single `ok` for the team. It is read-only and needs no configuration.
2 changes: 2 additions & 0 deletions docs/subsystems/timeline.md
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ Everything in `plugins/timeline/`: the recorder, the trace file, the read model
| `plugins/timeline/src/recorder.js` | Feed events to compact trace records; rebuilds every field from a strict alphabet. |
| `plugins/timeline/src/store.js` | The trace: single writer, write-tmp + fsync + rename, capped segments. |
| `plugins/timeline/src/window.js` | Pure read model: records to lanes, turns, tools, arrows, edges, utilization, the token spend per Bot (#357) and per-Bot outcomes and recent failures (#471). |
| `plugins/timeline/src/health.js` | Team health (#357): pure `buildHealth(window)` turning the window into per-Bot signals and flags. |
| `plugins/timeline/client.js` | Browser entry (`@flybot/timeline`): the 2 s poller exposed as `ctx.flybot.timeline`, the geometry functions (`layoutSwimlanes`, `layoutMap`, `waitRows`, `utilizationRows`) and the panel (swimlanes, who waits on whom, utilization), opened from its `flybot.sidebar.section` row, the only entry (not in the + menu, #269). |
| `plugins/timeline/src/sanitize.js` | The cleaners for ids, labels, codes, times, counts. |
| `plugins/timeline/src/otel.js` | OTLP export: maps trace records to OpenTelemetry GenAI spans (`spansFrom`, `otlpPayload`) and a cursor exporter (`createExporter`). Off unless `config.exporter` is set. |
Expand All @@ -36,6 +37,7 @@ Everything in `plugins/timeline/`: the recorder, the trace file, the read model
- `plugins/timeline/index.js` — loaded as `@flybot/timeline`.
- Endpoint `timeline/window` (wire name `flybot/timeline/window`), registered `{ read: true }` so a read-only host still answers. Body `{ windowMs?, to? }`; window 30 s to 24 h, default 15 min.
- Endpoint `timeline/export` (#357, read-only): the current trace mapped to one OTLP/HTTP JSON payload (`resourceSpans`), the same mapping the timer exporter uses. Ids only unless the body sets `names: true` (#483); `serviceName` overrides the default. The panel or an automation can pull it on demand instead of waiting for the next flush.
- Endpoint `timeline/health` (#357, read-only): the same window reduced to per-Bot signals and flags (`errors`, `busy`, `idle`, `waiting`) and a team `ok`; the action view over the numbers the panel draws. Body `{ windowMs?, to? }` like `timeline/window`.

## Invariants

Expand Down
47 changes: 47 additions & 0 deletions plugins/timeline/src/health.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
// A team-health read model (#357): the window's raw numbers turned into per-Bot signals and a
// short set of flags a person can act on, so observability becomes a decision instead of a chart.
// Pure: a window in, a plain object out. It reads only what the window already carries (turns,
// errors, utilization, waits), so it records nothing new and never touches a Session.
const ERROR_RATE_MIN = 0.5
const ERROR_TURNS_MIN = 3
const BUSY_SHARE = 0.9

const share = (part, whole) => (whole > 0 ? part / whole : 0)
const round = value => Math.round(value * 1000) / 1000

/**
* `window` is a `timeline/window` answer. Resolves `{ version, now, windowMs, live, ok, bots,
* unhealthy }`. Each Bot carries its turns, errors, error rate, share of the window it was busy,
* whether it is running now, how many of its requests are unanswered, and flags
* (`errors` / `busy` / `idle` / `waiting`). `ok` is false when any Bot has an `errors` or
* `waiting` flag; `unhealthy` names them.
*/
export function buildHealth(window = {}) {
const lanes = new Map((window.lanes ?? []).map(lane => [lane.id, lane]))
// Unanswered requests per sender Bot: where a Bot's work is waiting on someone else.
const waitingOn = new Map()
for (const edge of window.edges ?? []) {
if (!(edge?.unanswered > 0) || typeof edge.from !== 'string') continue
waitingOn.set(edge.from, (waitingOn.get(edge.from) ?? 0) + edge.unanswered)
}
const bots = (window.utilization ?? []).map((use) => {
const lane = lanes.get(use.lane)
const errorRate = share(use.errors, use.turns)
const busyShare = share(use.busyMs, use.windowMs)
const waiting = waitingOn.get(use.lane) ?? 0
const flags = []
if (use.turns >= ERROR_TURNS_MIN && errorRate >= ERROR_RATE_MIN) flags.push({ code: 'errors', detail: `${use.errors} of ${use.turns} turns failed` })
if (use.turns > 0 && busyShare >= BUSY_SHARE) flags.push({ code: 'busy', detail: `busy ${Math.round(busyShare * 100)}% of the window` })
if (use.turns === 0) flags.push({ code: 'idle', detail: 'no turns in the window' })
if (waiting > 0) flags.push({ code: 'waiting', detail: `${waiting} request(s) unanswered` })
return {
id: use.lane,
name: lane?.name ?? use.lane,
...(lane?.main === true ? { main: true } : {}),
turns: use.turns, errors: use.errors, errorRate: round(errorRate), busyShare: round(busyShare),
running: use.running === true, waiting, flags,
}
})
const unhealthy = bots.filter(bot => bot.flags.some(flag => flag.code === 'errors' || flag.code === 'waiting')).map(bot => bot.id)
return { version: 1, now: window.now ?? null, windowMs: window.windowMs ?? null, live: window.live === true, bots, unhealthy, ok: unhealthy.length === 0 }
}
8 changes: 7 additions & 1 deletion plugins/timeline/src/timeline.js
Original file line number Diff line number Diff line change
Expand Up @@ -3,9 +3,11 @@ import { createRecorder } from './recorder.js'
import { openTrace, TraceLocked } from './store.js'
import { buildWindow, clampWindow } from './window.js'
import { createExporter, otlpPayload, spansFrom } from './otel.js'
import { buildHealth } from './health.js'

export const ENDPOINT = 'timeline/window'
export const EXPORT_ENDPOINT = 'timeline/export'
export const HEALTH_ENDPOINT = 'timeline/health'

export function createTimeline(adapter, { dir, persist = true, now = Date.now, exporter, ...limits } = {}) {
let trace
Expand Down Expand Up @@ -49,12 +51,16 @@ export function createTimeline(adapter, { dir, persist = true, now = Date.now, e
// sends another home's names by accident.
const exportWindow = (body = {}) => otlpPayload(spansFrom(trace.records(), { names: body?.names === true ? trace.names() : {} }), { serviceName: body?.serviceName })
const offExport = adapter.handle(EXPORT_ENDPOINT, exportWindow)
// Team health (#357): the same window, reduced to per-Bot signals and flags. Read-only.
const health = (body = {}) => buildHealth(window(body))
const offHealth = adapter.handle(HEALTH_ENDPOINT, health)
return {
trace, recorder, window, exportNow, exportWindow,
trace, recorder, window, exportNow, exportWindow, health,
dispose() {
if (exportTimer !== undefined) clearInterval(exportTimer)
offEndpoint()
offExport()
offHealth()
off()
trace.close()
},
Expand Down
42 changes: 42 additions & 0 deletions plugins/timeline/test/health.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
import assert from 'node:assert/strict'
import { test } from 'node:test'
import { buildHealth } from '../src/health.js'

const make = (extra = {}) => ({
version: 1, now: 1000, windowMs: 1000, live: true,
lanes: [{ id: 'user', kind: 'user', name: 'You' }, { id: 'a', kind: 'bot', name: 'Chief', main: true }, { id: 'b', kind: 'bot', name: 'Writer' }],
utilization: [], edges: [], ...extra,
})

test('health: a Bot with many failed turns is flagged, and the team is unhealthy', () => {
const health = buildHealth(make({
utilization: [
{ lane: 'a', windowMs: 1000, busyMs: 900, turns: 4, errors: 3, running: false },
{ lane: 'b', windowMs: 1000, busyMs: 100, turns: 2, errors: 0, running: true },
],
edges: [{ from: 'b', to: 'a', requests: 1, replies: 0, unanswered: 1 }],
}))
const a = health.bots.find(bot => bot.id === 'a')
assert.deepEqual(a.flags.map(flag => flag.code), ['errors', 'busy'])
assert.equal(a.errorRate, 0.75)
assert.equal(a.name, 'Chief')
assert.equal(a.main, true)
const b = health.bots.find(bot => bot.id === 'b')
assert.deepEqual(b.flags.map(flag => flag.code), ['waiting'])
assert.equal(b.waiting, 1)
assert.equal(b.running, true)
assert.equal(health.ok, false)
assert.deepEqual(health.unhealthy, ['a', 'b'])
})

test('health: an idle Bot is flagged but does not make the team unhealthy', () => {
const health = buildHealth(make({ utilization: [{ lane: 'b', windowMs: 1000, busyMs: 0, turns: 0, errors: 0, running: false }] }))
const b = health.bots.find(bot => bot.id === 'b')
assert.deepEqual(b.flags, [{ code: 'idle', detail: 'no turns in the window' }])
assert.equal(health.ok, true)
assert.deepEqual(health.unhealthy, [])
})

test('health: an empty window is ok and has no Bots', () => {
assert.deepEqual(buildHealth({}), { version: 1, now: null, windowMs: null, live: false, bots: [], unhealthy: [], ok: true })
})
Loading