# 3pm-backend route `insight`

Paths are relative to the backend's origin (https://fleet.edgecr4ft.net, or your own 3pm-backend), not to this site. Index: https://3pm.edgecr4ft.net/docs/backend/api.md

---

tokens:
  claude: 4153
  o200k: 2691
# GET /api/buckets/{clusterId}/insights/{incident} (insight)

One incident of `insights` and the findings it groups, each with its detector, series, score and summary — text for an LLM by default, JSON with `?format=json` or `Accept: application/json`.

- auth: an account — Authorization: Bearer 3pmu_<token>, or the dashboard's session cookie
- node: also open to a node's own bearer (3pma_…) when its row is granted fleet:read; that node's bucket only, and for the calls it placed itself
- answer: InsightDetail (JSON)

## Details

One incident by its `id` (as `insights` lists it, URL-encoded; it stays the same while the incident lasts), with what makes it: `{ tokens, pass_at_ms?, next_pass_ms?, stale, error?, sources, lang, incident, members, n_members, events?, story? }`. `incident` is shaped as in `insights`; `members` are the findings it groups, best first (at most 30; `n_members` counts them all), each `{ app, node_id?, hostname?, metric?, metric_name?, labels?, score?, weight?, summary }` — `app` is the detector, `weight` its share of the score. `events` are the 3pm process events of the last 6 hours on the incident's node, per app: counts by event, and `last_ms`. `story` places it among the incidents of the same machine that started together: `{ head_family, families?, rank? }` — `head_family` is the metric family of the incident heading them, `families` (on the head) all of theirs, `rank` (on the others) its place. An id `insights` does not list (an incident that ended, one of another bucket, a guess) is a 404 `ERR_NOT_FOUND`, answered without asking the insights apps; `?format=json` or `Accept: application/json` for the JSON, the text otherwise.

## Types

