{% extends layout %}{% import 'ui/kit.html' as kit %} {% import 'ui/telemetry/_t.html' as t with context %} {% block title %}Ingest protocol ยท amebo{% endblock %} {%- macro field_table(caption, sub, fields) %}
| field | type | required | notes |
|---|---|---|---|
| {{ name }} | {{ type }} | {{ required }} | {{ blurb }} |
| {{ status }} | {{ blurb }} |
Four endpoints turn what your services are doing into traces, logs, metrics and issues. amebo stores all of it itself, in the same PostgreSQL or SQLite database as the broker: nothing is proxied to a third party and nothing leaves your network.
Every ingest endpoint authenticates with an application's api key, minted with
POST /v1/applications/:application/apikey (or from the application's page) and sent as
Authorization: Bearer <apikey> (x-amebo-apikey: <apikey> also works).
The key decides which application the data belongs to: when a payload does not name its
service.name, the key's application is used, and an error report is always filed under
the key's application, so an exporter cannot report as another service.
Setting AMEBO_INGEST_OPEN=1 accepts telemetry without a key, for demos and local
development only. Reading telemetry back (this dashboard, and GET /v1/traces/:trace,
/v1/logs, /v1/metrics/query, /v1/issues) always needs a session
or a credential. A superadmin sees every service; an application sees the services mapped to it
plus unclaimed ones; a user sees the services of every application owning an action they may
read, plus unclaimed ones.
/v1/tracesThis is stock OpenTelemetry. Point any OTel SDK at amebo and it works with no amebo library at all:
export OTEL_EXPORTER_OTLP_ENDPOINT=http://amebo:3310 export OTEL_EXPORTER_OTLP_PROTOCOL=http/protobuf export OTEL_EXPORTER_OTLP_HEADERS="authorization=Bearer <apikey>"
The body is an ExportTraceServiceRequest as protobuf or OTLP JSON, gzip optional.
Success is an empty response in the same encoding; errors are google.rpc.Status, per the
OTLP spec. The resource's service.name stays the grouping truth, and amebo resolves it
against the registered applications. A name nothing matches is reported as unclaimed on the
Needs attention page
rather than silently inventing a catalog entry for it.
Every span is counted into per-minute aggregates keyed by service and route, which is where the percentiles come from. Those are exact at bounded storage, from base-2 exponential histograms, so a p99 over a week is the same arithmetic as a p99 over a minute and neither depends on sampling. Minutes are rolled up into hours after a day and kept for 30 days.
Raw spans are sampled, because keeping every span forever buys nothing once the aggregates already
counted them. Every error is kept, every span slower than AMEBO_TRACE_SLOW_MS is kept,
and the rest keep AMEBO_TRACE_SAMPLE_PCT percent chosen by a hash of the trace id rather
than a coin flip, so a kept trace is kept WHOLE and its waterfall never has holes. One exception: a
trace carrying an amebo event is never sampled away, because those are exactly the traces the event
detail page exists to show.
| attribute | what it does |
|---|---|
| amebo.event_id | Names the amebo event this span was processing. This is the consumer half of the correlation: it puts the trace on that event's detail page, and pins the trace against sampling. messaging.message.id works too. |
| http.route | The route template the aggregates group by. Without it amebo groups by span name, which is usually fine and occasionally explodes the cardinality of an unparameterized URL (routes per service are capped). |
You often need neither. When the publisher is instrumented, amebo takes the W3C
traceparent off POST /v1/events and stamps the same header on the outgoing
webhook, so the consumer's SDK continues the producer's trace by itself and the correlation happens
with no application code at all.
A span event named exception carries the OpenTelemetry exception conventions:
exception.type, exception.message and exception.stacktrace.
amebo parses the stack trace, groups it, and you have error tracking from the tracing SDK you already
installed. An unrecognised stack dialect degrades to message-based grouping rather than being dropped.
/v1/logsEither a stock OTLP ExportLogsServiceRequest (protobuf or JSON), which is what any
OpenTelemetry SDK sends, or, for scripts and services with no SDK, {"logs": [...]} JSON
where each line carries message, severity, an optional
timestamp (seconds or nanoseconds), trace_id, span_id and
logger; every other field becomes a searchable attribute. The JSON form answers
202 {"accepted": n}. Lines below AMEBO_LOG_MIN_SEVERITY are dropped at
ingest, and every line is counted per minute and severity, which is what the
Logs volume chart reads.
curl -X POST http://amebo:3310/v1/logs \
-H 'authorization: Bearer <apikey>' \
-H 'content-type: application/json' \
-d '{"logs": [{"message": "charged card", "severity": "info", "order": "o-8812"}]}'
/v1/metricsA stock OTLP ExportMetricsServiceRequest, same credential and same resource attributes
as traces and logs. Every data point is folded into a per-minute bucket keyed by service, metric and
attribute set, so the write rate is bounded by cardinality rather than by how often you export. A
metric past 500 attribute sets folds its tail into one series; the
metric browser shows
the series count so the bill is visible.
/v1/errorsFor everything an OTel span event cannot carry: a user, breadcrumbs, source context, your own tags.
The body is one report object or {"events": [...]} carrying at most 50. Send
Content-Encoding: gzip if you like; amebo refuses anything that decompresses past 1 MiB,
and any single report past 256 KiB once normalized.
This is not /v1/events. That is the broker's business-event
publish path and always will be: schema-validated against a registered action, persisted to the
ledger, fanned out to subscribers. An error report is none of those things and lives in a different
table with a different retention.
curl -X POST http://amebo:3310/v1/errors \
-H 'authorization: Bearer <apikey>' \
-H 'content-type: application/json' \
-d '{"message":"checkout failed for cart 8812","level":"error"}'
curl -X POST http://amebo:3310/v1/errors \
-H 'authorization: Bearer <apikey>' \
-H 'content-type: application/json' \
-d '{
"level": "error",
"release": "2026.9.1",
"environment": "production",
"trace_id": "4bf92f3577b34da6a3ce929d0e0e4736",
"tags": {"region": "eu-west-1", "amebo.event_id": "evt-order-88231"},
"user": {"id": "u_9931"},
"breadcrumbs": [
{"type": "http", "category": "request", "level": "info", "message": "POST /v1/claims 201"}
],
"exceptions": [{
"type": "TypeError",
"value": "Cannot read properties of undefined (reading planId)",
"frames": [
{"file": "/srv/app/server.js", "function": "main", "line": 12, "in_app": true},
{"file": "/srv/app/pricing.js", "function": "priceClaim", "line": 88, "in_app": true,
"context_line": " return plan.rate * units"}
]
}]
}'
{{ code_table(error_codes) }}
{{ field_table('The report object', 'top level of one report', event_fields) }}
{{ field_table('An exception entry', 'inside `exceptions`, cause first', exception_fields) }}
{{ field_table('A stack frame', 'inside `frames`, oldest first', frame_fields) }}
Two reports with the same fingerprint are the same issue. The material is the exception type, the service, and the last five in-app frames as path and function. It deliberately EXCLUDES line numbers, so a refactor that shifts code does not fork an issue, and it deliberately INCLUDES the service, so the same bug in two services is two pages with two owners. With no exception, the material is the level and the message with its volatile parts collapsed, so "user 4231 not found" and "user 998 not found" group together.
Send fingerprint to override all of that. It is the escape hatch for the case where
amebo groups something wrong, and it should be rare. A resolved issue that sees a new report becomes
regressed; an ignored one stays ignored. Resolving, ignoring, prioritising and assigning an issue takes
a superadmin or the application that owns the service, and is recorded in the audit trail.
Thresholds are on the Thresholds page. A crossed threshold is published as an ordinary amebo event
against {{ alert_breached }}, with {{ alert_recovered }} for the all-clear, so
you subscribe to it exactly the way you subscribe to anything else and get the same signed envelope,
the same exponential backoff, the same dead-lettering and the same replay. An alert that never reached
your pager is a row you can find, not a silence.