Skip to content

clients report what paints red in the browser console to one server route - #3337

Merged
colinmegill merged 4 commits into
edgefrom
client-error-reporting-2
Nov 4, 2026
Merged

colinmegill merged 4 commits into
edgefrom
client-error-reporting-2

Conversation

@colinmegill

Copy link
Copy Markdown
Member

Stacked on #3336 (recordings match routes by method and path); this PR's base is that branch and retargets to edge when it merges. Supersedes #3334, which carried the recordings in the same PR.

Observable changes

  1. New route POST /api/v3/clientErrors on Node. Always answers 204, empty, Cache-Control: no-store, including for invalid, oversized, compressed, wrong-type and rate-limited bodies. Handled before the shared body parser and authentication; reads no cookie, identity or database; shares no pool, lock or wait with the participation path. Other methods keep Node's existing answer.
  2. New server log event client_error (warn), one per accepted report. Fields: client, build, page, kind, error_message, stack, resource, method, status, duration_ms, signature, count, user_agent. No address is logged. The signature (16 hex characters) comes from the client, the kind, the status or message category, and the location (first stack frame with the bundle's content hash removed, line and column kept; else the resource), so two errors at different positions are two signatures and the same position keeps its signature across builds. Admission: both limits must admit before either spends. Per participant address, burst 10 then 1 per minute (IPv4 address or IPv6 /64, taken from the X-Forwarded-For entry before a recognized Cloudflare IPv4 edge, otherwise the trusted proxy hop); per process, burst 100 then 1 per second; at most 4096 addresses held, an entry replaceable after 10 minutes idle.
  3. New same-origin POST from all four clients. Every page of admin, report, legacy participation (including vis2 and the embed) and alpha sends to /api/v3/clientErrors by sendBeacon or a credential-less keepalive fetch, on: an uncaught error or unhandled rejection; a script, style, image or font load failure; console.error or console.warn; a fetch or XHR with status 0 or 400 and above (304 and other statuses below 400 are not reported), including network failure, timeout or navigation abort; a CSP violation. Each distinct position is its own report; repeats of one position batch after 1 s or on page hide. Limits: 50 signatures per page, 20 sends per minute, 200 per tab session.
  4. Changed browser globals. fetch, XMLHttpRequest.prototype.open/send and console.error/warn are replaced by observing wrappers. New window.PolisClientErrors, window.__polisClientErrorReporters, and the sessionStorage key polis-client-errors-rate (counters only).
  5. New inline <script> at the top of <head> on admin, report, legacy participation and every alpha page. A future Content-Security-Policy must allow it.
  6. Report contract validator now checks maxLength (no existing schema uses it).
  7. Route census: adds POST /api/v3/clientErrors (the recorders name routes by method and path since recordings: cases name their route by method and path, and the census regains two routes #3336, so no fixture changes).
  8. Shared budget on single-address stacks. A local or self-hosted stack where every browser reaches Node from one address, with no recognized Cloudflare hop, shares one budget: 10 reports, then 1 per minute. Production derives the participant from the checked Cloudflare hop.
  9. New files and targets. docs/client-error-contract.md, docs/client-error-triage.md; Rust binary render-client-errors in queue-rs (precise rows by default; --group-builds opt-in cross-build view); make check-client-errors with ci/local/client-errors.sh and the generator ci/local/client-error-paths.cjs; workflow .github/workflows/client-errors.yml; a ROUTING.md row and an assertion in file-server/nginx/test-edge-routing.py.

No schema change. No third-party host. Nothing posts to any automated system; triage of what this reveals is a person's decision.

Why

No Polis client reported errors anywhere, and no server route accepted reports, so a broken page or a broken API response was visible only in a participant's browser console. This is a prerequisite for routing the first endpoint to the Rust API.

Evidence

Three independent review rounds, the last in a real browser (three planted TypeErrors in three modules of a production admin build gave three reports with three stacks; 31 participants behind one Cloudflare edge gave 31 log lines; one participant sending 31 gave 10; nine API routes gave nine signatures). On this split: all five recording targets pass against the #3336 fixtures unchanged; lint on four packages; make check-client-errors 123, admin 343, report 106, alpha 181 plus format/lint/build; focused server Jest 81; the production import script 170; routing recordings 104 replay cases with all 12 mutation refusals.

The full server integration suite showed intermittent failures on a work box that an independent investigation traced to the box's port allocation (macOS hands IPv6 sockets ephemeral ports already held on IPv4, and the test database is reached as localhost), present on unchanged edge in the same way; a harness fix follows separately. Matched runs: edge 3 of 3, this change 3 of 3.

🤖 Generated with Claude Code

colinmegill and others added 2 commits November 3, 2026 23:15
… regains two routes

The identity, Treevite and POST-comments recorders matched each case's route
hits by the route's global registration ordinal, so mounting one more
Express app before the routes shifted every id and made live Node disagree
with every committed fixture. The recorders now discover the mounted apps
and resolve a hit by its method and path, so a new app leaves the fixtures
untouched; the re-recorded fixtures differ from the old ones only in route
identity (every old id was one too high), with the comparators, expected
differences, cases and policies byte-identical.

The route census regains GET /api/v3/version, which was registered but
missing, and names the participation route's middleware as registered; the
census check now passes on the source it describes. The legacy census test
is updated for the repaired census; two tests that walked all registrations
select them explicitly.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…oute

Until now no Polis client reported errors anywhere and no server route
accepted such reports, so a broken response or a broken page was visible
only in a participant's browser console. Every page of the admin console,
the report, the legacy participation client (including vis2 and the embed)
and the alpha participation client now carries a dependency-free reporter
that catches uncaught errors and unhandled rejections, script, style, image
and font load failures, console.error and console.warn, failed fetch and
XMLHttpRequest calls (status 0 or 400 and above; 304 is success) and CSP
violations. Each distinct position is its own report, carrying a stable
signature (client, kind, status or message category, and the location with
the bundle's content hash removed) and a count; repeats batch after one
second or on page hide; a page sends at most 50 signatures, 20 sends a
minute and 200 per tab session, by sendBeacon or a credential-less keepalive
fetch to the client's own origin only.

The server gains POST /api/v3/clientErrors, handled before the body parser
and authentication. It reads no cookie, identity or database, touches no
pool or lock, caps the body, admits a report only when both a per-address
limit (burst 10, then 1 a minute; IPv4 address or IPv6 /64, taken from the
X-Forwarded-For entry before a recognized Cloudflare edge) and a per-process
limit (burst 100, then 1 a second) admit, always answers 204, and writes one
structured warn line, client_error, with no address in it. The participation
path shares nothing with it.

Triage is deliberate: a Rust binary, render-client-errors, turns an exported
Logs Insights result into a table of signatures with their message, count
and pages; precise rows by default, an opt-in cross-build grouping. Nothing
feeds an automated fixer.

The route census gains the new route; the recorders already name routes by
method and path, so no fixture changes.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Base automatically changed from recordings-by-route-identity to edge November 4, 2026 22:11
The only conflict was server/characterization/inventory.json: edge carries
the regenerated census from the recording prerequisite, and this branch
carries the same census plus the row for POST /api/v3/clientErrors; the
branch's version is kept.
The only conflict was the Makefile: edge added five local check targets at
the place this branch adds check-client-errors; both sets are kept.
@github-actions

github-actions Bot commented Nov 4, 2026

Copy link
Copy Markdown
Contributor

Delphi Coverage Report

File Stmts Miss Cover
polismath/init.py 2 0 100%
polismath/benchmarks/bench_pca.py 128 107 16%
polismath/benchmarks/bench_repness.py 81 65 20%
polismath/benchmarks/bench_update_votes.py 38 28 26%
polismath/benchmarks/benchmark_utils.py 34 0 100%
polismath/components/init.py 1 0 100%
polismath/components/config.py 165 70 58%
polismath/conversation/init.py 2 0 100%
polismath/conversation/conversation.py 1293 264 80%
polismath/conversation/group_vote_counts.py 38 2 95%
polismath/conversation/manager.py 131 42 68%
polismath/database/init.py 1 0 100%
polismath/database/dynamodb.py 392 176 55%
polismath/database/postgres.py 414 95 77%
polismath/delphi_storage/init.py 1 0 100%
polismath/delphi_storage/codec.py 295 22 93%
polismath/delphi_storage/embedding_spool.py 58 6 90%
polismath/delphi_storage/golden_corpus.py 60 9 85%
polismath/delphi_storage/import_report.py 51 3 94%
polismath/delphi_storage/legacy_import.py 171 16 91%
polismath/delphi_storage/postgres.py 49 22 55%
polismath/delphi_storage/production_import.py 612 131 79%
polismath/delphi_storage/resource.py 179 32 82%
polismath/delphi_storage/scan_pacing.py 77 0 100%
polismath/delphi_storage/writer.py 196 13 93%
polismath/empty_output.py 29 2 93%
polismath/engine_adapter.py 317 265 16%
polismath/job_child/init.py 364 51 86%
polismath/job_child/census.py 74 9 88%
polismath/job_child/input_bundle.py 101 7 93%
polismath/pca_kmeans_rep/init.py 5 0 100%
polismath/pca_kmeans_rep/clusters.py 257 38 85%
polismath/pca_kmeans_rep/corr.py 98 17 83%
polismath/pca_kmeans_rep/group_k_smoother.py 28 0 100%
polismath/pca_kmeans_rep/legacy_kmeans.py 197 7 96%
polismath/pca_kmeans_rep/pca.py 162 19 88%
polismath/pca_kmeans_rep/repness.py 226 5 98%
polismath/poller/init.py 4 0 100%
polismath/poller/admission.py 318 15 95%
polismath/poller/backfill.py 936 32 97%
polismath/poller/capacity.py 485 27 94%
polismath/poller/capacity_queue.py 248 13 95%
polismath/poller/coordinator_bridge.py 317 317 0%
polismath/poller/math_writer.py 113 3 97%
polismath/poller/promotion.py 112 4 96%
polismath/poller/readiness.py 368 13 96%
polismath/poller/rebuild_child.py 133 5 96%
polismath/poller/service.py 756 54 93%
polismath/poller/worker_pool.py 140 0 100%
polismath/queue/executor.py 221 111 50%
polismath/regression/init.py 4 0 100%
polismath/regression/clojure_comparer.py 188 20 89%
polismath/regression/comparer.py 891 640 28%
polismath/regression/datasets.py 126 27 79%
polismath/regression/recorder.py 36 27 25%
polismath/regression/utils.py 130 85 35%
polismath/replay/init.py 2 0 100%
polismath/replay/attribution_capture.py 49 0 100%
polismath/replay/axis_continuity.py 347 24 93%
polismath/replay/certify.py 999 59 94%
polismath/replay/crosslang.py 128 2 98%
polismath/replay/decision_ties.py 51 8 84%
polismath/replay/diagnostics.py 50 7 86%
polismath/replay/driver.py 73 1 99%
polismath/replay/dsn_admission.py 36 1 97%
polismath/replay/equiv_query.py 10 0 100%
polismath/replay/event_ingress.py 95 1 99%
polismath/replay/fixture_bundle.py 815 62 92%
polismath/replay/fixture_config.py 266 11 96%
polismath/replay/fixture_extract.py 285 12 96%
polismath/replay/fixture_generate.py 103 1 99%
polismath/replay/fixture_samples.py 157 14 91%
polismath/replay/fixture_selection.py 119 0 100%
polismath/replay/fixture_survey.py 147 11 93%
polismath/replay/legacy_pca.py 29 17 41%
polismath/replay/polarity.py 319 25 92%
polismath/replay/poller_equiv.py 776 99 87%
polismath/replay/prodclone.py 193 2 99%
polismath/replay/real_data.py 146 10 93%
polismath/replay/schedule.py 205 10 95%
polismath/replay/served_math.py 159 4 97%
polismath/replay/shard_bench.py 189 65 66%
polismath/replay/stagecompare.py 890 161 82%
polismath/replay/stages.py 256 40 84%
polismath/replay/stepcompare.py 86 1 99%
polismath/replay/store.py 120 11 91%
polismath/replay/tie_capture.py 107 19 82%
polismath/replay/types.py 58 6 90%
polismath/run_math_pipeline.py 284 93 67%
polismath/tools/engine_rebuild.py 230 11 95%
polismath/types.py 6 0 100%
polismath/utils/cli_flags.py 15 0 100%
polismath/utils/clj_hash.py 44 0 100%
polismath/utils/env_flags.py 13 0 100%
polismath/utils/general.py 63 39 38%
polismath/utils/output_profile.py 66 2 97%
polismath/utils/serialization.py 10 5 50%
polismath/utils/vote_convention.py 159 5 97%
umap_narrative/700_datamapplot_for_layer.py 514 470 9%
umap_narrative/701_static_datamapplot_for_layer.py 310 310 0%
umap_narrative/702_consensus_divisive_datamapplot.py 435 275 37%
umap_narrative/801_narrative_report_batch.py 657 209 68%
umap_narrative/803_check_batch_status.py 268 148 45%
umap_narrative/llm_factory_constructor/init.py 2 0 100%
umap_narrative/llm_factory_constructor/model_provider.py 232 106 54%
umap_narrative/narrative_data.py 178 55 69%
umap_narrative/numerical_stages.py 80 14 82%
umap_narrative/polismath_commentgraph/init.py 1 0 100%
umap_narrative/polismath_commentgraph/cli.py 268 268 0%
umap_narrative/polismath_commentgraph/core/init.py 3 3 0%
umap_narrative/polismath_commentgraph/core/clustering.py 108 108 0%
umap_narrative/polismath_commentgraph/core/embedding.py 104 104 0%
umap_narrative/polismath_commentgraph/lambda_handler.py 219 219 0%
umap_narrative/polismath_commentgraph/schemas/init.py 2 0 100%
umap_narrative/polismath_commentgraph/schemas/dynamo_models.py 80 6 92%
umap_narrative/polismath_commentgraph/tests/conftest.py 17 17 0%
umap_narrative/polismath_commentgraph/tests/test_clustering.py 74 74 0%
umap_narrative/polismath_commentgraph/tests/test_embedding.py 55 55 0%
umap_narrative/polismath_commentgraph/tests/test_storage.py 87 87 0%
umap_narrative/polismath_commentgraph/utils/init.py 9 7 22%
umap_narrative/polismath_commentgraph/utils/converter.py 283 48 83%
umap_narrative/polismath_commentgraph/utils/group_data.py 332 279 16%
umap_narrative/polismath_commentgraph/utils/storage.py 609 481 21%
umap_narrative/reset_conversation.py 162 53 67%
umap_narrative/run_pipeline.py 355 203 43%
umap_narrative/topic_naming.py 148 11 93%
Total 25600 7365 71%

@colinmegill
colinmegill merged commit 38de7ae into edge Nov 4, 2026
60 checks passed
@colinmegill
colinmegill deleted the client-error-reporting-2 branch November 4, 2026 22:54
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