Repository navigation
Record unhandled Flask exceptions in telemetry - #3984
Merged
Merged
Conversation
record_unhandled_exception records an exception that reached a catch-all error handler as an unhandled server error. Observability failures are suppressed so they cannot change the response. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The catch-all Flask error handler returned a 500 without recording the exception, so telemetry only saw a failed request with no exception type or stack. It now records the exception before building the unchanged response. 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 #3984 +/- ##
===========================================
+ Coverage 46.17% 84.62% +38.45%
===========================================
Files 239 258 +19
Lines 13987 15689 +1702
Branches 2272 2643 +371
===========================================
+ Hits 6458 13277 +6819
+ Misses 6958 1637 -5321
- Partials 571 775 +204 ☔ View full report in Codecov by Harness. 🚀 New features to boost your workflow:
|
The existing tests replace runtime.record_exception with a stand-in that accepts any keyword arguments. If an installed policyengine-observability release stopped accepting a keyword that record_unhandled_exception passes, the call would raise a TypeError that the helper suppresses, recording would stop, and every test would still pass. The new test instruments a Flask app with a real runtime whose records go to an in-memory destination, raises an exception in a route, and checks that exactly one exception.recorded record is delivered with the exception type, message and stack, error.handled=false, status 500, the route, and the same request ID as the request's request.completed record. Also remove raising=False from the monkeypatch calls. error_routes now defines record_unhandled_exception, so a missing attribute should fail the test instead of being created silently. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…r exception In 3.0.2, Flask's teardown callback received no exception once response_generic_error had handled it, and end_request discarded the exception that record_unhandled_exception attached. The request's request.completed record then had no error fields. 3.0.3 (PolicyEngine/policyengine-observability#36) keeps the attached exception when the request has already failed. Requests that record a handled exception and still succeed keep their outcome. A new test checks that request.completed for an unhandled exception carries the exception's type, message and stack. uv.lock was regenerated with uv 0.12.1, the version CI uses, so its format revision is unchanged. The changelog fragment and gcp/observability/README.md now name 3.0.3. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
anth-volk
marked this pull request as ready for review
October 20, 2026 22:49
This was referenced Oct 20, 2026
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 #3983
Summary
policyengine_api/observability/runtime.py: newrecord_unhandled_exception(error, *, status_code=500). It callsruntime.record_exception(error, handled=False, status_code=status_code)insidecontextlib.suppress(Exception), the same wayset_runtime_contextprotects the response from telemetry failures.policyengine_api.observabilityexports it.policyengine_api/routes/error_routes.py:response_generic_error, the catch-all handler for exceptions that aren'tHTTPException, calls it before building the 500 response.response_http_exceptionand the status-code handlers (404, 405,abort(500)and other HTTP errors) are unchanged and record nothing.pyproject.tomlanduv.lock: requirepolicyengine-observability3.0.3, which keeps the recorded exception on a failed request (Keep the exception recorded by an error handler on request.completed policyengine-observability#36). The lock file was regenerated with uv 0.12.1, the version CI uses.gcp/observability/README.mdnames the new version.Effects on telemetry, for each exception that reaches
response_generic_error:exception.recordedERROR record, witherror.type,error.handled=false,http.response.status_code=500,http.route,request.id, the message and the stack. Itsrequest.idmatches the request'srequest.completedrecord, so the two records can be joined.request.completedrecord carries the sameerror.type,error.messageanderror.stack. Withpolicyengine-observability3.0.2 it did not:instrument_flaskends the request from Flask'steardown_requestcallback, which receivesNoneonce an error handler has handled the exception, andend_requestreplaced the attached error with thatNone. 3.0.3 keeps the attached error when the request has already failed, and leaves requests that record a handled exception and still succeed unchanged.policyengine.error.countgains one point withoperation.kind=unhandled, in addition to the existingoperation.kind=requestpoint thatend_requestadds for the same 500. The "API v1 elevated error rate" alert and the "PolicyEngine API v1 observability" dashboard used to add up every series, so they would have counted each of these failures twice. Both now count onlyoperation.kindvaluesrequestandoperation(changed in Cloud Monitoring on 2026-10-20), so each failed request or background operation counts once. The alert policy has no notification channels, so nobody is paged.logger.exceptionbefore re-raising it. Their failures now produce two ERROR records with the sameerror.type: the existing log record and the newexception.recordedrecord. A report that counts failures by exception type should count onlyexception.recordedrecords.Not covered by this change:
logger.exception.request.completedrecord already carrieserror.*fields, because the ASGI middleware passes the exception toend_request.The response status code and body don't change.
Migration scope: no route groups, migration metadata or user-visible API contracts change.
Testing
tests/unit/test_observability_runtime.pyhas 2 new tests: the helper callsrecord_exceptiononce withhandled=Falseand the status code, and it returns normally whenrecord_exceptionraises.tests/unit/routes/test_error_routes.py(5 tests) builds a Flask app witherror_bp:RuntimeErrorcalls the helper once with that error and status 500, and still returns the existing 500 body;exception.recordedrecord with the exception type, message, stack,error.handled=false, status 500 and route, and the samerequest.idas therequest.completedrecord. The other tests replacerecord_exceptionwith a stand-in that accepts any keyword argument. If a policyengine-observability release renamed one of its keywords, recording would stop without failing them; this test fails in that case;request.completedrecord hasoutcome=error, status 500 and the exception's type, message and stack. This test failed withpolicyengine-observability3.0.2 and passes with 3.0.3;FLASK_DEBUG=1 python -m pytest tests/unit/routes tests/unit/test_asgi_factory.py tests/unit/test_request_context.py tests/contract tests/unit/test_observability_runtime.py: 446 passed, with 3.0.3 installed.python scripts/run_quality_guards.py: passed.make formatandruff checkare clean on the changed files. No inline suppressions are added.🤖 Generated with Claude Code