Skip to content

Report each day's production telemetry error groups as GitHub issues - #3990

Draft
anth-volk wants to merge 15 commits into
masterfrom
feat/telemetry-error-issues
Draft

anth-volk wants to merge 15 commits into
masterfrom
feat/telemetry-error-issues

Conversation

@anth-volk

@anth-volk anth-volk commented Oct 20, 2026 •

Copy link
Copy Markdown
Collaborator

Fixes #3989

Merge on or after 2026-10-23

Do not merge before 2026-10-23. The first live report must cover a full US Eastern day that started after #3984 reached production. Before #3984, an unhandled Flask 500 has no exception record, so its failures would group as "HTTP 500 without recorded exception" and open issues that stop matching once #3984 is deployed. #3984 merged on 2026-10-20. If it is not in production by the end of 2026-10-21 Eastern, move this date later by the same number of days.

Right after merging, before 00:45 Eastern that night:

  1. gh workflow run telemetry-error-issues.yml -f dry_run=true. This first run confirms the value of the existing GCP_WORKLOAD_IDENTITY_PROVIDER secret, which can't be read. Check that its log and job summary contain no message or stack text.
  2. gh workflow run telemetry-error-issues.yml -f dry_run=false, then review the issues it opens.

From the next night on, the schedule runs it. Scheduled runs are never dry runs.

