feat(ingest): accept Loki push JSON at POST /loki/api/v1/push - #5
Merged
Merged
Conversation
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>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
What changed
Two focused commits:
feat(web): decompress gzip request bodies in Plug.Parsers— addsPulsoWeb.CompressedBodyReader, a body reader wired into the endpoint'sPlug.Parsers. Any receiver that goes through the JSON parser now transparently acceptsContent-Encoding: gzippayloads. A body advertised as gzip but not actually gzipped surfaces as:invalid_gzipinstead of decoding garbage.feat(ingest): accept Loki push JSON at POST /loki/api/v1/push— addsPulso.Loki.Push(JSON decoder) andPulsoWeb.LokiController(thin HTTP surface). Success returns 204 No Content per the Loki convention; the decode-reject count is surfaced via anX-Pulso-Rejected-Recordsresponse 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.mdunder "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, Prometheusremote_writefor the JSON debug path) benefits from.Approach
Body reader vs. per-route decompression. Wiring gzip into
Plug.Parsers'body_readeroption was the smallest change that makes every JSON receiver work uniformly. The alternative — a per-controllerread_body+gunzipblock — 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 dedicatedLog.servicefield, withserviceas a fallback for older Alloy configs.level/detected_levellifts toLog.severity_text. Per-record fields come from Loki 3.0 structured metadata: it populatesLog.attributes, withtrace_id/span_idpromoted 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
partialSuccesswould break naive clients that only look at the status code. A dedicatedX-Pulso-Rejected-Recordsheader 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_configsends 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-protobufreturns 415 with an explicitunsupported_content_typepayload so operators know this is a missing feature, not a bad body. JSON is enough to be useful today (Alloy supportsformat: json, hand-rolled clients emit it,curldemos work) and is the natural first step.Impact
trace_id/span_idare lifted into the same struct fields.Validation
The new tests cover the Loki decoder (label lifting, timestamp parsing including numeric fallback, structured metadata promotion, whole-stream rejects when
streamis malformed), the controller (X-Scope-OrgID routing,SharedSecretauth including the four failure modes, gzip body decoding, 415 on protobuf,X-Pulso-Rejected-Recordsheader 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