Skip to content

feat(ingest): accept Loki push JSON at POST /loki/api/v1/push - #5

Merged
pepicrft merged 2 commits into
mainfrom
chore/plan-next-work
Sep 26, 2026
Merged

pepicrft merged 2 commits into
mainfrom
chore/plan-next-work

Conversation

@pepicrft

Copy link
Copy Markdown
Contributor

What changed

Two focused commits:

  • feat(web): decompress gzip request bodies in Plug.Parsers — adds PulsoWeb.CompressedBodyReader, a body reader wired into the endpoint's Plug.Parsers. Any receiver that goes through the JSON parser now transparently accepts Content-Encoding: gzip payloads. A body advertised as gzip but not actually gzipped surfaces as :invalid_gzip instead of decoding garbage.
  • feat(ingest): accept Loki push JSON at POST /loki/api/v1/push — adds Pulso.Loki.Push (JSON decoder) and PulsoWeb.LokiController (thin HTTP surface). Success returns 204 No Content per the Loki convention; the decode-reject count is surfaced via an X-Pulso-Rejected-Records response header so senders can monitor drops without breaking clients that expect a bodyless 204.

Why

The architecture calls out Loki push as one of the three logs on-ramps we want to accept from existing agents unchanged (see docs/architecture.md under "Wire protocols accepted"). Point Grafana Alloy or Promtail at Pulso and telemetry lands without reconfiguring the fleet. This is the first non-OpenTelemetry Protocol (OTLP) ingest surface, so it also flushes out a small but genuinely reusable piece of plumbing: gzip decompression at the parser layer, which every future JSON-shaped receiver (Loki push, OTLP over HTTP with compression, Prometheus remote_write for the JSON debug path) benefits from.

Approach

Body reader vs. per-route decompression. Wiring gzip into Plug.Parsers' body_reader option was the smallest change that makes every JSON receiver work uniformly. The alternative — a per-controller read_body + gunzip block — would have forked the ingest path and left OTLP without compression support. The reader returns {:more, ...} unchanged so a body larger than the parser's configured length still gets rejected as 413; a decompressor cannot invent missing bytes.

Loki label mapping. Stream labels are the Loki analog of OpenTelemetry (OTel) resource attributes — constant across a stream, identify the emitter — so they populate Log.resource. service_name (Grafana 3.0 convention aligned with OTel semantic conventions) is lifted to the dedicated Log.service field, with service as a fallback for older Alloy configs. level / detected_level lifts to Log.severity_text. Per-record fields come from Loki 3.0 structured metadata: it populates Log.attributes, with trace_id / span_id promoted into their canonical struct fields so a caller filtering by trace id sees them in one place regardless of the ingest path.

Rejects surfaced in a header, not the body. Loki's push contract is a bodyless 204 on success. Returning a JSON envelope like OTLP's partialSuccess would break naive clients that only look at the status code. A dedicated X-Pulso-Rejected-Records header carries the count when it is nonzero, and is omitted when everything landed — a signal an operator can scrape without changing the protocol contract.

Snappy protobuf deferred. Alloy's default push_config sends Snappy-framed protobuf. That needs a protobuf definition and a Snappy decoder, both of which are their own vertical. Rather than shipping a broken subset, application/x-protobuf returns 415 with an explicit unsupported_content_type payload so operators know this is a missing feature, not a bad body. JSON is enough to be useful today (Alloy supports format: json, hand-rolled clients emit it, curl demos work) and is the natural first step.

Impact

  • Operators can point Grafana Alloy or Promtail with a JSON push encoder at Pulso and see logs land, with tenant routing, auth, and idempotency behaving identically to the OTLP path.
  • Every JSON receiver now accepts gzip-compressed bodies, so the OTLP path gets compression support as a side effect.
  • No schema, storage layout, or manifest changes. Records ingested via Loki push are indistinguishable at query time from OTLP records because trace_id / span_id are lifted into the same struct fields.

Validation

mix precommit
# 114 passed, 25 excluded (integration-tagged)

The new tests cover the Loki decoder (label lifting, timestamp parsing including numeric fallback, structured metadata promotion, whole-stream rejects when stream is malformed), the controller (X-Scope-OrgID routing, SharedSecret auth including the four failure modes, gzip body decoding, 415 on protobuf, X-Pulso-Rejected-Records header presence and omission), and the body reader (plain pass-through, gzip round-trip, invalid gzip error, non-gzip encoding pass-through).

🤖 Generated with Claude Code

pepicrft and others added 2 commits September 26, 2026 11:07
Add PulsoWeb.CompressedBodyReader, a body_reader for Plug.Parsers that
transparently gunzips Content-Encoding: gzip bodies before parsing.
Wire it into the endpoint so every JSON receiver (OTLP, Loki push, and
future Prometheus remote_write) accepts compressed payloads without a
per-route decompression hop.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Add a second logs ingest surface alongside OTLP so the existing Grafana
Alloy install base can point at Pulso unchanged. The controller mirrors
the OTLP receiver: tenant from X-Scope-OrgID, verified via Pulso.Auth,
Idempotency-Key propagated to Storage.append. Success returns 204 (Loki
convention); the decode-reject count is surfaced via the
X-Pulso-Rejected-Records response header.

Pulso.Loki.Push maps stream labels to Log.resource, lifts service_name /
service to Log.service and level / detected_level to severity_text, and
promotes trace_id / span_id from structured metadata into their
dedicated struct fields so records ingested via Loki push are queryable
by the same trace-id lookup path as OTLP records.

Snappy-framed protobuf (Alloy's default push_config) is not yet
supported; a request with application/x-protobuf returns 415 so
operators know it is a missing feature, not a bad body.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
@pepicrft
pepicrft marked this pull request as ready for review September 26, 2026 09:23
@pepicrft
pepicrft merged commit 6a5a6c9 into main Sep 26, 2026
8 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant