Skip to content

Commit 9a464fd

Browse files
committed
feat: add Sentry SDK integration for in-process PII scrubbing
DataFogSentryIntegration registers a global event processor covering error events and transactions (messages, exception values, stack-frame local variables, breadcrumbs, request data, extra, tags, span descriptions), leaving the application's before_send slot free. A standalone scrub_event() is exported for manual before_send wiring. Same adapter conventions as the LiteLLM guardrail and Claude Code hook: high-precision default entity set (EMAIL, PHONE, CREDIT_CARD, SSN), allowlist / allowlist_patterns, fail_policy open|closed, and matched values never echoed into logs (counts only). Safety properties, each locked in by a regression test: - text beyond scan caps is replaced with [UNSCANNED_N_CHARS] markers, never emitted raw (a PII value straddling a cap boundary would otherwise reassemble unredacted) - tuples/sets are rebuilt as lists and scanned (parity with the Claude Code hook walker) - aliased containers are scanned once; cyclic structures terminate - the integration lookup in the global processor fails open so event delivery never breaks Ships as pip install datafog[sentry] (sentry-sdk 2.x).
1 parent 9df6980 commit 9a464fd

5 files changed

Lines changed: 775 additions & 0 deletions

File tree

CHANGELOG.MD

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,23 @@
22

33
## [Unreleased]
44

5+
### Added
6+
7+
- **Sentry SDK integration** (`datafog.integrations.sentry`): scrubs PII
8+
from Sentry events in-process, before they leave the machine. Add
9+
`DataFogSentryIntegration()` to `sentry_sdk.init(integrations=[...])`
10+
it registers a global event processor covering error events *and*
11+
transactions (messages, exception values, stack-frame local variables,
12+
breadcrumbs, request data, `extra`, tags, span descriptions), so the
13+
application's own `before_send` slot stays free. A standalone
14+
`scrub_event(event, hint)` is also exported for manual `before_send`
15+
wiring. Same configuration surface as the other adapters:
16+
`entity_types` (default `EMAIL`, `PHONE`, `CREDIT_CARD`, `SSN`),
17+
`allowlist` / `allowlist_patterns`, and `fail_policy` (`open` sends the
18+
event unscanned on engine error; `closed` drops it). Matched values are
19+
never echoed into logs — counts only. Install with
20+
`pip install datafog[sentry]` (any sentry-sdk 2.x).
21+
522
## [2026-07-05]
623

724
### `datafog-python` [4.8.0]

datafog/integrations/sentry.py

