fix(aio): never drop or leak multimodal content in ai capture - #762
Conversation
|
| ) | ||
| if ( | ||
| item.get("type") == "image_url" | ||
| and isinstance(item.get("image_url"), dict) | ||
| and "url" in item["image_url"] | ||
| strong | ||
| and len(value) >= _STRONG_CONTEXT_MIN_LEN | ||
| and _BASE64_BODY_RE.match(value) | ||
| ): |
There was a problem hiding this comment.
Short media bypasses redaction
When multimodal capture is disabled and a recognized media field contains valid raw base64 shorter than 200 characters, this length check leaves the value unchanged, causing raw Anthropic, Gemini, or OpenAI media data to be included in the captured AI event.
Knowledge Base Used: AI Observability (posthog/ai)
Prompt To Fix With AI
This is a comment left during a code review.
Path: posthog/ai/sanitization.py
Line: 121-126
Comment:
**Short media bypasses redaction**
When multimodal capture is disabled and a recognized media field contains valid raw base64 shorter than 200 characters, this length check leaves the value unchanged, causing raw Anthropic, Gemini, or OpenAI media data to be included in the captured AI event.
**Knowledge Base Used:** [AI Observability (posthog/ai)](https://app.greptile.com/posthog-org-19734/-/custom-context/knowledge-base/posthog/posthog-python/-/docs/ai-observability.md)
How can I resolve this? If you propose a fix, please make it concise.There was a problem hiding this comment.
Yeah how did you choose 200?
There was a problem hiding this comment.
Honestly, 200 is somewhat arbitrary — it's a heuristic to avoid false positives, not a number derived from any specific minimum media size.
The reasoning is:
- Common non-media strings that happen to match the base64 charset (UUIDs at ~36 chars, SHA-256 hashes at 64 chars, short JWT segments, API keys) are typically well under 200 characters.
- Real base64-encoded media is almost always orders of magnitude larger (even a 1×1 PNG is ~100 bytes → ~136 base64 chars, but any meaningful image is thousands).
The old code used len(text) > 20, which was too aggressive and caused over-redaction of legitimate tokens (one of the bugs mentioned in the PR description). 200 is a more conservative boundary.
The weak point of this choice is that it's not grounded in an actual lower bound for media payloads — it's "big enough to not be a normal string, small enough to not miss real media." If you want a tighter guarantee, it could be raised (e.g. to 500 or even 1000), since no real image/audio content is going to be under 500 base64 chars. But the current value is defensible as a conservative heuristic.
Tip: You can customize Greptile's behavior for this repo with .greptile/rules.md and .greptile/config.json.
There was a problem hiding this comment.
It's grounded on sampling from actual data and also makes no sense cost-wise to go through S3 for smaller blobs
posthog-python Compliance ReportDate: 2026-07-27 14:07:24 UTC ✅ All Tests Passed!111/111 tests passed Capture_V1 Tests✅ 94/94 tests passed View Details
Feature_Flags Tests✅ 17/17 tests passed View Details
|
| @@ -0,0 +1,5 @@ | |||
| --- | |||
| pypi/posthog: minor | |||
There was a problem hiding this comment.
title is a fix, and here is a minor, do we fix the changeset or the title?
|
looks like something that @PostHog/team-ai-observability should stamp? feels like very product specific, sdk changes is ok |
| ) | ||
| if ( | ||
| item.get("type") == "image_url" | ||
| and isinstance(item.get("image_url"), dict) | ||
| and "url" in item["image_url"] | ||
| strong | ||
| and len(value) >= _STRONG_CONTEXT_MIN_LEN | ||
| and _BASE64_BODY_RE.match(value) | ||
| ): |
There was a problem hiding this comment.
Yeah how did you choose 200?
Generated-By: PostHog Code Task-Id: b2e1882a-162c-41be-8e8f-7e76772fb1ae
💡 Motivation and Context
AI capture silently dropped or mangled content, and base64 redaction failed in both directions.
Dropped/mangled:
tool_calls, Responses API output items (streaming and non-streaming), and image-generation outputs were lost or given wrong type labels.ChatCompletionMessageback intomessagesraised.Redaction:
Fix routes every AI content property through one choke point (
finalize_ai_content→redact_media) with structural, media-type-aware redaction, and normalizes typed SDK objects to plain dicts so nothing collapses to a repr string.💚 How did you test it?
New tests added with the change:
test_capture_contract.py,test_capture_pipeline.py— end-to-end capture behavior across providers and streaming/non-streaming paths.test_openai_converter.py,test_gemini_converter.py,test_anthropic_converter.py,test_processor_content.py) andtest_media.py.test_sanitization.pyfor the structural redactor (leak paths, over-redaction, media-specific placeholders).ruff formatandruff checkclean on all touched files.📝 Checklist
If releasing new changes
sampo addto generate a changeset file🤖 Agent context
Autonomy: Human-driven (agent-assisted) — Carlos is DRI.
The multimodal capture fix was human-authored. Claude Code (Opus 4.8) did a final cleanup pass: stripped the inline comments and newly-added docstrings this change introduced, keeping only those that guard against a specific breakage (ordering constraints, the base64-leak boundary, the single-choke-point invariant, and the back-compat wrappers/param that look deletable), and tightened the changelog entry to one sentence. Comments and docstrings that pre-existed or that the change only updated for accuracy were left alone.