Skip to content

fix: upload default replay screenshots at one size whether or not they are masked - #967

Merged
ioannisj merged 1 commit into
mainfrom
fix/replay-screenshot-consistent-size
Oct 28, 2026
Merged

ioannisj merged 1 commit into
mainfrom
fix/replay-screenshot-consistent-size

Conversation

@ioannisj

Copy link
Copy Markdown
Contributor

💡 Motivation and Context

In screenshot mode, the size of an uploaded frame depended on whether it had masks. UIView.toImage renders at the screen's native scale, and only the mask pass in RRWireframe.maskImage redrew the image at scale 1. So on a 3x iPhone an unmasked frame went up at 1206x2622 and a masked one at 402x874. Nobody picked that on purpose, it just fell out of where the redraw happened.

Every screenshot frame now goes through the same scale-1 redraw before encoding, masks or not, so it always uploads at the window size in points. No new config.

The capture itself doesn't change: drawHierarchy still renders at native scale on the main thread. The resize happens in RRWireframe.toDict, which runs on the replay dispatch queue, with the same .none interpolation the mask pass already used. So unmasked frames now look exactly like masked frames already did. Wireframe-mode images are left alone, the redraw only applies to screenshot wireframes.

Cross-SDK:

  • posthog-android also uploads one size whether or not a frame is masked (PixelCopy fills a bitmap sized view.width/height * screenshotScale and paints masks onto it). Its default size is different though: screenshotScale defaults to 1.0, which is full physical pixels, while iOS uploads at points.
  • posthog-js records the DOM through rrweb, so there's no screenshot size to match.
  • sdk-specs: session-replay-privacy covers when masks get painted but says nothing about screenshot size, so this doesn't diverge from any spec.

💚 How did you test it?

New suite PostHogReplayScreenshotSizeTest (Swift Testing, iOS simulator):

  • an unmasked frame uploads at one pixel per point from every renderer (render(in:), drawHierarchy, afterScreenUpdates)
  • a masked frame uploads at the unmasked size, with the mask on the same pixels where the unmasked frame shows the secret and the pixels around it untouched
  • a wireframe-mode image keeps its own size

The first two fail on main and pass here. The last one guards the scope, so it passes on both.

Real pipeline (toImage → toDict → WebP) with the example app as test host, iPhone 17 Pro simulator, a 402x874 settings-style list:

main this PR
unmasked upload 1206x2622, 77,766 B 402x874, 25,456 B
masked upload 402x874, 23,700 B 402x874, 23,700 B

Masked frames come out the same size in bytes as before. Encoding an unmasked frame on the replay queue also gets a lot cheaper, since WebP now gets a ninth of the pixels.

I also ran it on a physical iPhone 17 Pro against a local mock server, with a screen that switches every 3 s between nothing maskable and a manual mask + text field + image:

main this PR
unmasked frames 1206x2622 402x874
masked frames 402x874 402x874, same size in bytes as main

All 29 frames from the PR build uploaded and decoded, and in the masked ones the manual mask, the text field and the image are all covered.

Full iOS simulator suite (1,603 tests) and the hosted presentation-masking suites (31 tests) pass, plus make lint and make buildSdk for iOS, macOS and Mac Catalyst.

📝 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 27, 2026
@ioannisj
ioannisj marked this pull request as ready for review October 27, 2026 11:50
@ioannisj
ioannisj requested a review from a team as a code owner October 27, 2026 11:50
@github-actions

Copy link
Copy Markdown
Contributor

posthog-ios Compliance Report

Date: 2026-10-27T11:54:01.274652+00:00
Duration: 231722ms

⚠️ 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 ✅ 3001ms
Format Validation.Event Has Uuid ✅ 2856ms
Format Validation.Event Has Lib Properties ✅ 2930ms
Format Validation.Distinct Id Is String ✅ 2946ms
Format Validation.Token Is Present ✅ 428ms
Format Validation.Custom Properties Preserved ✅ 2867ms
Format Validation.Event Has Timestamp ✅ 2841ms
Format Validation.Non Utc Event Timestamp Is Converted To Utc ✅ 2919ms
Retry Behavior.Retries On 503 ✅ 11956ms
Retry Behavior.Does Not Retry On 400 ✅ 4922ms
Retry Behavior.Does Not Retry On 401 ✅ 2471ms
Retry Behavior.Respects Retry After Header ✅ 7962ms
Retry Behavior.Implements Backoff ✅ 15419ms
Retry Behavior.Retries On 500 ✅ 5528ms
Retry Behavior.Retries On 502 ✅ 9239ms
Retry Behavior.Retries On 504 ✅ 9189ms
Retry Behavior.Max Retries Respected ❌ 21406ms
Deduplication.Generates Unique Uuids ✅ 3279ms
Deduplication.Preserves Uuid On Retry ✅ 9270ms
Deduplication.Preserves Uuid And Timestamp On Retry ✅ 16240ms
Deduplication.Preserves Uuid And Timestamp On Batch Retry ✅ 7895ms
Deduplication.No Duplicate Events In Batch ✅ 3094ms
Deduplication.Different Events Have Different Uuids ✅ 3383ms
Compression.Sends Gzip When Enabled ✅ 2785ms
Batch Format.Uses Proper Batch Structure ✅ 513ms
Batch Format.Flush With No Events Sends Nothing ✅ 296ms
Batch Format.Multiple Events Batched Together ✅ 2962ms
Error Handling.Does Not Retry On 403 ✅ 4823ms
Error Handling.Does Not Retry On 413 ✅ 4790ms
Error Handling.Retries On 408 ✅ 9232ms

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 ✅ 2820ms
Request Payload.Flags Request Uses V2 Query Param ✅ 2828ms
Request Payload.Flags Request Hits Flags Path Not Decide ✅ 2998ms
Request Payload.Flags Request Omits Authorization Header ✅ 2906ms
Request Payload.Token In Flags Body Matches Init ✅ 2958ms
Request Payload.Groups Round Trip ✅ 2912ms
Request Payload.Groups Default To Empty Object ✅ 2987ms
Request Payload.Disable Geoip False Propagates As Geoip Disable False ✅ 2967ms
Request Payload.Disable Geoip Omitted Defaults To False ✅ 2841ms
Request Payload.Flag Keys To Evaluate Contains Only Requested Key ✅ 2926ms
Request Lifecycle.No Flags Request On Init Alone ✅ 90ms
Request Lifecycle.No Flags Request On Normal Capture ✅ 2747ms
Request Lifecycle.Two Flag Calls Produce Two Remote Requests ✅ 5868ms
Request Lifecycle.Mock Response Value Is Returned To Caller ✅ 3505ms
Retry Behavior.Retries Flags On 502 ✅ 2993ms
Retry Behavior.Retries Flags On 504 ✅ 3176ms
Side Effect Events.Get Feature Flag Captures Feature Flag Called Event ✅ 3183ms

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