Horopter

HTTP API

Every route on horopter-ingress and horopter-egress, with its capability, parameters and responses.

horopter-ingress records events; horopter-egress serves what they describe. Both speak JSON over HTTP.

Routes

routedaemoncapability
POST /v1/eventshoropter-ingressappend
POST /v1/events/cloudeventshoropter-ingressappend
POST /v1/admin/refoldhoropter-ingressadmin
GET /healthzhoropter-ingressnone
GET /readyzhoropter-ingressnone
GET /v1/changeshoropter-egressread
GET /v1/eventshoropter-egressread
GET /adapters/grafana/annotationshoropter-egressread
GET /adapters/otlp/spanshoropter-egressread
GET /healthzhoropter-egressnone
GET /readyzhoropter-egressnone

Each daemon also serves Prometheus metrics at /metrics, on a listener of its own; see Metrics.

Credentials

A credential is sent as Authorization: Bearer <key>. Which role grants which capability is Credentials and Capabilities.

answerwhen
401no credential, or one that is malformed, unknown, revoked or expired. The body is the same for each
403a credential without the route's capability, or one whose --subject-pattern does not cover the event's subject

A revocation reaches new requests within the credential cache's TTL, 30 seconds.

Tenants

Ingress takes the tenant from the credential: a request body naming tenant is answered 400. Every egress route reads ?tenant=:

credential?tenant=answer
scoped to a tenantabsent, or its ownread in its own tenant
scoped to a tenantanother403
globalabsent400
globala tenant that does not exist404

Errors

Every error Horopter answers is a JSON object, {"error": "<message>"}. A read that fails is answered 500, and an append the store cannot take 503, with a generic message; the cause, which can name tables and the database host, goes to the daemon's log.

POST /v1/events

Appends one event. The body is a JSON object, at most 1 MiB (413 above it):

fieldrequiredmeaning
typeyeswhat happened, e.g. deployment.started. Its suffix may assert a lifecycle; see Type Suffixes
subjectyeswhat it happened to, e.g. acme/prod
occurred_atyeswhen, RFC 3339
correlation_refnothe ref events share to describe one change. Without one, the event is a point
titlenoa human-readable title; a change without one is titled by its type
actornowho or what did it
idempotency_keynoa key a retry repeats; an append matching a stored key returns the stored event
attributesnoa JSON object; see Attribute Size
lifecyclenostarted, completed, failed, rolled_back, cancelled or none

tenant and source are refused with 400, whatever their value, null included: both come from the credential.

The Append Response

201 for a new event, 200 for an idempotency key already stored. Both append routes answer the same four fields:

fielduse
idthe event's id, for audit
refthe stored ref, <locus>/<ref>: look the change up with GET /v1/changes?ref=
correlation_refthe amend handle: send it unmodified as the next event's correlation_ref to add to this change
createdfalse when the append matched a stored idempotency key

Do not send ref as the next correlation_ref: it is already prefixed with the credential's locus, and would be prefixed again. Ingress refuses that mistake with 400, naming what would have been stored: a correlation_ref whose first segment is the credential's own locus. HOROPTER_INGRESS_ALLOW_LOCUS_PREFIXED_REF=true lifts the check for every producer on that ingress; such a ref is then stored with the double prefix. A ref may otherwise contain / freely: myorg/repo#123 is stored as <locus>/myorg/repo#123.

Attribute Size

An event whose attributes encode to more than HOROPTER_INGRESS_MAX_EVENT_ATTRIBUTE_BYTES (default 4096) bytes of compact JSON, measured without HTML escaping, is refused with 400:

invalid event "01J8Z3Q4R5S6T7V8W9X0Y1Z2A3": attributes encode to 5120 bytes of JSON;
the limit is 4096 — keep attributes to identifiers and short text, and link to anything
larger

(one line, wrapped here). Events already stored are not checked again when the limit changes. What belongs in attributes is Attributes.

POST /v1/events/cloudevents

Appends one event from a CloudEvents 1.0 envelope in structured mode, with Content-Type: application/cloudevents+json. It answers as POST /v1/events does.

  • id and source are required, and together are the idempotency key, so a redelivered envelope is deduplicated. The event's own source comes from the credential, not the envelope.
  • time becomes occurred_at; without it, the time the envelope was received.
  • type and subject are the event's.
  • data, when present, must be a JSON object. Four keys in it are reserved, lifted into the event's own fields and removed from its attributes; every other key is kept as an attribute.
data keybecomesif absent
correlation_refthe change's refthe event is a point
titlethe titlethe type
actorthe actorempty
lifecyclethe lifecycle assertionthe type's suffix decides

A reserved key holding anything but a string is left in the attributes, and the field is treated as absent. A data.tenant of any value, null included, is refused with 400.

Type Suffixes and the Lifecycle Field

Each event asserts whether its change starts, ends, or neither. An explicit lifecycle, the field or the data key, decides; without one, the type's final dot-separated segment does:

assertstype ends in
a start.started, .begun
completed.completed, .finished, .succeeded, .resolved, .closed, .done, .ended
failed.failed, .errored, .timed_out
rolled_back.rolled_back, .rolledback
cancelled.cancelled, .canceled, .aborted

A type ending in anything else asserts nothing. lifecycle: "none" asserts nothing whatever the type ends in, so a type that happens to end .started can be recorded as a point.

POST /v1/admin/refold

Rebuilds one stored change from its events, with ingest running. The body is {"ref": "<locus>/<ref>"}; a global key adds "tenant": "<tenant id>". The answer names how many events were read and which fields of the stored change were repaired, [] when none were:

{"ref":"ci/dep-1","events":12,"altered":["end","status"]}

A ref with no events is answered 404. How and when to use it is Re-fold One Change.

GET /v1/changes

The changes overlapping a window, oldest first.

parametermeaning
from, tothe window, RFC 3339; required unless ref is given. from after to is a 400
refone change, by its stored <locus>/<ref>
limitat most this many rows, from 1; above 1000 is read as 1000
cursorthe page to continue from; see Paging
tenantsee Tenants

It takes the filters too. The answer is {"changes": [...], "next": "..."}, each change an object of:

fieldmeaning
refthe stored ref
subject, type, title, actorfolded from its events
startwhen it started: started_at when a producer asserted one, first_seen_at otherwise
started_atthe earliest start a producer asserted; absent until one does
first_seen_atits earliest event's occurred_at
endwhen it ended; absent while it is open
statusin_progress while open, occurred for a point, or how it ended: completed, failed, rolled_back or cancelled
attributesits events' attributes, a later event's value replacing an earlier one's
sourcesthe source of every credential that reported it
last_event_idthe latest event folded into it

GET /v1/events

A tenant's event log, for audit and export, oldest first.

parametermeaning
refone change's events, by its stored <locus>/<ref>
limit, cursor, tenantas on GET /v1/changes

The answer is {"events": [...], "next": "..."}, each event an object of id, tenant, idempotency_key, schema_version, source, type, subject, actor, title, occurred_at, received_at, correlation_ref, attributes, lifecycle, locus, origin (whether a human or a process appended it) and edited.

GET /adapters/grafana/annotations

The changes overlapping a window, as Grafana annotations: an array of objects with time, timeEnd, title, tags and text. text is the change's horopter.description attribute.

parametermeaning
from, tothe window, in epoch milliseconds; required
limit, tenantas on GET /v1/changes

It takes the filters. ref is refused with 400: fetch one change with GET /v1/changes?ref=. Wiring it to Grafana is Show Changes on Your Grafana Dashboards.

GET /adapters/otlp/spans

The changes overlapping a window, as OTLP/JSON trace data (resourceSpans), one span per change, carrying horopter.subject, horopter.ref and horopter.status among its attributes.

parametermeaning
from, tothe window, RFC 3339; required
limit, tenantas on GET /v1/changes

It takes the filters.

Filters

GET /v1/changes and both adapter routes take these, all optional, combined with AND. An empty value is no filter.

parametermatches
subjecta pattern, against every subject the change touched up to its end; repeat it to match any of several
typethe change's type, exactly
statusthe change's status, exactly
locusthe part of the stored ref before its first /, exactly
sourceany source that reported the change, exactly
attr.<key>an attribute's value, exactly. Only deployment.environment.name is filterable

subject uses path.Match syntax, * within one /-separated segment, ? one character, [...] a class and \ an escape, plus {a,b} alternation: subject=*/{lastlight,logging}/*. Inside braces every comma separates alternatives, and \, does not escape one. A subject first seen after the change ended does not count. A malformed pattern, one that is not valid UTF-8, or a reversed range such as [9-0] is a 400.

Any other attr. key is a 400 naming the keys that are filterable, its value empty or not.

Paging

Every read returns at most 1000 rows. GET /v1/changes and GET /v1/events page past that: while rows remain, the answer carries next, the next request with every parameter repeated and a cursor added, and the same URL in a Link: <...>; rel="next" header. Follow it until it is absent:

url="/v1/changes?from=$FROM&to=$TO"
while [ -n "$url" ]; do
  page="$(curl -s -H "Authorization: Bearer $KEY" "$EGRESS$url")"
  echo "$page" | jq -c '.changes[]'
  url="$(echo "$page" | jq -r '.next // empty')"
done

A cursor is bound to its query: sent with other filters or another window, it is a 400. limit may change between pages. Pages are not a snapshot: a change's start can move while you page, so a change can be missed or returned twice.

Truncation

When rows were left out, the answer carries X-Horopter-Truncated: true; when none were, the header is absent. All four read routes set it, the adapters too, whose vendor-owned bodies have no room for a field. Rows are oldest first, so what is left out is the most recent. Narrow the window or filter, and ask again until the header is gone.

Read Admission

Egress serves a read only when it has the capacity to: Read Admission.

answerwhen
503 with Retry-Aftera gate could not admit the read within HOROPTER_EGRESS_ADMISSION_WAIT; Retry-After is that wait in whole seconds
200 with X-Horopter-Truncated: truethe read was larger than the memory pool, and was cut to the oldest rows that fit
422the first row alone needs more than the pool; the body names the limit

GET /healthz and GET /readyz

Both daemons answer them without a credential, and read admission never sheds them. /healthz answers 200 while the process runs and never touches the database; /readyz answers 200 when the database answers, and 503 when it does not.

On this page