Skip to content

feat(session-replay): Add experimental flags to use a more efficient view renderer for Session Replay#4940

Merged
philprime merged 24 commits intomainfrom
philprime/session-replay-custom-graphics-renderer
Mar 11, 2025
Merged

feat(session-replay): Add experimental flags to use a more efficient view renderer for Session Replay#4940
philprime merged 24 commits intomainfrom
philprime/session-replay-custom-graphics-renderer

Conversation

@philprime
Copy link
Copy Markdown
Member

@philprime philprime commented Mar 4, 2025

📜 Description

TL;DR: Reduces the time required to render a session replay frame from ~160ms to ~30-36ms (benchmarked on an iPhone 8)

  • Implements a custom graphics image renderer.
  • Adds a new experimental feature enableExperimentalViewRenderer to choose the view renderer (enables our graphics renderer)
  • Adds a new experimental feature enableFastViewRendering to choose the view draw method (switches from UIKit to CoreGraphics rendering, might be incomplete)
  • Adds an experimental graphics image renderer
  • Adds a view renderer using the experimental graphics image renderer
  • Adds a mask renderer using the experimental graphics image renderer

Note

The mask renderer is using a points-to-pixel scale of 1, therefore any higher resolution view render will be downscaled.

💡 Motivation and Context

See #4000 for more information for the main issue of frame performance.

To understand the changes, we need to look at the implementation of the SentryDefaultViewRenderer used by the SentryViewPhotographer:

@objcMembers
class SentryDefaultViewRenderer: NSObject, SentryViewRenderer {
    func render(view: UIView) -> UIImage {
        // START BLOCK - Setup
        let image = UIGraphicsImageRenderer(size: view.bounds.size).image { context in
            // START BLOCK - Draw
            view.drawHierarchy(in: view.bounds, afterScreenUpdates: false)
            // END BLOCK - Draw
        }
        // END BLOCK - Setup
        return image
    }
}

The rendering consists of two nested blocks:

  • Setup: to create a rendering context which is then used to convert rendered bitmap data into an in image
  • Draw: which is drawing the view hierarchy into the bitmap context

Both need to be analysed for their performance and potential improvement candidates.

In addition we need to reconsider how the resolutions and coordinate system works:

  • View has a size of a width and height in points (i.e. 375pt x 667pt for iPhone 8)
  • Windows have a scale at which the UI should be rendered (i.e. 2.0 for iPhone 8)
  • Screens have a resolution of size * scale pixels (i.e. 750px x 1334px for iPhone 8)

Therefore I also analyzed the impact of creating a scaled vs unscaled image, because the difference can be seen visually not only due to changes in resolution, but also due to blurriness:

Example - iOS-Swift at scale 1.0:
scale-1

Example - iOS-Swift at scale 2.0:
scale-2

The drawHierarchy performance has also been discussed in the Apple Developer Forums, leading to ReplayKit as an alternative (TBD)

💚 How did you test it?

I tried to create unit tests to test the view photographer using a complex UI built in a Storyboard file. I was not able to get it working, because unit tests have no render server, therefore trying to render a view to an image fails with the following error:

Rendering a view (0x105223a80, UILayoutContainerView) that has not been committed to render server is not supported.

Instead I performed manual testing by adapting the SentryViewPhotographer to use this method, then looking at the values over time when running the sample iOS-Swift on an iPhone 8:

func image(view: UIView, onComplete: @escaping ScreenshotCallback) {
    let viewSize = view.bounds.size

    let startTime = DispatchTime.now().uptimeNanoseconds
    let startRedactTime = DispatchTime.now().uptimeNanoseconds
    let redact = redactBuilder.redactRegionsFor(view: view)
    let endRedactTime = DispatchTime.now().uptimeNanoseconds

    // The render method is synchronous and must be called on the main thread.
    // This is because the render method accesses the view hierarchy which is managed from the main thread.
    let startRenderTime = DispatchTime.now().uptimeNanoseconds
    let renderedScreenshot = renderer.render(view: view)
    let endRenderTime = DispatchTime.now().uptimeNanoseconds
    let endTime = DispatchTime.now().uptimeNanoseconds

    printSummary(
        redactDiff: endRedactTime - startRedactTime,
        renderDiff: endRenderTime - startRenderTime,
        totalDiff: endTime - startTime
    )

    dispatchQueue.dispatchAsync { [maskRenderer] in
        // The mask renderer does not need to be on the main thread.
        // Moving it to a background thread to avoid blocking the main thread, therefore reducing the performance
        // impact/lag of the user interface.
        let maskedScreenshot = maskRenderer.maskScreenshot(screenshot: renderedScreenshot, size: viewSize, masking: redact)
        onComplete(maskedScreenshot)
    }
}