Summary

  • .github/scripts/sync_telemetry_error_issues.py (standard library only):
    • Window: reports one US Eastern calendar day, midnight to midnight; days when the clocks change last 23 or 25 hours. --report-date YYYY-MM-DD reports another day, and refuses a day that hasn't ended.

    • Queries: reads the linked dataset's _AllLogs view with two named-parameter queries, each limited to 5 GiB billed.

      • ERROR records with the policyengine.api-v1 namespace, for the day and the seven days before it, excluding health-check routes, with duplicate deliveries removed.
      • request.completed counts per route for the day, for error rates.
      • Only code-defined or normalized values leave BigQuery: stack frames (path", line N, in function headers) from the stack's first traceback, a truncation flag, a message's first three whole words outside quoted values, and SHA-256 hashes of request IDs, observability IDs and traces. A single quote opens a value only at the start or after a character that isn't a letter or digit, so apostrophes don't. The patterns are query parameters, and the tests apply the same ones.
    • One record per failure: records of one service that share a request ID (or a trace) are one failure. The failure is represented by its exception record, else its error message, else its failed request.completed record.

    • Fingerprints: 16 hex characters of HMAC-SHA256, algorithm 1, keyed by TELEMETRY_ERROR_FINGERPRINT_KEY.

      Kind Key parts
      exception Service family, exception class, three innermost frames in policyengine_api, policyengine_fastapi, policyengine_simulation_* or policyengine_stage12_* (path and function, no line), from the stack's first traceback: for a chained exception, its first cause. The logging library keeps only the first 16,384 characters of a stack, so the final traceback can be dropped but the first is always kept. The route is left out, so one shared failure opens one issue.
      message Service family, route or operation, message template, error_type and failure_category attributes
      failed_request Service family, route, HTTP status

      Versioned simulation services (policyengine-simulation-py<x>-<y>-<z>) form one family, and the Modal gateway's main environment counts as production.

    • Measures, for production and staging: occurrences, distinct affected requests, observability IDs, error rate on the affected routes (affected requests that have a route, over completed requests on those routes), occurrences per hour in the day and over the seven days before, first and last seen, and up to three related fingerprints that share traces.

    • Issues:

      • Opening: a production group opens an issue at 3 occurrences in at least 2 requests, at most 5 per run, largest first.
      • Matching: by the fingerprint in a hidden state comment, or in the title, among open and closed issues by github-actions[bot].
      • Updating: each run replaces only the marked section, and only when it changed. Titles and text outside the section are never changed, and a newer day's section is never overwritten by a rerun of an earlier day.
      • Reopening: an issue closed as completed reopens with regression and a comment when production failures that start 24 hours after it closed meet the same thresholds. Issues closed as not planned or duplicate stay closed.
      • Labels: telemetry-error, service: <family> and regression; missing labels are created, and existing labels match regardless of case, as GitHub matches them.
      • Writes go one at a time, at least a second apart. Rate-limited 403 and 429 responses are retried as GitHub advises: after retry-after, until x-ratelimit-reset when no requests remain, or after a minute.
    • Public output:

      • Titles, bodies, the job summary and logs contain only fingerprints, counts, validated code-defined names and a Logs Explorer link. Every name is checked against a pattern and shown as <unrecognized> if it doesn't match.
      • A failed stage prints only the stage, the exception class and an API's HTTP status and reason.
  • .github/scripts/sync_telemetry_error_issues.sh: requires its variables, masks the access token and key, and adds --dry-run and --report-date when set.
  • .github/workflows/telemetry-error-issues.yml:
    • Runs at 00:45 America/New_York. That time is never skipped or repeated when the clocks change, and it avoids GitHub's delays for schedules at the start of an hour. It can also be run manually, defaulting to a dry run.
    • The job runs only in this repository, from master, in the telemetry-error-issues environment, one run at a time, with contents: read, issues: write and id-token: write.
    • It authenticates through Workload Identity Federation without writing a credentials file.
  • Documentation: gcp/observability/README.md describes the job, how to run it by hand, the infrastructure, why the fingerprint key must never be rotated on its own, and the Cloud Run platform-failure gap.
  • Changelog: fragment added.

Migration scope: no route groups, migration metadata or user-visible API contracts change. This adds a workflow and its script.

Infrastructure (created 2026-10-20)

Resource Details
Linked BigQuery dataset observability_logs On the policyengine-observability bucket, in location US, with the _AllLogs view
Service account telemetry-error-issues@policyengine-observability.iam.gserviceaccount.com roles/bigquery.jobUser on the project; roles/bigquery.dataViewer on the dataset only (granted with GRANT ... ON SCHEMA). It needs no Cloud Logging role.
Workload Identity binding roles/iam.workloadIdentityUser on the service account for principal://.../workloadIdentityPools/github/subject/repo:PolicyEngine/policyengine-api:environment:telemetry-error-issues only
GitHub environment telemetry-error-issues Deployments limited to master

Environment variables

Name Provider Wiring Status
GCP_WORKLOAD_IDENTITY_PROVIDER Repository secret secrets. in the auth step Exists; value unreadable, so confirmed by the first manual dry run
TELEMETRY_ERROR_ISSUES_SERVICE_ACCOUNT telemetry-error-issues environment variable vars. in the auth step Set
GOOGLE_OAUTH_ACCESS_TOKEN steps.auth.outputs.access_token Step env: to the wrapper (required, masked) to the script Created at run time from the binding above
TELEMETRY_ERROR_FINGERPRINT_KEY telemetry-error-issues environment secret Step env: to the wrapper (required, masked) to the script Set; random 32 bytes, stored only in GitHub
OBSERVABILITY_PROJECT_ID Repository variable (policyengine-observability) Step env: to the wrapper to --project-id Set at repository level, so the environment job can read it
GITHUB_TOKEN github.token, with issues: write Step env: Built into Actions
GITHUB_REPOSITORY, GITHUB_STEP_SUMMARY Actions defaults Read by the wrapper and script Built into Actions
DRY_RUN, REPORT_DATE Manual-run inputs; false and empty on scheduled runs Step env: to wrapper flags Covered by tests

Review fixes

A review found these, each now fixed with a test written first:

  1. Chained exceptions with a long cause got two fingerprints. Shortening the stack could drop the final traceback, so frames came from different tracebacks. Frames now come from the first traceback only.
  2. An apostrophe paired with the next quote and put the quoted value into the message template, making one group per value.
  3. The error rate could exceed 100%, because the numerator included failures without a route.
  4. Label names were compared exactly. An existing label that differs only in case would have made label creation fail with HTTP 422.
  5. A secondary-rate-limit 403 without retry-after failed the run instead of waiting.

Testing

  • Dry runs against live data, for 2026-10-19, first with operator credentials and then as the service account:
    • They read 1,648 ERROR records across eight days and found 15 groups, 5 of them above the issue thresholds.
    • The queries scanned about 0.18 GiB and 0.02 GiB.
    • A generated Logs Explorer link opened the group's 12 records.
  • tests/unit/test_telemetry_error_issues.py: 212 tests, each commit written test-first.
    • Covered:
      • the window and its 23- and 25-hour days;
      • frame extraction from standard, chained, exception group and truncated tracebacks;
      • value validation;
      • choosing one record per failure;
      • fingerprints;
      • measures;
      • the BigQuery requests and SQL text, including that stacks and messages are read only through their expressions and identifiers only as hashes;
      • rendering;
      • the issue sync, against an in-memory GitHub fake;
      • the end-to-end run;
      • the workflow file;
      • the wrapper, run with subprocess and a fake python3 first on PATH.
    • Marker strings placed in every record field never appear in any title, body, link, log line or job summary.
    • The file also passes under Python 3.12, the version the workflow uses.
    • Running the template and frame patterns in BigQuery on the tests' strings gave the same results as the tests' Python version of them.
  • Workflow suites: with tests/unit/test_alembic_workflows.py and tests/unit/test_cloud_run_deploy_scripts.py, 354 passed.
  • Repository checks:
    • python scripts/run_quality_guards.py passed, including ci-scripts with shellcheck.
    • make format and ruff check are clean on the changed files.
    • There are no inline suppressions.

🤖 Generated with Claude Code

anth-volk and others added 10 commits October 21, 2026 03:03
The new script reports one US Eastern calendar day, from midnight to
midnight, so a run at 00:45 Eastern never falls in the hour the clocks
skip or repeat. Days when the clocks change last 23 or 25 hours.

--report-date selects a different day; an empty value means the
previous day, as on scheduled runs.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
- Versioned simulation services (policyengine-simulation-py<x>-<y>-<z>)
  share one family; the Modal gateway's "main" environment counts as
  production.
- FRAME_PATTERN extracts 'path", line N, in function' from standard,
  chained, exception group and truncated tracebacks. It has one capturing
  group so BigQuery's REGEXP_EXTRACT_ALL can apply it, and no source line
  or message text matches it.
- Frame paths lose their installation prefix and line number. The
  fingerprint uses the three innermost frames in the Public API and
  simulation packages, or the innermost frames when there are none.
- Error types, route templates, service families, operation names and
  HTTP statuses are validated before any public output; anything else is
  shown as <unrecognized>.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A failed request can log an exception, an error message and a failed
request.completed record. Records of one service that share a request ID
(or a trace, without one) are one failure, represented by its exception
record, else its message record, else its request.completed record. An
unhandled exception is preferred over a handled one. Failures in two
services stay separate even when they share a request.

Fingerprints are 16 hex characters of HMAC-SHA256 (algorithm 1) over:
- exception: service family, exception type and the chosen frames; the
  route is left out so one shared failure opens one issue;
- message: service family, route or operation, message template, and the
  error_type and failure_category attributes;
- failed_request: service family, route and HTTP status.

The key must be at least 32 characters.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
For each group with a failure in the window, and separately for
production and staging:
- occurrences, and distinct affected requests (request ID, else
  observability ID, else trace);
- distinct observability IDs;
- error rate: affected requests divided by the requests completed on the
  routes the group failed on, adding all simulation service versions;
- occurrences per hour in the window and over the seven days before it,
  measured in UTC so 23- and 25-hour days are counted correctly;
- first and last seen across the eight queried days.

Each group also lists up to three groups that share the most traces with
it, and is described only by validated values: the exception type and its
most frequent production route. Groups seen only in other environments,
or only before the window, are not reported.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Two standard SQL queries read the linked dataset's _AllLogs view:
- error records from the window and the seven days before it: Public
  API and simulation records (service namespace policyengine.api-v1) at
  ERROR or above, without the health-check routes, with duplicate
  deliveries removed;
- request.completed counts per service, environment and route in the
  window, for error rates.

Only code-defined or normalized values leave BigQuery. Stacks are read
only through FRAME_PATTERN and a truncation flag, messages only as their
first three whole words outside quotes, and request IDs, observability
IDs and traces only as SHA-256 hashes.

The urllib client sends named parameters, a 5 GiB maximumBytesBilled
limit and job labels, polls until the job completes, follows page tokens
in the job's location and converts values from their string form. An
error response raises an exception that holds only the HTTP status and
the API's reason code, never the response body.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Titles name the service family and the exception type, "logged error",
or "HTTP <status> without recorded exception", then the most frequent
route (or operation) and the fingerprint. The fingerprint lets the job
find its issue even if someone removes the marked section. The route is
dropped when the title would exceed GitHub's 256-character limit.

The body has a short note, then a marked section that each run replaces:
- the fingerprint, kind, service, type and route;
- the window in Eastern time, with its length in hours;
- a production and staging table of the measures;
- related fingerprints;
- a Logs Explorer link to the _AllLogs view, filtered by service,
  exception type (or route and status) and the window's timestamps;
- a hidden state comment with the fingerprint, algorithm, kind and
  window end.

Every value comes from the validated fields of the group, so a test with
marker strings in every record field finds none of them in any title,
body or link.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The job matches error groups to the open and closed issues that
github-actions[bot] opened, by the fingerprint in the hidden state
comment, or in the title if someone removed the marked section. Issues
from other authors are ignored.

- A group without an issue opens one only after 3 production
  occurrences in at least 2 distinct requests during the window.
  Staging-only groups never open issues. At most 5 issues open per run,
  largest production groups first; the rest wait for a later run.
- An existing issue gets the new marked section only when its content
  changed. Text outside the section and the title are never changed, and
  a removed section is added back at the end. A section from a later
  window is kept rather than overwritten by a rerun of an earlier day.
- An issue closed as completed, or without a reason, reopens with the
  regression label and a comment, but only when production failures
  starting 24 hours after it closed meet the same thresholds. Issues
  closed as not planned or duplicate stay closed.
- Missing labels are created first, and a created issue whose labels
  GitHub didn't apply gets them added, or the run fails.
- Writes go one at a time, at least a second apart. A 429, or a 403 with
  retry-after, is retried after the requested wait, up to 3 times.
- A dry run reads issues but makes no write requests.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
main reads its settings from the environment, refuses a report date
that hasn't ended in Eastern time, queries BigQuery, groups the errors,
syncs the issues and prints a summary, which it also appends to
GITHUB_STEP_SUMMARY. The summary lists only fingerprints, kinds, service
families, counts, issue numbers and actions.

Each stage runs inside a handler that replaces any exception with one
line, printed to stderr: the stage, the exception class, and for an API
error its HTTP status and reason. A configuration error also names the
missing variables. The run then exits with 1. A test with marker strings
in every record field finds none of them in stdout, stderr, the job
summary or the issues created.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The workflow runs at 00:45 America/New_York. That time is never skipped
or repeated when the clocks change, and it avoids GitHub's delays for
schedules at the start of an hour. Manual runs default to a dry run and
can report a chosen Eastern day.

The job:
- runs only in PolicyEngine/policyengine-api, only from master, and in
  the telemetry-error-issues environment, whose deployments are limited
  to master;
- has permission to read contents, write issues and request an OIDC
  token, and runs one at a time;
- authenticates as the report's service account through Workload
  Identity Federation, without writing a credentials file;
- calls sync_telemetry_error_issues.sh, which requires its variables,
  masks the access token and fingerprint key, and adds --dry-run and
  --report-date when set.

A scheduled run is never a dry run. gcp/observability/README.md
describes the job, how to run it by hand, the infrastructure it needs,
why the fingerprint key must not be rotated, and the missing Cloud Run
platform failures.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The linked dataset is in the BigQuery US location. A dataset-level
dataViewer grant was enough, with no Cloud Logging role, and a dry run
as the service account scanned about 0.2 GiB for one day's report.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
anth-volk and others added 5 commits October 21, 2026 03:47
The logging library keeps only the first 16,384 characters of
error.stack, and Python prints a chained exception's cause first. A long
cause traceback could therefore drop the final traceback. The same
exception then got its frames from different tracebacks depending on how
long its cause was, and so two fingerprints. Its counts split, often
below the issue thresholds.

The query now removes everything after the first traceback, matched by
CHAINED_EXCEPTION_PATTERN as a query parameter, before extracting frames.
For a chained exception that is its first cause, which is always kept.
A test builds a chained stack with a short and a long cause, shortens
the long one as the library does, and checks that both give the same
fingerprint. Both queries pass a BigQuery dry run.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The message template removed '[^']*' spans, so the apostrophe in a
contraction or possessive paired with the next quote. In "Can't load
policy 'alpha'" it removed the fixed words and kept the value, so each
value formed its own group, usually below the issue thresholds.

