feat(personhog): make gRPC rejections visible, and share the serving layer - #89941
feat(personhog): make gRPC rejections visible, and share the serving layer#89941benjackwhite wants to merge 3 commits into
Conversation
|
Merging to
After your PR is submitted to the merge queue, this comment will be automatically updated with its status. If the PR fails, failure details will also be posted here |
👀 Auto-assigned reviewersThese soft owners were skipped because they only have minor changes here. Nothing blocks merge, so self-assign if you'd like a look:
Soft owners come from each directory's |
🤖 CI report✅ Trunk lane — non-backend laneThis PR is assigned to the non-backend lane. It does not run backend Python tests and may merge in parallel with PRs in other lanes. |
Prompt To Fix All With AI### Issue 1
rust/common/grpc/src/lib.rs:411-413
**Trailer errors report success**
If response-size enforcement is enabled, `GrpcMetricsLayer` records the handler response as `code="ok"` before the outer `AsyncGzipLayer` replaces an oversized body with `grpc-status: 11` trailers, causing clients to receive `OUT_OF_RANGE` while the rejection metric reports success.
---
For each issue above, determine whether it is valid and should be fixed. If so, fix it directly.Reviews (1): Last reviewed commit: "feat(usage-ingestion): report gRPC reque..." | Re-trigger Greptile |
| let code = match &result { | ||
| Ok(response) => response_code(response.headers()), | ||
| Err(_) => NON_STATUS, |
There was a problem hiding this comment.
If response-size enforcement is enabled, GrpcMetricsLayer records the handler response as code="ok" before the outer AsyncGzipLayer replaces an oversized body with grpc-status: 11 trailers, causing clients to receive OUT_OF_RANGE while the rejection metric reports success.
Prompt To Fix With AI
This is a comment left during a code review.
Path: rust/common/grpc/src/lib.rs
Line: 411-413
Comment:
**Trailer errors report success**
If response-size enforcement is enabled, `GrpcMetricsLayer` records the handler response as `code="ok"` before the outer `AsyncGzipLayer` replaces an oversized body with `grpc-status: 11` trailers, causing clients to receive `OUT_OF_RANGE` while the rejection metric reports success.
---
For each issue above, determine whether it is valid and should be fixed. If so, fix it directly.771fce9 to
66708e0
Compare
…grpc The metrics, load-shedding, connection-tracking and caller task-local layers in personhog-common were usable only by personhog services, because depending on them also pulled in personhog-proto, sqlx and the persons and partitioning modules. They now live in common-grpc, next to common-metrics and common-health. personhog-common keeps the semantic refusal helpers, which encode the router's retry contract, and ClientInFlightGuard, whose metric name is personhog-specific. GZIP_OVERHEAD_HEADER moves next to the AsyncGzipLayer that sets it. No metric is renamed. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
grpc_server_requests_total carried only method and client, so a request a handler rejected was indistinguishable from one it served. It now carries a code label in the code_as_str vocabulary, so a rejection is visible on the server that made it. A failed gRPC call is a trailers-only response: an empty body with grpc-status in the headers. That is the shape tonic gives a handler error, the shape GrpcLoadShedLayer synthesises when it sheds, and the shape the router builds its own errors in, so the layer reads the status without polling the body. An absent header means the handler returned Ok, whose grpc-status: 0 rides the body trailers instead. A response whose status header will not parse counts as non_status rather than passing for a success. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
ingest_billing_usage rejects an empty or oversized batch, a malformed record, and a team with no organization mapping. None of those were visible: the only signal was a producer-side drop counter that said a record died but not why. The service now runs the shared gRPC metrics layer, so each request is counted by method and status code and timed. A run against the local stack reports code=ok, code=invalid_argument and code=not_found on grpc_server_requests_total, with matching durations. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
66708e0 to
a6af72d
Compare
Problem
A usage-ingestion caller whose billing records are rejected gets no server-side signal saying why.
ingest_billing_usagerefuses an empty or oversized batch, a malformed record, and a team with no organization mapping. The only trace is a producer-side drop counter that says a record died, not what killed it.grpc_server_requests_totalcarriesmethodandclient, so a rejected request looks the same as a served one on every PostHog gRPC server, not just this one.usage-ingestion cannot use the layer that would fix this. It lives in
personhog-common, so depending on it also pulls inpersonhog-proto, sqlx, and personhog's persons and partitioning modules.Changes
grpc_server_requests_totalbycodeand see which rejections a server returns.common-grpccrate, next tocommon-metricsandcommon-health. Any service can take it without taking personhog.A failed gRPC call is a trailers-only response: an empty body with
grpc-statusin the headers. tonic gives a handler error that shape,GrpcLoadShedLayersynthesises it when it sheds, and the router builds its own errors in it. So the layer reads the status at the head and never polls the body.An absent header means the handler returned Ok, whose
grpc-status: 0rides the body trailers. This holds while every method is unary, and no proto in the repo declares a stream. A status header that will not parse counts asnon_statusrather than passing for a success.personhog-commonkeeps the semantic refusal helpers, which encode the router's retry contract for handoff fences and ownership races.ClientInFlightGuardstays with them because it emitspersonhog_router_client_requests_in_flight, and moving it would put a personhog metric name in a shared crate.GZIP_OVERHEAD_HEADERmoves next to theAsyncGzipLayerthat sets it, so nothing about response compression leavespersonhog-common.The three commits are separable and review in order: the move is mechanical, the label and the wiring are not.
Note
The
codelabel splits every existinggrpc_server_requests_totalseries. An aggregating query is unaffected. A dashboard or alert that reads the raw series now gets one series per observed code.No metric is renamed. cymbal was also on
personhog_common::grpcand moves with the rest.Cardinality is safe. prod-us carries 911 series today, 592 on the replica and 289 on the router, and only observed codes create new ones.
Nothing user-visible changes, so there are no screenshots.
How did you test this code?
Three tests in
common-grpc, each red-checked by breakingresponse_codeand confirming the predicted failure:handler_error_reads_as_its_own_codepins the label againsttonic::Status::into_httprather than a hand-built response. The whole design rests on tonic putting the status in the headers.shed_response_reads_as_unavailableruns the stacked layers, because the metrics layer sits above the load shed layer in every server.response_code_vocabularycovers absent,0, real codes, an out-of-range code, and an unparseable one.The 16 moved tests still pass in their new home.
Manual run against the local stack
The usage-ingestion binary ran against local Postgres and Kafka. Four requests drove every path: an empty batch, an empty
record_id, an unmapped team, and a valid record.Not checked: the personhog e2e harness gates and the etcd-backed integration suites. This change does not touch the coordination protocol.
personhog-router backend::discovery::sync_active_*fails under parallel load and passes alone. The same four tests fail the same way on a cleanorigin/masterworktree, so this branch does not cause it.Automatic notifications
Docs update
None.
🤖 Agent context
Autonomy: Human-driven (agent-assisted)
Claude Code (Opus 5) wrote this branch under Ben's direction.
Skills invoked:
/reviewing-personhog-protocol,/reviewing-before-pr,/writing-pr-descriptions.hogli reviewcould not run. The Greptile CLI fails to install in this environment withEPERMon~/.config/posthog/tools/greptile. The local pass was the harness fallback over the branch diff, so the independent review is still the bot's, and theno-greptilelabel is not applied.The task specified that an absent
grpc-statusshould count asnon_status. That premise does not hold: tonic'sStatus::into_httpputs a handler error in the response headers, whichpersonhog-router/src/grpc_http.rsalready documents and relies on. Labelling an absent headernon_statuswould tag almost every request as a non-status failure. The shipped code labels itokand reservesnon_statusfor a header that will not parse.Reading the body trailers instead was the rejected alternative. It would change when the counter fires, add a body wrapper and a new trait bound to every caller's stack, and reach into the
AsyncGzipLayerthat wraps the metrics layer on the replica and the leader. It would only buy coverage for a mid-stream failure, and no proto here declares a stream.