private let sampleLimit = 120
private var redactDiffHistory = [UInt64]()
private var renderDiffHistory = [UInt64]()
private var totalDiffHistory = [UInt64]()

func printSummary(redactDiff: UInt64, renderDiff: UInt64, totalDiff: UInt64) {
    redactDiffHistory = (redactDiffHistory + [redactDiff]).suffix(sampleLimit)
    renderDiffHistory = (renderDiffHistory + [renderDiff]).suffix(sampleLimit)
    totalDiffHistory = (totalDiffHistory + [totalDiff]).suffix(sampleLimit)

    let redactDiffHistoryMin = redactDiffHistory.min() ?? 0
    let redactDiffHistoryMax = redactDiffHistory.max() ?? 0
    let redactDiffHistoryAverage = Double(redactDiffHistory.reduce(0, +)) / Double(max(redactDiffHistory.count, 1))
    let sortedRedactDiffHistory = redactDiffHistory.sorted()
    let redactDiffHistoryP50 = sortedRedactDiffHistory[redactDiffHistory.count / 2]
    let redactDiffHistoryP75 = sortedRedactDiffHistory[Int(Double(redactDiffHistory.count) * 0.75)]
    let redactDiffHistoryP95 = sortedRedactDiffHistory[Int(Double(redactDiffHistory.count) * 0.95)]
    let redactDiffHistoryLast = redactDiffHistory.last ?? 0

    let renderDiffHistoryMin = renderDiffHistory.min() ?? 0
    let renderDiffHistoryMax = renderDiffHistory.max() ?? 0
    let renderDiffHistoryAverage = Double(renderDiffHistory.reduce(0, +)) / Double(max(renderDiffHistory.count, 1))
    let sortedRenderDiffHistory = renderDiffHistory.sorted()
    let renderDiffHistoryP50 = sortedRenderDiffHistory[renderDiffHistory.count / 2]
    let renderDiffHistoryP75 = sortedRenderDiffHistory[Int(Double(renderDiffHistory.count) * 0.75)]
    let renderDiffHistoryP95 = sortedRenderDiffHistory[Int(Double(renderDiffHistory.count) * 0.95)]
    let renderDiffHistoryLast = renderDiffHistory.last ?? 0

    let totalDiffHistoryMin = totalDiffHistory.min() ?? 0
    let totalDiffHistoryMax = totalDiffHistory.max() ?? 0
    let totalDiffHistoryAverage = Double(totalDiffHistory.reduce(0, +)) / Double(max(totalDiffHistory.count, 1))
    let sortedTotalDiffHistory = totalDiffHistory.sorted()
    let totalDiffHistoryP50 = sortedTotalDiffHistory[totalDiffHistory.count / 2]
    let totalDiffHistoryP75 = sortedTotalDiffHistory[Int(Double(totalDiffHistory.count) * 0.75)]
    let totalDiffHistoryP95 = sortedTotalDiffHistory[Int(Double(totalDiffHistory.count) * 0.95)]
    let totalDiffHistoryLast = totalDiffHistory.last ?? 0
    
    func f(_ value: UInt64) -> String {
        String(format: "%8.4f ms", Double(value) / 1_000_000.0)
    }

    func f(_ value: Double) -> String {
        String(format: "%8.4f ms", Double(value) / 1_000_000.0)
    }

    let samples = String(format: "%4.i Samples", redactDiffHistory.count)
    print("| \(samples) | Redact      | Render      | Total       |")
    print("|--------------|-------------|-------------|-------------|")
    print("| Min          | \(f(redactDiffHistoryMin)) | \(f(renderDiffHistoryMin)) | \(f(totalDiffHistoryMin)) |")
    print("| Max          | \(f(redactDiffHistoryMax)) | \(f(renderDiffHistoryMax)) | \(f(totalDiffHistoryMax)) |")
    print("| Avg          | \(f(redactDiffHistoryAverage)) | \(f(renderDiffHistoryAverage)) | \(f(totalDiffHistoryAverage)) |")
    print("| p50          | \(f(redactDiffHistoryP50)) | \(f(renderDiffHistoryP50)) | \(f(totalDiffHistoryP50)) |")
    print("| p75          | \(f(redactDiffHistoryP75)) | \(f(renderDiffHistoryP75)) | \(f(totalDiffHistoryP75)) |")
    print("| p95          | \(f(redactDiffHistoryP95)) | \(f(renderDiffHistoryP95)) | \(f(totalDiffHistoryP95)) |")
    print("| Last         | \(f(redactDiffHistoryLast)) | \(f(renderDiffHistoryLast)) | \(f(totalDiffHistoryLast)) |")
}

