Repository: getsentry/sentry-cocoa
Pull request #4941: fix: Correctly finish TTFD span when no new frame
Author: login: philipphofmann
Created: 2025-03-05T09:00:56Z | Merged: 2025-03-07T08:06:23Z



## :scroll: Description

This PR fixes wrongly finishing the TTFD span as the deadline exceeded when the user calls finishSpansIfNotFinished, and the UIViewControllerPerformanceTracker calls finishSpansIfNotFinished before a new frame gets drawn. This happened for a customer using a SplashScreenUIViewController. Now, the TimeToDisplayTracker finishes the TTFD span in finishSpansIfNotFinished when the user called finishSpansIfNotFinished.

## :bulb: Motivation and Context

A customer reported this bug via Slack.

## :green_heart: How did you test it?
Unit tests.

## :pencil: Checklist

You have to check all boxes before merging:

- [x] I added tests to verify the changes.
- [x] No new PII added or SDK only sends newly added PII if `sendDefaultPII` is enabled.
- [x] I updated the docs if needed.
- [x] I updated the wizard if needed.
- [x] Review from the native team if needed.
- [x] No breaking change or entry added to the changelog.
- [x] No breaking change for hybrid SDKs or communicated to hybrid SDKs.

## :crystal_ball: Next steps



Diff:
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 89532920b8a..8e223ed7804 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -6,6 +6,10 @@
 
 - Add extension for `Data` to track file I/O operations with Sentry (#4862)
 
+### Fixes
+
+- Correctly finish TTFD span when no new frame (#4941)
+
 ### Improvements
 
 - More debug logs for UIViewController tracing (#4942)
diff --git a/Sources/Sentry/SentryTimeToDisplayTracker.m b/Sources/Sentry/SentryTimeToDisplayTracker.m
index d8205e9983c..96ca480a448 100644
--- a/Sources/Sentry/SentryTimeToDisplayTracker.m
+++ b/Sources/Sentry/SentryTimeToDisplayTracker.m
@@ -86,9 +86,9 @@ - (BOOL)startForTracer:(SentryTracer *)tracer
     [tracer setShouldIgnoreWaitForChildrenCallback:^(id<SentrySpan> span) {
         if ([span.origin isEqualToString:SentryTraceOriginAutoUITimeToDisplay]) {
             return YES;
-        } else {
-            return NO;
         }
+
+        return NO;
     }];
     [tracer setFinishCallback:^(SentryTracer *_tracer) {
         [SentryDependencyContainer.sharedInstance.framesTracker removeListener:self];
@@ -140,11 +140,22 @@ - (void)reportFullyDisplayed
 
 - (void)finishSpansIfNotFinished
 {
+    [SentryDependencyContainer.sharedInstance.framesTracker removeListener:self];
+
     if (self.initialDisplaySpan.isFinished == NO) {
         [self.initialDisplaySpan finish];
     }
 
     if (self.fullDisplaySpan.isFinished == NO) {
+        if (_fullyDisplayedReported) {
+            SENTRY_LOG_DEBUG(
+                @"SentrySDK.reportFullyDisplayed() was called but didn't receive a new frame to "
+                @"finish the TTFD span. Finishing the full display span so the SDK can start a new "
+                @"time to display tracker.");
+            [self.fullDisplaySpan finish];
+            return;
+        }
+
         SENTRY_LOG_WARN(@"You didn't call SentrySDK.reportFullyDisplayed() for UIViewController: "
                         @"%@. Finishing full display span with status: %@.",
             _name, nameForSentrySpanStatus(kSentrySpanStatusDeadlineExceeded));
diff --git a/Tests/SentryTests/Integrations/Performance/UIViewController/SentryTimeToDisplayTrackerTest.swift b/Tests/SentryTests/Integrations/Performance/UIViewController/SentryTimeToDisplayTrackerTest.swift
index e7dfe3e7f49..21cf8c39e3e 100644
--- a/Tests/SentryTests/Integrations/Performance/UIViewController/SentryTimeToDisplayTrackerTest.swift
+++ b/Tests/SentryTests/Integrations/Performance/UIViewController/SentryTimeToDisplayTrackerTest.swift
@@ -481,6 +481,86 @@ class SentryTimeToDisplayTrackerTest: XCTestCase {
         
         XCTAssertEqual(Dynamic(self.fixture.framesTracker).listeners.count, 0)
     }
+    
+    func testFinishSpansIfNotFinished_FullyDisplayedRecorded_ButNoNewFrame() throws {
+        // Arrange
+        fixture.dateProvider.setDate(date: Date(timeIntervalSince1970: 9))
+
+        let tracer = try fixture.getTracer()
+        
+        let sut = fixture.getSut(name: "UIViewController", waitForFullDisplay: true)
+
+        sut.start(for: tracer)
+
+        fixture.dateProvider.setDate(date: Date(timeIntervalSince1970: 10))
+        sut.reportInitialDisplay()
+        fixture.displayLinkWrapper.normalFrame()
+
+        fixture.dateProvider.setDate(date: Date(timeIntervalSince1970: 11))
+        sut.reportFullyDisplayed()
+        
+        fixture.dateProvider.setDate(date: Date(timeIntervalSince1970: 12))
+        
+        // Act
+        sut.finishSpansIfNotFinished()
+        
+        fixture.dateProvider.setDate(date: Date(timeIntervalSince1970: 13))
+        tracer.finish()
+        
+        // Assert
+        let ttidSpan = try XCTUnwrap(sut.initialDisplaySpan)
+        XCTAssertEqual(ttidSpan.startTimestamp, Date(timeIntervalSince1970: 9))
+        XCTAssertEqual(ttidSpan.timestamp, Date(timeIntervalSince1970: 10))
+        XCTAssertEqual(ttidSpan.status, .ok)
+        assertMeasurement(tracer: tracer, name: "time_to_initial_display", duration: 1_000)
+        
+        let ttfdSpan = try XCTUnwrap(sut.fullDisplaySpan)
+        XCTAssertEqual(ttfdSpan.startTimestamp, ttidSpan.startTimestamp)
+        XCTAssertEqual(ttfdSpan.timestamp, Date(timeIntervalSince1970: 12))
+        XCTAssertEqual(ttfdSpan.status, .ok)
+        XCTAssertEqual(ttfdSpan.spanDescription, "UIViewController full display")
+        XCTAssertEqual(ttfdSpan.operation, SentrySpanOperationUiLoadFullDisplay)
+        XCTAssertEqual(ttfdSpan.origin, SentryTraceOriginManualUITimeToDisplay)
+        
+        assertMeasurement(tracer: tracer, name: "time_to_full_display", duration: 3_000)
+    }
+    
+    func testFinishSpansIfNotFinished_FullyDisplayedRecorded_RemovesListener() throws {
+        // Arrange
+        fixture.dateProvider.setDate(date: Date(timeIntervalSince1970: 9))
+
+        let tracer = try fixture.getTracer()
+        
+        let sut = fixture.getSut(name: "UIViewController", waitForFullDisplay: true)
+
+        sut.start(for: tracer)
+
+        sut.reportFullyDisplayed()
+        
+        // Act
+        sut.finishSpansIfNotFinished()
+
+        // Assert
+        XCTAssertEqual(Dynamic(self.fixture.framesTracker).listeners.count, 0, "Frames tracker listener should be removed")
+    }
+    
+    func testFinishSpansIfNotFinished_RemovesFramesTrackerListener() throws {
+        // Arrange
+        fixture.dateProvider.setDate(date: Date(timeIntervalSince1970: 9))
+        let tracer = try fixture.getTracer()
+        let sut = fixture.getSut(name: "UIViewController", waitForFullDisplay: true)
+        sut.start(for: tracer)
+
+        fixture.dateProvider.setDate(date: Date(timeIntervalSince1970: 10))
+        sut.reportInitialDisplay()
+        fixture.displayLinkWrapper.normalFrame()
+        
+        // Act
+        sut.finishSpansIfNotFinished()
+        
+        // Assert
+        XCTAssertEqual(Dynamic(self.fixture.framesTracker).listeners.count, 0, "Frames tracker listener should be removed")
+    }
 
     private func assertMeasurement(tracer: SentryTracer, name: String, duration: TimeInterval) {
         XCTAssertEqual(tracer.measurements[name]?.value, NSNumber(value: duration))


Reviews:
user: login: philprime
body: LGTM, left comments but probably fine to continue
state: APPROVED
author_association: MEMBER
submitted_at: 2025-03-06T09:20:43Z
user: login: philipphofmann
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-03-06T15:45:07Z

Review comments:
diff_hunk: @@ -86,9 +86,9 @@ - (BOOL)startForTracer:(SentryTracer *)tracer
     [tracer setShouldIgnoreWaitForChildrenCallback:^(id<SentrySpan> span) {
         if ([span.origin isEqualToString:SentryTraceOriginAutoUITimeToDisplay]) {
             return YES;
-        } else {
-            return NO;
         }
+
+        return NO;
path: Sources/Sentry/SentryTimeToDisplayTracker.m
user: login: philprime
body: Thanks for this little change making the early-exit cleaner 💯
created_at: 2025-03-06T09:17:36Z
author_association: MEMBER
diff_hunk: @@ -143,12 +143,23 @@ - (void)finishSpansIfNotFinished
     }
 
     if (self.fullDisplaySpan.isFinished == NO) {
+        if (_fullyDisplayedReported) {
+            SENTRY_LOG_DEBUG(
+                @"SentrySDK.reportFullyDisplayed() was called but didn't receive a new frame to "
+                @"finish the TTFD span. Finishing the full display span so the SDK can start a new "
+                @"time to display tracker.");
+            [self.fullDisplaySpan finish];
path: Sources/Sentry/SentryTimeToDisplayTracker.m
user: login: philprime
body: `m`: Not sure if I have enough insights into TTID/TTFD and frame tracking, but shouldn't we also remove the listener here as we do in L162?
created_at: 2025-03-06T09:19:23Z
author_association: MEMBER
diff_hunk: @@ -143,12 +143,23 @@ - (void)finishSpansIfNotFinished
     }
 
     if (self.fullDisplaySpan.isFinished == NO) {
+        if (_fullyDisplayedReported) {
+            SENTRY_LOG_DEBUG(
+                @"SentrySDK.reportFullyDisplayed() was called but didn't receive a new frame to "
+                @"finish the TTFD span. Finishing the full display span so the SDK can start a new "
+                @"time to display tracker.");
+            [self.fullDisplaySpan finish];
path: Sources/Sentry/SentryTimeToDisplayTracker.m
user: login: philipphofmann
body: Good catch yes. I will add this at the top of the method.
created_at: 2025-03-06T15:45:07Z
author_association: MEMBER

PR comments:
user: login: codecov[bot]
created_at: 2025-03-05T09:50:33Z
body: ## [Codecov](https://app.codecov.io/gh/getsentry/sentry-cocoa/pull/4941?dropdown=coverage&src=pr&el=h1&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=getsentry) Report
All modified and coverable lines are covered by tests :white_check_mark:
> Project coverage is 92.452%. Comparing base [(`bf809be`)](https://app.codecov.io/gh/getsentry/sentry-cocoa/commit/bf809be7e31563483872aa196accf36da28e2191?dropdown=coverage&el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=getsentry) to head [(`a56c216`)](https://app.codecov.io/gh/getsentry/sentry-cocoa/commit/a56c216a4e9d4842b6de0e9c6212227fdec3f65b?dropdown=coverage&el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=getsentry).
> Report is 2 commits behind head on main.

<details><summary>Additional details and impacted files</summary>


[![Impacted file tree graph](https://app.codecov.io/gh/getsentry/sentry-cocoa/pull/4941/graphs/tree.svg?width=650&height=150&src=pr&token=PTZKtOJlrs&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=getsentry)](https://app.codecov.io/gh/getsentry/sentry-cocoa/pull/4941?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=getsentry)

```diff
@@            Coverage Diff            @@
##              main     #4941   +/-   ##
=========================================
  Coverage   92.452%   92.452%           
=========================================
  Files          666       666           
  Lines        78326     78410   +84     
  Branches     28380     28404   +24     
=========================================
+ Hits         72414     72492   +78     
- Misses        5812      5819    +7     
+ Partials       100        99    -1     
```

| [Files with missing lines](https://app.codecov.io/gh/getsentry/sentry-cocoa/pull/4941?dropdown=coverage&src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=getsentry) | Coverage Δ | |
|---|---|---|
| [Sources/Sentry/SentryTimeToDisplayTracker.m](https://app.codecov.io/gh/getsentry/sentry-cocoa/pull/4941?src=pr&el=tree&filepath=Sources%2FSentry%2FSentryTimeToDisplayTracker.m&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=getsentry#diff-U291cmNlcy9TZW50cnkvU2VudHJ5VGltZVRvRGlzcGxheVRyYWNrZXIubQ==) | `100.000% <100.000%> (ø)` | |
| [...iewController/SentryTimeToDisplayTrackerTest.swift](https://app.codecov.io/gh/getsentry/sentry-cocoa/pull/4941?src=pr&el=tree&filepath=Tests%2FSentryTests%2FIntegrations%2FPerformance%2FUIViewController%2FSentryTimeToDisplayTrackerTest.swift&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=getsentry#diff-VGVzdHMvU2VudHJ5VGVzdHMvSW50ZWdyYXRpb25zL1BlcmZvcm1hbmNlL1VJVmlld0NvbnRyb2xsZXIvU2VudHJ5VGltZVRvRGlzcGxheVRyYWNrZXJUZXN0LnN3aWZ0) | `100.000% <100.000%> (ø)` | |

... and [10 files with indirect coverage changes](https://app.codecov.io/gh/getsentry/sentry-cocoa/pull/4941/indirect-changes?src=pr&el=tree-more&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=getsentry)

------

[Continue to review full report in Codecov by Sentry](https://app.codecov.io/gh/getsentry/sentry-cocoa/pull/4941?dropdown=coverage&src=pr&el=continue&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=getsentry).
> **Legend** - [Click here to learn more](https://docs.codecov.io/docs/codecov-delta?utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=getsentry)
> `Δ = absolute <relative> (impact)`, `ø = not affected`, `? = missing data`
> Powered by [Codecov](https://app.codecov.io/gh/getsentry/sentry-cocoa/pull/4941?dropdown=coverage&src=pr&el=footer&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=getsentry). Last update [bf809be...a56c216](https://app.codecov.io/gh/getsentry/sentry-cocoa/pull/4941?dropdown=coverage&src=pr&el=lastupdated&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=getsentry). Read the [comment docs](https://docs.codecov.io/docs/pull-request-comments?utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=getsentry).

</details>
<details><summary>🚀 New features to boost your workflow: </summary>

- ❄ [Test Analytics](https://docs.codecov.com/docs/test-analytics): Detect flaky tests, report on failures, and find test suite problems.
</details>
author_association: NONE
user: login: github-actions[bot]
created_at: 2025-03-07T07:41:24Z
body: ##  Performance metrics :rocket:
<table>
  <tr>
    <th>&nbsp;</th>
    <th align="right">Plain</th>
    <th align="right">With Sentry</th>
    <th align="right">Diff</th>
  </tr>
  <tr>
    <th align="left">Startup time</th>
    <td align="right">1230.90 ms</td>
    <td align="right">1249.21 ms</td>
    <td align="right"><strong>18.31 ms</strong></td>
  </tr>
  <tr>
    <th align="left">Size</th>
    <td align="right">22.30 KiB</td>
    <td align="right">822.00 KiB</td>
    <td align="right"><strong>799.70 KiB</strong></td>
  </tr>
</table>
<details><summary><h3>Baseline results on branch: main</h3></summary>
<h3>Startup times</h3>
<table>
  <tr>
    <th>Revision</th>
    <th align="right">Plain</th>
    <th align="right">With Sentry</th>
    <th align="right">Diff</th>
  </tr><tr>
  <th align="left">bec79143e64c735c6345d4cc4f02190c251bbd5a</th>
  <td align="right">1214.02 ms</td>
  <td align="right">1236.20 ms</td>
  <td align="right"><strong>22.18 ms</strong></td>
</tr><tr>
  <th align="left">d9146965a87b6e82aa6cb21b60118dd07d75d955</th>
  <td align="right">1225.19 ms</td>
  <td align="right">1247.16 ms</td>
  <td align="right"><strong>21.98 ms</strong></td>
</tr><tr>
  <th align="left">371db897557023c231e04b3f54bf7c8b16d0d18e</th>
  <td align="right">1226.40 ms</td>
  <td align="right">1251.54 ms</td>
  <td align="right"><strong>25.14 ms</strong></td>
</tr><tr>
  <th align="left">e89dc54f3fd0c7ad010d9a6c7cb02ac178f3fc33</th>
  <td align="right">1207.86 ms</td>
  <td align="right">1218.27 ms</td>
  <td align="right"><strong>10.41 ms</strong></td>
</tr><tr>
  <th align="left">a0cc9d6c722d576d43f89add906797b7ddcccc93</th>
  <td align="right">1228.98 ms</td>
  <td align="right">1252.36 ms</td>
  <td align="right"><strong>23.38 ms</strong></td>
</tr><tr>
  <th align="left">98cca71ab965606c68f46b2f6a0a9c710ce6cdb2</th>
  <td align="right">1226.66 ms</td>
  <td align="right">1242.94 ms</td>
  <td align="right"><strong>16.28 ms</strong></td>
</tr><tr>
  <th align="left">c5fef162e8ce1024512f10c3d02c1e43781ff1de</th>
  <td align="right">1221.71 ms</td>
  <td align="right">1241.49 ms</td>
  <td align="right"><strong>19.78 ms</strong></td>
</tr><tr>
  <th align="left">df9fb5bd6b0bce5f6e1af71bf2635cf40f81eb3e</th>
  <td align="right">1239.18 ms</td>
  <td align="right">1254.00 ms</td>
  <td align="right"><strong>14.82 ms</strong></td>
</tr><tr>
  <th align="left">46467d08c03a17625050d538c7b6d845b69f74c8</th>
  <td align="right">1229.00 ms</td>
  <td align="right">1241.27 ms</td>
  <td align="right"><strong>12.27 ms</strong></td>
</tr><tr>
  <th align="left">c810e5844b1c3af97a17302d35c8d1f45ae97afa</th>
  <td align="right">1221.57 ms</td>
  <td align="right">1250.45 ms</td>
  <td align="right"><strong>28.88 ms</strong></td>
</tr></table>
<h3>App size</h3>
<table>
  <tr>
    <th>Revision</th>
    <th align="right">Plain</th>
    <th align="right">With Sentry</th>
    <th align="right">Diff</th>
  </tr><tr>
  <th align="left">bec79143e64c735c6345d4cc4f02190c251bbd5a</th>
  <td align="right">21.58 KiB</td>
  <td align="right">707.43 KiB</td>
  <td align="right"><strong>685.85 KiB</strong></td>
</tr><tr>
  <th align="left">d9146965a87b6e82aa6cb21b60118dd07d75d955</th>
  <td align="right">21.58 KiB</td>
  <td align="right">629.83 KiB</td>
  <td align="right"><strong>608.25 KiB</strong></td>
</tr><tr>
  <th align="left">371db897557023c231e04b3f54bf7c8b16d0d18e</th>
  <td align="right">20.76 KiB</td>
  <td align="right">427.31 KiB</td>
  <td align="right"><strong>406.55 KiB</strong></td>
</tr><tr>
  <th align="left">e89dc54f3fd0c7ad010d9a6c7cb02ac178f3fc33</th>
  <td align="right">22.85 KiB</td>
  <td align="right">412.60 KiB</td>
  <td align="right"><strong>389.75 KiB</strong></td>
</tr><tr>
  <th align="left">a0cc9d6c722d576d43f89add906797b7ddcccc93</th>
  <td align="right">21.58 KiB</td>
  <td align="right">706.47 KiB</td>
  <td align="right"><strong>684.89 KiB</strong></td>
</tr><tr>
  <th align="left">98cca71ab965606c68f46b2f6a0a9c710ce6cdb2</th>
  <td align="right">22.85 KiB</td>
  <td align="right">411.14 KiB</td>
  <td align="right"><strong>388.29 KiB</strong></td>
</tr><tr>
  <th align="left">c5fef162e8ce1024512f10c3d02c1e43781ff1de</th>
  <td align="right">22.31 KiB</td>
  <td align="right">760.65 KiB</td>
  <td align="right"><strong>738.34 KiB</strong></td>
</tr><tr>
  <th align="left">df9fb5bd6b0bce5f6e1af71bf2635cf40f81eb3e</th>
  <td align="right">21.90 KiB</td>
  <td align="right">708.90 KiB</td>
  <td align="right"><strong>687.00 KiB</strong></td>
</tr><tr>
  <th align="left">46467d08c03a17625050d538c7b6d845b69f74c8</th>
  <td align="right">21.58 KiB</td>
  <td align="right">698.11 KiB</td>
  <td align="right"><strong>676.53 KiB</strong></td>
</tr><tr>
  <th align="left">c810e5844b1c3af97a17302d35c8d1f45ae97afa</th>
  <td align="right">22.32 KiB</td>
  <td align="right">761.10 KiB</td>
  <td align="right"><strong>738.78 KiB</strong></td>
</tr></table>
</details>

author_association: CONTRIBUTOR