Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/eager-ships-hug.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"posthog-ios": minor
---

Add an experimental `sessionReplayConfig.screenshotScale` option that sets session replay screenshot resolution as a fraction of the screen's native resolution. Left unset, screenshots stay at one pixel per point.
5 changes: 5 additions & 0 deletions .changeset/tame-rules-roll.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"posthog-ios": patch
---

Keep small text readable in downsized session replay screenshots by resampling them with smoothing instead of nearest-neighbour.
21 changes: 16 additions & 5 deletions PostHog/Replay/PostHogGraphicsImageRenderer.swift
Original file line number Diff line number Diff line change
Expand Up @@ -17,15 +17,24 @@
final class PostHogGraphicsImageRenderer {
private let size: CGSize
private let scale: CGFloat
private let roundingUp: Bool

init(size: CGSize, scale: CGFloat) {
/// `roundingUp` rounds the pixel size up instead of down, and stretches `size` over every pixel so none
/// is left undrawn.
init(size: CGSize, scale: CGFloat, roundingUp: Bool = false) {
self.size = size
self.scale = scale
self.roundingUp = roundingUp
}

private func pixels(_ points: CGFloat) -> Int {
// The tolerance keeps float error, such as 100 × 0.30000000000000004, from adding a pixel.
roundingUp ? Int((points * scale - 0.001).rounded(.up)) : Int(points * scale)
}

func image(actions: (CGContext) -> Void) -> UIImage? {
let pixelsPerRow = Int(size.width * scale)
let pixelsPerColumn = Int(size.height * scale)
let pixelsPerRow = pixels(size.width)
let pixelsPerColumn = pixels(size.height)
let bytesPerPixel = 4
let bytesPerRow = bytesPerPixel * pixelsPerRow
let bitsPerComponent = 8
Expand Down Expand Up @@ -53,8 +62,10 @@
}

// UIKit's coordinate system is flipped relative to CoreGraphics.
context.translateBy(x: 0, y: size.height * scale)
context.scaleBy(x: scale, y: -scale)
let scaleX = roundingUp ? CGFloat(pixelsPerRow) / size.width : scale
let scaleY = roundingUp ? CGFloat(pixelsPerColumn) / size.height : scale
context.translateBy(x: 0, y: size.height * scaleY)
context.scaleBy(x: scaleX, y: -scaleY)

UIGraphicsPushContext(context)
actions(context)
Expand Down
1 change: 1 addition & 0 deletions PostHog/Replay/PostHogReplayIntegration.swift
Original file line number Diff line number Diff line change
Expand Up @@ -1185,6 +1185,7 @@

let wireframe = createBasicWireframe(window)
wireframe.maskableWidgets = maskableWidgets
wireframe.screenshotPixelScale = config?.sessionReplayConfig.screenshotPixelScale(nativeScale: window.screen.scale) ?? 1
wireframe.type = "screenshot"
return wireframe
}
Expand Down
23 changes: 23 additions & 0 deletions PostHog/Replay/PostHogSessionReplayConfig.swift
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,29 @@
/// Default: false
@objc public var screenshotModeBackgroundCapture: Bool = false

/// Resolution of `screenshotMode` screenshots, as a multiplier of the screen's native resolution.
///
/// - `nil`: one pixel per point on every device, so a 402×874 pt screen uploads 402×874 pixels.
/// - A value: that fraction of native resolution. On a 3x device, `1.0` uploads 1206×2622 pixels
/// (native) and `0.5` uploads 603×1311 (1.5 pixels per point).
///
/// Values are clamped to 0.1...1.0, and NaN or infinite values reset it to `nil`. The replay's viewport in
/// points is unchanged. Does not affect wireframe mode or compression quality. Exposed as `NSNumber` so
/// Objective-C can leave it unset.
///
/// Experimental.
/// Default: nil (one pixel per point)
@objc public var screenshotScale: NSNumber? {
didSet {
guard let value = screenshotScale?.doubleValue else { return }
screenshotScale = value.isFinite ? NSNumber(value: min(max(value, 0.1), 1)) : nil
}
}

func screenshotPixelScale(nativeScale: CGFloat) -> CGFloat {
screenshotScale.map { nativeScale * CGFloat($0.doubleValue) } ?? 1
}

/// Debouncer delay used to reduce the number of snapshots captured and reduce performance impact
/// This is used for capturing the view as a wireframe or screenshot
/// The lower the number more snapshots will be captured but higher the performance impact
Expand Down
50 changes: 32 additions & 18 deletions PostHog/Replay/RRWireframe.swift
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ class RRWireframe {
#if os(iOS)
var image: UIImage?
var maskableWidgets: [CGRect]?
var screenshotPixelScale: CGFloat = 1
/// Set by `toDict()` when mask rects were collected but the redacted image could not
/// be rendered. The caller must drop the frame: the wireframe carries no image, and
/// the raw screenshot would show masked content.
Expand Down Expand Up @@ -52,28 +53,42 @@ class RRWireframe {
guard hasMaskableWidgets(), let image else {
return nil
}
return RRWireframe.maskImage(image, maskableWidgets: maskableWidgets ?? [])
return RRWireframe.maskImage(image, maskableWidgets: maskableWidgets ?? [], pixelScale: screenshotPixelScale)
}

// Shared so tests can redact through the exact production path instead of reimplementing it.
static func maskImage(_ image: UIImage, maskableWidgets: [CGRect]) -> UIImage? {
static func maskImage(_ image: UIImage, maskableWidgets: [CGRect], pixelScale: CGFloat = 1) -> UIImage? {
guard !maskableWidgets.isEmpty else { return nil }
return redrawAtOnePixelPerPoint(image, maskableWidgets: maskableWidgets)
return autoreleasepool {
// Mask at native resolution so resampling can't blend what a mask covers into the output.
paintMasks(maskableWidgets, on: image).flatMap { resample($0, pixelScale: pixelScale) }
}
}

/// Screenshots render at native scale on main; this downscale runs at encode time, off main,
/// so every screenshot uploads at the same size whether or not it is masked.
private static func redrawAtOnePixelPerPoint(_ image: UIImage, maskableWidgets: [CGRect] = []) -> UIImage? {
autoreleasepool {
let renderer = PostHogGraphicsImageRenderer(size: image.size, scale: 1)
return renderer.image { context in
context.interpolationQuality = .none
image.draw(at: .zero)

for rect in maskableWidgets {
UIColor.black.setFill()
UIRectFill(rect)
}
/// Nothing to hide, so unlike masking a failed resample can fall back to the native image.
static func unmaskedScreenshot(_ image: UIImage, pixelScale: CGFloat) -> UIImage {
autoreleasepool { resample(image, pixelScale: pixelScale) ?? image }
}

private static func resample(_ image: UIImage, pixelScale: CGFloat) -> UIImage? {
// Already at the upload resolution, so resampling would only cost time.
guard pixelScale < image.scale else { return image }
return PostHogGraphicsImageRenderer(size: image.size, scale: pixelScale, roundingUp: true).image { context in
// Nearest-neighbour drops thin glyph stems when downsampling.
context.interpolationQuality = .medium
image.draw(at: .zero)
}
}

private static func paintMasks(_ masks: [CGRect], on image: UIImage) -> UIImage? {
PostHogGraphicsImageRenderer(size: image.size, scale: image.scale, roundingUp: true).image { context in
image.draw(at: .zero)
// Whole pixels, so a mask edge that falls mid-pixel can't leave part of it showing.
let toPixels = context.userSpaceToDeviceSpaceTransform
let toPoints = toPixels.inverted()
UIColor.black.setFill()
for rect in masks {
UIRectFill(rect.applying(toPixels).integral.applying(toPoints))
}
}
}
Expand Down Expand Up @@ -115,8 +130,7 @@ class RRWireframe {
maskRenderFailed = true
}
} else if type == "screenshot" {
// Nothing to hide, so unlike masking a failed redraw can fall back to the native image.
base64 = (RRWireframe.redrawAtOnePixelPerPoint(image) ?? image).toBase64()
base64 = RRWireframe.unmaskedScreenshot(image, pixelScale: screenshotPixelScale).toBase64()
} else {
base64 = image.toBase64()
}
Expand Down
6 changes: 3 additions & 3 deletions PostHogTests/PostHogMaskFailClosedTest.swift
Original file line number Diff line number Diff line change
Expand Up @@ -92,15 +92,15 @@

// MARK: - Mask render failure

/// An image whose pixel size rounds to zero, so `PostHogGraphicsImageRenderer`
/// returns nil exactly as it does when the full-frame malloc or the CGContext fails.
/// An image whose pixel size rounds to zero even when rounding up (1/10,000 pt), so
/// `PostHogGraphicsImageRenderer` returns nil exactly as it does when the full-frame malloc or the CGContext fails.
private func makeUnrenderableImage() -> UIImage {
let pixel = CGContext(
data: nil, width: 1, height: 1, bitsPerComponent: 8, bytesPerRow: 4,
space: CGColorSpaceCreateDeviceRGB(),
bitmapInfo: CGImageAlphaInfo.premultipliedLast.rawValue
)!
return UIImage(cgImage: pixel.makeImage()!, scale: 8, orientation: .up)
return UIImage(cgImage: pixel.makeImage()!, scale: 10000, orientation: .up)
}

private func makeRenderableImage() -> UIImage {
Expand Down
153 changes: 152 additions & 1 deletion PostHogTests/PostHogReplayScreenshotSizeTest.swift
Original file line number Diff line number Diff line change
Expand Up @@ -23,14 +23,30 @@
return window
}

private func uploaded(_ image: UIImage, masks: [CGRect]) throws -> CGImage {
private func uploaded(_ image: UIImage, masks: [CGRect], pixelScale: CGFloat = 1) throws -> CGImage {
let wireframe = RRWireframe()
wireframe.type = "screenshot"
wireframe.image = image
wireframe.maskableWidgets = masks
wireframe.screenshotPixelScale = pixelScale
return try decode(wireframe)
}

private func pixelScale(screenshotScale: NSNumber?, on window: UIWindow) -> CGFloat {
let config = PostHogSessionReplayConfig()
config.screenshotScale = screenshotScale
return config.screenshotPixelScale(nativeScale: window.screen.scale)
}

private func solidImage(_ color: UIColor, size: CGSize, scale: CGFloat) -> UIImage {
let format = UIGraphicsImageRendererFormat()
format.scale = scale
return UIGraphicsImageRenderer(size: size, format: format).image { context in
color.setFill()
context.fill(CGRect(origin: .zero, size: size))
}
}

private func decode(_ wireframe: RRWireframe) throws -> CGImage {
let base64 = try #require(wireframe.toDict()["base64"] as? String)
let encoded = try #require(base64.split(separator: ",").last.map(String.init))
Expand Down Expand Up @@ -118,5 +134,140 @@
#expect(try isWhite(rgb(masked, at: point)), "masked frame at \(point)")
}
}

@Test("an unset screenshotScale uploads one pixel per point")
func nilScaleUploadsPointSize() throws {
let window = try window()
defer { window.isHidden = true }
let image = try #require(window.toImage(preferFidelityRenderer: false))
let scale = pixelScale(screenshotScale: nil, on: window)
#expect(scale == 1)
#expect(try pixelSize(uploaded(image, masks: [], pixelScale: scale)) == window.bounds.size)
#expect(try pixelSize(uploaded(image, masks: [Self.secretRect], pixelScale: scale)) == window.bounds.size)
}

@Test("screenshotScale uploads that fraction of native resolution, masked or not", arguments: [0.5, 1.0])
func explicitScaleUploadsFractionOfNative(screenshotScale: Double) throws {
let window = try window()
defer { window.isHidden = true }
let image = try #require(window.toImage(preferFidelityRenderer: false))
let scale = pixelScale(screenshotScale: NSNumber(value: screenshotScale), on: window)
#expect(scale == window.screen.scale * CGFloat(screenshotScale))

let expected = CGSize(width: (window.bounds.width * scale).rounded(.up),
height: (window.bounds.height * scale).rounded(.up))
#expect(try pixelSize(uploaded(image, masks: [], pixelScale: scale)) == expected)
#expect(try pixelSize(uploaded(image, masks: [Self.secretRect], pixelScale: scale)) == expected)
}

@Test("screenshotScale is clamped to 0.1...1, and NaN or infinity unset it",
arguments: [(0.7, 0.7), (0.01, 0.1), (-1, 0.1), (5, 1), (.nan, nil), (.infinity, nil), (-.infinity, nil)] as [(Double, Double?)])
func screenshotScaleIsClamped(value: Double, expected: Double?) {
let config = PostHogSessionReplayConfig()
config.screenshotScale = NSNumber(value: value)
#expect(config.screenshotScale?.doubleValue == expected)
}

@Test("a fractional output size rounds up and leaves no pixel undrawn")
func fractionalSizeRoundsUp() throws {
let image = solidImage(.white, size: CGSize(width: 201, height: 301), scale: 3)
let uploaded = try uploaded(image, masks: [], pixelScale: 1.5)
#expect(pixelSize(uploaded) == CGSize(width: 302, height: 452))
#expect(try isWhite(rgb(uploaded, at: CGPoint(x: 301, y: 451))))
}

@Test("a mask lands on the secret's pixels at a non-1x scale")
func maskLandsAtNonUnitScale() throws {
let window = try window()
defer { window.isHidden = true }
let image = try #require(window.toImage(preferFidelityRenderer: false))
let scale = pixelScale(screenshotScale: 0.5, on: window)
let unmasked = try uploaded(image, masks: [], pixelScale: scale)
let masked = try uploaded(image, masks: [Self.secretRect], pixelScale: scale)

let rect = Self.secretRect
let inside = [CGPoint(x: rect.midX, y: rect.midY), CGPoint(x: rect.minX + 12, y: rect.minY + 12),
CGPoint(x: rect.maxX - 12, y: rect.maxY - 12)]
for point in inside {
let pixel = CGPoint(x: point.x * scale, y: point.y * scale)
#expect(try isRed(rgb(unmasked, at: pixel)), "unmasked frame at \(point)")
#expect(try isBlack(rgb(masked, at: pixel)), "masked frame at \(point)")
}

let outside = [CGPoint(x: rect.minX - 8, y: rect.midY), CGPoint(x: rect.maxX + 8, y: rect.midY),
CGPoint(x: rect.midX, y: rect.maxY + 8)]
for point in outside {
let pixel = CGPoint(x: point.x * scale, y: point.y * scale)
#expect(try isWhite(rgb(masked, at: pixel)), "masked frame at \(point)")
}
}

@Test("a mask edge that falls mid-pixel covers that whole native pixel")
func maskEdgeRoundsOutward() throws {
let image = solidImage(.red, size: CGSize(width: 200, height: 300), scale: 3)
// At 3 pixels per point these edges land on pixels 120.3 and 419.7.
let mask = CGRect(x: 40.1, y: 60, width: 99.8, height: 80)
let masked = try #require(RRWireframe.maskImage(image, maskableWidgets: [mask], pixelScale: 3)?.cgImage)
let row = CGFloat(300)
#expect(try isBlack(rgb(masked, at: CGPoint(x: 120, y: row))))
#expect(try isBlack(rgb(masked, at: CGPoint(x: 419, y: row))))
#expect(try isRed(rgb(masked, at: CGPoint(x: 119, y: row))))
#expect(try isRed(rgb(masked, at: CGPoint(x: 420, y: row))))
}

@Test("an unmasked frame already at native resolution is not redrawn")
func nativeScaleSkipsRedraw() throws {
let window = try window()
defer { window.isHidden = true }
let image = try #require(window.toImage(preferFidelityRenderer: false))
let native = pixelScale(screenshotScale: 1, on: window)
#expect(RRWireframe.unmaskedScreenshot(image, pixelScale: native) === image)
#expect(RRWireframe.unmaskedScreenshot(image, pixelScale: 1) !== image)
}

@Test("downsampling keeps a one-pixel line whichever source pixel it falls on")
func downsamplingKeepsThinLines() throws {
let size = CGSize(width: 30, height: 30)
let format = UIGraphicsImageRendererFormat()
format.scale = 3
// One-pixel lines at 3x, one in each phase of the 3:1 downsample: nearest-neighbour keeps only one.
let image = UIGraphicsImageRenderer(size: size, format: format).image { context in
UIColor.white.setFill()
context.fill(CGRect(origin: .zero, size: size))
UIColor.black.setFill()
for (column, phase) in [(10, 0), (11, 1), (12, 2)] {
context.fill(CGRect(x: CGFloat(column * 3 + phase) / 3, y: 0, width: 1.0 / 3, height: size.height))
}
}
let downsampled = try #require(RRWireframe.unmaskedScreenshot(image, pixelScale: 1).cgImage)
for column in 10 ... 12 {
let pixel = try rgb(downsampled, at: CGPoint(x: column, y: 15))
#expect(!isWhite(pixel), "column \(column) lost its line: \(pixel)")
}
}

@Test("no pixel of a resized masked frame shows any of the secret", arguments: [1.0, 1.5], [0.0, 1.0 / 3, 2.0 / 3])
func maskedSecretDoesNotBleed(pixelScale: CGFloat, offset: CGFloat) throws {
let size = CGSize(width: 200, height: 300)
let secret = Self.secretRect.offsetBy(dx: offset, dy: offset)
let format = UIGraphicsImageRendererFormat()
format.scale = 3
let image = UIGraphicsImageRenderer(size: size, format: format).image { context in
UIColor.white.setFill()
context.fill(CGRect(origin: .zero, size: size))
UIColor.red.setFill()
context.fill(secret)
}
let masked = try #require(RRWireframe.maskImage(image, maskableWidgets: [secret], pixelScale: pixelScale)?.cgImage)
var rgba = [UInt8](repeating: 0, count: masked.width * masked.height * 4)
let context = try #require(CGContext(
data: &rgba, width: masked.width, height: masked.height, bitsPerComponent: 8, bytesPerRow: masked.width * 4,
space: CGColorSpaceCreateDeviceRGB(), bitmapInfo: CGImageAlphaInfo.premultipliedLast.rawValue
))
context.draw(masked, in: CGRect(x: 0, y: 0, width: masked.width, height: masked.height))
// Black and white keep every channel equal; only the secret adds red.
let tinted = stride(from: 0, to: rgba.count, by: 4).filter { Int(rgba[$0]) - Int(max(rgba[$0 + 1], rgba[$0 + 2])) > 2 }
#expect(tinted.isEmpty, "\(tinted.count) pixels show the secret")
}
}
#endif
Binary file modified PostHogTests/__MaskSnapshots__/case_1.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file modified PostHogTests/__MaskSnapshots__/case_10.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file modified PostHogTests/__MaskSnapshots__/case_11.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file modified PostHogTests/__MaskSnapshots__/case_12.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file modified PostHogTests/__MaskSnapshots__/case_13.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file modified PostHogTests/__MaskSnapshots__/case_14.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file modified PostHogTests/__MaskSnapshots__/case_17.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file modified PostHogTests/__MaskSnapshots__/case_2.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file modified PostHogTests/__MaskSnapshots__/case_20.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file modified PostHogTests/__MaskSnapshots__/case_21.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file modified PostHogTests/__MaskSnapshots__/case_22.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file modified PostHogTests/__MaskSnapshots__/case_23.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file modified PostHogTests/__MaskSnapshots__/case_24.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file modified PostHogTests/__MaskSnapshots__/case_25.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file modified PostHogTests/__MaskSnapshots__/case_26.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file modified PostHogTests/__MaskSnapshots__/case_27.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file modified PostHogTests/__MaskSnapshots__/case_28.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file modified PostHogTests/__MaskSnapshots__/case_3.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file modified PostHogTests/__MaskSnapshots__/case_4.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file modified PostHogTests/__MaskSnapshots__/case_5.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file modified PostHogTests/__MaskSnapshots__/case_6.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file modified PostHogTests/__MaskSnapshots__/case_7.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file modified PostHogTests/__MaskSnapshots__/case_8.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file modified PostHogTests/__MaskSnapshots__/case_9.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Loading