Baseline Performance

These are the results of using the currently released SentryViewPhotographer.image(view:onComplete:). We are not measuring the maskRenderer.maskScreenshot(...) because it is run on the background thread and therefore not blocking the main thread.

If a screen uses 60 frames per seconds to render graphics, the maximum time used to render one frame should be less than 1 / 60 = 16.667 ms. If a frame render takes longer, the next frame will be not be rendered (= a dropped frame) to catch up the lost time. This results in a stuttering user interface and visible to the user.

Results at screen scale (i.e. 2.0 for iPhone 8):

let image = UIGraphicsImageRenderer(size: view.bounds.size).image { _ in
    view.drawHierarchy(in: view.bounds, afterScreenUpdates: false)
}

The data shows that we have a significant frame delay with up to 161.5842ms = ~9 frames dropped every second.

120 Samples Redact Render Total
Min 3.0583 ms 145.3815 ms 151.4525 ms
Max 6.5138 ms 155.8338 ms 161.8351 ms
Avg 5.8453 ms 149.8243 ms 155.6732 ms
p50 6.0484 ms 149.2103 ms 154.1397 ms
p75 6.1136 ms 151.9487 ms 158.0255 ms
p95 6.2567 ms 155.3496 ms 161.3549 ms
Last 6.1190 ms 150.4965 ms 156.6198 ms

Results at scale 1.0:

let image = UIGraphicsImageRenderer(size: view.bounds.size, format: .init(for: .init(displayScale: 1))).image { _ in
    view.drawHierarchy(in: view.bounds, afterScreenUpdates: false)
}

No significant changes compared to the native screen scale.

120 Samples Redact Render Total
Min 5.9785 ms 146.0440 ms 152.2056 ms
Max 6.4990 ms 156.3045 ms 162.4112 ms
Avg 6.1184 ms 149.6858 ms 155.8079 ms
p50 6.1055 ms 148.6769 ms 155.1323 ms
p75 6.1640 ms 151.9197 ms 158.0497 ms
p95 6.3104 ms 155.7726 ms 161.8109 ms
Last 5.9875 ms 147.1455 ms 153.1360 ms

Alternative Attempts

Using UIView.snapshotView(afterScreenUpdates:)

The first attempt to optimize the performance was using the method UIView.snapshotView(afterScreenUpdates:) to create a dummy view hierarchy as stated in the documentation, to see if the dummy view would render faster than the normal one:

This method very efficiently captures the current rendered appearance of a view and uses it to build a new snapshot view. You can use the returned view as a visual stand-in for the current view in your app. [..] Because the content is captured from the already rendered content, this method reflects the current visual appearance of the view and is not updated to reflect animations that are scheduled or in progress. However, calling this method is faster than trying to render the contents of the current view into a bitmap image yourself.

During testing the snapshot view never displayed any visual data when rendered into an UIImage.

An Apple Engineer mentioned in the Apple Developer Forum that a snapshot can not be rendered to an image due to security reasons.

Results:

Discarded this approach because not possible.

Reuse the UIGraphicsImageRenderer

The documentation mentions that this renderer uses a built-in cache, therefore recommends to reuse renderer instances:

After initializing an image renderer, you can use it to draw multiple images with the same configuration. An image renderer keeps a cache of Core Graphics contexts, so reusing the same renderer can be more efficient than creating new renderers.

But the UIGraphicsImageRenderer is set to a fixed size and in our case the size of the view could eventually change.

To reduce the memory footprint I decided to have a maximum of one cached renderer, discarding and re-creating it whenever the size changes. In the worst case it has to re-create the renderer every time, yielding the same performance as without the cache. In the best case the size never changes and the renderer is reused forever.

