Skip to content

Commit 995b436

Browse files
authored
Add sentry.replay_id attribute to logs (#6515)
1 parent 3da30a9 commit 995b436

File tree

7 files changed

+199
-1
lines changed

7 files changed

+199
-1
lines changed

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,7 @@
3333
- Move `enableFileManagerSwizzling` from experimental options to top-level options (#6592).
3434
This option is still disabled by default and will be enabled in a future major release.
3535
- Move `enableDataSwizzling` from experimental options to top-level options (#6592). This option remains enabled by default.
36+
- Add `sentry.replay_id` attribute to logs ([#6515](https://github.com/getsentry/sentry-cocoa/pull/6515))
3637

3738
### Fixes
3839

SentryTestUtils/TestHub.swift

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -56,4 +56,12 @@ public class TestHub: SentryHub {
5656
capturedReplayRecordingVideo.record((replayEvent, replayRecording, videoURL))
5757
onReplayCapture?()
5858
}
59+
#if canImport(UIKit) && !SENTRY_NO_UIKIT
60+
#if os(iOS) || os(tvOS)
61+
public var mockReplayId: String?
62+
public override func getSessionReplayId() -> String? {
63+
return mockReplayId
64+
}
65+
#endif
66+
#endif
5967
}

Sources/Sentry/SentryHub.m

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@
1616
#import "SentrySamplingContext.h"
1717
#import "SentryScope+Private.h"
1818
#import "SentrySerialization.h"
19+
#import "SentrySessionReplayIntegration+Private.h"
1920
#import "SentrySwift.h"
2021
#import "SentryTraceOrigin.h"
2122
#import "SentryTracer.h"
@@ -849,6 +850,22 @@ - (void)unregisterSessionListener:(id<SentrySessionListener>)listener
849850
return integrations;
850851
}
851852

853+
#if SENTRY_TARGET_REPLAY_SUPPORTED
854+
- (NSString *__nullable)getSessionReplayId
855+
{
856+
SentrySessionReplayIntegration *integration =
857+
[self getInstalledIntegration:[SentrySessionReplayIntegration class]];
858+
if (integration == nil || integration.sessionReplay == nil) {
859+
return nil;
860+
}
861+
SentryId *replayId = integration.sessionReplay.sessionReplayId;
862+
if (replayId == nil) {
863+
return nil;
864+
}
865+
return replayId.sentryIdString;
866+
}
867+
#endif
868+
852869
@end
853870

854871
NS_ASSUME_NONNULL_END

Sources/Sentry/include/SentryHub+Private.h

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -82,6 +82,10 @@ NS_ASSUME_NONNULL_BEGIN
8282
- (void)unregisterSessionListener:(id<SentrySessionListener>)listener;
8383
- (nullable id<SentryIntegrationProtocol>)getInstalledIntegration:(Class)integrationClass;
8484

85+
#if SENTRY_TARGET_REPLAY_SUPPORTED
86+
- (NSString *__nullable)getSessionReplayId;
87+
#endif
88+
8589
@end
8690

8791
NS_ASSUME_NONNULL_END

Sources/Swift/Tools/SentryLogger.swift

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -198,6 +198,7 @@ public final class SentryLogger: NSObject {
198198
addOSAttributes(to: &logAttributes)
199199
addDeviceAttributes(to: &logAttributes)
200200
addUserAttributes(to: &logAttributes)
201+
addReplayAttributes(to: &logAttributes)
201202

202203
let propagationContextTraceIdString = hub.scope.propagationContextTraceIdString
203204
let propagationContextTraceId = SentryId(uuidString: propagationContextTraceIdString)
@@ -280,6 +281,21 @@ public final class SentryLogger: NSObject {
280281
attributes["user.email"] = .init(string: userEmail)
281282
}
282283
}
284+
285+
private func addReplayAttributes(to attributes: inout [String: SentryLog.Attribute]) {
286+
#if canImport(UIKit) && !SENTRY_NO_UIKIT
287+
#if os(iOS) || os(tvOS)
288+
if let scopeReplayId = hub.scope.replayId {
289+
// Session mode: use scope replay ID
290+
attributes["sentry.replay_id"] = .init(string: scopeReplayId)
291+
} else if let sessionReplayId = hub.getSessionReplayId() {
292+
// Buffer mode: scope has no ID but integration does
293+
attributes["sentry.replay_id"] = .init(string: sessionReplayId)
294+
attributes["sentry._internal.replay_is_buffering"] = .init(boolean: true)
295+
}
296+
#endif
297+
#endif
298+
}
283299
}
284300

285301
#if SWIFT_PACKAGE

Tests/SentryTests/SentryHubTests.swift

Lines changed: 91 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1514,6 +1514,72 @@ class SentryHubTests: XCTestCase {
15141514

15151515
XCTAssertEqual(expected, span.sampled)
15161516
}
1517+
1518+
#if canImport(UIKit) && !SENTRY_NO_UIKIT
1519+
#if os(iOS) || os(tvOS)
1520+
func testGetSessionReplayId_ReturnsNilWhenIntegrationNotInstalled() {
1521+
let result = sut.getSessionReplayId()
1522+
XCTAssertNil(result)
1523+
}
1524+
1525+
func testGetSessionReplayId_ReturnsNilWhenSessionReplayIsNil() {
1526+
let integration = SentrySessionReplayIntegration()
1527+
sut.addInstalledIntegration(integration, name: "SentrySessionReplayIntegration")
1528+
1529+
let result = sut.getSessionReplayId()
1530+
1531+
XCTAssertNil(result)
1532+
}
1533+
1534+
func testGetSessionReplayId_ReturnsNilWhenSessionReplayIdIsNil() {
1535+
let integration = SentrySessionReplayIntegration()
1536+
let mockSessionReplay = createMockSessionReplay()
1537+
Dynamic(integration).sessionReplay = mockSessionReplay
1538+
sut.addInstalledIntegration(integration, name: "SentrySessionReplayIntegration")
1539+
1540+
let result = sut.getSessionReplayId()
1541+
1542+
XCTAssertNil(result)
1543+
}
1544+
1545+
func testGetSessionReplayId_ReturnsIdStringWhenSessionReplayIdExists() {
1546+
let integration = SentrySessionReplayIntegration()
1547+
let mockSessionReplay = createMockSessionReplay()
1548+
let rootView = UIView()
1549+
mockSessionReplay.start(rootView: rootView, fullSession: true)
1550+
1551+
Dynamic(integration).sessionReplay = mockSessionReplay
1552+
sut.addInstalledIntegration(integration, name: "SentrySessionReplayIntegration")
1553+
1554+
let result = sut.getSessionReplayId()
1555+
1556+
XCTAssertNotNil(result)
1557+
XCTAssertEqual(result, mockSessionReplay.sessionReplayId?.sentryIdString)
1558+
}
1559+
1560+
private func createMockSessionReplay() -> MockSentrySessionReplay {
1561+
return MockSentrySessionReplay()
1562+
}
1563+
1564+
private class MockSentrySessionReplay: SentrySessionReplay {
1565+
init() {
1566+
super.init(
1567+
replayOptions: SentryReplayOptions(sessionSampleRate: 0, onErrorSampleRate: 0),
1568+
experimentalOptions: SentryExperimentalOptions(),
1569+
replayFolderPath: FileManager.default.temporaryDirectory,
1570+
screenshotProvider: MockScreenshotProvider(),
1571+
replayMaker: MockReplayMaker(),
1572+
breadcrumbConverter: SentrySRDefaultBreadcrumbConverter(),
1573+
touchTracker: nil,
1574+
dateProvider: TestCurrentDateProvider(),
1575+
delegate: MockReplayDelegate(),
1576+
displayLinkWrapper: TestDisplayLinkWrapper(),
1577+
environmentChecker: TestSessionReplayEnvironmentChecker(mockedIsReliableReturnValue: true)
1578+
)
1579+
}
1580+
}
1581+
#endif
1582+
#endif
15171583
}
15181584

15191585
#if os(iOS) || os(tvOS) || targetEnvironment(macCatalyst)
@@ -1527,6 +1593,30 @@ class TestTimeToDisplayTracker: SentryTimeToDisplayTracker {
15271593
override func reportFullyDisplayed() {
15281594
registerFullDisplayCalled = true
15291595
}
1530-
15311596
}
15321597
#endif
1598+
1599+
#if canImport(UIKit) && !SENTRY_NO_UIKIT
1600+
#if os(iOS) || os(tvOS)
1601+
private class MockScreenshotProvider: NSObject, SentryViewScreenshotProvider {
1602+
func image(view: UIView, onComplete: @escaping Sentry.ScreenshotCallback) {
1603+
onComplete(UIImage())
1604+
}
1605+
}
1606+
1607+
private class MockReplayDelegate: NSObject, SentrySessionReplayDelegate {
1608+
func sessionReplayShouldCaptureReplayForError() -> Bool { return true }
1609+
func sessionReplayNewSegment(replayEvent: SentryReplayEvent, replayRecording: SentryReplayRecording, videoUrl: URL) {}
1610+
func sessionReplayStarted(replayId: SentryId) {}
1611+
func breadcrumbsForSessionReplay() -> [Breadcrumb] { return [] }
1612+
func currentScreenNameForSessionReplay() -> String? { return nil }
1613+
}
1614+
1615+
private class MockReplayMaker: NSObject, SentryReplayVideoMaker {
1616+
func createVideoInBackgroundWith(beginning: Date, end: Date, completion: @escaping ([Sentry.SentryVideoInfo]) -> Void) {}
1617+
func createVideoWith(beginning: Date, end: Date) -> [Sentry.SentryVideoInfo] { return [] }
1618+
func addFrameAsync(timestamp: Date, maskedViewImage: UIImage, forScreen: String?) {}
1619+
func releaseFramesUntil(_ date: Date) {}
1620+
}
1621+
#endif
1622+
#endif

Tests/SentryTests/SentryLoggerTests.swift

Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -775,6 +775,68 @@ final class SentryLoggerTests: XCTestCase {
775775
XCTAssertNil(nilCallback, "Dynamic access should allow setting to nil")
776776
}
777777

778+
// MARK: - Replay Attributes Tests
779+
#if canImport(UIKit) && !SENTRY_NO_UIKIT
780+
#if os(iOS) || os(tvOS)
781+
func testReplayAttributes_SessionMode_AddsReplayId() {
782+
// Setup replay integration
783+
let replayOptions = SentryReplayOptions(sessionSampleRate: 1.0, onErrorSampleRate: 0.0)
784+
fixture.options.sessionReplay = replayOptions
785+
786+
let replayIntegration = SentrySessionReplayIntegration()
787+
fixture.hub.addInstalledIntegration(replayIntegration, name: "SentrySessionReplayIntegration")
788+
789+
// Set replayId on scope (session mode)
790+
let replayId = "12345678-1234-1234-1234-123456789012"
791+
fixture.scope.replayId = replayId
792+
793+
sut.info("Test message")
794+
795+
let log = getLastCapturedLog()
796+
XCTAssertEqual(log.attributes["sentry.replay_id"]?.value as? String, replayId)
797+
XCTAssertNil(log.attributes["sentry._internal.replay_is_buffering"])
798+
}
799+
800+
func testReplayAttributes_BufferMode_AddsReplayIdAndBufferingFlag() {
801+
// Set up buffer mode: hub has an ID, but scope.replayId is nil
802+
let mockReplayId = SentryId()
803+
fixture.hub.mockReplayId = mockReplayId.sentryIdString
804+
fixture.scope.replayId = nil
805+
806+
sut.info("Test message")
807+
808+
let log = getLastCapturedLog()
809+
let replayIdString = log.attributes["sentry.replay_id"]?.value as? String
810+
XCTAssertEqual(replayIdString, mockReplayId.sentryIdString)
811+
XCTAssertEqual(log.attributes["sentry._internal.replay_is_buffering"]?.value as? Bool, true)
812+
}
813+
814+
func testReplayAttributes_NoReplay_NoAttributesAdded() {
815+
// Don't set up replay integration
816+
817+
sut.info("Test message")
818+
819+
let log = getLastCapturedLog()
820+
XCTAssertNil(log.attributes["sentry.replay_id"])
821+
XCTAssertNil(log.attributes["sentry._internal.replay_is_buffering"])
822+
}
823+
824+
func testReplayAttributes_BothSessionAndScopeReplayId_SessionMode() {
825+
// Session mode: scope has the ID, hub also has one
826+
let replayId = "12345678-1234-1234-1234-123456789012"
827+
fixture.hub.mockReplayId = replayId
828+
fixture.scope.replayId = replayId
829+
830+
sut.info("Test message")
831+
832+
let log = getLastCapturedLog()
833+
// Session mode should use scope's ID (takes precedence) and not add buffering flag
834+
XCTAssertEqual(log.attributes["sentry.replay_id"]?.value as? String, replayId)
835+
XCTAssertNil(log.attributes["sentry._internal.replay_is_buffering"])
836+
}
837+
#endif
838+
#endif
839+
778840
// MARK: - Helper Methods
779841

780842
private func assertLogCaptured(

0 commit comments

Comments
 (0)