Repository navigation
Conversation
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>
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 Report✅ All modified and coverable lines are covered by tests. 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. 🚀 New features to boost your workflow:
|
This branch was previously deployed
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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:
gh workflow run telemetry-error-issues.yml -f dry_run=true. This first run confirms the value of the existingGCP_WORKLOAD_IDENTITY_PROVIDERsecret, which can't be read. Check that its log and job summary contain no message or stack text.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-DDreports another day, and refuses a day that hasn't ended.Queries: reads the linked dataset's
_AllLogsview with two named-parameter queries, each limited to 5 GiB billed.policyengine.api-v1namespace, for the day and the seven days before it, excluding health-check routes, with duplicate deliveries removed.request.completedcounts per route for the day, for error rates.path", line N, in functionheaders) 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.completedrecord.Fingerprints: 16 hex characters of HMAC-SHA256, algorithm 1, keyed by
TELEMETRY_ERROR_FINGERPRINT_KEY.exceptionpolicyengine_api,policyengine_fastapi,policyengine_simulation_*orpolicyengine_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.messageerror_typeandfailure_categoryattributesfailed_requestVersioned simulation services (
policyengine-simulation-py<x>-<y>-<z>) form one family, and the Modal gateway'smainenvironment 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:
github-actions[bot].regressionand 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.telemetry-error,service: <family>andregression; missing labels are created, and existing labels match regardless of case, as GitHub matches them.retry-after, untilx-ratelimit-resetwhen no requests remain, or after a minute.Public output:
<unrecognized>if it doesn't match..github/scripts/sync_telemetry_error_issues.sh: requires its variables, masks the access token and key, and adds--dry-runand--report-datewhen set..github/workflows/telemetry-error-issues.yml: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.master, in thetelemetry-error-issuesenvironment, one run at a time, withcontents: read,issues: writeandid-token: write.gcp/observability/README.mddescribes 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.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)
observability_logspolicyengine-observabilitybucket, in locationUS, with the_AllLogsviewtelemetry-error-issues@policyengine-observability.iam.gserviceaccount.comroles/bigquery.jobUseron the project;roles/bigquery.dataVieweron the dataset only (granted withGRANT ... ON SCHEMA). It needs no Cloud Logging role.roles/iam.workloadIdentityUseron the service account forprincipal://.../workloadIdentityPools/github/subject/repo:PolicyEngine/policyengine-api:environment:telemetry-error-issuesonlytelemetry-error-issuesmasterEnvironment variables
GCP_WORKLOAD_IDENTITY_PROVIDERsecrets.in the auth stepTELEMETRY_ERROR_ISSUES_SERVICE_ACCOUNTtelemetry-error-issuesenvironment variablevars.in the auth stepGOOGLE_OAUTH_ACCESS_TOKENsteps.auth.outputs.access_tokenenv:to the wrapper (required, masked) to the scriptTELEMETRY_ERROR_FINGERPRINT_KEYtelemetry-error-issuesenvironment secretenv:to the wrapper (required, masked) to the scriptOBSERVABILITY_PROJECT_IDpolicyengine-observability)env:to the wrapper to--project-idGITHUB_TOKENgithub.token, withissues: writeenv:GITHUB_REPOSITORY,GITHUB_STEP_SUMMARYDRY_RUN,REPORT_DATEfalseand empty on scheduled runsenv:to wrapper flagsReview fixes
A review found these, each now fixed with a test written first:
retry-afterfailed the run instead of waiting.Testing
tests/unit/test_telemetry_error_issues.py: 212 tests, each commit written test-first.subprocessand a fakepython3first onPATH.tests/unit/test_alembic_workflows.pyandtests/unit/test_cloud_run_deploy_scripts.py, 354 passed.python scripts/run_quality_guards.pypassed, includingci-scriptswith shellcheck.make formatandruff checkare clean on the changed files.🤖 Generated with Claude Code