Results:

There is no significant change compared to the baseline.

120 Samples Redact Render Total
Min 5.9499 ms 146.9310 ms 153.0000 ms
Max 6.7538 ms 156.0019 ms 161.9819 ms
Avg 6.0744 ms 149.5189 ms 155.5966 ms
p50 6.0541 ms 148.0545 ms 154.0874 ms
p75 6.1128 ms 151.6945 ms 157.7179 ms
p95 6.2532 ms 155.3220 ms 161.3656 ms
Last 6.0766 ms 147.0542 ms 153.1340 ms

Experimental View Renderer

UIKit is built on top of CoreGraphics and CoreAnimation also known as the QuartzCore.

The UIGraphicsImageRenderer has been introduced with iOS 10.0 to wrap around the CGContext provided by CoreGraphics, as the setup can be tedious and complicated.

The SentryGraphicsImageRenderer is a custom implementation creating a CGContext pixel buffer and also converting it into an UIImage afterwards.

Results at native scale (i.e. 2.0):

let scale = (view as? UIWindow ?? view.window)?.screen.scale ?? 1
let image = SentryGraphicsImageRenderer(size: view.bounds.size, scale: scale).image { context in
    view.drawHierarchy(in: view.bounds, afterScreenUpdates: false)
}
  • Significant decrease in render time compared to base line performance.
120 Samples Redact Render Total
Min 2.1284 ms 14.7570 ms 16.8885 ms
Max 6.7335 ms 32.5775 ms 38.6342 ms
Avg 6.0055 ms 25.4156 ms 31.4264 ms
p50 6.0432 ms 24.5575 ms 30.6436 ms
p75 6.0950 ms 27.3424 ms 33.3627 ms
p95 6.2285 ms 30.3218 ms 36.5009 ms
Last 6.0572 ms 24.3487 ms 30.4101 ms

Results at 1.0 scale:

let scale = 1.0
let image = SentryGraphicsImageRenderer(size: view.bounds.size, scale: scale).image { context in
    view.drawHierarchy(in: view.bounds, afterScreenUpdates: false)
}
  • Significant decrease in render time compared to base line performance.
  • Slower than native scale, probably due to downscaling of internal graphics data
120 Samples Redact Render Total
Min 2.1135 ms 27.0460 ms 29.1636 ms
Max 7.2708 ms 48.6616 ms 54.7815 ms
Avg 6.0159 ms 38.8028 ms 44.8226 ms
p50 6.1011 ms 38.4735 ms 44.7876 ms
p75 6.2221 ms 40.3663 ms 46.3873 ms
p95 6.4524 ms 44.4222 ms 50.5087 ms
Last 6.1820 ms 42.6977 ms 48.8843 ms

Replacing view.drawHierarchy(in:afterScreenUpdates:) with view.layer.render(in:)

Instead of drawing the view using the drawHierarchy provided on the UIKit-level, we can directly call the rendering of the layer used by the UIView provided by CoreGraphics.

Warning

During testing we noticed that the render is incomplete, in particular the tab bar icons have not been rendered. The exact impact is not known and can vary.

image_5

Results at native scale (i.e. 2.0):

let image = UIGraphicsImageRenderer(size: view.bounds.size).image { context in
    view.layer.render(in: context.cgContext)
}
  • Significant decrease in render time compared to the base line performance.
  • Faster than the SentryGraphicsImageRenderer + drawHierarchy
120 Samples Redact Render Total
Min 2.0926 ms 13.1221 ms 15.2177 ms
Max 6.3896 ms 24.6149 ms 30.6909 ms
Avg 6.0791 ms 20.5175 ms 26.6001 ms
p50 6.0992 ms 19.8446 ms 25.9533 ms
p75 6.1418 ms 21.5083 ms 27.6816 ms
p95 6.3071 ms 23.0865 ms 29.1678 ms
Last 6.0721 ms 24.6149 ms 30.6909 ms

Results at 1.0 scale:

let image = UIGraphicsImageRenderer(size: view.bounds.size, format: .init(for: .init(displayScale: 1))).image { context in
    view.layer.render(in: context.cgContext)
}
  • Slightly slower than native scale, probably due to downscaling of internal graphics data