A single quote now opens a value only at the start of the message or
after a character that isn't a letter or digit. The quote and word
patterns are module constants, passed to the query as parameters, so the
tests apply exactly what BigQuery runs. Running the patterns in BigQuery
on the test strings gave the same templates and frames as Python.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The numerator counted every affected request in the window, including
failures without a route, while the denominator counted completed
requests only on the routes the failures used. Exception fingerprints
leave the route out, so one group can hold failed requests and
background failures, and the rate could exceed 100%: one failed request
on a route with one completed request, plus two route-less failures,
showed "300.00% (3 of 1 requests)".

The rate now divides the distinct affected requests that have a route
(new route_affected_requests measure) by the completed requests on
those routes, and the issue table shows that count.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
GitHub label names are unique regardless of case, and applying a label
by name matches it the same way. The job compared names exactly. If the
repository had, say, "Regression", the job would try to create
"regression", get HTTP 422 and fail the run. It would also report the
labels on a new issue as missing when GitHub had applied the existing
spelling.

Known, applied and returned label names are now compared case-folded.
The test fake now behaves like GitHub: creating a name that differs only
in case returns 422, and labels are applied with their stored spelling.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…fter

A 403 or 429 was retried only when it had retry-after, or for a 429.
GitHub can send a secondary-rate-limit 403 without retry-after, which
failed the run and left that day unreported until someone dispatched it
again.

The job now retries a rate-limited 403 or 429 as GitHub advises: after
retry-after; when x-ratelimit-remaining is 0, until x-ratelimit-reset,
measured from the response's date header; otherwise after a minute. A
403 whose headers and message don't mention a rate limit is still a
permission failure and isn't retried. Waits over five minutes still fail
the run. The message is only searched, never printed.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@codecov

codecov Bot commented Oct 21, 2026 •

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.
✅ Project coverage is 84.71%. Comparing base (be0c586) to head (bf9ea6b).
⚠️ Report is 1 commits behind head on master.

Additional details and impacted files
@@             Coverage Diff             @@
##           master    #3990       +/-   ##
===========================================
+ Coverage   49.31%   84.71%   +35.39%     
===========================================
  Files         242      258       +16     
  Lines       14299    15677     +1378     
  Branches     2354     2639      +285     
===========================================
+ Hits         7052    13281     +6229     
+ Misses       6622     1626     -4996     
- Partials      625      770      +145     

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

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.

This branch was previously deployed

1 inactive deployment
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.

Report each day's production telemetry error groups as GitHub issues

1 participant