Skip to content

fix: clear the replay screenshot bitmap and skip failed draws - #999

Merged
ioannisj merged 3 commits into
mainfrom
fix/replay-renderer-clear-bitmap
Nov 2, 2026
Merged

ioannisj merged 3 commits into
mainfrom
fix/replay-renderer-clear-bitmap

Conversation

@ioannisj

Copy link
Copy Markdown
Contributor

💡 Motivation and Context

PostHogGraphicsImageRenderer (added in #578 to replace UIGraphicsImageRenderer on the screenshot hot path) allocates its bitmap with malloc and never clears it. Core Graphics only writes the pixels a draw covers, so a draw that fails or leaves part of the canvas untouched hands makeImage() whatever the heap held before, and that ends up in a $snapshot. Sentry's renderer, which ours is derived from, uses calloc; the port dropped that.

It reproduces on the simulator: the default upload resolution (one pixel per point, 402×874 on an iPhone 17 Pro) is a 1.4 MB buffer the allocator recycles, and an empty draw into it came back dirty in 30 of 30 runs. The native-scale 12.6 MB buffer came back clean every time because it comes from fresh zero pages.

I don't think anything actually leaked in practice. A normal capture of a visible, opaque window paints every pixel, so there's nothing left over to read. The exposure is limited to draws that fail or leave gaps: a drawHierarchy that returns false (hidden window, no window scene, nothing committed yet), transparent window regions, or source transparency in the mask and resample redraws in PostHog/Replay/RRWireframe.swift.

Two commits, one concern each:

  1. malloc becomes calloc, so undrawn pixels are transparent instead of leftover memory. No change to what gets masked.
  2. UIView.toImage ignored the Bool that drawHierarchy(in:afterScreenUpdates:) returns, so a failed draw still produced an image and renderAndEnqueueScreenshot uploaded it (before this, leftover memory; after commit 1 alone, a blank frame). It now returns nil, which the capture path already treats as "skip this tick". This matches posthog-android, where a PixelCopy failure leaves the screenshot discarded, and the fail-closed rule in the session-replay-privacy spec. In the example-app host drawHierarchy returns true on a visible window both on and off the main thread, so the background-capture mode is unaffected.

Cost of calloc on the simulator, median of 60 renders with no drawing (allocation plus makeImage()):

Bitmap malloc (today) calloc
1206×2622 (12.6 MB, native scale) 0.016 ms 0.016 ms
402×874 (1.4 MB, upload scale) 0.111 ms 0.175 ms

Large buffers come from fresh zero pages, so calloc is free there; the 1.4 MB case pays one memset of a recycled block. CGContext(data: nil, …) measured faster still (0.025 ms / 0.014 ms) because makeImage() can share the buffer instead of copying it, and came back zeroed, but that isn't a documented guarantee, so I left it as a follow-up rather than mixing it into a privacy fix.

Worth a follow-up to add an app-hosted test that drives captureBridgeSnapshot to a successful upload; the hostless runner can't, since drawHierarchy has no window scene there.

💚 How did you test it?

  • New PostHogGraphicsImageRendererTest: an empty draw and a partial draw at the default upload size must come back fully transparent outside the drawn area. Both fail on main in 3 of 3 runs and pass with the fix in 3 of 3 runs (iOS 26.5 simulator).
  • New PostHogScreenshotFailedDrawTest: toImage on a window drawHierarchy can't render returns nil through both drawHierarchy paths, and still returns an image through the presentation-tree renderer.
  • Three hostless tests asserted on frames from a drawHierarchy call that always fails in that runner, so they were asserting on blank frames. They now observe the same behaviour through the presentation-tree renderer, and the touch-capture emission test hands its frame to captureSnapshot directly, as the fail-closed suite already does.
  • Full iOS simulator suite: 1629 tests in 229 suites pass (iPhone 17 Pro, iOS 26.5).
  • Mask goldens pass on an iOS 26.2 simulator, so no masked pixel changed. Run with Xcode 27.1 rather than the pinned 26.3, which isn't installed here.
  • App-hosted presentation masking suites: 31 tests in 2 suites pass.
  • SPM suite: 1158 tests in 177 suites; the only failures are the pre-existing PostHogStorageTest app-group cases.
  • make lint is clean for the changed files. make buildSdk passes the iOS, macOS and Mac Catalyst lanes under Xcode 26.2; the tvOS and watchOS lanes need platform components this machine doesn't have, and the change is #if os(iOS) only.

📝 Checklist

  • All commits are signed (see Commit signing)
  • I reviewed the submitted code.
  • I added tests to verify the changes.
  • I updated the docs if needed.
  • No breaking change or entry added to the changelog.

If releasing new changes

  • Ran pnpm changeset to generate a changeset file

🤖 Agent context

DRI: @ioannisj
Autonomy: Human-driven (agent-assisted)

@ioannisj ioannisj self-assigned this Oct 30, 2026

@posthog posthog Bot 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.

PostHog Review · Standard · reviewed 42d957f: 1 Consider.

This is the only automatic review while this pull request is a draft. Comment @posthog review to review new changes sooner. Otherwise, a review of the new changes runs when you mark it ready for review.

See this review and its findings in PostHog Review. Comment @posthog review --deep for a Deep review. A Deep review takes longer and looks deeper than a Standard review.

Comment thread PostHog/Replay/UIView+Util.swift Resolved
@github-actions

github-actions Bot commented Oct 30, 2026 •

Copy link
Copy Markdown
Contributor

posthog-ios Compliance Report

Date: 2026-10-30T10:35:17.651791+00:00
Duration: 242626ms

⚠️ Some Tests Failed

46/47 tests passed, 1 failed


Capture Tests

⚠️ 29/30 tests passed, 1 failed

View Details
Test Status Duration
Format Validation.Event Has Required Fields ✅ 3540ms
Format Validation.Event Has Uuid ✅ 3177ms
Format Validation.Event Has Lib Properties ✅ 2813ms
Format Validation.Distinct Id Is String ✅ 2878ms
Format Validation.Token Is Present ✅ 2903ms
Format Validation.Custom Properties Preserved ✅ 2818ms
Format Validation.Event Has Timestamp ✅ 2838ms
Format Validation.Non Utc Event Timestamp Is Converted To Utc ✅ 370ms
Retry Behavior.Retries On 503 ✅ 11856ms
Retry Behavior.Does Not Retry On 400 ✅ 4859ms
Retry Behavior.Does Not Retry On 401 ✅ 4947ms
Retry Behavior.Respects Retry After Header ✅ 7942ms
Retry Behavior.Implements Backoff ✅ 21892ms
Retry Behavior.Retries On 500 ✅ 9364ms
Retry Behavior.Retries On 502 ✅ 9180ms
Retry Behavior.Retries On 504 ✅ 9314ms
Retry Behavior.Max Retries Respected ❌ 21773ms
Deduplication.Generates Unique Uuids ✅ 2978ms
Deduplication.Preserves Uuid On Retry ✅ 9201ms
Deduplication.Preserves Uuid And Timestamp On Retry ✅ 16791ms
Deduplication.Preserves Uuid And Timestamp On Batch Retry ✅ 7880ms
Deduplication.No Duplicate Events In Batch ✅ 2962ms
Deduplication.Different Events Have Different Uuids ✅ 472ms
Compression.Sends Gzip When Enabled ✅ 2913ms
Batch Format.Uses Proper Batch Structure ✅ 2788ms
Batch Format.Flush With No Events Sends Nothing ✅ 274ms
Batch Format.Multiple Events Batched Together ✅ 2947ms
Error Handling.Does Not Retry On 403 ✅ 4787ms
Error Handling.Does Not Retry On 413 ✅ 4844ms
Error Handling.Retries On 408 ✅ 9383ms

Failures

retry_behavior.max_retries_respected

Expected 4 requests, got 6

Feature_Flags Tests

✅ 17/17 tests passed

View Details
Test Status Duration
Request Payload.Request With Person Properties Device Id ✅ 2792ms
Request Payload.Flags Request Uses V2 Query Param ✅ 2838ms
Request Payload.Flags Request Hits Flags Path Not Decide ✅ 2871ms
Request Payload.Flags Request Omits Authorization Header ✅ 2883ms
Request Payload.Token In Flags Body Matches Init ✅ 2850ms
Request Payload.Groups Round Trip ✅ 2899ms
Request Payload.Groups Default To Empty Object ✅ 2899ms
Request Payload.Disable Geoip False Propagates As Geoip Disable False ✅ 2866ms
Request Payload.Disable Geoip Omitted Defaults To False ✅ 2921ms
Request Payload.Flag Keys To Evaluate Contains Only Requested Key ✅ 2922ms
Request Lifecycle.No Flags Request On Init Alone ✅ 91ms
Request Lifecycle.No Flags Request On Normal Capture ✅ 2830ms
Request Lifecycle.Two Flag Calls Produce Two Remote Requests ✅ 5604ms
Request Lifecycle.Mock Response Value Is Returned To Caller ✅ 2774ms
Retry Behavior.Retries Flags On 502 ✅ 2985ms
Retry Behavior.Retries Flags On 504 ✅ 3007ms
Side Effect Events.Get Feature Flag Captures Feature Flag Called Event ✅ 3089ms

@ioannisj
ioannisj marked this pull request as ready for review October 30, 2026 10:23
@ioannisj
ioannisj requested a review from a team as a code owner October 30, 2026 10:23

@dustinbyrne dustinbyrne 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.

The bitmap initialization and failed-draw handling look correct, including the distinction between attempted and deferred rendering. No actionable findings.

AI-assisted review.

@ioannisj
ioannisj merged commit 23cf425 into main Nov 2, 2026
55 checks passed
@ioannisj
ioannisj deleted the fix/replay-renderer-clear-bitmap branch November 2, 2026 19:56
turnipdabeets added a commit that referenced this pull request Nov 10, 2026
Conflict resolutions:
- UIView+Util toImage: keep the renderer switch and record drawHierarchy's result in both drawHierarchy branches, so #999's failed-draw skip applies to the fidelity and bridge renders.
- Replay integration: becoming active wakes the capture backoff (#896), then resumes plugins and prewarms GPU capture; collectScreenshotMetadata keeps the renderer API and skips a tick whose attempted render failed (#999).
- Tests: touch tests use the shared ReplayTestSupport helpers with main's new cases, PresentingController, makeWindow and the maskAllTextInputs parameter; the screenshot size test draws only the presentation-tree renderer, since failed drawHierarchy renders are now dropped in the hostless bundle.

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