OpenTelemetry over OTLP
OTLP/HTTP ingest in both JSON and protobuf, how a request is routed to a channel without a channel header, and how to forward the same events back out to a downstream collector.
axonpush speaks OTLP/HTTP in both directions. Anything that already exports
OTLP, a Collector, a language SDK’s otlphttp exporter, a sidecar, can point
at axonpush without changing application code, and anything axonpush stores can
be forwarded on to a downstream OTLP endpoint the same way.
In: the ingest endpoints
| Method | Path | Body |
|---|---|---|
POST | /v1/logs | OTLP ExportLogsServiceRequest, must contain resourceLogs[] |
POST | /v1/traces | OTLP ExportTraceServiceRequest, must contain resourceSpans[] |
Send Content-Type: application/json for OTLP/JSON, or
application/x-protobuf for the binary encoding. Both are decoded natively;
neither is a second-class path.
A body over 6 MB is rejected with 413 before anything is parsed. The check
reads Content-Length first and the decoded buffer second, so a lying
Content-Length does not get you past it.
Headers
| Header | Required | Meaning |
|---|---|---|
X-API-Key: ak_… | one of these | A server-side API key. Needs the publish scope. |
X-Public-Token: pt_… | one of these | A browser-safe token. Always requires X-Axonpush-Channel. |
X-Axonpush-Channel | conditional | Channel ID to write into. See routing below. |
X-Axonpush-Environment | no | Environment slug override. See Environments. |
Idempotency-Key | no | Deduplicates a retried export. |
Channel routing
X-Axonpush-Channel carries a channel ID, an opaque string. The header is
optional, and what happens when you omit it depends on the credential:
- An app-scoped API key (a key with an
appId) auto-routes./v1/logsgoes to a channel namedotlp-logsand/v1/tracesto one namedotlp-traces, both inside the key’s app, both created on first use. This is the path of least friction: mint an app-scoped key, set two environment variables on your collector, and you are done. - An API key with no app gets
400, there is nothing to route into. Pin the key to an app, or send the header. - A public ingest token always gets
400. Tokens are scoped to one channel and must name it explicitly.
Response
200 with an empty object when everything was stored:
{}When the batch could not be written, the OTLP partial-success shape comes back
instead, rejectedLogRecords on /v1/logs, rejectedSpans on /v1/traces:
{
"partialSuccess": {
"rejectedLogRecords": 3,
"errorMessage": "…"
}
}Two response headers report how the environment was resolved, which is the
fastest way to confirm from curl that your export landed where you meant:
x-axonpush-resolved-environment: prod
x-axonpush-resolved-via: apiKeyErrors
| Status | Cause |
|---|---|
400 | Body is missing resourceLogs[] / resourceSpans[], empty protobuf body, unroutable credential, or an environment override the key is not allowed to make. |
401 | Missing or invalid credential. |
403 | The credential does not own the channel in X-Axonpush-Channel, or lacks the publish scope. |
404 | No such channel. |
413 | Body over 6 MB. Lower your collector’s batch size. |
429 | Ingest quota exhausted. |
Collector configuration
The otlphttp exporter appends /v1/logs and /v1/traces itself, so the
endpoint is the bare host:
exporters:
otlphttp/axonpush:
endpoint: https://api.axonpush.xyz
encoding: json
headers:
X-API-Key: ${env:AXONPUSH_API_KEY}
X-Axonpush-Environment: prod
service:
pipelines:
logs:
receivers: [otlp]
exporters: [otlphttp/axonpush]
traces:
receivers: [otlp]
exporters: [otlphttp/axonpush]Add X-Axonpush-Channel if your key is not app-scoped, or if you want both
signals in one named channel rather than the two auto-created ones.
Straight from a language SDK
No axonpush package involved, this is the stock OpenTelemetry exporter:
import os
from opentelemetry.exporter.otlp.proto.http.trace_exporter import OTLPSpanExporter
from opentelemetry.sdk.trace import TracerProvider
from opentelemetry.sdk.trace.export import BatchSpanProcessor
exporter = OTLPSpanExporter(
endpoint="https://api.axonpush.xyz/v1/traces",
headers={"X-API-Key": os.environ["AXONPUSH_API_KEY"]},
)
provider = TracerProvider()
provider.add_span_processor(BatchSpanProcessor(exporter))Out: export destinations
An export destination forwards every matching event back out over OTLP/HTTP, so
axonpush can sit alongside a Datadog, Honeycomb or Grafana pipeline rather than
replacing it. Destinations are scoped to one environment, which is how you
forward production without also forwarding dev.
| Method | Path |
|---|---|
GET | /export-destinations?envSlug=prod |
POST | /export-destinations |
GET | /export-destinations/{id} |
PATCH | /export-destinations/{id} |
DELETE | /export-destinations/{id} |
curl -X POST https://api.axonpush.xyz/export-destinations \
-H "Authorization: Bearer $AXONPUSH_JWT" \
-H "Content-Type: application/json" \
-d '{
"name": "datadog-prod",
"envSlug": "prod",
"endpointUrl": "https://otlp.datadoghq.com",
"headers": { "DD-API-KEY": "…" },
"signals": ["logs", "traces"],
"serviceName": "checkout-api"
}'| Field | Notes |
|---|---|
endpointUrl | An OTLP/HTTP base URL. /v1/logs and /v1/traces are appended. Private and loopback addresses are rejected. |
headers | Header map for credentials. Stored server-side and never returned by any read endpoint. |
signals | At least one of logs, traces. |
eventTypeFilter | Allow-list of event types. Empty or absent means all. |
serviceName | Stamped as service.name on the exported resource. |
active | Defaults to true. DELETE deactivates rather than erasing. |
Signal mapping is worth being precise about: every matching event is
exported as an OTLP log when the destination subscribes to logs.
Additionally, events of type app.span are exported as OTLP spans when the
destination subscribes to traces. Subscribing to traces alone therefore
forwards spans only.
Delivery is fire-and-forget with respect to ingest, a destination that is down never fails or slows the write that triggered it. In a cloud deployment deliveries are queued and retried by a worker before landing in a dead-letter queue; in a self-host or local deployment there is no worker process, so delivery happens inline and a failure is logged rather than retried.
Create, update and delete are recorded in the audit log
as export_destination.created, .updated and .deleted.
OTLP or an SDK?
The OTLP endpoints are the right answer for a service that is already instrumented, you get axonpush without touching application code, and you keep the ability to leave by changing one exporter block.
The SDKs exist for the part OpenTelemetry does not model: agent semantics.
agent.tool_call.start, agent.handoff and agent.llm.token are axonpush
event types with dashboard and analytics meaning behind them, and the framework
integrations emit them for you. Both routes end in the same store and the same
trace, so mixing them is normal, OTLP from your HTTP and database layers, an
SDK integration around the agent.
The AxonPushSpanExporter in each SDK writes through the events API rather
than /v1/traces, so it carries agent attributes the raw OTLP path has no
field for. If you only have spans, the raw endpoint is simpler.
Related
- Environments, how
X-Axonpush-Environmentresolves - Sentry DSN ingest, the other zero-code way in
- Authentication, API keys and public ingest tokens
Environments
How dev, staging and prod isolation works, where an environment lives, how ingest resolves one per request, and what happens when a request asks for an environment its credential is not allowed to write to.
Sentry DSN ingest
Point an existing Sentry SDK at axonpush by changing one environment variable, the envelope routes, how each item type is normalised, and the two things that will surprise you.