Skip to content

fix(electric-telemetry): Fix process_type parsing for request processes that have a request id that is not the standard 20 bytes.#4658

Merged
robacourt merged 3 commits into
mainfrom
rob/request-process-type-fix
Jun 30, 2026
Merged

fix(electric-telemetry): Fix process_type parsing for request processes that have a request id that is not the standard 20 bytes.#4658
robacourt merged 3 commits into
mainfrom
rob/request-process-type-fix

Conversation

@robacourt

@robacourt robacourt commented Jun 28, 2026

Copy link
Copy Markdown
Contributor

Summary

Fixes an unbounded process_type cardinality leak in telemetry for request-handling processes.

Problem

process_type for request-handling processes is derived from the process label set by Electric.Plug.LabelProcessPlug, which looks like:

Request F-jPUudNHxbD8lIAABQG - GET /v1/shape?table=users&offset=-1

parse_binary_label/1 is meant to strip the request id and query, collapsing this to a bounded GET /v1/shape. But it did so with a fixed-width binary match that assumes the request id is exactly 20 bytes:

"Request " <> <<_req_id::binary-20, " - ", rest::binary>> -> ...

That holds for ids Plug.RequestId self-generates, but Plug.RequestId reuses an incoming x-request-id header when a client/proxy supplies one. A UUID (36 chars), an Envoy/nginx id, or an LB trace id is not 20 bytes, so the clause falls through to the generic branch that returns the first 20 bytes of the whole label — i.e. Request <first 12 chars of the id>. Every distinct incoming request id that GCs slowly then adds a permanent new series to prometheus_metrics_dist for events like vm.monitor.long_gc and vm.monitor.long_schedule. Request handlers building large shape responses are exactly the processes that trip those monitors, so the table grows without bound when a fronting proxy injects x-request-id.

Solution

Keep the fast fixed-width binary match for the common 20-byte case (Plug.RequestId's self-generated ids), and add a slow path that only runs when the id is a different length: it splits on the " - " delimiter regardless of request-id length, then strips the query string. All requests to a route collapse to a single process_type (e.g. GET /v1/shape) regardless of request-id source or length. A "Request " label with no delimiter falls back to truncation.


Also includes: unrelated CI fix

CI for this PR was failing on Electric.ShapeCacheTest (in sync-service, unrelated to the telemetry change). The cause is a semantic merge collision already on main: #4635 changed wait_until's signature to wait_until(fun, timeout_ms) (function first), while #4651 added a new call site at shape_cache_test.exs:1582 using the old arg order wait_until(1000, fn -> ... end). The two merged cleanly in git but not in meaning, so the slow suite raised a FunctionClauseError on every run. This PR swaps the arguments to match the current signature, unblocking both main and this PR.

@codecov

codecov Bot commented Jun 28, 2026

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.
✅ Project coverage is 60.18%. Comparing base (0130c4a) to head (767b117).
✅ All tests successful. No failed tests found.

Additional details and impacted files
@@            Coverage Diff             @@
##             main    #4658      +/-   ##
==========================================
+ Coverage   59.99%   60.18%   +0.18%     
==========================================
  Files         395      410      +15     
  Lines       43747    44348     +601     
  Branches    12582    12581       -1     
==========================================
+ Hits        26246    26689     +443     
- Misses      17423    17580     +157     
- Partials       78       79       +1     
Flag Coverage Δ
electric-telemetry 71.21% <100.00%> (?)
elixir 71.21% <100.00%> (?)
packages/agents 72.64% <ø> (ø)
packages/agents-mcp 77.70% <ø> (ø)
packages/agents-mobile 80.67% <ø> (ø)
packages/agents-runtime 83.72% <ø> (-0.02%) ⬇️
packages/agents-server 75.65% <ø> (+0.24%) ⬆️
packages/agents-server-ui 8.32% <ø> (ø)
packages/electric-ax 51.06% <ø> (ø)
packages/experimental 87.73% <ø> (ø)
packages/react-hooks 86.48% <ø> (ø)
packages/start 82.83% <ø> (ø)
packages/typescript-client 91.71% <ø> (-0.12%) ⬇️
packages/y-electric 56.05% <ø> (ø)
typescript 60.02% <ø> (+0.03%) ⬆️
unit-tests 60.18% <100.00%> (+0.18%) ⬆️

Flags with carried forward coverage won't be shown. Click here to find out more.

☔ View full report in Codecov by Harness.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

@robacourt robacourt self-assigned this Jun 28, 2026
@claude

claude Bot commented Jun 28, 2026

Copy link
Copy Markdown

Claude Code Review

Summary

This PR fixes a real, unbounded process_type cardinality leak in ElectricTelemetry.Processes.parse_binary_label/1 (proxy/LB-supplied x-request-id of non-20-byte length leaked a per-request id fragment into process_type), and now also folds in an unrelated CI fix for Electric.ShapeCacheTest. Both changes are correct and well-targeted. No change to the telemetry code since iteration 1; the new content this round is the shape_cache_test.exs argument-order fix.

What's Working Well

  • Telemetry fix (unchanged, still solid). The fixed-width fast path is preserved for Plug.RequestId's self-generated 20-byte ids, and the :binary.split(rest, " - ") slow path bounds cardinality for any other id length while only allocating off the hot path. Splitting on the first " - " correctly handles UUIDs with internal hyphens, and parse_request_label/1 then cuts at the first ?.
  • CI fix is correct and minimal. wait_until/2's signature is wait_until(fun, timeout_ms) (default 500) with an is_function(fun, 0) guard (test/support/test_utils.ex:146). The call site at shape_cache_test.exs:1582 was using the pre-Fix flaky PublicationManager relation tracker restart test #4635 arg order wait_until(1000, fn -> ... end), binding 1000 to fun and raising FunctionClauseError. Swapping to (fun, 1000) matches the current signature. Good root-cause writeup in the commit message identifying the semantic merge collision between Fix flaky PublicationManager relation tracker restart test #4635 and Restore subquery materializer state from disk after server restart #4651.
  • No other reversed call sites remain. Verified both wait_until call sites in shape_cache_test.exs (lines 1342 and 1582) now pass the function first, and a repo-wide search for an integer-first wait_until(...) finds no remaining old-order calls.
  • Changeset present and sharpened per @robacourt's suggestion (@core/electric-telemetry, patch).

Issues Found

Critical (Must Fix)

None.

Important (Should Fix)

None.

Suggestions (Nice to Have)

  • Residual (bounded) cardinality in the no-delimiter fallback - acknowledged in discussion. A "Request " label lacking the " - " delimiter falls to truncate_label/1, keeping a 20-byte prefix, so a short id fragment could still leak there. This is bounded and shouldn't occur in practice because LabelProcessPlug.process_label/1 always emits the delimiter. @robacourt's "Unparseable Request" + warning-log idea would close it entirely; leaving as-is is reasonable given the delimiter is always present and the DoS angle was ruled out.
  • Mixing an unrelated CI fix into this PR is pragmatic for unblocking, but worth a note: the shape_cache_test.exs change isn't covered by the changeset (correctly, since it's test-only) and is logically independent of the telemetry fix. Fine to land together given it's needed to get CI green.

