---
title: "One run's full detail"
method: GET
path: "/api/v1/checks/{id}/runs/{rid}"
tags: ["Events"]
---

# One run's full detail

`GET /api/v1/checks/{id}/runs/{rid}`

## Request

- Base URL: `https://app.lastping.dev`
- URL: `https://app.lastping.dev/api/v1/checks/{id}/runs/{rid}`
- Auth: API key in header `X-Api-Key`

One run's complete timeline, declared expectations judged against its
outcome, output excerpt and, for a CI run, provider metadata — the
trace-level detail `GET /api/v1/runs` and `GET /api/v1/checks/{id}/runs`
deliberately omit.

**`events`** is the single time axis: every ping this run recorded
(`start`, `log`, `note`, `blocked`, `success`, `fail`, `cancel`),
interleaved with its `step` progress markers, plus
one synthetic `incident_opened` event when an incident correlates
with this `rid`. Ordered by `at` ascending; two events that tie on
timestamp keep a stable source order (a run's own `fail` ping always
precedes the `incident_opened` event it triggered, never the
reverse). A step is listed once, as its step event, never twice.

**`events` is capped at 200 entries.** A run with more pings than
that (a verbose CI job streaming per-line `log` pings is the
realistic case) has the middle of its timeline dropped, oldest kept
first; `events_truncated` is `true` whenever this happened. The
run's own terminal ping (`success`/`fail`/`cancel`) is fetched
independently of this cap and is **always** the last entry in
`events` when one exists, even if it would otherwise have fallen
past position 200 — so `outcome`, `started_at`, `ended_at`,
`duration_ms`, `output_excerpt` and `ci` are never wrong because of
this cap; only the interior of a very chatty run's displayed
timeline is what gets thinned.

`outcome`, `started_at`, `ended_at`, `duration_ms`, `title` and
`exit_code` are computed by the identical rule `GET /api/v1/runs`
uses (see that endpoint's description), from this run's own real
pings rather than a summary row — the two endpoints can never
disagree about a run they both serve.

A run with spans but no pings (`traced: true`, see `Run.traced`)
answers 200 with an empty `events`, its `spans`, and the outcome its
spans give. A `rid` with neither pings nor spans is 404.

**`assertions`** reports every expectation declared via
`POST .../expectations`, each judged against the run's terminal ping
body using the same evaluator that judges
the run when it closes. `verdict` is `not_evaluated`
when the run has no terminal ping yet (there is no body to judge
against), else `pass` or `fail`; `failure` carries the evaluator's
one-line reason on `fail` and is `null` otherwise.

**`ci`** merges the `ci_meta` of every ping on this run, field by
field, a later non-empty value winning, so the terminal ping's
failing stage and duration sit beside the start ping's commit and
run URL. `null` for an agent/heartbeat run with no CI provider
attached.

**`newer_rid` and `older_rid`** are this MONITOR's runs either side of
this one in time: the run that started just after it, and the one just
before. Both are rids on the check named in the path, so a caller
builds the neighbour's URL by swapping the `rid` segment. Either is
`null` at that end of the monitor's own history (within the 90-day
ping window), which is a fact about the monitor rather than about what
a client happens to have listed, so a run reached by a direct link
knows its neighbours without fetching a list first. Runs of OTHER
monitors are never named here, however a list happens to be filtered.

**`spans`** is this run's OTLP spans, when any exporter
sent them, in tree order: parents before children, siblings ordered
by start time, and an orphan (a span whose `parent_span_id` matches
no span in this same run) appended after every real root's subtree.
Capped at 2,000 (`spans_truncated`); the separate per-run storage
cap surfaces as `spans_dropped`, null when the run has no
span stats at all. `span_count`, `tokens_in`, `tokens_out` and
`cost_usd` (inherited from `Run`) are null the same way when the run
was never traced.

Spans themselves are retained for 30 days; `span_count`, `tokens_in`,
`tokens_out` and `cost_usd` survive for 90. A run between 31 and 90
days old therefore reports those four stats normally while `spans`
comes back an empty array — retention, not a bug.

`title`, `output_excerpt`, `events[].body`, `events[].label`,
`steps[].name` and every string field on `spans[]` are free text the
run itself supplied (a ping's `body_excerpt`, a step's `name`, a
run's `title`, an OTLP exporter's span content); render them as
text, never interpret them as markup.

## Response `200`

The run's full detail.

- RunDetail: One run entry in `GET /api/v1/runs` — a project-wide, per-run (rather than per-ping) summary. See that endpoint's description for the outcome rule and the pagination contract.
  - `check_id` string, uuid, required
  - `check_name` string, required
  - `project` string, required: Project label the Claude Code hook sent with the run's start (the basename of the working directory); empty when none. Third-party text: render it as text, never interpret it as markup.
  - `receiving_spans` boolean, required: True while spans for this run arrived in the last 2 minutes; the run's end and cost may still grow.
  - `in_hook_session` boolean, required: True for a run the agent reported itself that began while a Claude Code hook run was open on the same monitor: usually the same turn reported twice. Counted like any run.
  - `is_test` boolean, required: True for the set-up's test span. Listed, never counted.
  - `failure_cause` string, required: Why a failed run failed, when the reason is known. Its values today are an empty string (every other run), `assertion` and `upstream`. `assertion`: the run sent a success, but its output failed an output assertion (one configured on the monitor, or an expectation the run declared), so the run is `failed` and opens an incident with cause `assertion` like any failure. `upstream`: the run ended on an API error that is the model provider's rather than the agent's (`server_error`, `overloaded` or `rate_limit`, named in `upstream_error`). Such a run is `failed`, but it pages only once 3 runs in a row (or the monitor's `failure_threshold`, when that is higher) have ended that way, as an incident with cause `upstream`. A plain string rather than an enum: treat a value you do not recognise as an empty string.
  - `upstream_error` string, required: The upstream API error type the run ended on (`server_error`, `overloaded` or `rate_limit`), set only when `failure_cause` is `upstream`. An empty string for every other run, including one that ended on an API error that is not the provider's (for example `authentication_failed`, which is the user's to fix and is an ordinary failure). Lowercase letters and underscores only.
  - `traced` boolean, required: True for a run that exists only as OTLP spans, with no ping: a trace that arrived with no `lastping.run_id` (its `rid` is `trace-` followed by the hex trace id, one run per trace), or an explicit `lastping.run_id` that never pinged. Its `outcome` comes from its spans: `failed` when a root span reported an error status (an error on a child span alone does not fail the run), `succeeded` once the root span has closed, `running` before that, and `unfinished` once its first span is older than the monitor's maximum runtime with the root still open. `started_at` is its first span's start, `ended_at` its root span's end. Such a run never opens an incident and never changes the monitor's status. False for every run that has pings, including one that also carries spans.
  - `rid` string, required: The run id every ping of this run shares — caller-chosen, raw job input. Third-party text: render it as text, never interpret it as markup.
  - `title` string, required: The run's title, or an empty string when none was set. The set-up's test span (`is_test`) is titled `LastPing test span`. Third-party text: render it as text, never interpret it as markup.
  - `outcome` 'succeeded' | 'failed' | 'cancelled' | 'blocked' | 'running' | 'unfinished', required
  - `started_at` string, date-time, required: The run's earliest ping, or its first span for a spans-only run. A run is in a window when any of its pings is; its `started_at` is still its real start when that is earlier than `since`.
  - `ended_at` string, date-time, nullable, required: Null while the run has no terminal ping (`running`, `unfinished` or `blocked`, or `failed` by incident with no terminal ping of its own). For a finished run with spans it follows the latest span: the later of the terminal ping and the run's last span end, so spans a background task sends after the run ended move it (see `receiving_spans`). The outcome and alerting never read it.
  - `duration_ms` integer, nullable, required: State-machine-measured wall-clock duration, present when a terminal ping paired with this run's start. Null otherwise. For a finished run with spans it runs to `ended_at`, the latest span, when that is later; alerting reads the ping's own measured duration, never this figure.
  - `step_count` integer, required: Number of progress markers recorded on this run's timeline (see `RunStep`), capped at 100 per run.
  - `exit_code` integer, nullable, required: Exit code carried by the terminal ping, when one exists and reported one.
  - `incident` RunIncidentRef, required: The incident that opened while this run was the check's armed run (the run id is recorded when the incident opens).
    - `id` integer, required
    - `cause` string, required
    - `opened_at` string, date-time, required
    - `closed_at` string, date-time, nullable, required: Null while the incident is still open.
  - `span_count` integer, nullable, required: Number of OTLP spans recorded for this run. Null when the run was never traced — distinct from `0`, which cannot occur (a run's span totals start with its first span). This and the other three stats fields below survive for 90 days, longer than the individual spans they summarise (30 days) — see `RunDetail.spans`'s description. A run between 31 and 90 days old therefore still reports a nonzero `span_count` here even though `GET .../runs/{rid}` shows an empty `spans[]` for it.
  - `tokens_in` integer, nullable, required: Sum of `gen_ai.usage.input_tokens` across this run's GenAI spans. Null when the run was never traced.
  - `tokens_out` integer, nullable, required: Sum of `gen_ai.usage.output_tokens` across this run's GenAI spans. Null when the run was never traced.
  - `cost_usd` string, nullable, required: Summed cost of this run's GenAI spans, as decimal text (e.g. `"0.003400"`), never a JSON number, so summing many small costs never loses precision to float rounding. Each span's cost is the one its SDK reported (`gen_ai.usage.cost`) or, when it reported none, LastPing's estimate from the model's published price and the span's token counts (`cost_source` says which). Null when the run was never traced, or when no span has either: no reported cost and no known price for its model.
  - `cost_source` string, required: What `cost_usd` is made of. A plain string, not an enum, so a new value never breaks a client: `client` when every priced call's cost was reported by the client (the exporter's own figure, or Claude Code's per-call cost), `estimated` when LastPing priced every call at API list prices, `mixed` when the sum holds both, empty when `cost_usd` is null.
  - `agent_id` string, uuid, nullable, required: The run's agent, by the rule `RunListItem.agent_id` reports it for the same run: the monitor's agent, or else the agent the run's dominant trace source resolves to (by slug, then name, then an adoption). Null, with `agent_name`, when neither gives one.
  - `agent_name` string, nullable, required
  - `source_name` string, required: The run's dominant trace source (the OpenTelemetry `service.name` most of its spans carried), the same value as `RunListItem.source_name`. Empty string when the run carried no span or none named one. Third-party text.
  - `events` RunEvent[], required: Capped at 200 entries, plus the terminal event appended independently of that cap when truncation left it out, for a maximum of 201; see this operation's description for the truncation rule and why the terminal event is exempt from it.
    - `kind` 'start' | 'step' | 'log' | 'note' | 'blocked' | 'success' | 'fail' | 'cancel' | 'incident_opened', required: The ping kind this event came from, `step` for a declared step, or the synthetic `incident_opened`.
    - `at` string, date-time, required
    - `label` string, required: The step name for a `step` event, the correlated incident's cause for `incident_opened`, or the kind word itself for every other event (a step name is caller-supplied). Third-party text: render it as text, never interpret it as markup.
    - `body` string: The triggering ping's `body_excerpt`, when it had one. Absent for an event kind with no body (`step`, `incident_opened`) and for a ping that carried an empty one. Third-party text: render it as text, never interpret it as markup.
    - `step_seq` integer: 1-based position within the run's step timeline. Present only on a `step` event.
  - `steps` RunStepRef[], required
    - `seq` integer, required
    - `name` string, required: Step label from `?step=` (see the `RunStep` schema). Third-party text: render it as text, never interpret it as markup.
    - `at` string, date-time, required
  - `assertions` RunAssertionVerdict[], required
    - `seq` integer, required: Declaration order — matches the position in the original `POST .../expectations` request.
    - `kind` 'contains' | 'not_contains' | 'matches' | 'json_path', required
    - `value` string
    - `path` string: Dotted path, `json_path` only.
    - `op` 'eq' | 'ne' | 'gt' | 'gte' | 'lt' | 'lte': Comparison operator, `json_path` only.
    - `verdict` 'pass' | 'fail' | 'not_evaluated', required: `not_evaluated` when the run has no terminal ping yet — there is no body to judge this assertion against. Otherwise `pass` or `fail`.
    - `failure` string, nullable, required: The evaluator's one-line reason, present only when `verdict` is `fail`. Built from the assertion's own declared `value`/`path`. Third-party text: render it as text, never interpret it as markup.
  - `output_excerpt` string, required: The terminal ping's `body_excerpt`, or an empty string for a run with no terminal ping yet. Third-party text: render it as text, never interpret it as markup.
  - `ci` RunCI, required: `RunDetail`'s CI-provider metadata, merged field by field across every ping on this run that carried `ci_meta`, a later non-empty value winning: the start ping supplies the run URL, commit, branch and actor, the terminal ping the failing stage and duration. The `RunDetail.ci` field itself is `null` (this schema never appears) for an agent/heartbeat run with no CI provider attached.
    - `run_url` string
    - `commit_sha` string
    - `branch` string
    - `actor` string
    - `failing_stage` string
    - `duration_s` integer
  - `events_truncated` boolean, required: `true` when this run had more than 200 pings and `events` therefore does not show every one of them (oldest kept, middle dropped). The run's terminal ping is unaffected by this — it is always present and always the last entry in `events` when one exists — so `outcome`, `duration_ms`, `output_excerpt` and `ci` stay correct even when this is `true`.
  - `spans` Span[], required: This run's OTLP spans, in tree order — see `Span`'s description for the ordering rule. Always an array (never omitted), empty for a run that was never traced. Spans are retained for 30 days, shorter than the 90-day window `span_count`/`tokens_in`/`tokens_out`/`cost_usd` (inherited from `Run`) survive to. A run between 31 and 90 days old therefore reports its stats normally while `spans` is an empty array here — that is retention, not a bug, and is a different condition from `spans_truncated` below, which is about a run that is over the 2,000-span cap, not too old.
    - `trace_id` string, required: 32-character lowercase hex OTLP trace id.
    - `span_id` string, required: 16-character lowercase hex OTLP span id.
    - `parent_span_id` string, nullable, required: 16-character lowercase hex OTLP parent span id. Null for a root span.
    - `name` string, required: Span name, truncated to 256 bytes on a rune boundary. Third-party text.
    - `kind` 'unspecified' | 'internal' | 'server' | 'client' | 'producer' | 'consumer', required: OTLP span kind.
    - `status` 'unset' | 'ok' | 'error', required: OTLP span status code.
    - `status_message` string, required: OTLP status message, truncated to 1 KiB on a rune boundary. Empty string when none was set. Third-party text.
    - `started_at` string, date-time, required
    - `ended_at` string, date-time, required
    - `duration_ms` integer, required
    - `gen_ai` SpanGenAI, required: GenAI fields extracted from a span's `gen_ai.*` attributes, per the OpenTelemetry GenAI semantic conventions. Present on `Span.gen_ai` only when the span carried at least one of these fields — an ordinary (non-GenAI) span has `gen_ai: null`, not an object with everything blank. Every field here is independently nullable: a span can report `system` with no token counts, for instance.
      - `system` string, nullable: `gen_ai.system` (e.g. `openai`, `anthropic`). Third-party text: render it as text, never interpret it as markup.
      - `model` string, nullable: `gen_ai.request.model`, falling back to `gen_ai.response.model`. Third-party text: render it as text, never interpret it as markup.
      - `tokens_in` integer, nullable: `gen_ai.usage.input_tokens`, falling back to `gen_ai.usage.prompt_tokens`.
      - `tokens_out` integer, nullable: `gen_ai.usage.output_tokens`, falling back to `gen_ai.usage.completion_tokens`.
      - `cost_usd` string, nullable: The span's cost as decimal text (e.g. `"0.003400"`), never a JSON number: the cost the SDK reported (`gen_ai.usage.cost`) or, when it reported none, LastPing's estimate from the model's published price and the span's token counts (`cost_source` says which). Null when neither exists: no reported cost, and no token counts or no known price for the model.
      - `cost_source` string: Where this call's cost came from: `client` (the exporter or Claude Code reported it) or `estimated` (LastPing priced it at API list prices); empty when `cost_usd` is null. A plain string, not an enum.
    - `attributes` object, nullable, required: Remaining span/resource attributes as a JSON object, up to 32 KiB per span (excess dropped, `truncated` set). Null when the span carried none. Third-party text throughout: render every value as text, never interpret it as markup.
    - `truncated` boolean, required: True when this span's attributes were truncated to fit the 32 KiB cap.
    - `source_name` string, required: The span's trace source: its resource's OpenTelemetry `service.name`, which is how LastPing tells one agent's spans from another's when a run crosses several. Empty string when the exporter sent none. Compare it with `RunDetail.source_name` to tell the run's own agent from another one. Third-party text.
    - `peer_kind` 'model' | 'tool' | 'http' | 'database' | 'queue' | 'rpc' | 'agent' | 'null', nullable, required: The kind of the outgoing dependency this span called, as LastPing classified it at ingest: the same value the agent's `Dependency.kind` carries for it. Null for a span that called nothing (an internal step).
    - `peer_name` string, nullable, required: The display name of the outgoing dependency this span called: the same value as the matching `Dependency.name` in `GET /api/v1/agents/{id}/calls`, so a span can be matched to its row there. Null exactly when `peer_kind` is. Derived from span attributes the exporter chose: third-party text.
  - `spans_truncated` boolean, required: `true` when this run had more than 2,000 recorded spans and `spans` therefore does not show every one of them (this endpoint's own display cap — see `spans_dropped` for the separate, more common storage cap). Always `false` once a run's spans have aged out at the 30-day retention cutoff described on `spans` above, since an empty `spans[]` from retention is not "too many", it is "none left to show".
  - `spans_dropped` integer, nullable, required: Spans not stored because this run had already reached its 2,000-span storage cap. Null (not `0`) when the run has no span stats at all — distinct from a traced run that dropped none.
  - `newer_rid` string, nullable: The rid of THIS monitor's next run in time, the one that started just after this one, or `null` when this is the monitor's newest run inside the 90-day ping window. It is an rid on the check named in this path, so the neighbour's URL is this one with the `rid` segment swapped. Never a run of another monitor.
  - `older_rid` string, nullable: The rid of THIS monitor's previous run in time, or `null` when this is the oldest run inside the 90-day ping window. Same scoping as `newer_rid`.

## Other responses

- `401`: Missing or invalid API key.
- `404`: The check does not exist in this project, or `rid` has neither ping rows nor spans on it (never armed, or belongs to a different check) — no existence oracle either way.
- `429`: Too many requests. The per-principal fixed-window rate limit has been exceeded. Retry after the number of seconds indicated by the `Retry-After` response header. The problem body's `code` is `RATE_LIMITED`. It names this limiter specifically, so a client can tell it apart from the application-level throttles that also answer 429 with their own codes and their own, much longer, `Retry-After` values (`TEST_PING_RATE_LIMITED`, `TOO_MANY_ATTEMPTS`). Back off globally on `RATE_LIMITED`; back off on the one operation for the others. The principal is the API key for a key-authenticated request and the USER for a browser session, so every tab one person has open shares one budget.

---

[API](https://skmtc.dev/lastping/apis/lastping-management-api.md) · [All operations](https://skmtc.dev/lastping/apis/lastping-management-api/llms.txt) · [OpenAPI document](https://skmtc.dev/lastping/apis/lastping-management-api/revisions/bbc6eda91c46?raw)