```ts
/** `insight(clusterId, incident)`: one incident and the findings it groups. */
interface InsightDetail extends InsightsPass {
  tokens: TokenCount;
  incident: Incident;
  /** The findings the incident groups, best first, at most 30. */
  members: IncidentMember[];
  /** How many there are in all. */
  n_members: number;
  /** The 3pm process events of the last 6 hours on its node, per app: counts by event (`exit≠0`, `errored`, `restart`…) and `last_ms`, the latest one; absent when none. */
  events?: Record<string, Record<string, number>>;
  /** Its place among the incidents of the same machine that started together; absent when none. */
  story?: IncidentStory;
}

/** The pass of the insights apps an answer comes from. */
interface InsightsPass {
  /** Epoch ms of the last ranking pass (one every five minutes). */
  pass_at_ms?: number;
  next_pass_ms?: number;
  /** The last pass is older than two intervals: the ranking stopped moving. */
  stale: boolean;
  /** Why the last pass failed, when it did: the incidents are then the pass before. */
  error?: string;
  /** Each detector read at that pass: `ok`, `fallback` (read from its state file), `stale`, `pending` (no pass yet), `error`, `absent`. */
  sources: Record<string, "ok" | "fallback" | "stale" | "pending" | "error" | "absent">;
  /** Per detector, the rules it evaluated only in part at that pass (a failed or truncated store query); only counts above 0, absent when every detector was whole. */
  partial?: Record<string, number>;
  /** The language of every `summary`: `fr`. */
  lang: string;
}

/** What an answer costs to put in a prompt: the first field of every answer that carries it (`metricsInventory`, `GET /api/v1/routes`). */
interface TokenCount {
  /** Claude's tokenizer, estimated from the bytes with a ratio measured per shape through the `count_tokens` API — within a few percent. */
  claude: number;
  /** OpenAI's `o200k_base`, exact. Claude counts 1.5–1.7× more on this backend's answers. */
  o200k: number;
}

/** What several detectors found on one machine and metric family, or on the fleet. Absent fields are empty. */
interface Incident {
  /** Stable while the incident lasts: `<node_id>:<family>[:<n>]` for a machine, `flotte@<cluster>:<family>` or `<group>@<cluster>:<family>[:départ]` fleet-wide. URL-encode it in `insight`'s path. */
  id: string;
  /** 0 to 10: 1 notable, 3 worth a look. */
  score: number;
  /** `problem`: something bad in itself opens it. `context`: only unusual, score damped. `inventory`: a standing fact. */
  tier: "problem" | "context" | "inventory";
  node_id?: NodeId;
  hostname?: string;
  /** Fleet-wide: no `node_id`; its machines in `hosts`. */
  fleet?: true;
  hosts?: NodeId[];
  /** `host.swap`, `process.events`, `app.automonit`… */
  family?: string;
  /** The catalog id of the headline series. */
  metric?: string;
  /** The store name, only when no catalog line has it (a node's state). */
  metric_name?: string;
  labels?: Record<string, string>;
  /** The detectors that agree: `rules`, `exhaustion`, `drift`, `peers`, `changepoints`, `profiles`, `events`. */
  sources: string[];
  /** What was found, in French, with the figures. */
  summary: string;
  /** Epoch ms: first listed. */
  since_ms?: number;
  /** Epoch ms: when the series moved, as the detectors date it. */
  onset_ms?: number;
  /** Ranking passes it has been listed in. */
  passes: number;
  /** A person's verdict, when one was given. */
  label?: { verdict: "true" | "false" | "known" | "unknown"; note?: string };
  /** Labelled `known`: listed only with `known=true`. */
  hidden?: true;
  /** A `queryMetrics` body that draws the headline series over the incident's span. Absent when `metric` is. */
  chart?: MetricsQuery;
}

/** One finding of an incident. */
interface IncidentMember {
  app: string;
  node_id?: NodeId;
  hostname?: string;
  metric?: string;
  metric_name?: string;
  labels?: Record<string, string>;
  score?: number;
  /** Its weight in the incident's score. */
  weight?: number;
  summary: string;
}

interface IncidentStory {
  /** The metric family of the incident heading the story (not an incident id). */
  head_family: string;
  /** On the head: every family of the story, its own first. */
  families?: string[];
  /** On the others: their place in it (2 = the second). */
  rank?: number;
}

/** `3pmn_…` — a node. */
type NodeId = string;

/** `POST /api/buckets/{id}/metrics/query`. */
interface MetricsQuery {
  range: TimeRange;
  /** 1–8. */
  queries: QuerySpec[];
  /** ≤ 8. */
  formulas?: Formula[];
  options?: QueryOptions;
}

interface TimeRange {
  from: TimeValue;
  /** Default `now`. */
  to?: TimeValue;
  /** `30s`, `5m`, `1h` or milliseconds (what `step_ms` answers); omitted ⇒ derived from `options.max_points`. Both ends are aligned to it. */
  step?: DurationValue;
}

interface QuerySpec {
  /** `^[A-Z][A-Z0-9]{0,7}$`, unique across queries and formulas. */
  ref: string;
  /** A catalog id, `process.cpu.utilization`. */
  metric: string;
  /** AND-ed; at most 16. */
  filters?: Filter[];
  /** Default: the catalog's `default_time` for the metric. */
  time?: TimeFn;
  aggregate?: Aggregate;
  /** Computed for a formula, not returned. */
  hidden?: boolean;
}

interface Formula {
  ref: string;
  /** Refs, numbers, `+ - * /` and parentheses: `A / B * 100`. */
  expr: string;
  unit?: string;
}

interface QueryOptions {
  /** 2–2000, default 600: the step is raised until the range fits. Every bound here is also `limits.queryMetrics` of `GET /api/v1/routes`, which is generated from the validator. */
  max_points?: number;
  /** 1–2000, default 200: the series kept are the top ones by `sort` over the whole range (the engine ranks, the backend re-ranks the same way). */
  max_series?: number;
  /** What ranks series for `max_series`, descending; default `avg`. */
  sort?: "avg" | "last";
  /** Default `null`. */
  fill?: "null" | "zero" | "previous";
  /** `series` (default) or `summary` (no values). */
  mode?: "series" | "summary";
  /** Add `results[].expr`: the expression as compiled for the engine. */
  explain?: boolean;
}

/** Epoch milliseconds, or `now` / `now-<duration>` (`now-1h`, `now-7d`). */
type TimeValue = EpochMs | string;

/** Milliseconds, or `30s` / `5m` / `1h` / `7d` / `2w`. */
type DurationValue = number | string;

interface Filter {
  label: QueryLabel;
  op: FilterOp;
  /** `eq`, `neq`, `regex`, `nregex`. ≤ 256 characters. */
  value?: string;
  /** `in`, `nin`: 1–64 values. */
  values?: string[];
}

interface TimeFn {
  fn: TimeFnName;
  /** `1m` or milliseconds, like `range.step`; default twice the step. Not for `raw`. */
  window?: DurationValue;
  /** 0..1, `quantile_over_time` and `quantile` (a histogram's) only. */
  q?: number;
}

interface Aggregate {
  fn: AggregateFnName;
  /** Labels the metric carries. */
  by?: QueryLabel[];
  /** 0..1, `quantile` only. */
  q?: number;
}

/** Milliseconds since the Unix epoch. */
type EpochMs = number;

/** The labels a query may filter or group on; the bucket itself is never one of them. */
/** A built-in label, or an application's own label name (`^[a-z][a-z0-9_]{0,31}$`, not reserved) on a metric that carries it. */
type QueryLabel = "node" | "app" | "instance" | "ns" | "mount" | "window" | "src" | "core" | "iface" | "blockdev" | "sensor" | "gpu" | "array" | "proc" | (string & {});

type FilterOp = "eq" | "neq" | "in" | "nin" | "regex" | "nregex";

/** `rate`, `increase`, `delta` for counters; the `*_over_time` family for gauges; `raw` for either; `quantile` (with `q`), `rate` or `increase` for a histogram. */
type TimeFnName =
  | "raw"
  | "rate"
  | "increase"
  | "delta"
  | "avg_over_time"
  | "min_over_time"
  | "max_over_time"
  | "last_over_time"
  | "quantile_over_time"
  | "quantile";

type AggregateFnName = "sum" | "avg" | "min" | "max" | "count" | "quantile";
```