Issue Conformance

No linked issue, but the PR description thoroughly documents both the telemetry problem/mechanism/fix and the CI fix's root cause. Implementation matches with no scope creep beyond the intentionally-included CI fix.

Previous Review Status

Iterations 1-2 found no Critical/Important issues in the telemetry change; that still holds (byte-for-byte unchanged). Resolution of prior discussion threads is unchanged:

  • DoS question (@alco -> @robacourt): Investigated and ruled out - unbounded growth traced to the Prometheus library not aggregating until drained, independent of label boundedness. Decision: keep current approach.
  • Fixed "Unparseable Request" label idea (@robacourt): Considered, intentionally left as-is.
  • Changeset wording (@robacourt): Applied.

New this round: the shape_cache_test.exs CI fix has been added and reviewed above - it's correct. Note the codecov failure comment near the top of the thread predates this fix (it is the FunctionClauseError this commit resolves).

LGTM.


Review iteration: 3 | 2026-06-30

Comment on lines +279 to +280
_ ->
truncate_label(label)

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

I'm wondering if we should have a fixed label, say "Unparseable Request" and to log a warning with the full label, to prevent an unbounded process type in this scenario

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

What specific problems does unbounded process type cardinality cause? Is it controlled by the client, i.e. does it have a DoS potential?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

It came up as DoS potential when investigating promethius issues, but it turns out the issue was to do with promethus library not aggregating until it is drained. It doesn't matter whether the label is unbounded or not, if the prometheus metrics are not aggregated, they grow unbounded. So I'll leave this as is.

@alco alco left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

👍

Comment thread .changeset/process-type-request-id-cardinality.md Outdated
@robacourt robacourt changed the title fix(electric-telemetry): bound process_type cardinality for proxy-supplied request ids fix(electric-telemetry): Fix process_type parsing for request processes that have a request id that is not the standard 20 bytes. Jun 30, 2026
robacourt and others added 3 commits June 30, 2026 11:25
…plied request ids

parse_binary_label/1 only stripped the request id from process labels when it
was exactly 20 bytes (the length Plug.RequestId self-generates). A proxy- or
load-balancer-supplied x-request-id (UUID, Envoy/nginx trace id, etc.) has a
different length, so the raw per-request id leaked into process_type, adding a
permanent new series to telemetry events like vm.monitor.long_gc and
vm.monitor.long_schedule for every distinct id.

Keep the fast fixed-width match for the common 20-byte case and add a slow path
that splits on the " - " delimiter regardless of request-id length, collapsing
all requests to a route back to a single process_type.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The `start_consumer_for_handle/2 starts a consumer plus dependencies` test
called `wait_until(1000, fn -> ... end)`, but #4635 changed the signature to
`wait_until(fun, timeout_ms \\ 500)` (function first). The new call site was
added by #4651 using the old arg order; the two merged cleanly in git but not
in meaning, so `1000` was bound to `fun`, failed the `is_function(fun, 0)`
guard, and raised FunctionClauseError whenever the slow suite ran on main.

Swap the arguments to match the current signature.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@robacourt robacourt force-pushed the rob/request-process-type-fix branch from 119d222 to 767b117 Compare June 30, 2026 10:26
@robacourt robacourt merged commit 044a8d5 into main Jun 30, 2026
70 of 71 checks passed
@robacourt robacourt deleted the rob/request-process-type-fix branch June 30, 2026 10:33
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants