Skip to content

Commit

Permalink
feat: Add frames delay to span data
Browse files Browse the repository at this point in the history
Also fix a couple of edge cases when calculating the frames delay when
an ongoing delayed frame is not yet recorded.
  • Loading branch information
philipphofmann committed Dec 14, 2023
1 parent de033da commit 36ad868
Show file tree
Hide file tree
Showing 7 changed files with 104 additions and 66 deletions.
2 changes: 1 addition & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@

## Features

- Add frames delay to transactions (#3487)
- Add frames delay to transactions and spans (#3487, #3495)
- Add slow and frozen frames to spans (#3450, #3478)

## 8.17.1
Expand Down
70 changes: 45 additions & 25 deletions Sources/Sentry/SentryDelayedFramesTracker.m
Original file line number Diff line number Diff line change
Expand Up @@ -92,8 +92,7 @@ - (void)removeOldDelayedFrames
- (CFTimeInterval)getFramesDelay:(uint64_t)startSystemTimestamp
endSystemTimestamp:(uint64_t)endSystemTimestamp
isRunning:(BOOL)isRunning
thisFrameTimestamp:(CFTimeInterval)thisFrameTimestamp
previousFrameTimestamp:(CFTimeInterval)previousFrameTimestamp
previousFrameSystemTimestamp:(uint64_t)previousFrameSystemTimestamp
slowFrameThreshold:(CFTimeInterval)slowFrameThreshold
{
CFTimeInterval cantCalculateFrameDelayReturnValue = -1.0;
Expand All @@ -115,12 +114,12 @@ - (CFTimeInterval)getFramesDelay:(uint64_t)startSystemTimestamp
return cantCalculateFrameDelayReturnValue;
}

if (previousFrameTimestamp < 0) {
if (previousFrameSystemTimestamp == 0) {
SENTRY_LOG_DEBUG(@"Not calculating frames delay because no frames yet recorded.");
return cantCalculateFrameDelayReturnValue;
}

NSArray<SentryDelayedFrame *> *frames;
NSMutableArray<SentryDelayedFrame *> *frames;
@synchronized(self.delayedFrames) {
uint64_t oldestDelayedFrameStartTimestamp = UINT64_MAX;
SentryDelayedFrame *oldestDelayedFrame = self.delayedFrames.firstObject;
Expand All @@ -135,17 +134,17 @@ - (CFTimeInterval)getFramesDelay:(uint64_t)startSystemTimestamp
}

// Copy as late as possible to avoid allocating unnecessary memory.
frames = self.delayedFrames.copy;
frames = self.delayedFrames.mutableCopy;
}

// Check if there is an delayed frame going on but not recorded yet.
CFTimeInterval frameDuration = thisFrameTimestamp - previousFrameTimestamp;
CFTimeInterval ongoingDelayedFrame = 0.0;
if (frameDuration > slowFrameThreshold) {
ongoingDelayedFrame = frameDuration - slowFrameThreshold;
}
// Add a delayed frame for a potentially ongoing but not recorded delayed frame
SentryDelayedFrame *currentFrameDelay = [[SentryDelayedFrame alloc]
initWithStartTimestamp:previousFrameSystemTimestamp
expectedDuration:slowFrameThreshold
actualDuration:nanosecondsToTimeInterval(
endSystemTimestamp - previousFrameSystemTimestamp)];

CFTimeInterval delay = ongoingDelayedFrame;
[frames addObject:currentFrameDelay];

// We need to calculate the intersections of the queried TimestampInterval
// (startSystemTimestamp - endSystemTimestamp) with the recorded frame delays. Doing that
Expand All @@ -160,6 +159,8 @@ - (CFTimeInterval)getFramesDelay:(uint64_t)startSystemTimestamp
NSDateInterval *queryDateInterval = [[NSDateInterval alloc] initWithStartDate:startDate
endDate:endDate];

CFTimeInterval delay = 0.0;

// Iterate in reverse order, as younger frame delays are more likely to match the queried
// period.
for (SentryDelayedFrame *frame in frames.reverseObjectEnumerator) {
Expand All @@ -170,25 +171,44 @@ - (CFTimeInterval)getFramesDelay:(uint64_t)startSystemTimestamp
break;
}

CFTimeInterval delayStartTime
= nanosecondsToTimeInterval(frame.startSystemTimestamp) + frame.expectedDuration;
NSDate *frameDelayStartDate =
[NSDate dateWithTimeIntervalSinceReferenceDate:delayStartTime];

NSDateInterval *frameDelayDateInterval = [[NSDateInterval alloc]
initWithStartDate:frameDelayStartDate
duration:(frame.actualDuration - frame.expectedDuration)];
delay = delay + [self calculateDelay:frame queryDateInterval:queryDateInterval];
}

if ([queryDateInterval intersectsDateInterval:frameDelayDateInterval]) {
NSDateInterval *intersection =
[queryDateInterval intersectionWithDateInterval:frameDelayDateInterval];
delay = delay + intersection.duration;
}
uint64_t queryTimeDuration = endSystemTimestamp - startSystemTimestamp;
if (queryTimeDuration < timeIntervalToNanoseconds(delay)) {
SENTRY_LOG_ERROR(
@"Not calculating frames delay because delay %f is longer than query time: %llu.",
delay, queryTimeDuration);
return cantCalculateFrameDelayReturnValue;
}

return delay;
}

- (CFTimeInterval)calculateDelay:(SentryDelayedFrame *)delayedFrame
queryDateInterval:(NSDateInterval *)queryDateInterval
{
CFTimeInterval delayStartTime = nanosecondsToTimeInterval(delayedFrame.startSystemTimestamp)
+ delayedFrame.expectedDuration;
NSDate *frameDelayStartDate = [NSDate dateWithTimeIntervalSinceReferenceDate:delayStartTime];

NSTimeInterval duration = delayedFrame.actualDuration - delayedFrame.expectedDuration;
if (duration < 0) {
return 0.0;
}

NSDateInterval *frameDelayDateInterval =
[[NSDateInterval alloc] initWithStartDate:frameDelayStartDate duration:duration];

if ([queryDateInterval intersectsDateInterval:frameDelayDateInterval]) {
NSDateInterval *intersection =
[queryDateInterval intersectionWithDateInterval:frameDelayDateInterval];
return intersection.duration;
} else {
return 0.0;
}
}

@end

NS_ASSUME_NONNULL_END
Expand Down
3 changes: 1 addition & 2 deletions Sources/Sentry/SentryFramesTracker.m
Original file line number Diff line number Diff line change
Expand Up @@ -240,8 +240,7 @@ - (CFTimeInterval)getFramesDelay:(uint64_t)startSystemTimestamp
return [self.delayedFramesTracker getFramesDelay:startSystemTimestamp
endSystemTimestamp:endSystemTimestamp
isRunning:_isRunning
thisFrameTimestamp:self.displayLinkWrapper.timestamp
previousFrameTimestamp:self.previousFrameTimestamp
previousFrameSystemTimestamp:self.previousFrameSystemTimestamp
slowFrameThreshold:slowFrameThreshold(_currentFrameRate)];
}