120 Samples Redact Render Total
Min 2.6087 ms 12.5871 ms 15.1984 ms
Max 6.7715 ms 26.2617 ms 32.3283 ms
Avg 6.0303 ms 22.2413 ms 28.2754 ms
p50 6.0662 ms 21.3212 ms 27.3122 ms
p75 6.1138 ms 24.0657 ms 30.1942 ms
p95 6.3048 ms 26.2106 ms 32.2714 ms
Last 6.7715 ms 20.5220 ms 27.2970 ms

Experimental Renderer + view.layer.render(in:)

Combining the previous two approaches.

Warning

During testing we noticed that the render is incomplete, in particular the tab bar icons have not been rendered. The exact impact is not known and can vary.

Results at native scale (i.e. 2.0):

let scale = (view as? UIWindow ?? view.window)?.screen.scale ?? 1
let image = SentryGraphicsImageRenderer(size: view.bounds.size, scale: scale).image { context in
    view.layer.render(in: context.cgContext)
}
  • Significant decrease in render time compared to the base line performance.
  • Large improvements compared to experimental renderer with view.drawHierarchy(...)
  • No improvements compared to UIGraphicsImageRenderer + view.layer.render(in:)
120 Samples Redact Render Total
Min 5.9651 ms 18.5304 ms 24.5483 ms
Max 6.5470 ms 24.9161 ms 30.9267 ms
Avg 6.0764 ms 20.7397 ms 26.8196 ms
p50 6.0500 ms 19.8425 ms 26.0733 ms
p75 6.1109 ms 22.4187 ms 28.5533 ms
p95 6.3331 ms 24.6614 ms 30.6779 ms
Last 6.0334 ms 18.7154 ms 24.7522 ms

Results at 1.0 scale:

let scale = 1.0
let image = SentryGraphicsImageRenderer(size: view.bounds.size, scale: scale).image { context in
    view.layer.render(in: context.cgContext)
}
  • Slower than native scale, probably due to downscaling of internal graphics data
120 Samples Redact Render Total
Min 5.9296 ms 19.2106 ms 25.2905 ms
Max 6.2822 ms 27.0288 ms 33.0322 ms
Avg 6.0384 ms 21.6527 ms 27.6942 ms
p50 6.0178 ms 20.7255 ms 26.7658 ms
p75 6.0690 ms 22.8727 ms 28.9003 ms
p95 6.2106 ms 26.0749 ms 32.0846 ms
Last 6.1801 ms 23.6582 ms 29.8419 ms

Detailed Analysis of Implementation

Looking at one of the samples using drawViewHierarchyInRect in detail we can analyze the duration of the different calls in detail. In particular we notice that the most expensive calls are inside UIKit and CoreGraphics.

26.00 ms  100,0 % Sentry            |  SentryGraphicsImageRenderer.image(actions:)
25.00 ms   96,2 % Sentry            |   closure #1 in SentryExperimentalViewRenderer.render(view:)
25.00 ms   96,2 % UIKitCore         |    -[UIView drawViewHierarchyInRect:afterScreenUpdates:]
21.00 ms   80,8 % UIKitCore         |     -[UIImage drawInRect:blendMode:alpha:]
21.00 ms   80,8 % CoreGraphics      |      CGContextDrawImageWithOptions
20.00 ms   76,9 % CoreGraphics      |       ripc_DrawImage
15.00 ms   57,7 % CoreGraphics      |        ripc_AcquireRIPImageData
 5.00 ms   19,2 % CoreGraphics      |        RIPLayerBltImage
 1.00 ms    3,8 % libdispatch.dylib |       _dispatch_once_callout
 4.00 ms   15,4 % UIKitCore         |     _UIRenderViewImageAfterCommit
 1.00 ms    3,8 % Sentry            |   SentryGraphicsImageRenderer.Context.currentImage.getter
 1.00 ms    3,8 % CoreGraphics      |    CGBitmapContextCreateImage

It seems like UIView is backed by an UIImage from a previous render, then draws it again into the bitmap context. As this is is a private API, we can not access it directly. No further optimization possible.

@philprime philprime self-assigned this Mar 4, 2025
@github-actions
Copy link
Copy Markdown
Contributor

github-actions bot commented Mar 4, 2025

Fails
🚫 Please consider adding a changelog entry for the next release.
Messages
📖 Do not forget to update Sentry-docs with your feature once the pull request gets approved.

