# Collector HTTP API

> Look up the collector's OTLP ingestion and local query routes, their bounds, guarded writes and error codes.

The [collector](/reference/glossary/#collector) serves OTLP ingestion and bounded JSON queries on loopback. Coding agents should prefer the `blop` CLI, which adds `blop.cli/v1` envelopes and exit codes; use HTTP for SDK export and direct clients. This is the local API, not a Blop Cloud API.

## Discovery routes

The base URL is `http://127.0.0.1:27811` unless a project or explicit setting changes it; use the URL that `blop status --json` prints. Direct clients choose their base URL themselves; `BLOP_MONITOR_URL` points the CLI at another collector.

| Route | Returns |
|---|---|
| `GET /api/health` | Liveness, collector identity, store path, retention, size cap and ingest receipt |
| `GET /api/ping` | Cheap liveness check with start time and version |
| `GET /openapi.json` | OpenAPI 3.0.3 document of every route |
| `GET /docs` | HTML route index |

```bash
curl -s http://127.0.0.1:27811/api/health
```

```json
{
  "ok": true,
  "reachable": true,
  "sourceLifecycle": "unknown",
  "ingestion": { "lastSuccessfulIngestAt": "2026-11-03T13:07:25.855Z", "acceptedSpans": 1, "acceptedLogs": 1 },
  "retentionHours": 720,
  "maxDbSizeMb": 1024
}
```

`lastSuccessfulIngestAt` is the collector's receipt time of its latest non-empty successful batch; it survives restarts, and empty probes and failed batches do not move it. Receipt does not prove the application is running or that a request succeeded. `stats.latestTelemetryAt` is the event time reported by the source.

## OTLP ingestion

| Route | Accepts | Success |
|---|---|---|
| `POST /v1/traces` | OTLP/HTTP JSON or protobuf | `{ "accepted": SPAN_COUNT }` |
| `POST /v1/logs` | OTLP/HTTP JSON or protobuf | `{ "accepted": LOG_COUNT }` |
| `POST /v1/metrics` | OTLP/HTTP JSON or protobuf; gauge and sum points | OTLP response; `partialSuccess` counts rejected points |

```bash
curl -s -X POST http://127.0.0.1:27811/v1/logs -H 'Content-Type: application/json' \
  -d '{"resourceLogs":[{"resource":{"attributes":[{"key":"service.name","value":{"stringValue":"checkout"}}]},"scopeLogs":[{"logRecords":[{"severityText":"ERROR","body":{"stringValue":"card declined"}}]}]}]}'
```

```json
{ "accepted": 1 }
```

| Rule | Value |
|---|---|
| Content type | `application/json` (also when absent), `application/x-protobuf` or `application/protobuf` |
| Content encoding | None, `identity` or `gzip`. OTLP/gRPC, Brotli and Deflate are not supported. |
| Request size | 16 MiB, checked before and after decompression |
| Concurrent writes | 8 pending requests; more return `503` with `Retry-After: 1` |
| Metrics | Gauge and sum points are stored, temporality kept; histograms and summaries are rejected and counted in `partialSuccess` |

| Status | Meaning |
|---|---|
| `400` | Malformed payload, or an invalid hook batch |
| `403` | Trace owned by an enrolled machine; loopback intake cannot overwrite it |
| `409` | Hook event hash conflicts with one already stored |
| `410` | Hook event older than the 720-hour replay horizon |
| `413` | Payload too large |
| `415` | Unsupported content type or encoding |
| `503` | Queue full or capture process unavailable; retry |

A write completes before the response, so an accepted span is immediately queryable. Replaying a span with the same trace and span ID replaces it; logs have no identity, so retries can duplicate them. Coding agent hook batches use `POST /v1/traces` too and can return exact `receipts`. An exact metric export replay is deduplicated.

### Application source capture

A resource attribute `blop.source.kind=application` or `filelog` applies that source's capture policy, keyed by `blop.source.id`; a service name never selects it. Without consent, custom attributes and log bodies are dropped. Consented content is redacted and capped before storage:

| Field | Cap |
|---|---|
| Application log body | 4 KiB |
| Attribute value | 2 KiB |
| Attribute key, span or log name | 256 B |
| Attribute map | 16 KiB |
| GenAI content field, GenAI content map | 16 KiB, 64 KiB |

Exact original timestamps are kept as `blop.otlp.start_time_unix_nano`, `blop.otlp.end_time_unix_nano`, `blop.otlp.time_unix_nano` and `blop.otlp.observed_time_unix_nano`, and numeric severity as `blop.otlp.severity_number`; receipt uses the collector clock. Without severity text, numeric ranges map to TRACE through FATAL, and zero stays UNSPECIFIED.

`GET /api/sources/check?id=SOURCE_ID` returns reachability, capture `configuration` (`unconfigured`, `enabled` or `disabled`) and an `ingestion` receipt for that source ID. Disabling a source stops new intake and keeps retained history.

## Trace, span and log queries

List routes take durations like `15m`, `2h` or `1d`. Without `lookback` they default to one hour, capped at 24 hours. Traces default to 20 results (at most 100); spans and logs to 100 (at most 500). Copy `meta.nextCursor` unchanged to get the next page.

| Route | Main parameters |
|---|---|
| `GET /api/services` | `lookback`, `summary=1`, `series=1` |
| `GET /api/traces` (alias `/api/traces/search`) | `service`, `status`, `operation`, `durationMin`, `lookback`, `limit`, `cursor`, `attr.KEY` |
| `GET /api/traces/stats` | `groupBy` (`service`, `operation`, `status`, `attr.KEY`), `agg` (`count`, `avg_duration`, `p95_duration`, `error_rate`), trace filters |
| `GET /api/traces/:traceId` | `include` |
| `GET /api/traces/:traceId/spans` | None |
| `GET /api/traces/:traceId/logs` | `lookback`, `limit`, `cursor` |
| `GET /api/spans/search` | `service`, `traceId`, `operation`, `parentOperation`, `status`, `lookback`, `limit`, `attr.KEY`, `attrContains.KEY` |
| `GET /api/spans/:spanId` | None |
| `GET /api/spans/:spanId/logs` | `lookback`, `limit`, `cursor` |
| `GET /api/logs` (alias `/api/logs/search`) | `service`, `severity`, `traceId`, `spanId`, `body` or `contains`, `lookback`, `limit`, `cursor`, `attr.KEY`, `attrContains.KEY` |
| `GET /api/logs/stats` | `groupBy` (`service`, `severity`, `scope`, `attr.KEY`), `agg=count`, log filters |
| `GET /api/facets` | `type=traces` with `field` = `service`, `operation`, `status`, `attribute_keys` or `attribute_values` (needs `key`); `type=logs` with `service`, `severity` or `scope` |

```bash
curl -s 'http://127.0.0.1:27811/api/traces?service=checkout&lookback=15m&limit=1'
```

```json
{
  "data": [{ "traceId": "4bf92f3577b34da6a3ce929d0e0e4736", "serviceName": "checkout", "rootOperationName": "POST /pay", "durationMs": 1000, "spanCount": 1, "errorCount": 1 }],
  "meta": { "limit": 1, "lookback": "15m", "returned": 1, "truncated": false, "nextCursor": "eyJr…" },
  "observation": { "collectorInstanceId": "00167e64-…", "evidenceRevision": "4", "consistency": "independent-read" }
}
```

`observation`, and the `X-Blop-Collector-Instance` and `X-Blop-Evidence-Revision` headers, identify one independent read, not a snapshot across routes. Errors are usually JSON `{ "error": "…" }`, but Host and Origin refusals are plain text and some guarded routes return no body: check the status before parsing.

## Runs, issues and analysis routes

| Route | Returns |
|---|---|
| `GET /api/runs` | Recorded [runs](/reference/glossary/#run); `limit`, `branch`, `status` |
| `GET /api/runs/:id` | One run with counts, new issues and the previous run on its branch; `timeline=1` adds its spans |
| `GET /api/runs/:id/settled` | Whether issue detection still waits on this run |
| `GET /api/issues` | [Issues](/reference/glossary/#issue), filtered by state, branch, time, run, session, trace and service |
| `GET /api/issues/:id`, `/fix-context`, `/samples` | One issue; its Markdown fix context; sample traces |
| `GET /api/why` | The `blop.why/v1` explanation `blop why` prints |
| `GET /api/stream` | Server-sent events for issues, regressions, runs and sessions; `activity=1` adds activity |
| `GET /api/usage` | Token usage from span attributes; `since`, `groupBy=service\|agent\|model`, `service`, `agent`, `limit`. Not billing. |
| `GET /api/metrics` | Stored metric points and per-series totals; `session`, `source`, `since`, `name`, `worktree`, `limit` |
| `GET /api/audit` | Deterministic [audit](/reference/glossary/#audit) findings; `service`, `lookbackMinutes`, `baselineMinutes`, `durationMin` |
| `GET /api/failure-context` | `blop.failure-context/v1` evidence for a `source` plus a `trace`, `service`, `session` or `attempt`; `since` ≤ 1 day, `limit` ≤ 100, `max_bytes` 4096–262144, `deadline_ms` 1–5000; a pinned `until` and `revision` return `409` when invalidated |
| `GET /api/alerts` | `blop.alerts/v1` rules, state and recent events |
| `GET /api/workers` | `blop.worker/v1` worker lifecycle from enrolled machines only |

```bash
curl -s 'http://127.0.0.1:27811/api/issues?limit=1'
```

```json
{ "data": [{ "id": "iss_4adb40de97fb", "code": "app_error", "title": "checkout failed on POST /pay", "service": "checkout", "state": "open", "occurrences": 1 }] }
```

## Inspector routes

These serve the [Inspector](/reference/glossary/#inspector) and send no CORS headers.

| Route | Returns |
|---|---|
| `GET /api/inspector/home`, `/runs`, `/failures`, `/insights`, `/sources` | `blop.inspector-summary/v1` page summaries |
| `GET /api/inspector/activity`, `/activity/detail` | Session activity list and one session or trace |
| `GET /api/inspector/session-tree`, `/session-structure` | Session, trace or conversation tree; the structure omits captured text |
| `GET /api/inspector/session-observation-content` | Exact content for one revision; subject to capture and retention |
| `GET /api/inspector/logs`, `/step-logs` | Log search; logs for one recorded step |
| `GET /api/inspector/traces/:traceId` | One trace; optional exact `log` |
| `GET /api/inspector/agent-graph`, `/agent-map` | Agent graph of one session (≤ 200 nodes); agent map over a window |
| `GET /api/graphs/:sessionId` | `blop.graph/v1` session graph; `limit` 1–200 |
| `GET /api/inspector/settings` | Effective settings and capture policies, without secrets |
| `GET /api/inspector/chat/config`, `/chat/threads`, `/chat/threads/:threadId` | Chat model configuration (never the key) and stored threads |
| `GET /api/test-coverage` | Latest browser check coverage report, or `{ "report": null }` |

The Sessions view returns at most 50 observations and 256 KiB per page, with 4,000-byte previews. A full content read is capped at 64 KiB (`413` above it), and a changed revision returns `409 stale_revision`. Metadata is normalized to 8 KiB, 64 members, 6 levels, 128-byte keys and 1 KiB strings, with `metadataTruncated` set when cut.

## Application observation routes

Open **Sessions → Observations** at `/inspector?view=sessions&tab=observations`. These routes return `{ "data": RESULT }`.

| Route | Returns |
|---|---|
| `GET /api/observations/sources` | Up to 200 source keys with service and count; `cursor`, `limit` |
| `GET /api/observations/query?request=JSON` | Typed query page with facets, coverage and `nextCursor` |
| `GET /api/observations/export?request=JSON` | The same page as a JSON attachment |
| `GET /api/observations/users?request=JSON` | Per-source application end-user summaries |
| `GET /api/observations/graph?request=JSON` | Calls, grouped steps, recorded parent edges and an accessible list |
| `GET /api/observations/prices?source=KEY`, `/views?source=KEY` | Latest 200 local price definitions; saved views |

`source` is the 64-hex key from discovery, not a service name, and every query needs it. `request` is JSON of at most 32 KiB with `filter`, `search`, `facets`, `limit` (1–200, default 50), `maxBytes` (1 KiB–1 MiB) and `cursor`. A filter is a `condition`, an `and`, a same-field `or`, or a `not`:

```json
{
  "kind": "and",
  "filters": [
    { "kind": "condition", "field": "type", "type": "string", "op": "eq", "value": "generation" },
    { "kind": "condition", "field": "metadata.retries", "type": "number", "op": "gte", "value": 1 }
  ]
}
```

Unknown keys, duplicate parameters and type mismatches return `400`; a filter is never dropped. `search` takes `mode` (`token`, `phrase` or `exact`), `field` (`input`, `output` or `both`) and `text`, and only covers masked, consented content (`--capture-gen-ai-content`). An empty result with incomplete `coverage` does not prove content was absent. Recorded costs, including zero, win over computed prices, and totals are exact decimal strings per currency. A price write needs `sourceKey`, exact `model`, a three-letter `currency`, `effectiveFromMs` and increasing `tiers` starting at `minInputTokens: "0"`; it applies only to new observations and never reprices old ones.

## Guarded local writes

Every write needs the collector's loopback Host and exact Origin, `Content-Type: application/json`, its marker header set to `1`, no `Content-Encoding`, and no cross-site fetch. Without them it returns `403`.

| Route | Marker header | Effect |
|---|---|---|
| `POST /api/runs`, `POST /api/runs/:id/finish` | `x-blop-run` | Record a run's start and end; never runs a command |
| `DELETE /api/runs` | `x-blop-run-delete` | Delete 1 to 50 finished runs with their spans, logs and issue occurrences; `{ "ids": [...], "dryRun": true }` reports the counts first and a running run answers 409 |
| `POST /api/inspector/settings/limits` | `x-blop-settings` | Save `retentionHours` (1 to 8,760) and `maxDbSizeMb` and apply them to the running collector |
| `POST /api/issues/mark-wrong`, `POST /api/issues/:id/state` | `x-blop-issue` | Mark a session or trace wrong; change issue state |
| `POST /api/inspector/session-bookmark` | `x-blop-session` | Bookmark a session, trace or conversation |
| `POST /api/inspector/connect/doctor` | `x-blop-doctor` | Run setup checks with a probe span |
| `POST /api/inspector/capture-policy` | `x-blop-capture-policy` | Save a capture policy |
| `POST /api/inspector/chat` | `x-blop-chat` | Ask the chat; streams NDJSON and sends context to the configured provider |
| `POST /api/observations/views`, `/views/delete`, `/prices` | `x-blop-observations` | Save or delete a view (`expectedRevision: null` to create, else the current revision); add a price |
| `POST /api/alerts`, `/api/alerts/:id/(enabled\|delete\|test)`, `/api/alerts/delivery` | `x-blop-alerts` | Manage alert rules and delivery |
| `POST /api/workers/ack` | `x-blop-ledger-ack` | Acknowledge a worker attempt |

The rebuilt Inspector pages also read these JSON routes: `GET /api/inspector/overview` (KPI tiles, run days, issue patterns and the agent map for a `range` or `from`/`to`), `GET /api/inspector/memory` (memory graph nodes filtered by `kind`, `status`, `authority` and `q`, with per-kind counts), `GET /api/inspector/cloud` (the local sign-in profile: who and where, never a token) and `GET /api/test-coverage/history` (the last 20 coverage runs, one summary per run).

The capture-policy body is at most 4,096 bytes and needs exactly `adapterId`, `projectId`, the current `revision`, `mode` (`full`, `conversation-only` or `metadata-only`), `exclude` and `contentRetentionHours` (1–720). It returns `{ "data": POLICY }`, or `400` invalid, `405` wrong method, `409` stale revision or unknown binding, `413` too large. It cannot create or enable a binding, and only affects future events. Observation writes are capped at 32 KiB.

`POST /api/test-coverage` is not a guarded write. It takes JSON up to 8 MiB from `blop test run --coverage --coverage-endpoint <base-url>`, validates the report, replaces the latest one and returns `{ "runId": "…" }`; the Inspector shows it at `/inspector?view=coverage`. The collector never fetches URLs in the report. See [Chromium JavaScript coverage](/checks/agent-authored/#chromium-javascript-coverage).

## Security boundary

The collector refuses a non-loopback bind. Every route checks the Host header, and every route except OTLP intake refuses a foreign Origin; only OTLP intake sends wildcard CORS. There is no bearer token or TLS on these routes, so never expose the port through a proxy. Remote OTLP intake from other machines uses machine enrollment, which does not expose queries. The collector never executes submitted code.
