Skip to content

feat(personhog): make gRPC rejections visible, and share the serving layer - #89941

Open
benjackwhite wants to merge 3 commits into
masterfrom
extract-common-grpc-crate
Open

feat(personhog): make gRPC rejections visible, and share the serving layer#89941
benjackwhite wants to merge 3 commits into
masterfrom
extract-common-grpc-crate

Conversation

@benjackwhite

Copy link
Copy Markdown
Contributor

Problem

A usage-ingestion caller whose billing records are rejected gets no server-side signal saying why. ingest_billing_usage refuses 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_total carries method and client, 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 in personhog-proto, sqlx, and personhog's persons and partitioning modules.

Changes

  • An operator can now split grpc_server_requests_total by code and see which rejections a server returns.
  • usage-ingestion reports gRPC request metrics for the first time. It had no layers at all.
  • The generic serving layer moves to a new common-grpc crate, next to common-metrics and common-health. Any service can take it without taking personhog.

A failed gRPC call is a trailers-only response: an empty body with grpc-status in the headers. tonic gives a handler error that shape, GrpcLoadShedLayer synthesises 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: 0 rides 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 as non_status rather than passing for a success.

personhog-common keeps the semantic refusal helpers, which encode the router's retry contract for handoff fences and ownership races. ClientInFlightGuard stays with them because it emits personhog_router_client_requests_in_flight, and moving it would put a personhog metric name in a shared crate. GZIP_OVERHEAD_HEADER moves next to the AsyncGzipLayer that sets it, so nothing about response compression leaves personhog-common.

The three commits are separable and review in order: the move is mechanical, the label and the wiring are not.

Note

The code label splits every existing grpc_server_requests_total series. 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::grpc and 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 breaking response_code and confirming the predicted failure:

  • handler_error_reads_as_its_own_code pins the label against tonic::Status::into_http rather than a hand-built response. The whole design rests on tonic putting the status in the headers.
  • shed_response_reads_as_unavailable runs the stacked layers, because the metrics layer sits above the load shed layer in every server.
  • response_code_vocabulary covers 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.

grpc_server_requests_total{method="IngestBillingUsage",client="unknown",code="ok"} 1
grpc_server_requests_total{method="IngestBillingUsage",client="unknown",code="invalid_argument"} 2
grpc_server_requests_total{method="IngestBillingUsage",client="unknown",code="not_found"} 1
grpc_server_request_duration_ms_count{method="IngestBillingUsage",client="unknown"} 4

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 clean origin/master worktree, so this branch does not cause it.

Automatic notifications

  • Publish to changelog?

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 review could not run. The Greptile CLI fails to install in this environment with EPERM on ~/.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 the no-greptile label is not applied.

The task specified that an absent grpc-status should count as non_status. That premise does not hold: tonic's Status::into_http puts a handler error in the response headers, which personhog-router/src/grpc_http.rs already documents and relies on. Labelling an absent header non_status would tag almost every request as a non-status failure. The shipped code labels it ok and reserves non_status for 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 AsyncGzipLayer that 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.

@benjackwhite benjackwhite self-assigned this Aug 27, 2026
@trunk-io

trunk-io Bot commented Aug 27, 2026

Copy link
Copy Markdown

Merging to master in this repository is managed by Trunk.

  • To merge this pull request, check the box to the left or comment /trunk merge below.

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

@benjackwhite
benjackwhite requested a review from a team August 27, 2026 09:24
@benjackwhite
benjackwhite marked this pull request as ready for review August 27, 2026 09:24
@pr-assigner-resolver-posthog

Copy link
Copy Markdown

👀 Auto-assigned reviewers

These soft owners were skipped because they only have minor changes here. Nothing blocks merge, so self-assign if you'd like a look:

  • @PostHog/team-error-tracking (rust/owners.yaml)

Soft owners come from each directory's owners.yaml and each product's product.yaml (resolved nearest-file-wins). The locator after each owner is the file that decided it. Generated files and lockfiles are ignored when deciding ownership.

@github-actions

Copy link
Copy Markdown
Contributor

🤖 CI report

Trunk lane — non-backend lane

This 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.

@greptile-apps

greptile-apps Bot commented Aug 27, 2026

Copy link
Copy Markdown
Contributor
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

Comment on lines +411 to +413
let code = match &result {
Ok(response) => response_code(response.headers()),
Err(_) => NON_STATUS,

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 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.

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.

@benjackwhite
benjackwhite force-pushed the extract-common-grpc-crate branch from 771fce9 to 66708e0 Compare August 27, 2026 09:30
benjackwhite and others added 3 commits August 27, 2026 11:41
…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>
@benjackwhite
benjackwhite force-pushed the extract-common-grpc-crate branch from 66708e0 to a6af72d Compare August 27, 2026 09:41
@trunk-io

trunk-io Bot commented Aug 27, 2026

Copy link
Copy Markdown

Static BadgeStatic BadgeStatic Badge

View Full Report ↗︎Docs

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