Lines changed: 322 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,322 @@
1+
"""Sentry SDK integration: scrub PII from events before they leave the process.
2+
3+
Usage::
4+
5+
import sentry_sdk
6+
from datafog.integrations.sentry import DataFogSentryIntegration
7+
8+
sentry_sdk.init(
9+
dsn="...",
10+
integrations=[DataFogSentryIntegration()],
11+
)
12+
13+
Or wire the scrubber into ``before_send`` directly::
14+
15+
sentry_sdk.init(dsn="...", before_send=scrub_event)
16+
17+
Behavior:
18+
19+
- The integration registers a *global event processor*, so it scrubs both
20+
error events and transactions (span descriptions and data ride inside
21+
transaction events) while leaving the single ``before_send`` slot free
22+
for the application's own callback.
23+
- Scrubbing is content-based: every string value in the event — messages,
24+
exception values, stack-frame local variables, breadcrumbs, request
25+
data, ``extra``, tags, span descriptions — is scanned and findings are
26+
replaced with ``[TYPE_N]`` tokens. This catches PII that Sentry's
27+
built-in ``EventScrubber`` misses, because that scrubber only matches
28+
sensitive *key names* and never inspects values.
29+
- ``fail_policy`` — ``open`` (default) sends the event unscrubbed if the
30+
engine errors, so a scrubber bug never costs you crash reports;
31+
``closed`` drops the event instead, for compliance deployments where
32+
unscanned egress is worse than a missing event.
33+
34+
Errors report entity type counts only — matched PII is never echoed into
35+
logs or exceptions.
36+
37+
Requires ``sentry-sdk`` >= 2.0 (this module is not imported by ``datafog``
38+
core; install with ``pip install datafog[sentry]`` or alongside your app's
39+
existing sentry-sdk).
40+
"""
41+
42+
import logging
43+
from typing import Any, Optional
44+
45+
import sentry_sdk
46+
from sentry_sdk.integrations import Integration
47+
48+
# High-precision defaults, matching the LiteLLM guardrail and Claude Code
49+
# hook adapters: noisy-in-practice types (IP_ADDRESS, DOB, ZIP) must be
50+
# opted into explicitly.
51+
DEFAULT_ENTITY_TYPES = ["EMAIL", "PHONE", "CREDIT_CARD", "SSN"]
52+
53+
VALID_FAIL_POLICIES = {"open", "closed"}
54+
55+
# Per-string and per-event scan caps, mirroring the Claude Code hook: a
56+
# pathological payload (huge repr in a frame var, giant request body) must
57+
# not stall event delivery. Text beyond a cap is REPLACED with an
58+
# [UNSCANNED_N_CHARS] marker, never sent raw: emitting an unscanned tail
59+
# would let a PII value that straddles the cut point reassemble unredacted,
60+
# and unscanned egress is the one thing this integration must not do. Both
61+
# caps sit far above Sentry's own event-size limits, so real events never
62+
# reach them.
63+
MAX_SCAN_CHARS = 1_000_000
64+
TOTAL_SCAN_CHARS = 8_000_000
65+
66+
# Top-level event fields that are machine identifiers, not free text.
67+
# Skipping them avoids mangling values that only *look* like PII (a release
68+
# tag containing an @, a server hostname) and matches what Sentry's own
69+
# EventScrubber leaves alone. The skip applies at the event's top level
70+
# only — a nested key that happens to be named "release" (e.g. inside
71+
# ``extra``) is still scanned.
72+
_SKIP_TOP_LEVEL_KEYS = frozenset(
73+
{
74+
"event_id",
75+
"timestamp",
76+
"start_timestamp",
77+
"received",
78+
"platform",
79+
"sdk",
80+
"modules",
81+
"release",
82+
"dist",
83+
"environment",
84+
"server_name",
85+
"type",
86+
"level",
87+
}
88+
)
89+
90+
logger = logging.getLogger(__name__)
91+
92+
93+
class _EventScrubber:
94+
"""Single-event scrub pass: walks the event, redacts string values.
95+
96+
Mutates containers in place — deliberate: the Sentry processor contract
97+
is mutate-and-return, and deep-copying every event would double memory
98+
while leaving an unredacted copy other processors could still see.
99+
100+
A fresh instance is created per event (see ``_scrub_with_policy``), so
101+
``counts``, ``_budget``, and ``_seen`` are never shared across events or
102+
threads. Do not hoist construction out of the per-event path: a reused
103+
instance would leak budget and alias state between events.
104+
"""
105+
106+
def __init__(
107+
self,
108+
entity_types: list[str],
109+
allowlist: Optional[list[str]],
110+
allowlist_patterns: Optional[list[str]],
111+
) -> None:
112+
self.entity_types = entity_types
113+
self.allowlist = allowlist
114+
self.allowlist_patterns = allowlist_patterns
115+
self.counts: dict[str, int] = {}
116+
self.unscanned_chars = 0
117+
self._budget = TOTAL_SCAN_CHARS
118+
self._seen: set[int] = set()
119+
120+
def _unscanned(self, length: int) -> str:
121+
self.unscanned_chars += length
122+
return f"[UNSCANNED_{length}_CHARS]"
123+
124+
def _redact_string(self, text: str) -> str:
125+
limit = min(MAX_SCAN_CHARS, self._budget)
126+
if limit <= 0:
127+
return self._unscanned(len(text))
128+
import datafog
129+
130+
chunk = text[:limit]
131+
self._budget -= len(chunk)
132+
result = datafog.redact(
133+
chunk,
134+
engine="regex",
135+
entity_types=self.entity_types,
136+
allowlist=self.allowlist,
137+
allowlist_patterns=self.allowlist_patterns,
138+
)
139+
for entity in result.entities:
140+
self.counts[entity.type] = self.counts.get(entity.type, 0) + 1
141+
tail = text[len(chunk) :]
142+
if tail:
143+
# Never reattach the unscanned tail raw: a PII value straddling
144+
# the cut point would fail to match in the truncated chunk and
145+
# reassemble unredacted in the output.
146+
return result.redacted_text + self._unscanned(len(tail))
147+
return result.redacted_text if result.entities else text
148+
149+
def _transform(self, value: Any, stack: list[Any]) -> Any:
150+
"""Redact a string, or queue a container for walking.
151+
152+
Tuples and sets are rebuilt as lists — the Sentry serializer emits
153+
all three as JSON arrays, and a list can be walked and mutated in
154+
place. ``_seen`` dedupes aliased containers, so a structure reachable
155+
from two places is scanned once (double-scanning would double-spend
156+
the budget) and cyclic structures terminate. Other value types
157+
(numbers, custom objects) pass through untouched: the SDK reprs
158+
custom objects only after processors run, so their contents are
159+
out of reach here.
160+
"""
161+
if isinstance(value, str):
162+
return self._redact_string(value)
163+
if isinstance(value, (tuple, set, frozenset)):
164+
value = list(value)
165+
if isinstance(value, (dict, list)) and id(value) not in self._seen:
166+
self._seen.add(id(value))
167+
stack.append(value)
168+
return value
169+
170+
def scrub(self, event: dict) -> dict:
171+
"""Redact every string value in the event.
172+
173+
Iterative (explicit stack), so adversarially deep structures in
174+
``extra`` or frame vars cannot trigger ``RecursionError`` and
175+
silently skip the scan. Dict *keys* are left alone, matching
176+
Sentry's EventScrubber.
177+
"""
178+
stack: list[Any] = []
179+
for key in list(event.keys()):
180+
if key in _SKIP_TOP_LEVEL_KEYS:
181+
continue
182+
new_value = self._transform(event[key], stack)
183+
if new_value is not event[key]:
184+
event[key] = new_value
185+
while stack:
186+
current = stack.pop()
187+
if isinstance(current, dict):
188+
for key in list(current.keys()):
189+
new_value = self._transform(current[key], stack)
190+
if new_value is not current[key]:
191+
current[key] = new_value
192+
else: # list — _transform only queues dicts and lists
193+
for index, value in enumerate(current):
194+
new_value = self._transform(value, stack)
195+
if new_value is not value:
196+
current[index] = new_value
197+
if self.unscanned_chars:
198+
logger.debug(
199+
"DataFog Sentry scrubber: %d chars beyond scan caps replaced "
200+
"with [UNSCANNED_N_CHARS] markers",
201+
self.unscanned_chars,
202+
)
203+
return event
204+
205+
206+
def _summary(counts: dict[str, int]) -> str:
207+
return ", ".join(f"{etype} x{n}" for etype, n in sorted(counts.items()))
208+
209+
210+
def _scrub_with_policy(
211+
event: dict,
212+
entity_types: list[str],
213+
allowlist: Optional[list[str]],
214+
allowlist_patterns: Optional[list[str]],
215+
fail_policy: str,
216+
) -> Optional[dict]:
217+
scrubber = _EventScrubber(entity_types, allowlist, allowlist_patterns)
218+
try:
219+
scrubbed = scrubber.scrub(event)
220+
except Exception as exc: # noqa: BLE001 — fail policy decides
221+
# Only the exception *type* is ever logged. Engine exception
222+
# messages can embed the text being scanned, so interpolating
223+
# str(exc) would leak the PII this integration exists to contain.
224+
if fail_policy == "closed":
225+
logger.warning(
226+
"DataFog Sentry scrubber failed and fail_policy is 'closed'; "
227+
"dropping event (%s).",
228+
type(exc).__name__,
229+
)
230+
return None
231+
logger.warning(
232+
"DataFog Sentry scrubber error (fail-open, event sent unscanned "
233+
"beyond partial redaction): %s",
234+
type(exc).__name__,
235+
)
236+
return event
237+
if scrubber.counts:
238+
logger.debug("DataFog Sentry scrubber redacted: %s", _summary(scrubber.counts))
239+
return scrubbed
240+
241+
242+
def scrub_event(
243+
event: dict,
244+
hint: Optional[dict] = None,
245+
*,
246+
entity_types: Optional[list[str]] = None,
247+
allowlist: Optional[list[str]] = None,
248+
allowlist_patterns: Optional[list[str]] = None,
249+
fail_policy: str = "open",
250+
) -> Optional[dict]:
251+
"""Scrub a Sentry event dict; drop-in ``before_send`` callback.
252+
253+
The two positional parameters match Sentry's ``before_send(event, hint)``
254+
signature so the function can be passed directly. The keyword-only
255+
options mirror :class:`DataFogSentryIntegration`; to preconfigure them,
256+
wrap in ``functools.partial`` or use the integration instead.
257+
"""
258+
if fail_policy not in VALID_FAIL_POLICIES:
259+
raise ValueError(f"fail_policy must be one of: {sorted(VALID_FAIL_POLICIES)}")
260+
return _scrub_with_policy(
261+
event,
262+
entity_types or DEFAULT_ENTITY_TYPES,
263+
allowlist,
264+
allowlist_patterns,
265+
fail_policy,
266+
)
267+
268+
269+
class DataFogSentryIntegration(Integration):
270+
"""Content-based PII scrubbing for the Sentry Python SDK."""
271+
272+
identifier = "datafog"
273+
274+
def __init__(
275+
self,
276+
entity_types: Optional[list[str]] = None,
277+
allowlist: Optional[list[str]] = None,
278+
allowlist_patterns: Optional[list[str]] = None,
279+
fail_policy: str = "open",
280+
) -> None:
281+
if fail_policy not in VALID_FAIL_POLICIES:
282+
raise ValueError(
283+
f"fail_policy must be one of: {sorted(VALID_FAIL_POLICIES)}"
284+
)
285+
self.entity_types = entity_types or DEFAULT_ENTITY_TYPES
286+
self.allowlist = allowlist
287+
self.allowlist_patterns = allowlist_patterns
288+
self.fail_policy = fail_policy
289+
290+
@staticmethod
291+
def setup_once() -> None:
292+
# Registered once per process. The processor resolves the integration
293+
# instance from the *current* client on every event, so config always
294+
# comes from the active ``sentry_sdk.init`` call and clients without
295+
# this integration are passed through untouched.
296+
from sentry_sdk.scope import add_global_event_processor
297+
298+
add_global_event_processor(_global_processor)
299+
300+
301+
def _global_processor(event: dict, hint: Optional[dict]) -> Optional[dict]:
302+
try:
303+
integration = sentry_sdk.get_client().get_integration(DataFogSentryIntegration)
304+
except Exception as exc: # noqa: BLE001 — never break event delivery
305+
# The lookup runs outside _scrub_with_policy's error handling and no
306+
# integration (hence no fail_policy) is resolvable here, so the only
307+
# safe default is to pass the event through.
308+
logger.warning(
309+
"DataFog Sentry integration lookup failed (event passed "
310+
"through unscanned): %s",
311+
type(exc).__name__,
312+
)
313+
return event
314+
if integration is None:
315+
return event
316+
return _scrub_with_policy(
317+
event,
318+
integration.entity_types,
319+
integration.allowlist,
320+
integration.allowlist_patterns,
321+
integration.fail_policy,
322+
)

0 commit comments

Comments
 (0)