Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@

- Add SentryDistribution as Swift Package Manager target (#6149)
- Add option `enablePropagateTraceparent` to support OTel/W3C trace propagation (#6356)
- Add `sentry.replay_id` attribute to logs ([#6515](https://github.com/getsentry/sentry-cocoa/pull/6515))

### Fixes

Expand Down
8 changes: 8 additions & 0 deletions SentryTestUtils/TestHub.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
17 changes: 17 additions & 0 deletions Sources/Sentry/SentryHub.m
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -843,6 +844,22 @@ - (void)unregisterSessionListener:(id<SentrySessionListener>)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
4 changes: 4 additions & 0 deletions Sources/Sentry/include/SentryHub+Private.h
Original file line number Diff line number Diff line change
Expand Up @@ -82,6 +82,10 @@ NS_ASSUME_NONNULL_BEGIN
- (void)unregisterSessionListener:(id<SentrySessionListener>)listener;
- (nullable id<SentryIntegrationProtocol>)getInstalledIntegration:(Class)integrationClass;

#if SENTRY_TARGET_REPLAY_SUPPORTED
- (NSString *__nullable)getSessionReplayId;
#endif

@end

NS_ASSUME_NONNULL_END
16 changes: 16 additions & 0 deletions Sources/Swift/Tools/SentryLogger.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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
Expand Down
92 changes: 91 additions & 1 deletion Tests/SentryTests/SentryHubTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -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
62 changes: 62 additions & 0 deletions Tests/SentryTests/SentryLoggerTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down
Loading