Skip to content

fix(sentry): stop capturing request bodies and scrub Postgres DETAIL rows - #866

Open
blarghmatey wants to merge 3 commits into
mainfrom
tmacey/sentry-scrub-request-bodies
Open

fix(sentry): stop capturing request bodies and scrub Postgres DETAIL rows#866
blarghmatey wants to merge 3 commits into
mainfrom
tmacey/sentry-scrub-request-bodies

Conversation

@blarghmatey

@blarghmatey blarghmatey commented Sep 8, 2026

Copy link
Copy Markdown
Member

This repo

Changes the SENTRY_SEND_HTTP_REQUEST_BODIES default in ol_openedx_sentry from "small" to "never", and adds the Postgres DETAIL: scrub to the existing sentry_event_filter.

Unlike the standalone Django apps in this change, this plugin was never on the SDK default — the author chose "small" deliberately, in the same commit that made PII opt-in. But "small" still admits bodies up to 1,000 bytes, and the Open edX write endpoints that show up in the error data are xblock handler POSTs, whose payloads can fall under that cap. So the setting does not reliably protect the payload it was chosen to protect. "never" does; operators can still widen it per deployment via the env token.

The scrub runs as the first statement in the filter, so the module's documented fail-open handler returns an already-scrubbed event. A privacy control that fails open is not one.

This applies to every Open edX deployment that installs ol_openedx_sentry.

What

Two independent ways learner data reaches Sentry, neither of them governed by send_default_pii. Part of a ten-repo change on branch tmacey/sentry-scrub-request-bodies.

1. HTTP request bodies are captured with no PII gate

request_info["data"] is set unconditionally in RequestExtractor.extract_into_event. The only control is max_request_body_size, checked by request_body_within_bounds:

value effect
never no bodies
small bodies <= 1,000 bytes
medium bodies <= 10,000 bytes — the SDK default
always no limit

Verified against sentry-sdk source: request_body_within_bounds does the bounds check, RequestExtractor.extract_into_event sets the body unconditionally, and "medium" is the max_request_body_size default. The repos in this change pin 1.9.0 to 2.68.0, and the behaviour is the same across them.

The bound is checked against the declared Content-Length, and a missing or malformed header counts as 0. Under WSGI, Django then reads 0 bytes, so nothing gets past it. Under ASGI, Django takes CONTENT_LENGTH from the header and reads the whole body, so on learn-ai and mit-learn a chunked request with no Content-Length is captured in full. The Starlette integration (ol-analytics-api) attaches no body when the header is absent.

Which endpoints that actually means, measured in Sentry over the 30 days to 2026-09-08 (dataset=errors, http.method:[POST,PUT,PATCH], grouped by transaction):

transaction project events
PATCH /scim/v2/Users/{uuid} mitxonline 17,610
POST /api/v1/webhooks/content_files/ mit-learn 5,039
POST /api/v1/enrollments/ mitxonline 1,436
POST .../xblock/{usage_id}/handler/{handler}/ openedx-mitxpro 553
POST /cms/pages/{page_id}/edit/ micromasters 273
POST /api/profile/details/ mitxonline 132
POST /api/checkout/result/ mitxonline 65
POST /api/checkout/redeem_discount/ mitxonline 46

Enrollment, checkout, profile edits, SCIM user attributes, and xblock handler submissions — governed by a knob nobody set.

2. Postgres DETAIL: lines leak rows through the exception text

A constraint violation carries a DETAIL: Failing row contains (...) line reproducing the whole offending row, and psycopg puts it in str(exc). It therefore ships inside the exception value, where no SDK privacy option reaches it — send_default_pii governs user/cookie/header capture and max_request_body_size governs bodies; neither touches exception text.

Measured on MITXONLINE-6PK: a SCIM PATCH IntegrityError on users_user whose DETAIL line reproduces a learner email address three times per event. First seen 2026-05-27, 46,764 occurrences, last seen 2026-09-08 — still firing.

before_send now walks the whole serialized event and truncates any string at its DETAIL: line, whether the newline arrives raw or as a literal \n (the SDK repr()s frame locals and non-string log params before before_send runs). It keeps the primary error that names the failure and drops the row echo plus any HINT/CONTEXT after it.

Testing

Unit tests next to the module use a real MITXONLINE-6PK exception value with the learner identifiers replaced. They assert the primary message survives, the email and external UUID do not, and HINT/CONTEXT are dropped, for both the raw DETAIL: line and the repr'd form the SDK produces for frame locals and log params. One test goes through a real SDK client, with sentry_event_filter and a fake transport, and checks that no outgoing event carries the email. Against the pre-review module, 4 of the tests fail, that one included.

Not in scope

send_default_pii and the salted-user-hash work are deliberately untouched here — different knob, different failure mode, tracked separately.

🤖 Generated with Claude Code

https://claude.ai/code/session_01RVJKTGk9KHUTN2xfU59ujX

The other nine PRs

Same branch name (tmacey/sentry-scrub-request-bodies) in each repo. Independent — no merge order required.