Instructions and example for changelog

Please add an entry to CHANGELOG.md to the "Unreleased" section. Make sure the entry includes this PR's number.

Example:

## Unreleased

- Add experimental flags to use a more efficient view renderer for Session Replay ([#4940](https://github.com/getsentry/sentry-cocoa/pull/4940))

If none of the above apply, you can opt out of this check by adding #skip-changelog to the PR description.

Generated by 🚫 dangerJS against aa8c2f2

@github-actions
Copy link
Copy Markdown
Contributor

github-actions bot commented Mar 5, 2025

Performance metrics 🚀

  Plain With Sentry Diff
Startup time 1221.89 ms 1246.18 ms 24.29 ms
Size 22.30 KiB 829.17 KiB 806.86 KiB

Baseline results on branch: main

Startup times

Revision Plain With Sentry Diff
8aec30e 1244.71 ms 1262.20 ms 17.49 ms
313b1d9 1240.18 ms 1258.44 ms 18.26 ms
649d940 1231.69 ms 1250.76 ms 19.06 ms
1734d1b 1198.69 ms 1221.62 ms 22.93 ms
ed1c644 1225.92 ms 1241.24 ms 15.33 ms
99fe600 1226.16 ms 1236.88 ms 10.71 ms
887502e 1225.40 ms 1249.92 ms 24.51 ms
2de284c 1234.92 ms 1254.45 ms 19.53 ms
3f6c30b 1252.98 ms 1266.22 ms 13.24 ms
2b19b82 1226.73 ms 1243.27 ms 16.53 ms

App size

Revision Plain With Sentry Diff
8aec30e 21.58 KiB 616.76 KiB 595.18 KiB
313b1d9 22.85 KiB 414.11 KiB 391.26 KiB
649d940 21.58 KiB 695.36 KiB 673.78 KiB
1734d1b 21.58 KiB 418.82 KiB 397.24 KiB
ed1c644 21.58 KiB 670.39 KiB 648.81 KiB
99fe600 21.90 KiB 708.13 KiB 686.23 KiB
887502e 21.58 KiB 704.26 KiB 682.67 KiB
2de284c 21.58 KiB 542.39 KiB 520.80 KiB
3f6c30b 22.85 KiB 408.88 KiB 386.03 KiB
2b19b82 21.58 KiB 542.18 KiB 520.60 KiB

Previous results on branch: philprime/session-replay-custom-graphics-renderer

Startup times

Revision Plain With Sentry Diff
5e61c3b 1235.49 ms 1253.18 ms 17.69 ms
12fe555 1221.86 ms 1238.86 ms 17.00 ms
3953cf6 1217.63 ms 1230.34 ms 12.71 ms
46b6763 1224.19 ms 1242.85 ms 18.66 ms
4b2daf3 1211.94 ms 1235.12 ms 23.19 ms
c63a1ee 1217.98 ms 1252.40 ms 34.42 ms
289ec39 1206.51 ms 1235.11 ms 28.60 ms
643bfcb 1231.92 ms 1255.00 ms 23.08 ms

App size

Revision Plain With Sentry Diff
5e61c3b 22.30 KiB 826.76 KiB 804.46 KiB
12fe555 22.30 KiB 832.44 KiB 810.13 KiB
3953cf6 22.30 KiB 829.10 KiB 806.80 KiB
46b6763 22.30 KiB 826.23 KiB 803.92 KiB
4b2daf3 22.30 KiB 832.44 KiB 810.13 KiB
c63a1ee 22.30 KiB 830.36 KiB 808.06 KiB
289ec39 22.30 KiB 828.99 KiB 806.69 KiB
643bfcb 22.30 KiB 830.26 KiB 807.96 KiB

@codecov
Copy link
Copy Markdown

codecov bot commented Mar 5, 2025

Codecov Report

Attention: Patch coverage is 63.67521% with 85 lines in your changes missing coverage. Please review.

Project coverage is 92.415%. Comparing base (e445ef2) to head (aa8c2f2).
Report is 6 commits behind head on main.

Files with missing lines Patch % Lines
...ools/ViewCapture/SentryGraphicsImageRenderer.swift 0.000% 58 Missing ⚠️
...s/ViewCapture/SentryExperimentalViewRenderer.swift 0.000% 14 Missing ⚠️
...s/ViewCapture/SentryExperimentalMaskRenderer.swift 0.000% 9 Missing ⚠️
Sources/Sentry/SentrySessionReplayIntegration.m 89.473% 2 Missing ⚠️
...s/SentrySwiftUI/Preview/PreviewRedactOptions.swift 0.000% 2 Missing ⚠️
Additional details and impacted files

Impacted file tree graph

@@              Coverage Diff              @@
##              main     #4940       +/-   ##
=============================================
- Coverage   92.498%   92.415%   -0.083%     
=============================================
  Files          666       669        +3     
  Lines        78996     79164      +168     
  Branches     28578     28637       +59     
=============================================
+ Hits         73070     73160       +90     
- Misses        5829      5907       +78     
  Partials        97        97               
Files with missing lines Coverage Δ
Sources/Sentry/SentryScreenshot.m 82.258% <100.000%> (+0.290%) ⬆️
...es/Swift/Helper/SentryEnabledFeaturesBuilder.swift 100.000% <100.000%> (ø)
...ssionReplay/Preview/SentryMaskingPreviewView.swift 86.274% <100.000%> (+0.274%) ⬆️
...tegrations/SessionReplay/SentryReplayOptions.swift 95.774% <100.000%> (+0.774%) ⬆️
.../Tools/ViewCapture/SentryDefaultMaskRenderer.swift 100.000% <100.000%> (ø)
...ift/Tools/ViewCapture/SentryViewPhotographer.swift 73.333% <100.000%> (+3.333%) ⬆️
...sts/Helper/SentryEnabledFeaturesBuilderTests.swift 100.000% <100.000%> (ø)
...ests/ViewCapture/SentryViewPhotographerTests.swift 99.665% <100.000%> (ø)
...SentryTests/ViewCapture/UIRedactBuilderTests.swift 100.000% <ø> (ø)
Sources/Sentry/SentrySessionReplayIntegration.m 88.610% <89.473%> (-0.096%) ⬇️
... and 4 more

... and 17 files with indirect coverage changes


Continue to review full report in Codecov by Sentry.

Legend - Click here to learn more
Δ = absolute <relative> (impact), ø = not affected, ? = missing data
Powered by Codecov. Last update e445ef2...aa8c2f2. Read the comment docs.

🚀 New features to boost your workflow:
  • Test Analytics: Detect flaky tests, report on failures, and find test suite problems.

@philprime philprime force-pushed the philprime/session-replay-custom-graphics-renderer branch from eef381e to 0c33278 Compare March 5, 2025 16:12
@philprime philprime changed the title feat(session-replay): Add experimental graphics renderer feat(session-replay): Add experimental flags to use a more efficient view renderer for Session Replay Mar 5, 2025
@philprime philprime marked this pull request as ready for review March 5, 2025 17:11
Copy link
Copy Markdown
Member

@philipphofmann philipphofmann left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This looks great. I think it's OK to skip automated test for now. Once we know the solution is working correctly, we can come up with proper tests to avoid regressions when changing the code.

Copy link
Copy Markdown
Member

@philipphofmann philipphofmann left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't have enough insights yet into SR to clearly answer the following question: The SentryViewPhotographer.image already dispatches masking to the background. Can't we dispatch more heavy work of capturing the screenshot to a BG thread?

…wift

Co-authored-by: Philipp Hofmann <philipp.hofmann@sentry.io>
@philprime
Copy link
Copy Markdown
Member Author

@philipphofmann the capturing of the screenshot consists of three steps:

  1. calculate the redaction frames from the view hierarchy
  2. render the current view hierarchy to a bitmap context
  3. draw the redaction frames into the bitmap context to hide sensible data.

Step 1 and 2 must be run on main thread because all access to the view hierarchy must be on main. Step 3 can be performed in the background (as it is for session replay, not for screenshots), because it only uses the output of the previous two steps.

Copy link
Copy Markdown
Member

@philipphofmann philipphofmann left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LGTM, as this is experimental. Once we know the approaches are working, we should think about how we can test this. Please also double check CI before merging.

@philprime
Copy link
Copy Markdown
Member Author

Ready to merge after #4968

Copy link
Copy Markdown
Member

@philipphofmann philipphofmann left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Let's ship this

@philprime philprime enabled auto-merge (squash) March 11, 2025 14:51
@philprime philprime merged commit 82cac0c into main Mar 11, 2025
73 of 75 checks passed
@philprime philprime deleted the philprime/session-replay-custom-graphics-renderer branch March 11, 2025 14:52
@brustolin
Copy link
Copy Markdown
Contributor

Hey, this is amazing!
I’m a little late to the party, but here’s a small contribution:

“layer.render” was the first strategy used in the POV, and it seems fine at first with the Sentry sample, which is a small project. However, after trying it in other projects like Pocketcast or my own app, its performance was worse than drawViewHierarchy.

So I suggest double-checking this—maybe it won’t be an issue with the new GraphicRenderer.

P.S.: It’s missing some elements with “layer.render” because it doesn’t render CoreGraphics filters, so no blur, shadow, tinting (which I believe is related to the tabbar issue), and others.

@brustolin
Copy link
Copy Markdown
Contributor

So, I was super curious about this one and did the checking already with my Bible app:

Running with drawViewHierarchy:

0.03214001655578613 seconds
0.03184199333190918 seconds
0.02697896957397461 seconds
0.02715301513671875 seconds
0.027709007263183594 seconds
0.025877952575683594 seconds
0.026553988456726074 seconds
0.028463006019592285 seconds
0.02812492847442627 seconds
0.018550992012023926 seconds
0.025982975959777832 seconds
0.02735292911529541 seconds
0.025730013847351074 seconds
0.02535700798034668 seconds
0.028676986694335938 seconds
0.02624499797821045 seconds
0.02596604824066162 seconds
0.027943968772888184 seconds
0.02551400661468506 seconds
0.03146004676818848 seconds
0.026134014129638672 seconds
0.026159048080444336 seconds

Running with layer.render(in:):

0.04942798614501953 seconds
0.05032908916473389 seconds
0.04682600498199463 seconds
0.045825958251953125 seconds
0.04468393325805664 seconds
0.04770100116729736 seconds
0.04804098606109619 seconds
0.04778289794921875 seconds
0.04955899715423584 seconds
0.04842698574066162 seconds
0.0463329553604126 seconds
0.047294020652770996 seconds
0.04603302478790283 seconds
0.045884013175964355 seconds
0.04868292808532715 seconds
0.046957969665527344 seconds
0.04730403423309326 seconds
0.04790091514587402 seconds
0.04799091815948486 seconds
0.04570889472961426 seconds
0.04750800132751465 seconds
0.04862701892852783 seconds
0.051267027854919434 seconds
0.04524993896484375 seconds
0.04711604118347168 seconds

Old Approach with UIGraphicsImageRenderer and drawViewHierarchy:
0.04188692569732666 seconds
0.04566299915313721 seconds
0.04651010036468506 seconds
0.032945990562438965 seconds
0.03756093978881836 seconds
0.043107032775878906 seconds
0.039204955101013184 seconds
0.041670918464660645 seconds
0.042729973793029785 seconds
0.03195595741271973 seconds
0.04273808002471924 seconds
0.03807806968688965 seconds
0.04124295711517334 seconds
0.03776395320892334 seconds
0.042593955993652344 seconds
0.03877103328704834 seconds
0.04190397262573242 seconds
0.04012894630432129 seconds
0.04018902778625488 seconds
0.039456963539123535 seconds
0.04144597053527832 seconds
0.04522097110748291 seconds

As I expected, the performance seems to be in favor of the new SentryGraphicsRenderer and drawViewHierarchy (which also yields a better result). The difference from the old UIGraphicsImageRenderer is not 5x, but it is better.

Tested with iPhone 14 Pro Max
Each line is a call to "image(actions:)"

@kahest
Copy link
Copy Markdown
Contributor

kahest commented Mar 20, 2025

@brustolin hey thanks for chiming in and running the tests :) the perf improvements are definitely more relevant on lower-end devices, in iPhone 14 Pro Max it's expected to have less of an impact 👍

@brustolin
Copy link
Copy Markdown
Contributor

brustolin commented Mar 20, 2025

@kahest Hey, its a pleasure to participate.

the perf improvements are definitely more relevant on lower-end devices

Yeah, I can imagine that. My only take here is to stick to drawViewHierarchy, better performance on complex View (not even that complex as my Bible) and better results on image fidelity.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants