diff --git a/CHANGELOG.md b/CHANGELOG.md index 3b0c62a69d3..46f7a2d2cf3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -32,6 +32,7 @@ - Move `enableFileManagerSwizzling` from experimental options to top-level options (#6592). This option is still disabled by default and will be enabled in a future major release. - Move `enableDataSwizzling` from experimental options to top-level options (#6592). This option remains enabled by default. +- Add `sentry.replay_id` attribute to logs ([#6515](https://github.com/getsentry/sentry-cocoa/pull/6515)) ### Fixes diff --git a/SentryTestUtils/TestHub.swift b/SentryTestUtils/TestHub.swift index eb5e7b32957..686570508da 100644 --- a/SentryTestUtils/TestHub.swift +++ b/SentryTestUtils/TestHub.swift @@ -56,4 +56,12 @@ public class TestHub: SentryHub { capturedReplayRecordingVideo.record((replayEvent, replayRecording, videoURL)) onReplayCapture?() } +#if canImport(UIKit) && !SENTRY_NO_UIKIT +#if os(iOS) || os(tvOS) + public var mockReplayId: String? + public override func getSessionReplayId() -> String? { + return mockReplayId + } +#endif +#endif } diff --git a/Sources/Sentry/SentryHub.m b/Sources/Sentry/SentryHub.m index 21bb32289f4..c40fb648584 100644 --- a/Sources/Sentry/SentryHub.m +++ b/Sources/Sentry/SentryHub.m @@ -16,6 +16,7 @@ #import "SentrySamplingContext.h" #import "SentryScope+Private.h" #import "SentrySerialization.h" +#import "SentrySessionReplayIntegration+Private.h" #import "SentrySwift.h" #import "SentryTraceOrigin.h" #import "SentryTracer.h" @@ -849,6 +850,22 @@ - (void)unregisterSessionListener:(id)listener return integrations; } +#if SENTRY_TARGET_REPLAY_SUPPORTED +- (NSString *__nullable)getSessionReplayId +{ + SentrySessionReplayIntegration *integration = + [self getInstalledIntegration:[SentrySessionReplayIntegration class]]; + if (integration == nil || integration.sessionReplay == nil) { + return nil; + } + SentryId *replayId = integration.sessionReplay.sessionReplayId; + if (replayId == nil) { + return nil; + } + return replayId.sentryIdString; +} +#endif + @end NS_ASSUME_NONNULL_END diff --git a/Sources/Sentry/include/SentryHub+Private.h b/Sources/Sentry/include/SentryHub+Private.h index 7f67b813b63..ee963f788cd 100644 --- a/Sources/Sentry/include/SentryHub+Private.h +++ b/Sources/Sentry/include/SentryHub+Private.h @@ -82,6 +82,10 @@ NS_ASSUME_NONNULL_BEGIN - (void)unregisterSessionListener:(id)listener; - (nullable id)getInstalledIntegration:(Class)integrationClass; +#if SENTRY_TARGET_REPLAY_SUPPORTED +- (NSString *__nullable)getSessionReplayId; +#endif + @end NS_ASSUME_NONNULL_END diff --git a/Sources/Swift/Tools/SentryLogger.swift b/Sources/Swift/Tools/SentryLogger.swift index dd5bbff5d60..57a6c9fc1d1 100644 --- a/Sources/Swift/Tools/SentryLogger.swift +++ b/Sources/Swift/Tools/SentryLogger.swift @@ -198,6 +198,7 @@ public final class SentryLogger: NSObject { addOSAttributes(to: &logAttributes) addDeviceAttributes(to: &logAttributes) addUserAttributes(to: &logAttributes) + addReplayAttributes(to: &logAttributes) let propagationContextTraceIdString = hub.scope.propagationContextTraceIdString let propagationContextTraceId = SentryId(uuidString: propagationContextTraceIdString) @@ -280,6 +281,21 @@ public final class SentryLogger: NSObject { attributes["user.email"] = .init(string: userEmail) } } + + private func addReplayAttributes(to attributes: inout [String: SentryLog.Attribute]) { +#if canImport(UIKit) && !SENTRY_NO_UIKIT +#if os(iOS) || os(tvOS) + if let scopeReplayId = hub.scope.replayId { + // Session mode: use scope replay ID + attributes["sentry.replay_id"] = .init(string: scopeReplayId) + } else if let sessionReplayId = hub.getSessionReplayId() { + // Buffer mode: scope has no ID but integration does + attributes["sentry.replay_id"] = .init(string: sessionReplayId) + attributes["sentry._internal.replay_is_buffering"] = .init(boolean: true) + } +#endif +#endif + } } #if SWIFT_PACKAGE diff --git a/Tests/SentryTests/SentryHubTests.swift b/Tests/SentryTests/SentryHubTests.swift index 37849811b44..4d305287de5 100644 --- a/Tests/SentryTests/SentryHubTests.swift +++ b/Tests/SentryTests/SentryHubTests.swift @@ -1514,6 +1514,72 @@ class SentryHubTests: XCTestCase { XCTAssertEqual(expected, span.sampled) } + +#if canImport(UIKit) && !SENTRY_NO_UIKIT +#if os(iOS) || os(tvOS) + func testGetSessionReplayId_ReturnsNilWhenIntegrationNotInstalled() { + let result = sut.getSessionReplayId() + XCTAssertNil(result) + } + + func testGetSessionReplayId_ReturnsNilWhenSessionReplayIsNil() { + let integration = SentrySessionReplayIntegration() + sut.addInstalledIntegration(integration, name: "SentrySessionReplayIntegration") + + let result = sut.getSessionReplayId() + + XCTAssertNil(result) + } + + func testGetSessionReplayId_ReturnsNilWhenSessionReplayIdIsNil() { + let integration = SentrySessionReplayIntegration() + let mockSessionReplay = createMockSessionReplay() + Dynamic(integration).sessionReplay = mockSessionReplay + sut.addInstalledIntegration(integration, name: "SentrySessionReplayIntegration") + + let result = sut.getSessionReplayId() + + XCTAssertNil(result) + } + + func testGetSessionReplayId_ReturnsIdStringWhenSessionReplayIdExists() { + let integration = SentrySessionReplayIntegration() + let mockSessionReplay = createMockSessionReplay() + let rootView = UIView() + mockSessionReplay.start(rootView: rootView, fullSession: true) + + Dynamic(integration).sessionReplay = mockSessionReplay + sut.addInstalledIntegration(integration, name: "SentrySessionReplayIntegration") + + let result = sut.getSessionReplayId() + + XCTAssertNotNil(result) + XCTAssertEqual(result, mockSessionReplay.sessionReplayId?.sentryIdString) + } + + private func createMockSessionReplay() -> MockSentrySessionReplay { + return MockSentrySessionReplay() + } + + private class MockSentrySessionReplay: SentrySessionReplay { + init() { + super.init( + replayOptions: SentryReplayOptions(sessionSampleRate: 0, onErrorSampleRate: 0), + experimentalOptions: SentryExperimentalOptions(), + replayFolderPath: FileManager.default.temporaryDirectory, + screenshotProvider: MockScreenshotProvider(), + replayMaker: MockReplayMaker(), + breadcrumbConverter: SentrySRDefaultBreadcrumbConverter(), + touchTracker: nil, + dateProvider: TestCurrentDateProvider(), + delegate: MockReplayDelegate(), + displayLinkWrapper: TestDisplayLinkWrapper(), + environmentChecker: TestSessionReplayEnvironmentChecker(mockedIsReliableReturnValue: true) + ) + } + } +#endif +#endif } #if os(iOS) || os(tvOS) || targetEnvironment(macCatalyst) @@ -1527,6 +1593,30 @@ class TestTimeToDisplayTracker: SentryTimeToDisplayTracker { override func reportFullyDisplayed() { registerFullDisplayCalled = true } - } #endif + +#if canImport(UIKit) && !SENTRY_NO_UIKIT +#if os(iOS) || os(tvOS) +private class MockScreenshotProvider: NSObject, SentryViewScreenshotProvider { + func image(view: UIView, onComplete: @escaping Sentry.ScreenshotCallback) { + onComplete(UIImage()) + } +} + +private class MockReplayDelegate: NSObject, SentrySessionReplayDelegate { + func sessionReplayShouldCaptureReplayForError() -> Bool { return true } + func sessionReplayNewSegment(replayEvent: SentryReplayEvent, replayRecording: SentryReplayRecording, videoUrl: URL) {} + func sessionReplayStarted(replayId: SentryId) {} + func breadcrumbsForSessionReplay() -> [Breadcrumb] { return [] } + func currentScreenNameForSessionReplay() -> String? { return nil } +} + +private class MockReplayMaker: NSObject, SentryReplayVideoMaker { + func createVideoInBackgroundWith(beginning: Date, end: Date, completion: @escaping ([Sentry.SentryVideoInfo]) -> Void) {} + func createVideoWith(beginning: Date, end: Date) -> [Sentry.SentryVideoInfo] { return [] } + func addFrameAsync(timestamp: Date, maskedViewImage: UIImage, forScreen: String?) {} + func releaseFramesUntil(_ date: Date) {} +} +#endif +#endif diff --git a/Tests/SentryTests/SentryLoggerTests.swift b/Tests/SentryTests/SentryLoggerTests.swift index 3a298b0f188..086de049294 100644 --- a/Tests/SentryTests/SentryLoggerTests.swift +++ b/Tests/SentryTests/SentryLoggerTests.swift @@ -775,6 +775,68 @@ final class SentryLoggerTests: XCTestCase { XCTAssertNil(nilCallback, "Dynamic access should allow setting to nil") } + // MARK: - Replay Attributes Tests +#if canImport(UIKit) && !SENTRY_NO_UIKIT +#if os(iOS) || os(tvOS) + func testReplayAttributes_SessionMode_AddsReplayId() { + // Setup replay integration + let replayOptions = SentryReplayOptions(sessionSampleRate: 1.0, onErrorSampleRate: 0.0) + fixture.options.sessionReplay = replayOptions + + let replayIntegration = SentrySessionReplayIntegration() + fixture.hub.addInstalledIntegration(replayIntegration, name: "SentrySessionReplayIntegration") + + // Set replayId on scope (session mode) + let replayId = "12345678-1234-1234-1234-123456789012" + fixture.scope.replayId = replayId + + sut.info("Test message") + + let log = getLastCapturedLog() + XCTAssertEqual(log.attributes["sentry.replay_id"]?.value as? String, replayId) + XCTAssertNil(log.attributes["sentry._internal.replay_is_buffering"]) + } + + func testReplayAttributes_BufferMode_AddsReplayIdAndBufferingFlag() { + // Set up buffer mode: hub has an ID, but scope.replayId is nil + let mockReplayId = SentryId() + fixture.hub.mockReplayId = mockReplayId.sentryIdString + fixture.scope.replayId = nil + + sut.info("Test message") + + let log = getLastCapturedLog() + let replayIdString = log.attributes["sentry.replay_id"]?.value as? String + XCTAssertEqual(replayIdString, mockReplayId.sentryIdString) + XCTAssertEqual(log.attributes["sentry._internal.replay_is_buffering"]?.value as? Bool, true) + } + + func testReplayAttributes_NoReplay_NoAttributesAdded() { + // Don't set up replay integration + + sut.info("Test message") + + let log = getLastCapturedLog() + XCTAssertNil(log.attributes["sentry.replay_id"]) + XCTAssertNil(log.attributes["sentry._internal.replay_is_buffering"]) + } + + func testReplayAttributes_BothSessionAndScopeReplayId_SessionMode() { + // Session mode: scope has the ID, hub also has one + let replayId = "12345678-1234-1234-1234-123456789012" + fixture.hub.mockReplayId = replayId + fixture.scope.replayId = replayId + + sut.info("Test message") + + let log = getLastCapturedLog() + // Session mode should use scope's ID (takes precedence) and not add buffering flag + XCTAssertEqual(log.attributes["sentry.replay_id"]?.value as? String, replayId) + XCTAssertNil(log.attributes["sentry._internal.replay_is_buffering"]) + } +#endif +#endif + // MARK: - Helper Methods private func assertLogCaptured(