Expand Down
10 changes: 10 additions & 0 deletions Sources/Sentry/SentrySpan.m
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@ @implementation SentrySpan {
NSMutableDictionary<NSString *, id> *_tags;
NSObject *_stateLock;
BOOL _isFinished;
uint64_t _startSystemTime;
#if SENTRY_HAS_UIKIT
NSUInteger initTotalFrames;
NSUInteger initSlowFrames;
Expand All @@ -49,6 +50,7 @@ - (instancetype)initWithContext:(SentrySpanContext *)context
#endif // SENTRY_HAS_UIKIT
{
if (self = [super init]) {
_startSystemTime = SentryDependencyContainer.sharedInstance.dateProvider.systemTime;
self.startTimestamp = [SentryDependencyContainer.sharedInstance.dateProvider date];
_data = [[NSMutableDictionary alloc] init];

Expand Down Expand Up @@ -207,6 +209,14 @@ - (void)finishWithStatus:(SentrySpanStatus)status
#if SENTRY_HAS_UIKIT
if (_framesTracker.isRunning) {

CFTimeInterval framesDelay = [_framesTracker
getFramesDelay:_startSystemTime
endSystemTimestamp:SentryDependencyContainer.sharedInstance.dateProvider.systemTime];

if (framesDelay >= 0) {
[self setDataValue:@(framesDelay) forKey:@"frames.delay"];
}

SentryScreenFrames *currentFrames = _framesTracker.currentFrames;
NSInteger totalFrames = currentFrames.total - initTotalFrames;
NSInteger slowFrames = currentFrames.slow - initSlowFrames;
Expand Down
6 changes: 2 additions & 4 deletions Sources/Sentry/include/SentryDelayedFramesTracker.h
Original file line number Diff line number Diff line change
Expand Up @@ -45,17 +45,15 @@ SENTRY_NO_INIT
* delay.
* @param endSystemTimestamp The end system time stamp for the time interval to query frames delay.
* @param isRunning Wether the frames tracker is running or not.
* @param thisFrameTimestamp The timestamp of the current frame.
* @param previousFrameTimestamp The timestamp of the previous frame.
* @param previousFrameSystemTimestamp The system timestamp of the previous frame.
* @param slowFrameThreshold The threshold for a slow frame. For 60 fps this is roughly 16.67 ms.
*
* @return the frames delay duration or -1 if it can't calculate the frames delay.
*/
- (CFTimeInterval)getFramesDelay:(uint64_t)startSystemTimestamp
endSystemTimestamp:(uint64_t)endSystemTimestamp
isRunning:(BOOL)isRunning
thisFrameTimestamp:(CFTimeInterval)thisFrameTimestamp
previousFrameTimestamp:(CFTimeInterval)previousFrameTimestamp
previousFrameSystemTimestamp:(uint64_t)previousFrameSystemTimestamp
slowFrameThreshold:(CFTimeInterval)slowFrameThreshold;

@end
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -310,7 +310,7 @@ class SentryFramesTrackerTests: XCTestCase {

let startSystemTime = fixture.dateProvider.systemTime()

let delay = 0.5
let delay = 0.02
fixture.dateProvider.advance(by: delay)

let endSystemTime = fixture.dateProvider.systemTime()
Expand All @@ -321,6 +321,31 @@ class SentryFramesTrackerTests: XCTestCase {
expect(actualFrameDelay).to(beCloseTo(expectedDelay, within: 0.0001))
}

func testDelayedFrames_FrameIsDelayedSmallerThanSlowFrameThreshold_ReturnsDelay() {
fixture.dateProvider.advance(by: 2.0)

let sut = fixture.sut
sut.start()

let displayLink = fixture.displayLinkWrapper
displayLink.call()
displayLink.normalFrame()

fixture.dateProvider.advance(by: slowFrameThreshold(fixture.displayLinkWrapper.currentFrameRate.rawValue))

let startSystemTime = fixture.dateProvider.systemTime()

let delay = 0.0001
fixture.dateProvider.advance(by: delay)

let endSystemTime = fixture.dateProvider.systemTime()

let expectedDelay = delay

let actualFrameDelay = sut.getFramesDelay(startSystemTime, endSystemTimestamp: endSystemTime)
expect(actualFrameDelay).to(beCloseTo(expectedDelay, within: 0.0001))
}

private func testFrameDelay(timeIntervalAfterFrameStart: TimeInterval = 0.0, timeIntervalBeforeFrameEnd: TimeInterval = 0.0, expectedDelay: TimeInterval) {
let sut = fixture.sut
sut.start()
Expand Down
52 changes: 19 additions & 33 deletions Tests/SentryTests/Transaction/SentrySpanTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -499,30 +499,7 @@ class SentrySpanTests: XCTestCase {
expect(sut.data["frames.frozen"]) == nil
}

func testAddZeroSlowFrozenFrames_WhenSpanStartedBackgroundThread() {
let (displayLinkWrapper, framesTracker) = givenFramesTracker()

let expectation = expectation(description: "SpanStarted on a background thread")
DispatchQueue.global().async {
let sut = SentrySpan(context: SpanContext(operation: "TEST"), framesTracker: framesTracker)

let slow = 2
let frozen = 1
let normal = 100
displayLinkWrapper.renderFrames(slow, frozen, normal)

sut.finish()

expect(sut.data["frames.total"] as? NSNumber) == NSNumber(value: slow + frozen + normal)
expect(sut.data["frames.slow"] as? NSNumber) == NSNumber(value: slow)
expect(sut.data["frames.frozen"] as? NSNumber) == NSNumber(value: frozen)
expectation.fulfill()
}

wait(for: [expectation], timeout: 1.0)
}

func testAddSlowFrozenFramesToData_WithPreexistingCounts() {
func testAddFrameStatisticsToData_WithPreexistingCounts() {
let (displayLinkWrapper, framesTracker) = givenFramesTracker()
let preexistingSlow = 1
let preexistingFrozen = 2
Expand All @@ -531,16 +508,24 @@ class SentrySpanTests: XCTestCase {

let sut = SentrySpan(context: SpanContext(operation: "TEST"), framesTracker: framesTracker)

let slow = 7
let frozen = 8
let normal = 9
displayLinkWrapper.renderFrames(slow, frozen, normal)
let slowFrames = 1
let frozenFrames = 1
let normalFrames = 100
let totalFrames = slowFrames + frozenFrames + normalFrames
_ = displayLinkWrapper.slowestSlowFrame()
_ = displayLinkWrapper.fastestFrozenFrame()
displayLinkWrapper.renderFrames(0, 0, normalFrames)

sut.finish()

expect(sut.data["frames.total"] as? NSNumber) == NSNumber(value: slow + frozen + normal)
expect(sut.data["frames.slow"] as? NSNumber) == NSNumber(value: slow)
expect(sut.data["frames.frozen"] as? NSNumber) == NSNumber(value: frozen)
expect(sut.data["frames.total"] as? NSNumber) == NSNumber(value: totalFrames)
expect(sut.data["frames.slow"] as? NSNumber) == NSNumber(value: slowFrames)
expect(sut.data["frames.frozen"] as? NSNumber) == NSNumber(value: frozenFrames)

let expectedFrameDuration = slowFrameThreshold(displayLinkWrapper.currentFrameRate.rawValue)
let expectedDelay = displayLinkWrapper.slowestSlowFrameDuration + displayLinkWrapper.fastestFrozenFrameDuration - expectedFrameDuration * 2 as NSNumber

expect(sut.data["frames.delay"] as? NSNumber).to(beCloseTo(expectedDelay, within: 0.0001))
}

func testNoFramesTracker_NoFramesAddedToData() {
Expand All @@ -551,11 +536,12 @@ class SentrySpanTests: XCTestCase {
expect(sut.data["frames.total"]) == nil
expect(sut.data["frames.slow"]) == nil
expect(sut.data["frames.frozen"]) == nil
expect(sut.data["frames.delay"]) == nil
}

private func givenFramesTracker() -> (TestDisplayLinkWrapper, SentryFramesTracker) {
let displayLinkWrapper = TestDisplayLinkWrapper()
let framesTracker = SentryFramesTracker(displayLinkWrapper: displayLinkWrapper, dateProvider: TestCurrentDateProvider(), keepDelayedFramesDuration: 0)
let displayLinkWrapper = TestDisplayLinkWrapper(dateProvider: self.fixture.currentDateProvider)
let framesTracker = SentryFramesTracker(displayLinkWrapper: displayLinkWrapper, dateProvider: self.fixture.currentDateProvider, keepDelayedFramesDuration: 10)
framesTracker.start()
displayLinkWrapper.call()

Expand Down

0 comments on commit 36ad868

Please sign in to comment.