repo PR body size
open-edx-plugins #866 never
mit-learn mitodl/mit-learn#3915 small
mitxonline mitodl/mitxonline#3936 small
mitxpro mitodl/mitxpro#4088 small
ocw-studio mitodl/ocw-studio#3198 small
odl-video-service mitodl/odl-video-service#1591 small
micromasters mitodl/micromasters#5561 small (as request_bodies, SDK 1.9.0)
open-discussions mitodl/open-discussions#4463 small
learn-ai mitodl/learn-ai#62 small
ol-analytics-api mitodl/ol-analytics-api#54 small

… DETAIL

Two separate ways learner data reaches Sentry, neither covered by
send_default_pii.

Request bodies are not gated on the PII flag. The SDK sets request.data
unconditionally at sentry_sdk/integrations/_wsgi_common.py:123, and
max_request_body_size (checked at :61) is the only control. The plugin
already set it to "small", but "small" admits bodies up to 1,000 bytes,
and the Open edX write endpoints that actually error are xblock handler
POSTs -- graded problem submissions, routinely well under that cap. So
the existing setting did not protect the payload it was chosen to
protect. Default to "never"; SENTRY_SEND_HTTP_REQUEST_BODIES still lets
an operator widen it per deployment.

Postgres appends a DETAIL line to constraint violations that echoes the
whole offending row, and psycopg puts it in str(exc), so it ships inside
the exception value where no SDK privacy option reaches it. Measured on
mitxonline MITXONLINE-6PK: a SCIM PATCH IntegrityError reproducing a
learner email address three times per event, 46,764 occurrences since
2026-05-27. before_send now truncates at the DETAIL marker across
exception values, logentry, and the legacy top-level message, keeping
the primary error that identifies the failure.

The scrub runs as the first statement in the filter so the module's
documented fail-open handler returns an already-scrubbed event; a
privacy control that fails open is not one.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RVJKTGk9KHUTN2xfU59ujX

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🟡 Changes recommended

Breadcrumbs remain unsanitized, and release metadata and documentation need updates.

Once you've addressed the issues Copilot identified, you can request another Copilot review.

Pull request overview

Hardens the Sentry plugin against learner-data exposure.

Changes:

  • Disables HTTP request-body capture by default.
  • Scrubs PostgreSQL DETAIL: rows from Sentry events.
  • Adds regression and configuration tests.
File summaries
File Description
tests/test_sentry.py Tests scrubbing and request-body settings.
settings/sentry.py Implements scrubbing and safer defaults.
Review details

Suppressed comments (1)

src/ol_openedx_sentry/ol_openedx_sentry/settings/sentry.py:336

  • The public settings table still documents this default as "small" (src/ol_openedx_sentry/README.rst:70-73). That now gives operators incorrect privacy behavior expectations; update the README default and opt-in guidance alongside this change.
        max_request_body_size=env_tokens.get(
            "SENTRY_SEND_HTTP_REQUEST_BODIES", "never"
        ),
  • Files reviewed: 2/2 changed files
  • Comments generated: 3
  • Review effort level: Balanced

💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.

Comment thread src/ol_openedx_sentry/ol_openedx_sentry/settings/sentry.py Outdated
Comment thread src/ol_openedx_sentry/ol_openedx_sentry/settings/sentry.py
Comment thread src/ol_openedx_sentry/ol_openedx_sentry/settings/sentry.py
blarghmatey and others added 2 commits September 8, 2026 11:55
Addresses all three Copilot review threads on #866.

1. Breadcrumbs (and more) were unscrubbed. Verified against sentry-sdk
   2.55.0: the first pass enumerated three paths and missed
   breadcrumbs[].message (integrations/logging.py:311 -- exactly
   MITXONLINE-6PK's shape, mechanism=logging), logentry.params (:274),
   and frames[].vars, which is populated because include_local_variables
   defaults to True (consts.py:1028, utils.py:616). Confirmed the old
   implementation leaked on all three before changing it. Replaced with a
   recursive walk rather than a longer path list, so it does not go stale
   when the SDK grows another such field; the walk rewrites only str
   leaves, with a test pinning that.

   Copilot also suggested normalizing exception-valued params. Not
   needed: client._prepare_event serializes the event before calling
   before_send (client.py:650 vs :658), so every leaf is already a JSON
   primitive and no live exception objects remain.

2. The fail-open contract said "returned unfiltered", which stopped being
   true when the scrub moved ahead of it. Corrected in the module design
   note and README.rst: fail-open returns the event rather than dropping
   it, and the DETAIL scrub is the one thing that still runs, because it
   is the filter's first statement.

   Also corrected the README settings table, which still documented
   SENTRY_SEND_HTTP_REQUEST_BODIES as defaulting to "small" -- the
   suppressed comment on the same review. It is "never" now, with the
   reasoning recorded next to it.

3. Version 0.4.0 -> 0.5.0 per AGENTS.md:237, minor rather than patch
   because this changes shipped default behavior. uv.lock regenerated so
   its recorded version matches.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RVJKTGk9KHUTN2xfU59ujX
The SDK repr()s frame locals and non-string logging params before
before_send, so the DETAIL line arrives with a literal backslash-n and the
old "\nDETAIL:" find missed it. Match both forms, and add an SDK-driven
test so the fixtures stop using a shape the SDK never produces.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MYc2F3cFvSCfVzqeRMshGy
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.

2 participants