Zum Inhalt springen

Anvil Feed: Server-Vertrag

Dokumentation · Anbindung

Der Vertrag der Annahme für Betriebsfehler (englisch): Endpunkte, Statuscodes und Ablehnungsgründe, Grenzwerte, die Reihenfolge der Prüfungen und ein curl-Beispiel — für alle, die aus einer anderen Sprache melden.

The Anvil Feed ingest — the server-side contract

This is the entrance the anvil-feed-* SDKs post to. It is the wire contract of chapter 03 §2/§3 of the Anvil Feed Protocol specification v0.1, as this server implements it (issue #59, release A).

It replaced POST /api/observability/{tenantId}/{projectKey}. That endpoint is gone, with its X-Observability-Secret header and the single shared secret behind it: every sender now authenticates with the token of its own channel, which can be revoked one customer at a time.


Endpoints

POST /api/feed/events          Content-Type: application/json   Authorization: Bearer anv_srv_…
POST /api/feed/events/batch    same headers

…/events carries one event object, …/events/batch a JSON array of at most 50. Both answer the same body:

{
  "accepted": ["f4b1a0c2-0f4a-4a1e-9d7a-2b6c5d8e1f30"],
  "rejected": [
    { "event_id": "1c2d3e4f-…", "reason_code": "no_binding",
      "message": "no binding for environment 'staging'" }
  ]
}

Everything an SDK sends gets exactly one verdict. An event is in accepted or in rejected, never in neither and never in both — except in the 503 case below, where events that are in neither list are the ones the sender must send again.

The host is the cockpit’s public host, for example https://app.anvil-coder.tech/api/feed/events. The ingest is served by the read tier, not by the orchestrator: it only writes rows, and it keeps accepting while the orchestrator restarts.


Authentication

Authorization: Bearer anv_srv_<32 base62 characters>.

The token is issued per connection in the cockpit, shown once, and stored here as its first sixteen characters (the lookup key) plus a SHA-256 hash. It carries its organisation and its project itself — no tenant and no project id is ever read out of the URL or the body, so there is no identity for a caller to assert.

Unknown prefix, revoked connection and a prefix that exists with the wrong secret all answer the same bare 401. That is deliberate: a finer answer would turn this endpoint into a service that tells an attacker which token prefixes exist and which of them are still live.

A request that carries an Origin header and a token that resolves is refused with 403. A server token is meant to live in a server process; an Origin means a browser sent it, which means it is in somebody’s bundle and every visitor of that page now holds it. Rotate it.

The order is fixed, and an SDK’s test double has to have it the same way round: body size (413), then the token (401), then Origin (403), then the rate limit (429). So an Origin on a missing, unknown, revoked or mismatched token is a 401, never a 403 — a 403 says the token exists and is live, and handing that answer to anybody with an Origin header and a guessed prefix would be exactly the lookup service the generic 401 above refuses to be. This paragraph used to read “refused with 403, whatever the token says”; the server never did that.


Status codes

StatusWhenRetry?
202the batch was processed; read accepted and rejectedno
400the body is not JSON, is neither an object nor an array, or the array holds more than 50 eventsno — fix the sender
401no Bearer anv_srv_… token, or the token is unknown, revoked or wrongno
403an Origin header was present (FR-9f: a leaked server token)no
413the request body is above 256 KiBno — send fewer events per batch
429more than 600 requests from this connection within a minute; Retry-After: 60yes, after the delay
503some events hit a transient store failure; Retry-After: 30yes, the whole batch

4xx is never worth retrying; 5xx is. That is the SDKs’ rule and this table keeps it true.

About the 503. The body still carries the accepted and rejected lists of the events that did get through. The sender repeats the whole batch, which is safe because the delivery key is (organisation, event_id): an event that was already stored is recognised and counted exactly once. A 202 in that situation would be a promise nobody keeps.

The organisation is part of that key on purpose. event_id comes from the sender — this contract requires a canonical UUID and cannot require randomness — so a key on the event alone would let the first organisation to use an id own it for everybody: another organisation’s delivery of the same id would be answered 202 with the event in accepted and stored nowhere.


Reason codes

Every entry in rejected carries one of these. They are a closed set; a sender may switch on them.

reason_codeMeaning
schema_violationa required field is missing or unusable (event_id, timestamp, title, level, environment, service, sdk, exception.type), or the value breaks a rule — an environment that is not a label, say
unsupported_schema_versionthe event declares a schema_version with a different major; this ingest speaks major 1
payload_too_largethe request body was above the limit (this one arrives with a 413 and a null event_id)
no_bindingthe token’s project has no binding for this event’s environment, and no wildcard binding either — connect the environment in the cockpit
binding_disabledthe binding exists and its repair policy is off; the event was seen and deliberately not kept
redaction_failedthe event could not be scrubbed safely, so it was not stored
internal_errora fault on our side; the event is refused rather than left to be retried for ever

Limits

LimitValueEffect
request body256 KiB (262144 bytes)above it: 413
events per batch50above it: 400, the whole request refused
requests per minute600requests per connection, fixed 60-second window; above it: 429

These are the values of the hosted service. An operator of a self-hosted installation can change them, and the endpoint does not exist at all until that operator has armed it — a 404 on every path is the truth about such an installation, not a fault of the sender.

The rate limit is keyed by the connection, not by the client address: the identity that has to be bounded is the project. A customer whose service runs on two hundred pods still gets one budget, and a noisy neighbour cannot spend somebody else’s.

It counts requests, not events. The limiter is consulted before the body is parsed, so at that point there is no event count to charge against — which means a sender batching 50 at a time effectively gets 600 × 50 = 30 000 events per minute per connection per pod, and more than one pod serves the ingest. Read the 600 as the back-pressure an SDK is expected to respect, not as a ceiling on how many incidents can arrive. If an event budget is what you need, that is a separate change: the check would have to move after the parse, which changes the documented order above.

A body far above the limit may be refused by the proxy in front of the service instead, with an HTML error page and no reason code. The proxy’s bound sits at twice the application’s on purpose, so that an ordinary oversized body meets the 413 with payload_too_large and a sender can tell what happened.


What the server does with the event

Unknown fields are ignored (the specification’s Z3: evolution is additive), so an SDK a version ahead keeps working. Long values are bounded rather than refused — a delivery with two hundred stack frames is a useful delivery with fifty — and where something is cut, the cut is visible.

user.ip_hash is set server-side and whatever the sender put there is discarded (FR-5c). It is sha256(client address + daily salt) with daily salt = sha256(UTC date + connection id). This is a per-day pseudonym, not a secret: the salt is built from nothing confidential, so anyone who can guess an address can confirm it. What it buys is what the requirement asks for — the raw address is never stored, two events from one machine on one day are recognisable as such, and the link breaks at midnight and never crosses organisations. The client address is the first entry of X-Forwarded-For when the proxy in front of the service set one, otherwise the socket’s own.

browser is accepted and deliberately not carried in release A. It is a schema-1.0 field, so an SDK that posts it — the Astro one will, on every event — is behaving correctly and is answered 202. The value is simply read past: the browser path of the protocol (the anv_pub_ token) is stage B, and nothing here stores it yet. This is stated rather than left to be discovered, because “a field we have never heard of” and “a field we know and keep nothing for” are different promises, and only the second has a release attached.

event_id must be a canonical UUID string. Chapter 03 §3 types it uuid-v4. Anything else is refused per event as schema_violation with a message naming the format. What comes back in accepted and in a rejected entry is the id exactly as you spelled it — an upper-case UUID returns upper-case — so an SDK may clear its send buffer by matching the answer against what it posted.

request.url is kept only when it is an http:// or https:// URL. Any other scheme — javascript:, data:, file:, a custom app scheme — is dropped silently and the event is still accepted: the value is a piece of a customer’s production request that ends up rendered as a link in somebody’s cockpit, and the two things it may be there are a web address or nothing. Dropping the field rather than refusing the event is deliberate: the error report is the valuable part, and an SDK that cannot fix its own URL scheme would otherwise lose every report it sends.

Events are persisted before the answer goes out. There is no executor and no background queue behind the 202, so there is nothing a rollout can throw away.

The portfolio dashboard’s production card

After the batch is written, the project’s production card on the portfolio dashboard is replaced from the incident store — every incident of that project whose status is not CLOSED or IGNORED, under the source name anvil-feed. The same replacement happens after every decision in the incident inbox and after every status the repair worker writes, so the card and the inbox can never disagree.

One card, one producer per project. A project that already receives incidents through the external dashboard ingest API (PUT /api/ingest/dashboard/{tenant}/{config}/incidents) keeps that producer: the incident store does not overwrite a card somebody else is writing. Only where no external producer has claimed the card does the incident store feed it. Two writers on one card would mean the last write wins and neither picture is ever complete — and the external producer is the one a customer configured deliberately.

The repair lifecycle is mapped onto the four states a card knows:

incident statuson the card
OBSERVED, TRIAGED, WAITING_APPROVAL, QUEUED, NEEDS_HUMANOPEN
RUNNING, PR_OPENINVESTIGATING
CLOSED, IGNOREDnot on the card at all

NEEDS_HUMAN counts as OPEN, not as MITIGATED: nothing about the fault has been mitigated — the machine has stopped and is waiting for a person. A card that showed it as mitigated would be reassuring about the one state that most needs somebody to look.

Three properties of that are worth stating, because each is a decision:

  • The whole project is re-sent every time, not the one incident that changed. The card is a REPLACE: sending only the changed incident would wipe the project’s other incidents off it.
  • An empty list is sent. When the last incident of a project closes, something has to say so; skipping the empty snapshot would leave the last non-empty card standing for ever.
  • A failure to update the card never fails the ingest. The event is already stored. A 500 here would have the SDK retry a delivery the duplicate gate then swallows without counting it — a production error lost to a dashboard.

A project whose configuration has been deleted has no card to feed; that is not an error and is logged at DEBUG.


A curl example

curl -sS -X POST https://app.anvil-coder.tech/api/feed/events \
  -H 'Content-Type: application/json' \
  -H "Authorization: Bearer $ANVIL_FEED_TOKEN" \
  -d '{
    "schema_version": "1.0",
    "event_id": "f4b1a0c2-0f4a-4a1e-9d7a-2b6c5d8e1f30",
    "timestamp": "2026-09-16T10:11:12Z",
    "title": "NullPointerException in CheckoutService",
    "level": "error",
    "environment": "production",
    "service": "shop-api",
    "language": "java",
    "sdk": { "name": "anvil-feed-java", "version": "0.1.0", "runtime": "jdk-25" },
    "exception": {
      "type": "java.lang.NullPointerException",
      "message": "Cannot invoke \"Cart.total()\" because \"cart\" is null",
      "stacktrace": [
        { "file": "CheckoutService.java", "line": 88, "function": "charge",
          "context_code": "var sum = cart.total();", "in_app": true }
      ]
    },
    "release": "2026.9.3",
    "request": { "method": "POST", "url": "https://shop.example/api/checkout", "status_code": 500 },
    "user": { "id": "u-4711" },
    "breadcrumbs": ["cart.add sku=A1", "checkout.start"],
    "tags": { "tier": "gold" },
    "enrichment": { "affected_file": "src/main/java/shop/CheckoutService.java" },
    "agent_context": { "suggested_ticket_type": "bug", "repo": "shop/api", "branch": "main" }
  }'

Answer:

{"accepted":["f4b1a0c2-0f4a-4a1e-9d7a-2b6c5d8e1f30"],"rejected":[]}