🔒️(summary) redact meeting content from Sentry events

Sentry attaches stack frame locals to its events. When storing a
transcript in S3 failed, the full transcript held in `data` and
`transcript` was sent to Sentry.

Keep locals for debugging, but scrub variables and nested dict keys
known to hold transcripts, summaries, LLM prompts, participants'
personal data or pre-signed URLs. Share the Sentry init between the
API and the Celery worker, and never send request bodies.
This commit is contained in:
lebaudantoine
2026-10-01 19:46:22 +02:00
committed by aleb_the_flash
parent 3bf78f0f5b
commit 11e8470aa5
5 changed files with 309 additions and 9 deletions
+1
View File
@@ -21,6 +21,7 @@ and this project adheres to
- 🔒️(agents) upgrade libpcre2-8-0 to fix CVE-2026-103111
- 🔒️(frontend) upgrade pcre2 to fix CVE-2026-103111
- 🐛(summary) disable default S3 checksums for GCS-compatible storage
- 🔒️(summary) redact meeting content from Sentry events
## [1.33.0] - 2026-09-30
+5 -6
View File
@@ -9,7 +9,6 @@ from typing import Any
from urllib.parse import urljoin
import requests
import sentry_sdk
from celery import Celery, signals
from celery.utils.log import get_task_logger
from openai.types.audio import Transcription
@@ -42,6 +41,7 @@ from summary.core.prompt import (
PROMPT_SYSTEM_TLDR,
PROMPT_USER_PART,
)
from summary.core.sentry import init_sentry
from summary.core.shared_models import (
SummarizeWebhookFailurePayload,
SummarizeWebhookSuccessPayload,
@@ -75,12 +75,11 @@ celery = Celery(
celery.config_from_object("summary.core.celery_config")
if settings.sentry_dsn and settings.sentry_is_enabled:
@signals.celeryd_init.connect
def init_sentry(**_kwargs):
"""Initialize sentry."""
sentry_sdk.init(dsn=settings.sentry_dsn, enable_tracing=True)
@signals.celeryd_init.connect
def init_celery_sentry(**_kwargs):
"""Initialize Sentry in the Celery worker."""
init_sentry()
file_service = FileService()
+94
View File
@@ -0,0 +1,94 @@
"""Sentry configuration."""
import sentry_sdk
from sentry_sdk.scrubber import DEFAULT_DENYLIST, DEFAULT_PII_DENYLIST, EventScrubber
from summary.core.config import get_settings
# Exact names (case-insensitive) of variables and dict keys to redact.
SENSITIVE_DATA_DENYLIST = [
# Raw payloads and serialized bodies
"data",
"body",
"payload",
"args",
"kwargs",
"response",
"res",
# Transcripts
"transcript",
"transcription",
"transcription_json",
"transcription_res",
"new_transcription",
"segments",
"word_segments",
"words",
"text",
"content",
"formatted_output",
# Summaries and LLM exchanges
"summary",
"raw_summary",
"cleaned_summary",
"tldr",
"part",
"parts",
"parts_summarized",
"next_steps",
"title",
"titles",
"action",
"line",
"lines",
"user_prompt",
"prompt_user_part",
"messages",
"json_data", # OpenAI client internals
"opts",
"options",
"input_options",
# Participants' personal data
"email",
"user_email",
"assignees",
"participant_name",
"participant_names",
"participants_info",
"speaker_to_name",
# Signed / pre-authenticated URLs
"cloud_storage_url",
"transcription_data_url",
"summary_data_url",
]
def build_event_scrubber() -> EventScrubber:
"""Build the scrubber redacting meeting content and personal data."""
return EventScrubber(
denylist=DEFAULT_DENYLIST + SENSITIVE_DATA_DENYLIST,
pii_denylist=DEFAULT_PII_DENYLIST,
recursive=True,
)
def init_sentry() -> None:
"""Initialize Sentry if enabled in the settings."""
settings = get_settings()
if not settings.sentry_is_enabled:
return
if not settings.sentry_dsn:
return
sentry_sdk.init(
dsn=settings.sentry_dsn,
enable_tracing=True,
# Never attach request bodies, Celery task arguments or user data.
send_default_pii=False,
# Task creation requests carry the content to summarize.
max_request_body_size="never",
include_local_variables=True,
event_scrubber=build_event_scrubber(),
)
+2 -3
View File
@@ -1,18 +1,17 @@
"""Application."""
import sentry_sdk
from dockerflow.fastapi import router as dockerflow_router
from fastapi import FastAPI
from summary.api.main import api_router_v2
from summary.core import checks # noqa: F401 -- registers the Dockerflow checks
from summary.core.config import get_settings
from summary.core.sentry import init_sentry
settings = get_settings()
if settings.sentry_dsn and settings.sentry_is_enabled:
sentry_sdk.init(dsn=settings.sentry_dsn, enable_tracing=True)
init_sentry()
app = FastAPI(
title=settings.app_name,
+207
View File
@@ -0,0 +1,207 @@
"""Tests for the Sentry configuration.
Each test raises an error from code handling meeting content and inspects the
event Sentry would send: the content must be redacted, while harmless local
variables are kept for debugging.
"""
import json
from collections.abc import Callable, Iterator
from unittest.mock import Mock
import httpx
import openai
import pytest
import sentry_sdk
from botocore.exceptions import ClientError
from botocore.stub import Stubber
from sentry_sdk.transport import Transport
from summary.core import file_service
from summary.core import sentry as sentry_module
from summary.core.file_service import FileService
from summary.core.llm_service import LLMException, LLMService
from summary.core.shared_models import WhisperXResponse
CANARY = "CANARY-MEETING-CONTENT"
SentryEvents = Callable[[], list[str]]
class _CapturingTransport(Transport):
"""Keep serialized events in memory instead of sending them."""
def __init__(self):
super().__init__()
self.events: list[str] = []
def capture_envelope(self, envelope):
"""Store each serialized event of the envelope."""
for item in envelope.items:
if item.type == "event":
self.events.append(item.payload.get_bytes().decode())
@pytest.fixture
def sentry_events() -> Iterator[SentryEvents]:
"""Initialize Sentry as in production, with an in-memory transport."""
transport = _CapturingTransport()
sentry_sdk.init(
dsn="https://public@sentry.example.com/1",
transport=transport,
send_default_pii=False,
include_local_variables=True,
event_scrubber=sentry_module.build_event_scrubber(),
default_integrations=False,
)
def flush() -> list[str]:
sentry_sdk.flush()
return transport.events
yield flush
sentry_sdk.init() # Disable Sentry for the following tests
def _transcript() -> WhisperXResponse:
return WhisperXResponse.model_validate(
{
"segments": [
{
"start": 0.0,
"end": 1.0,
"text": f"I don't know {CANARY}",
"speaker": "SPEAKER_01",
"words": [
{
"word": CANARY,
"start": 0.0,
"end": 1.0,
"score": 0.9,
"speaker": "SPEAKER_01",
}
],
}
]
}
)
def _local_vars(event: str) -> list[dict]:
"""Return the local variables of every frame of the event."""
return [
frame.get("vars", {})
for exception in json.loads(event)["exception"]["values"]
for frame in exception["stacktrace"]["frames"]
]
@pytest.fixture
def s3_stubber(monkeypatch: pytest.MonkeyPatch) -> Iterator[Stubber]:
"""Stub the S3 client built by the file service."""
monkeypatch.setattr(
file_service,
"settings",
file_service.settings.model_copy(
update={
"aws_s3_endpoint_url": "garage:9000",
"aws_s3_secure_access": False,
"aws_s3_region_name": "fr-par",
"aws_storage_bucket_name": "meet-media-storage",
}
),
)
stubber = Stubber(file_service._build_s3_client())
stubber.activate()
monkeypatch.setattr(file_service, "_build_s3_client", lambda: stubber.client)
yield stubber
stubber.assert_no_pending_responses()
def test_sentry_store_transcript_failure_redacts_transcript(
sentry_events: SentryEvents, s3_stubber: Stubber
) -> None:
"""A failed S3 upload does not send the transcript, but keeps the job id."""
s3_stubber.add_client_error("put_object", service_error_code="InvalidDigest")
try:
FileService().store_transcript(transcript=_transcript(), job_id="job-1")
except ClientError:
sentry_sdk.capture_exception()
else:
pytest.fail("store_transcript should have failed")
[event] = sentry_events()
assert CANARY not in event
store_transcript_vars = next(
frame_vars
for frame_vars in _local_vars(event)
if "transcript_path" in frame_vars
)
assert store_transcript_vars["job_id"] == "'job-1'"
assert store_transcript_vars["transcript_path"] == "'transcripts/job-1.json'"
assert store_transcript_vars["data"] == "[Filtered]"
assert store_transcript_vars["transcript"] == "[Filtered]"
def test_sentry_llm_failure_redacts_prompts(sentry_events: SentryEvents) -> None:
"""A failed LLM call does not send the prompts, even from OpenAI internals."""
client = openai.OpenAI(
api_key="test-key",
base_url="https://llm.example.com/v1",
max_retries=0,
http_client=httpx.Client(
transport=httpx.MockTransport(lambda request: httpx.Response(500))
),
)
observability = Mock(is_enabled=False)
observability.get_openai_client.return_value = client
try:
LLMService(observability).call(
system_prompt="Summarize this meeting.",
user_prompt=f"Transcript: {CANARY}",
name="tldr",
)
except LLMException:
sentry_sdk.capture_exception()
else:
pytest.fail("the LLM call should have failed")
[event] = sentry_events()
assert CANARY not in event
def test_init_sentry_uses_the_event_scrubber(monkeypatch: pytest.MonkeyPatch) -> None:
"""Sentry is initialized with local variables and the content scrubber."""
settings = sentry_module.get_settings().model_copy(
update={"sentry_is_enabled": True, "sentry_dsn": "https://k@example.com/1"}
)
monkeypatch.setattr(sentry_module, "get_settings", lambda: settings)
init = Mock()
monkeypatch.setattr(sentry_module.sentry_sdk, "init", init)
sentry_module.init_sentry()
init.assert_called_once()
kwargs = init.call_args.kwargs
assert kwargs["send_default_pii"] is False
assert kwargs["max_request_body_size"] == "never"
assert kwargs["include_local_variables"] is True
denylist = kwargs["event_scrubber"].denylist
assert {"data", "transcript", "content", "summary", "password"} <= set(denylist)
def test_init_sentry_disabled(monkeypatch: pytest.MonkeyPatch) -> None:
"""Sentry is not initialized when disabled."""
settings = sentry_module.get_settings().model_copy(
update={"sentry_is_enabled": False, "sentry_dsn": "https://k@example.com/1"}
)
monkeypatch.setattr(sentry_module, "get_settings", lambda: settings)
init = Mock()
monkeypatch.setattr(sentry_module.sentry_sdk, "init", init)
sentry_module.init_sentry()
init.assert_not_called()