From dc4d80f4bd78afe63100164e01ddff228988cbc0 Mon Sep 17 00:00:00 2001 From: Philipp Hofmann Date: Mon, 3 Nov 2025 11:32:44 +0100 Subject: [PATCH 1/7] fix: HTTP Client errors mark session as errored Automatically captured HTTP client errors now mark sessions as errored. Fixes GH-3742 --- CHANGELOG.md | 1 + SentryTestUtils/TestClient.swift | 8 ++- SentryTestUtils/TestHub.swift | 7 ++ Sources/Sentry/SentryClient.m | 34 ++++----- Sources/Sentry/SentryHub.m | 72 ++++++++++++++----- Sources/Sentry/SentryNetworkTracker.m | 2 +- Sources/Sentry/include/SentryClient+Private.h | 5 ++ Sources/Sentry/include/SentryHub+Private.h | 2 + .../Network/SentryNetworkTrackerTests.swift | 53 +++++++------- Tests/SentryTests/SentryHubTests.swift | 43 ++++++++++- 10 files changed, 162 insertions(+), 65 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 52186a47385..58cc78bb97d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -25,6 +25,7 @@ - Enable enablePreWarmedAppStartTracing by default (#6508). With this option enabled, the SDK collects [prewarmed app starts](https://docs.sentry.io/platforms/apple/tracing/instrumentation/automatic-instrumentation/#prewarmed-app-start-tracing). - Change `value` and `type` of `SentryException` to be nullable (#6563) - Change the default trace context status to "ok" instead of "undefined" (#6611) +- [HTTP Client errors](https://docs.sentry.io/platforms/apple/guides/ios/configuration/http-client-errors/) now mark sessions as errored (#6631) ### Features diff --git a/SentryTestUtils/TestClient.swift b/SentryTestUtils/TestClient.swift index 38e6865fd9f..a0895113a84 100644 --- a/SentryTestUtils/TestClient.swift +++ b/SentryTestUtils/TestClient.swift @@ -111,7 +111,13 @@ public class TestClient: SentryClient { captureExceptionWithSessionInvocations.record((exception, callSessionBlockWithIncrementSessionErrors ? sessionBlock() : nil, scope)) return SentryId() } - + + @_spi(Private) public var captureErrorEventWithSessionInvocations = Invocations<(event: Event, session: SentrySession?, scope: Scope)>() + @_spi(Private) public override func captureErrorEvent(event: Event, scope: Scope, incrementSessionErrors sessionBlock: @escaping () -> SentrySession) -> SentryId { + captureErrorEventWithSessionInvocations.record((event, callSessionBlockWithIncrementSessionErrors ? sessionBlock() : nil, scope)) + return SentryId() + } + public var captureFatalEventInvocations = Invocations<(event: Event, scope: Scope)>() public override func captureFatalEvent(_ event: Event, with scope: Scope) -> SentryId { captureFatalEventInvocations.record((event, scope)) diff --git a/SentryTestUtils/TestHub.swift b/SentryTestUtils/TestHub.swift index eb5e7b32957..d5990bef18f 100644 --- a/SentryTestUtils/TestHub.swift +++ b/SentryTestUtils/TestHub.swift @@ -44,6 +44,13 @@ public class TestHub: SentryHub { return event.eventId } + @_spi(Private) public var capturedErrorEvents = Invocations() + public override func captureErrorEvent(event: Event) -> SentryId { + self.capturedErrorEvents.record((event)) + + return event.eventId + } + public var capturedTransactionsWithScope = Invocations<(transaction: [String: Any], scope: Scope)>() public override func capture(_ transaction: Transaction, with scope: Scope) { capturedTransactionsWithScope.record((transaction.serialize(), scope)) diff --git a/Sources/Sentry/SentryClient.m b/Sources/Sentry/SentryClient.m index 0292cef3e5c..32eda0b9264 100644 --- a/Sources/Sentry/SentryClient.m +++ b/Sources/Sentry/SentryClient.m @@ -189,14 +189,7 @@ - (SentryId *)captureException:(NSException *)exception incrementSessionErrors:(SentrySession * (^)(void))sessionBlock { SentryEvent *event = [self buildExceptionEvent:exception]; - event = [self prepareEvent:event withScope:scope alwaysAttachStacktrace:YES]; - - if (event != nil) { - SentrySession *session = sessionBlock(); - return [self sendEvent:event withSession:session withScope:scope]; - } - - return SentryId.empty; + return [self captureErrorEvent:event withScope:scope incrementSessionErrors:sessionBlock]; } - (SentryEvent *)buildExceptionEvent:(NSException *)exception @@ -235,14 +228,7 @@ - (SentryId *)captureError:(NSError *)error incrementSessionErrors:(SentrySession * (^)(void))sessionBlock { SentryEvent *event = [self buildErrorEvent:error]; - event = [self prepareEvent:event withScope:scope alwaysAttachStacktrace:YES]; - - if (event != nil) { - SentrySession *session = sessionBlock(); - return [self sendEvent:event withSession:session withScope:scope]; - } - - return SentryId.empty; + return [self captureErrorEvent:event withScope:scope incrementSessionErrors:sessionBlock]; } - (SentryEvent *)buildErrorEvent:(NSError *)error @@ -391,6 +377,22 @@ - (SentryId *)captureEvent:(SentryEvent *)event additionalEnvelopeItems:additionalEnvelopeItems]; } +- (SentryId *)captureErrorEvent:(SentryEvent *)event + withScope:(SentryScope *)scope + incrementSessionErrors:(SentrySession * (^)(void))sessionBlock +{ + SentryEvent *preparedEvent = [self prepareEvent:event + withScope:scope + alwaysAttachStacktrace:YES]; + + if (preparedEvent != nil) { + SentrySession *session = sessionBlock(); + return [self sendEvent:preparedEvent withSession:session withScope:scope]; + } + + return SentryId.empty; +} + - (SentryId *)sendEvent:(SentryEvent *)event withScope:(SentryScope *)scope alwaysAttachStacktrace:(BOOL)alwaysAttachStacktrace diff --git a/Sources/Sentry/SentryHub.m b/Sources/Sentry/SentryHub.m index 21bb32289f4..264013de8e7 100644 --- a/Sources/Sentry/SentryHub.m +++ b/Sources/Sentry/SentryHub.m @@ -488,19 +488,36 @@ - (SentryId *)captureError:(NSError *)error - (SentryId *)captureError:(NSError *)error withScope:(SentryScope *)scope { - SentrySession *currentSession = _session; - SentryClient *client = self.client; - if (client != nil) { - if (currentSession != nil) { - return [client captureError:error - withScope:scope - incrementSessionErrors:^(void) { return [self incrementSessionErrors]; }]; - } else { - _errorsBeforeSession++; - return [client captureError:error withScope:scope]; - } - } - return SentryId.empty; + SentryId * (^captureClientBlock)(SentryClient *) = ^SentryId *(SentryClient *clientParam) { + return [clientParam captureError:error + withScope:scope + incrementSessionErrors:^(void) { return [self incrementSessionErrors]; }]; + }; + + SentryId * (^captureClientBlockSessionNil)(SentryClient *) = ^SentryId *( + SentryClient *clientParam) { return [clientParam captureError:error withScope:scope]; }; + + return [self captureEventIncrementingSessionErrors:scope + captureClientBlock:captureClientBlock + captureClientSessionNilBlock:captureClientBlockSessionNil]; +} + +- (SentryId *)captureErrorEvent:(SentryEvent *)event +{ + SentryScope *scope = self.scope; + + SentryId * (^captureClientBlock)(SentryClient *) = ^SentryId *(SentryClient *clientParam) { + return [clientParam captureErrorEvent:event + withScope:scope + incrementSessionErrors:^(void) { return [self incrementSessionErrors]; }]; + }; + + SentryId * (^captureClientBlockSessionNil)(SentryClient *) = ^SentryId *( + SentryClient *clientParam) { return [clientParam captureEvent:event withScope:scope]; }; + + return [self captureEventIncrementingSessionErrors:scope + captureClientBlock:captureClientBlock + captureClientSessionNilBlock:captureClientBlockSessionNil]; } - (SentryId *)captureException:(NSException *)exception @@ -510,16 +527,37 @@ - (SentryId *)captureException:(NSException *)exception - (SentryId *)captureException:(NSException *)exception withScope:(SentryScope *)scope { + SentryId * (^captureClientBlock)(SentryClient *) = ^SentryId *(SentryClient *clientParam) { + return [clientParam captureException:exception + withScope:scope + incrementSessionErrors:^(void) { return [self incrementSessionErrors]; }]; + }; + + SentryId * (^captureClientBlockSessionNil)(SentryClient *) + = ^SentryId *(SentryClient *clientParam) { + return [clientParam captureException:exception withScope:scope]; + }; + + return [self captureEventIncrementingSessionErrors:scope + captureClientBlock:captureClientBlock + captureClientSessionNilBlock:captureClientBlockSessionNil]; +} + +- (SentryId *)captureEventIncrementingSessionErrors:(SentryScope *)scope + captureClientBlock:(SentryId * (^)( + SentryClient *))captureClientBlock + captureClientSessionNilBlock: + (SentryId * (^)(SentryClient *))captureClientSessionNilBlock +{ + SentrySession *currentSession = _session; SentryClient *client = self.client; if (client != nil) { if (currentSession != nil) { - return [client captureException:exception - withScope:scope - incrementSessionErrors:^(void) { return [self incrementSessionErrors]; }]; + return captureClientBlock(client); } else { _errorsBeforeSession++; - return [client captureException:exception withScope:scope]; + return captureClientSessionNilBlock(client); } } return SentryId.empty; diff --git a/Sources/Sentry/SentryNetworkTracker.m b/Sources/Sentry/SentryNetworkTracker.m index 9a4dc00bff5..2e761c41639 100644 --- a/Sources/Sentry/SentryNetworkTracker.m +++ b/Sources/Sentry/SentryNetworkTracker.m @@ -395,7 +395,7 @@ - (void)captureFailedRequests:(NSURLSessionTask *)sessionTask event.context = context; - [SentrySDK captureEvent:event]; + [SentrySDKInternal.currentHub captureErrorEvent:event]; } - (BOOL)containsStatusCode:(NSInteger)statusCode diff --git a/Sources/Sentry/include/SentryClient+Private.h b/Sources/Sentry/include/SentryClient+Private.h index 4d8bef564a4..fd4874c1449 100644 --- a/Sources/Sentry/include/SentryClient+Private.h +++ b/Sources/Sentry/include/SentryClient+Private.h @@ -56,6 +56,11 @@ NS_ASSUME_NONNULL_BEGIN additionalEnvelopeItems:(NSArray *)additionalEnvelopeItems NS_SWIFT_NAME(capture(event:scope:additionalEnvelopeItems:)); +- (SentryId *)captureErrorEvent:(SentryEvent *)event + withScope:(SentryScope *)scope + incrementSessionErrors:(SentrySession * (^)(void))sessionBlock + NS_SWIFT_NAME(captureErrorEvent(event:scope:incrementSessionErrors:)); + - (void)captureReplayEvent:(SentryReplayEvent *)replayEvent replayRecording:(SentryReplayRecording *)replayRecording video:(NSURL *)videoURL diff --git a/Sources/Sentry/include/SentryHub+Private.h b/Sources/Sentry/include/SentryHub+Private.h index 7f67b813b63..1e786d39997 100644 --- a/Sources/Sentry/include/SentryHub+Private.h +++ b/Sources/Sentry/include/SentryHub+Private.h @@ -64,6 +64,8 @@ NS_ASSUME_NONNULL_BEGIN additionalEnvelopeItems:(NSArray *)additionalEnvelopeItems NS_SWIFT_NAME(capture(event:scope:additionalEnvelopeItems:)); +- (SentryId *)captureErrorEvent:(SentryEvent *)event NS_SWIFT_NAME(captureErrorEvent(event:)); + - (void)captureSerializedFeedback:(NSDictionary *)serializedFeedback withEventId:(NSString *)feedbackEventId attachments:(NSArray *)feedbackAttachments; diff --git a/Tests/SentryTests/Integrations/Performance/Network/SentryNetworkTrackerTests.swift b/Tests/SentryTests/Integrations/Performance/Network/SentryNetworkTrackerTests.swift index 16069b019ce..e7dacf975f4 100644 --- a/Tests/SentryTests/Integrations/Performance/Network/SentryNetworkTrackerTests.swift +++ b/Tests/SentryTests/Integrations/Performance/Network/SentryNetworkTrackerTests.swift @@ -1015,11 +1015,9 @@ class SentryNetworkTrackerTests: XCTestCase { sut.urlSessionTask(task, setState: .completed) - guard let envelope = self.fixture.hub.capturedEventsWithScopes.first else { - XCTFail("Expected to capture 1 event") - return - } - let sentryRequest = try XCTUnwrap(envelope.event.request) + XCTAssertEqual(self.fixture.hub.capturedErrorEvents.count, 1, "Expected only one error event to be captured") + let capturedErrorEvent = try XCTUnwrap(self.fixture.hub.capturedErrorEvents.first) + let sentryRequest = try XCTUnwrap(capturedErrorEvent.request) XCTAssertEqual(sentryRequest.url, "https://www.domain.com/api") XCTAssertEqual(sentryRequest.method, "GET") @@ -1052,13 +1050,11 @@ class SentryNetworkTrackerTests: XCTestCase { sut.urlSessionTask(task, setState: .completed) - let envelope = try XCTUnwrap( - fixture.hub.capturedEventsWithScopes.first, - "Expected to capture 1 event" - ) - + XCTAssertEqual(self.fixture.hub.capturedErrorEvents.count, 1, "Expected only one error event to be captured") + let capturedErrorEvent = try XCTUnwrap(fixture.hub.capturedErrorEvents.first) + let graphQLContext = try XCTUnwrap( - envelope.event.context?["graphql"], + capturedErrorEvent.context?["graphql"], "Expected 'graphql' object in context" ) @@ -1082,11 +1078,10 @@ class SentryNetworkTrackerTests: XCTestCase { task.setResponse(try createResponse(code: 500)) sut.urlSessionTask(task, setState: .completed) - guard let envelope = self.fixture.hub.capturedEventsWithScopes.first else { - XCTFail("Expected to capture 1 event") - return - } - let sentryRequest = try XCTUnwrap(envelope.event.request) + XCTAssertEqual(self.fixture.hub.capturedErrorEvents.count, 1, "Expected only one error event to be captured") + let capturedErrorEvent = try XCTUnwrap(self.fixture.hub.capturedErrorEvents.first) + + let sentryRequest = try XCTUnwrap(capturedErrorEvent.request) XCTAssertEqual(sentryRequest.url, "https://[Filtered]:[Filtered]@www.domain.com/api") XCTAssertEqual(sentryRequest.headers, ["VALID_HEADER": "value"]) @@ -1106,11 +1101,11 @@ class SentryNetworkTrackerTests: XCTestCase { sut.urlSessionTask(task, setState: .completed) - guard let envelope = self.fixture.hub.capturedEventsWithScopes.first else { - XCTFail("Expected to capture 1 event") - return - } - let sentryResponse = try XCTUnwrap(envelope.event.context?["response"]) + XCTAssertEqual(self.fixture.hub.capturedErrorEvents.count, 1, "Expected only one error event to be captured") + + let capturedErrorEvent = try XCTUnwrap(self.fixture.hub.capturedErrorEvents.first) + + let sentryResponse = try XCTUnwrap(capturedErrorEvent.context?["response"]) XCTAssertEqual(sentryResponse["status_code"] as? NSNumber, 500) XCTAssertEqual(sentryResponse["headers"] as? [String: String], ["test": "test"]) @@ -1131,11 +1126,10 @@ class SentryNetworkTrackerTests: XCTestCase { task.setResponse(response) sut.urlSessionTask(task, setState: .completed) - guard let envelope = self.fixture.hub.capturedEventsWithScopes.first else { - XCTFail("Expected to capture 1 event") - return - } - let sentryResponse = try XCTUnwrap(envelope.event.context?["response"]) + XCTAssertEqual(self.fixture.hub.capturedErrorEvents.count, 1, "Expected only one error event to be captured") + let capturedErrorEvent = try XCTUnwrap(self.fixture.hub.capturedErrorEvents.first) + + let sentryResponse = try XCTUnwrap(capturedErrorEvent.context?["response"]) XCTAssertEqual(sentryResponse["headers"] as? [String: String], ["VALID_HEADER": "value"]) } @@ -1147,9 +1141,10 @@ class SentryNetworkTrackerTests: XCTestCase { sut.urlSessionTask(task, setState: .completed) - let envelope = try XCTUnwrap(self.fixture.hub.capturedEventsWithScopes.first) - - let exceptions = try XCTUnwrap(envelope.event.exceptions) + XCTAssertEqual(self.fixture.hub.capturedErrorEvents.count, 1, "Expected only one error event to be captured") + let capturedErrorEvent = try XCTUnwrap(self.fixture.hub.capturedErrorEvents.first) + + let exceptions = try XCTUnwrap(capturedErrorEvent.exceptions) XCTAssertEqual(exceptions.count, 1) let exception = try XCTUnwrap(exceptions.first) diff --git a/Tests/SentryTests/SentryHubTests.swift b/Tests/SentryTests/SentryHubTests.swift index 37849811b44..f11563ed35a 100644 --- a/Tests/SentryTests/SentryHubTests.swift +++ b/Tests/SentryTests/SentryHubTests.swift @@ -862,7 +862,48 @@ class SentryHubTests: XCTestCase { ) } } - + + func testCaptureErrorEvent_WithSession() { + let sut = fixture.getSut() + sut.startSession() + + let event = fixture.event + sut.captureErrorEvent(event: event).assertIsNotEmpty() + + XCTAssertEqual(1, fixture.client.captureErrorEventWithSessionInvocations.count) + if let eventArguments = fixture.client.captureErrorEventWithSessionInvocations.first { + XCTAssertEqual(event, eventArguments.event) + + XCTAssertEqual(1, eventArguments.session?.errors) + XCTAssertEqual(SentrySessionStatus.ok, eventArguments.session?.status) + + XCTAssertEqual(sut.scope, eventArguments.scope) + } + + // only session init is sent + XCTAssertEqual(1, fixture.client.captureSessionInvocations.count) + } + + func testCaptureErrorEvent_WithoutIncreasingErrorCount() { + let sut = fixture.getSut() + sut.startSession() + + fixture.client.callSessionBlockWithIncrementSessionErrors = false + + let event = fixture.event + sut.captureErrorEvent(event: event).assertIsNotEmpty() + + XCTAssertEqual(1, fixture.client.captureErrorEventWithSessionInvocations.count) + if let eventArguments = fixture.client.captureErrorEventWithSessionInvocations.first { + XCTAssertEqual(event, eventArguments.event) + XCTAssertNil(eventArguments.session) + XCTAssertEqual(sut.scope, eventArguments.scope) + } + + // only session init is sent + XCTAssertEqual(1, fixture.client.captureSessionInvocations.count) + } + func testCaptureClientIsNil_ReturnsEmptySentryId() { sut.bindClient(nil) From 9fd5d7d3573840831546c905bf5742164233b856 Mon Sep 17 00:00:00 2001 From: Philipp Hofmann Date: Mon, 3 Nov 2025 13:06:43 +0100 Subject: [PATCH 2/7] changelog --- CHANGELOG.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 96f0eac02e5..f68b2108f66 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -26,7 +26,7 @@ - Change `value` and `type` of `SentryException` to be nullable (#6563) - Change the default trace context status to "ok" instead of "undefined" (#6611) - Remove `getHash` from SentryDsn (#6605) -- [HTTP Client errors](https://docs.sentry.io/platforms/apple/guides/ios/configuration/http-client-errors/) now mark sessions as errored (#6631) +- [HTTP Client errors](https://docs.sentry.io/platforms/apple/guides/ios/configuration/http-client-errors/) now mark sessions as errored (#6633) ### Features From 2753e1a268355ec807ea8d2218b3e37dfe88afa6 Mon Sep 17 00:00:00 2001 From: Philipp Hofmann Date: Tue, 4 Nov 2025 16:58:10 +0100 Subject: [PATCH 3/7] add SentrySessionDelegate --- SentryTestUtils/Sources/TestClient.swift | 15 ++-- Sources/Sentry/SentryClient.m | 25 +++--- Sources/Sentry/SentryHub.m | 34 +++++--- Sources/Sentry/include/SentryClient+Private.h | 23 ++--- Tests/SentryTests/SentryClientTests.swift | 86 +++++++++++++------ 5 files changed, 115 insertions(+), 68 deletions(-) diff --git a/SentryTestUtils/Sources/TestClient.swift b/SentryTestUtils/Sources/TestClient.swift index 103242d3833..ef8f15bba47 100644 --- a/SentryTestUtils/Sources/TestClient.swift +++ b/SentryTestUtils/Sources/TestClient.swift @@ -94,22 +94,25 @@ public class TestClient: SentryClientInternal { @_spi(Private) public var captureErrorWithSessionInvocations = Invocations<(error: Error, session: SentrySession?, scope: Scope)>() @_spi(Private) - public override func captureError(_ error: Error, with scope: Scope, incrementSessionErrors sessionBlock: @escaping () -> SentrySession) -> SentryId { - captureErrorWithSessionInvocations.record((error, callSessionBlockWithIncrementSessionErrors ? sessionBlock() : nil, scope)) + public override func captureErrorIncrementingSessionErrorCount(_ error: Error, with scope: Scope) -> SentryId { + let session = callSessionBlockWithIncrementSessionErrors ? sessionDelegate?.incrementSessionErrors() : nil + captureErrorWithSessionInvocations.record((error, session, scope)) return SentryId() } @_spi(Private) public var captureExceptionWithSessionInvocations = Invocations<(exception: NSException, session: SentrySession?, scope: Scope)>() @_spi(Private) - public override func capture(_ exception: NSException, with scope: Scope, incrementSessionErrors sessionBlock: @escaping () -> SentrySession) -> SentryId { - captureExceptionWithSessionInvocations.record((exception, callSessionBlockWithIncrementSessionErrors ? sessionBlock() : nil, scope)) + public override func captureExceptionIncrementingSessionErrorCount(_ exception: NSException, with scope: Scope) -> SentryId { + let session = callSessionBlockWithIncrementSessionErrors ? sessionDelegate?.incrementSessionErrors() : nil + captureExceptionWithSessionInvocations.record((exception, session, scope)) return SentryId() } @_spi(Private) public var captureErrorEventWithSessionInvocations = Invocations<(event: Event, session: SentrySession?, scope: Scope)>() - @_spi(Private) public override func captureErrorEvent(event: Event, scope: Scope, incrementSessionErrors sessionBlock: @escaping () -> SentrySession) -> SentryId { - captureErrorEventWithSessionInvocations.record((event, callSessionBlockWithIncrementSessionErrors ? sessionBlock() : nil, scope)) + @_spi(Private) public override func captureErrorEventIncrementingSessionErrorCount(_ event: Event, with scope: Scope) -> SentryId { + let session = callSessionBlockWithIncrementSessionErrors ? sessionDelegate?.incrementSessionErrors() : nil + captureErrorEventWithSessionInvocations.record((event, session, scope)) return SentryId() } diff --git a/Sources/Sentry/SentryClient.m b/Sources/Sentry/SentryClient.m index 7cb06b8e549..4276c04fb2a 100644 --- a/Sources/Sentry/SentryClient.m +++ b/Sources/Sentry/SentryClient.m @@ -146,12 +146,11 @@ - (SentryId *)captureException:(NSException *)exception withScope:(SentryScope * return [self sendEvent:event withScope:scope alwaysAttachStacktrace:YES]; } -- (SentryId *)captureException:(NSException *)exception - withScope:(SentryScope *)scope - incrementSessionErrors:(SentrySession * (^)(void))sessionBlock +- (SentryId *)captureExceptionIncrementingSessionErrorCount:(NSException *)exception + withScope:(SentryScope *)scope { SentryEvent *event = [self buildExceptionEvent:exception]; - return [self captureErrorEvent:event withScope:scope incrementSessionErrors:sessionBlock]; + return [self captureErrorEventIncrementingSessionErrorCount:event withScope:scope]; } - (SentryEvent *)buildExceptionEvent:(NSException *)exception @@ -185,12 +184,11 @@ - (SentryId *)captureError:(NSError *)error withScope:(SentryScope *)scope return [self sendEvent:event withScope:scope alwaysAttachStacktrace:YES]; } -- (SentryId *)captureError:(NSError *)error - withScope:(SentryScope *)scope - incrementSessionErrors:(SentrySession * (^)(void))sessionBlock +- (SentryId *)captureErrorIncrementingSessionErrorCount:(NSError *)error + withScope:(SentryScope *)scope { SentryEvent *event = [self buildErrorEvent:error]; - return [self captureErrorEvent:event withScope:scope incrementSessionErrors:sessionBlock]; + return [self captureErrorEventIncrementingSessionErrorCount:event withScope:scope]; } - (SentryEvent *)buildErrorEvent:(NSError *)error @@ -339,16 +337,19 @@ - (SentryId *)captureEvent:(SentryEvent *)event additionalEnvelopeItems:additionalEnvelopeItems]; } -- (SentryId *)captureErrorEvent:(SentryEvent *)event - withScope:(SentryScope *)scope - incrementSessionErrors:(SentrySession * (^)(void))sessionBlock +- (SentryId *)captureErrorEventIncrementingSessionErrorCount:(SentryEvent *)event + withScope:(SentryScope *)scope { SentryEvent *preparedEvent = [self prepareEvent:event withScope:scope alwaysAttachStacktrace:YES]; if (preparedEvent != nil) { - SentrySession *session = sessionBlock(); + SentrySession *session = nil; + id delegate = self.sessionDelegate; + if (delegate != nil) { + session = [delegate incrementSessionErrors]; + } return [self sendEvent:preparedEvent withSession:session withScope:scope]; } diff --git a/Sources/Sentry/SentryHub.m b/Sources/Sentry/SentryHub.m index 5535184f20c..b0706f13551 100644 --- a/Sources/Sentry/SentryHub.m +++ b/Sources/Sentry/SentryHub.m @@ -26,7 +26,7 @@ NS_ASSUME_NONNULL_BEGIN -@interface SentryHubInternal () +@interface SentryHubInternal () @property (nullable, atomic, strong) SentryClientInternal *client; @property (nullable, nonatomic, strong) SentryScope *scope; @@ -70,6 +70,10 @@ - (instancetype)initWithClient:(nullable SentryClientInternal *)client _installedIntegrationNames = [[NSMutableSet alloc] init]; _errorsBeforeSession = 0; + if (_client != nil) { + _client.sessionDelegate = self; + } + if (_scope) { [_crashWrapper enrichScope:SENTRY_UNWRAP_NULLABLE(SentryScope, _scope)]; } @@ -491,9 +495,7 @@ - (SentryId *)captureError:(NSError *)error withScope:(SentryScope *)scope { SentryId * (^captureClientBlock)(SentryClientInternal *) = ^SentryId *(SentryClientInternal *clientParam) { - return [clientParam captureError:error - withScope:scope - incrementSessionErrors:^(void) { return [self incrementSessionErrors]; }]; + return [clientParam captureErrorIncrementingSessionErrorCount:error withScope:scope]; }; SentryId * (^captureClientBlockSessionNil)(SentryClientInternal *) @@ -512,9 +514,7 @@ - (SentryId *)captureErrorEvent:(SentryEvent *)event SentryId * (^captureClientBlock)(SentryClientInternal *) = ^SentryId *( SentryClientInternal *clientParam) { - return [clientParam captureErrorEvent:event - withScope:scope - incrementSessionErrors:^(void) { return [self incrementSessionErrors]; }]; + return [clientParam captureErrorEventIncrementingSessionErrorCount:event withScope:scope]; }; SentryId * (^captureClientBlockSessionNil)(SentryClientInternal *) @@ -534,12 +534,11 @@ - (SentryId *)captureException:(NSException *)exception - (SentryId *)captureException:(NSException *)exception withScope:(SentryScope *)scope { - SentryId * (^captureClientBlock)(SentryClientInternal *) = ^SentryId *( - SentryClientInternal *clientParam) { - return [clientParam captureException:exception - withScope:scope - incrementSessionErrors:^(void) { return [self incrementSessionErrors]; }]; - }; + SentryId * (^captureClientBlock)(SentryClientInternal *) + = ^SentryId *(SentryClientInternal *clientParam) { + return [clientParam captureExceptionIncrementingSessionErrorCount:exception + withScope:scope]; + }; SentryId * (^captureClientBlockSessionNil)(SentryClientInternal *) = ^SentryId *(SentryClientInternal *clientParam) { @@ -617,7 +616,16 @@ - (nullable SentryClientInternal *)getClient - (void)bindClient:(nullable SentryClientInternal *)client { + SentryClientInternal *currentClient = self.client; + if (currentClient != nil) { + currentClient.sessionDelegate = nil; + } + self.client = client; + + if (client != nil) { + client.sessionDelegate = self; + } } - (SentryScope *)scope diff --git a/Sources/Sentry/include/SentryClient+Private.h b/Sources/Sentry/include/SentryClient+Private.h index 04dc663c8d9..e6cf8123802 100644 --- a/Sources/Sentry/include/SentryClient+Private.h +++ b/Sources/Sentry/include/SentryClient+Private.h @@ -12,6 +12,12 @@ @class SentrySession; @class SentryDefaultThreadInspector; +@protocol SentrySessionDelegate + +- (nullable SentrySession *)incrementSessionErrors; + +@end + NS_ASSUME_NONNULL_BEGIN @protocol SentryClientAttachmentProcessor @@ -27,14 +33,13 @@ NS_ASSUME_NONNULL_BEGIN NSMutableArray> *attachmentProcessors; @property (nonatomic, strong) SentryDefaultThreadInspector *threadInspector; @property (nonatomic, strong) SentryFileManager *fileManager; +@property (nonatomic, weak, nullable) id sessionDelegate; -- (SentryId *)captureError:(NSError *)error - withScope:(SentryScope *)scope - incrementSessionErrors:(SentrySession * (^)(void))sessionBlock; +- (SentryId *)captureErrorIncrementingSessionErrorCount:(NSError *)error + withScope:(SentryScope *)scope; -- (SentryId *)captureException:(NSException *)exception - withScope:(SentryScope *)scope - incrementSessionErrors:(SentrySession * (^)(void))sessionBlock; +- (SentryId *)captureExceptionIncrementingSessionErrorCount:(NSException *)exception + withScope:(SentryScope *)scope; - (SentryId *)captureFatalEvent:(SentryEvent *)event withScope:(SentryScope *)scope; @@ -56,10 +61,8 @@ NS_ASSUME_NONNULL_BEGIN additionalEnvelopeItems:(NSArray *)additionalEnvelopeItems NS_SWIFT_NAME(capture(event:scope:additionalEnvelopeItems:)); -- (SentryId *)captureErrorEvent:(SentryEvent *)event - withScope:(SentryScope *)scope - incrementSessionErrors:(SentrySession * (^)(void))sessionBlock - NS_SWIFT_NAME(captureErrorEvent(event:scope:incrementSessionErrors:)); +- (SentryId *)captureErrorEventIncrementingSessionErrorCount:(SentryEvent *)event + withScope:(SentryScope *)scope; - (void)captureReplayEvent:(SentryReplayEvent *)replayEvent replayRecording:(SentryReplayRecording *)replayRecording diff --git a/Tests/SentryTests/SentryClientTests.swift b/Tests/SentryTests/SentryClientTests.swift index 7f2b466823f..2e7dd233818 100644 --- a/Tests/SentryTests/SentryClientTests.swift +++ b/Tests/SentryTests/SentryClientTests.swift @@ -476,9 +476,9 @@ class SentryClientTests: XCTestCase { } sut.addAttachmentProcessor(processor) - sut.captureError(error, with: Scope()) { - self.fixture.session - } + let sessionDelegate = SentryTestSessionDelegate { self.fixture.session } + sut.sessionDelegate = sessionDelegate + sut.captureErrorIncrementingSessionErrorCount(error, with: Scope()) let sentAttachments = fixture.transportAdapter.sentEventsWithSessionTraceState.first?.attachments ?? [] @@ -498,9 +498,11 @@ class SentryClientTests: XCTestCase { } sut.addAttachmentProcessor(processor) - sut.captureError(error, with: Scope()) { - return SentrySession(releaseName: "", distinctId: "some-id") + let sessionDelegate = SentryTestSessionDelegate { + SentrySession(releaseName: "", distinctId: "some-id") } + sut.sessionDelegate = sessionDelegate + sut.captureErrorIncrementingSessionErrorCount(error, with: Scope()) let sentAttachments = fixture.transportAdapter.sendEventWithTraceStateInvocations.first?.attachments ?? [] @@ -802,10 +804,13 @@ class SentryClientTests: XCTestCase { func testCaptureErrorWithSession() throws { let sessionBlockExpectation = expectation(description: "session block gets called") let scope = Scope() - let eventId = fixture.getSut().captureError(error, with: scope) { + let sut = fixture.getSut() + let sessionDelegate = SentryTestSessionDelegate { sessionBlockExpectation.fulfill() return self.fixture.session } + sut.sessionDelegate = sessionDelegate + let eventId = sut.captureErrorIncrementingSessionErrorCount(error, with: scope) wait(for: [sessionBlockExpectation], timeout: 0.2) eventId.assertIsNotEmpty() @@ -824,13 +829,16 @@ class SentryClientTests: XCTestCase { let sessionBlockExpectation = expectation(description: "session block does not get called") sessionBlockExpectation.isInverted = true - let eventId = fixture.getSut(configureOptions: { options in + let sut = fixture.getSut(configureOptions: { options in options.beforeSend = { _ in return nil } - }).captureError(error, with: Scope()) { + }) + let sessionDelegate = SentryTestSessionDelegate { // This should NOT be called sessionBlockExpectation.fulfill() return self.fixture.session } + sut.sessionDelegate = sessionDelegate + let eventId = sut.captureErrorIncrementingSessionErrorCount(error, with: Scope()) wait(for: [sessionBlockExpectation], timeout: 0.2) eventId.assertIsEmpty() @@ -1149,9 +1157,10 @@ class SentryClientTests: XCTestCase { } func testCaptureExceptionWithSession() { - let eventId = fixture.getSut().capture(exception, with: fixture.scope) { - self.fixture.session - } + let sut = fixture.getSut() + let sessionDelegate = SentryTestSessionDelegate { self.fixture.session } + sut.sessionDelegate = sessionDelegate + let eventId = sut.captureExceptionIncrementingSessionErrorCount(exception, with: fixture.scope) eventId.assertIsNotEmpty() XCTAssertNotNil(fixture.transportAdapter.sentEventsWithSessionTraceState.last) @@ -1166,13 +1175,16 @@ class SentryClientTests: XCTestCase { let sessionBlockExpectation = expectation(description: "session block does not get called") sessionBlockExpectation.isInverted = true - let eventId = fixture.getSut(configureOptions: { options in + let sut = fixture.getSut(configureOptions: { options in options.beforeSend = { _ in return nil } - }).capture(exception, with: fixture.scope) { + }) + let sessionDelegate = SentryTestSessionDelegate { // This should NOT be called sessionBlockExpectation.fulfill() return self.fixture.session } + sut.sessionDelegate = sessionDelegate + let eventId = sut.captureExceptionIncrementingSessionErrorCount(exception, with: fixture.scope) wait(for: [sessionBlockExpectation], timeout: 0.2) eventId.assertIsEmpty() @@ -1210,11 +1222,13 @@ class SentryClientTests: XCTestCase { let session = SentrySession(releaseName: "", distinctId: "some-id") fixture.getSut().capture(session: session) - fixture.getSut().capture(exception, with: Scope()) { - session - } + let sut = fixture.getSut() + let sessionDelegate = SentryTestSessionDelegate { session } + sut.sessionDelegate = sessionDelegate + + sut.captureExceptionIncrementingSessionErrorCount(exception, with: Scope()) .assertIsNotEmpty() - fixture.getSut().captureFatalEvent(fixture.event, with: session, with: Scope()) + sut.captureFatalEvent(fixture.event, with: session, with: Scope()) .assertIsNotEmpty() // No sessions sent @@ -1378,9 +1392,12 @@ class SentryClientTests: XCTestCase { func testNoDsn_EventWithSessionsNotSent() { _ = SentryEnvelope(event: Event()) - let eventId = fixture.getSut(configureOptions: { options in + let sut = fixture.getSut(configureOptions: { options in options.dsn = nil - }).captureFatalEvent(Event(), with: fixture.session, with: fixture.scope) + }) + let sessionDelegate = SentryTestSessionDelegate { self.fixture.session } + sut.sessionDelegate = sessionDelegate + let eventId = sut.captureFatalEvent(fixture.event, with: fixture.session, with: fixture.scope) eventId.assertIsEmpty() assertNothingSent() @@ -1388,11 +1405,12 @@ class SentryClientTests: XCTestCase { func testNoDsn_ExceptionWithSessionsNotSent() { _ = SentryEnvelope(event: Event()) - let eventId = fixture.getSut(configureOptions: { options in + let sut = fixture.getSut(configureOptions: { options in options.dsn = nil - }).capture(self.exception, with: fixture.scope) { - self.fixture.session - } + }) + let sessionDelegate = SentryTestSessionDelegate { self.fixture.session } + sut.sessionDelegate = sessionDelegate + let eventId = sut.captureExceptionIncrementingSessionErrorCount(self.exception, with: fixture.scope) eventId.assertIsEmpty() assertNothingSent() @@ -1400,11 +1418,12 @@ class SentryClientTests: XCTestCase { func testNoDsn_ErrorWithSessionsNotSent() { _ = SentryEnvelope(event: Event()) - let eventId = fixture.getSut(configureOptions: { options in + let sut = fixture.getSut(configureOptions: { options in options.dsn = nil - }).captureError(self.error, with: fixture.scope) { - self.fixture.session - } + }) + let sessionDelegate = SentryTestSessionDelegate { self.fixture.session } + sut.sessionDelegate = sessionDelegate + let eventId = sut.captureErrorIncrementingSessionErrorCount(self.error, with: fixture.scope) eventId.assertIsEmpty() assertNothingSent() @@ -2245,6 +2264,19 @@ class SentryClientTests: XCTestCase { } private extension SentryClientTests { + + final class SentryTestSessionDelegate: NSObject, SentrySessionDelegate { + private let handler: () -> SentrySession? + + init(handler: @escaping () -> SentrySession?) { + self.handler = handler + } + + func incrementSessionErrors() -> SentrySession? { + handler() + } + } + private func givenEventWithDebugMeta() -> Event { let event = Event(level: SentryLevel.fatal) let debugMeta = DebugMeta() From 0b217defd8daf1f8c13c95cc8c24793b07bfb0fa Mon Sep 17 00:00:00 2001 From: Philipp Hofmann Date: Wed, 12 Nov 2025 10:52:59 +0100 Subject: [PATCH 4/7] pr feedback --- SentryTestUtils/Sources/TestClient.swift | 36 ++-- Sources/Sentry/SentryClient.m | 37 ++-- Sources/Sentry/SentryHub.m | 73 ++----- Sources/Sentry/include/SentryClient+Private.h | 10 +- .../Session/SentrySessionTrackerTests.swift | 12 +- Tests/SentryTests/SentryClientTests.swift | 24 +-- Tests/SentryTests/SentryHubTests.swift | 196 ++++++------------ .../SentryTestSessionDelegate.swift | 13 ++ 8 files changed, 137 insertions(+), 264 deletions(-) create mode 100644 Tests/SentryTests/SentryTestSessionDelegate.swift diff --git a/SentryTestUtils/Sources/TestClient.swift b/SentryTestUtils/Sources/TestClient.swift index 7c586b497a2..5e91f7e3b01 100644 --- a/SentryTestUtils/Sources/TestClient.swift +++ b/SentryTestUtils/Sources/TestClient.swift @@ -66,53 +66,43 @@ public class TestClient: SentryClientInternal { return SentryId() } - var captureErrorInvocations = Invocations() + public var captureErrorInvocations = Invocations() public override func capture(error: Error) -> SentryId { + super.capture(error: error) + captureErrorInvocations.record(error) return SentryId() } public var captureErrorWithScopeInvocations = Invocations<(error: Error, scope: Scope)>() public override func capture(error: Error, scope: Scope) -> SentryId { + super.capture(error: error, scope: scope) + captureErrorWithScopeInvocations.record((error, scope)) return SentryId() } var captureExceptionInvocations = Invocations() public override func capture(exception: NSException) -> SentryId { + super.capture(exception: exception) + captureExceptionInvocations.record(exception) return SentryId() } public var captureExceptionWithScopeInvocations = Invocations<(exception: NSException, scope: Scope)>() public override func capture(exception: NSException, scope: Scope) -> SentryId { + super.capture(exception: exception, scope: scope) + captureExceptionWithScopeInvocations.record((exception, scope)) return SentryId() } - public var callSessionBlockWithIncrementSessionErrors = true - @_spi(Private) - public var captureErrorWithSessionInvocations = Invocations<(error: Error, session: SentrySession?, scope: Scope)>() - @_spi(Private) - public override func captureErrorIncrementingSessionErrorCount(_ error: Error, with scope: Scope) -> SentryId { - let session = callSessionBlockWithIncrementSessionErrors ? sessionDelegate?.incrementSessionErrors() : nil - captureErrorWithSessionInvocations.record((error, session, scope)) - return SentryId() - } - - @_spi(Private) - public var captureExceptionWithSessionInvocations = Invocations<(exception: NSException, session: SentrySession?, scope: Scope)>() - @_spi(Private) - public override func captureExceptionIncrementingSessionErrorCount(_ exception: NSException, with scope: Scope) -> SentryId { - let session = callSessionBlockWithIncrementSessionErrors ? sessionDelegate?.incrementSessionErrors() : nil - captureExceptionWithSessionInvocations.record((exception, session, scope)) - return SentryId() - } + @_spi(Private) public var captureEventIncrementingSessionErrorCountInvocations = Invocations<(event: Event, scope: Scope)>() + @_spi(Private) public override func captureEventIncrementingSessionErrorCount(_ event: Event, with scope: Scope) -> SentryId { + super.captureEventIncrementingSessionErrorCount(event, with: scope) - @_spi(Private) public var captureErrorEventWithSessionInvocations = Invocations<(event: Event, session: SentrySession?, scope: Scope)>() - @_spi(Private) public override func captureErrorEventIncrementingSessionErrorCount(_ event: Event, with scope: Scope) -> SentryId { - let session = callSessionBlockWithIncrementSessionErrors ? sessionDelegate?.incrementSessionErrors() : nil - captureErrorEventWithSessionInvocations.record((event, session, scope)) + captureEventIncrementingSessionErrorCountInvocations.record((event, scope)) return SentryId() } diff --git a/Sources/Sentry/SentryClient.m b/Sources/Sentry/SentryClient.m index 11967e3c1ac..ffeed221328 100644 --- a/Sources/Sentry/SentryClient.m +++ b/Sources/Sentry/SentryClient.m @@ -153,14 +153,7 @@ - (SentryId *)captureException:(NSException *)exception - (SentryId *)captureException:(NSException *)exception withScope:(SentryScope *)scope { SentryEvent *event = [self buildExceptionEvent:exception]; - return [self sendEvent:event withScope:scope alwaysAttachStacktrace:YES]; -} - -- (SentryId *)captureExceptionIncrementingSessionErrorCount:(NSException *)exception - withScope:(SentryScope *)scope -{ - SentryEvent *event = [self buildExceptionEvent:exception]; - return [self captureErrorEventIncrementingSessionErrorCount:event withScope:scope]; + return [self captureEventIncrementingSessionErrorCount:event withScope:scope]; } - (SentryEvent *)buildExceptionEvent:(NSException *)exception @@ -191,14 +184,7 @@ - (SentryId *)captureError:(NSError *)error - (SentryId *)captureError:(NSError *)error withScope:(SentryScope *)scope { SentryEvent *event = [self buildErrorEvent:error]; - return [self sendEvent:event withScope:scope alwaysAttachStacktrace:YES]; -} - -- (SentryId *)captureErrorIncrementingSessionErrorCount:(NSError *)error - withScope:(SentryScope *)scope -{ - SentryEvent *event = [self buildErrorEvent:error]; - return [self captureErrorEventIncrementingSessionErrorCount:event withScope:scope]; + return [self captureEventIncrementingSessionErrorCount:event withScope:scope]; } - (SentryEvent *)buildErrorEvent:(NSError *)error @@ -347,8 +333,8 @@ - (SentryId *)captureEvent:(SentryEvent *)event additionalEnvelopeItems:additionalEnvelopeItems]; } -- (SentryId *)captureErrorEventIncrementingSessionErrorCount:(SentryEvent *)event - withScope:(SentryScope *)scope +- (SentryId *)captureEventIncrementingSessionErrorCount:(SentryEvent *)event + withScope:(SentryScope *)scope { SentryEvent *preparedEvent = [self prepareEvent:event withScope:scope @@ -360,6 +346,7 @@ - (SentryId *)captureErrorEventIncrementingSessionErrorCount:(SentryEvent *)even if (delegate != nil) { session = [delegate incrementSessionErrors]; } + return [self sendEvent:preparedEvent withSession:session withScope:scope]; } @@ -443,7 +430,7 @@ - (SentryId *)sendEvent:(SentryEvent *)event } - (SentryId *)sendEvent:(SentryEvent *)event - withSession:(SentrySession *)session + withSession:(nullable SentrySession *)session withScope:(SentryScope *)scope { if (event == nil) { @@ -468,10 +455,14 @@ - (SentryId *)sendEvent:(SentryEvent *)event return event.eventId; } - [self.transportAdapter sendEvent:event - withSession:session - traceContext:traceContext - attachments:attachments]; + if (session != nil) { + [self.transportAdapter sendEvent:event + withSession:SENTRY_UNWRAP_NULLABLE(SentrySession, session) + traceContext:traceContext + attachments:attachments]; + } else { + [self.transportAdapter sendEvent:event traceContext:traceContext attachments:attachments]; + } return event.eventId; } diff --git a/Sources/Sentry/SentryHub.m b/Sources/Sentry/SentryHub.m index 33f23048a05..f2d70a5107d 100644 --- a/Sources/Sentry/SentryHub.m +++ b/Sources/Sentry/SentryHub.m @@ -219,6 +219,8 @@ - (nullable SentrySession *)incrementSessionErrors [_session incrementErrors]; [self storeCurrentSession:SENTRY_UNWRAP_NULLABLE(SentrySession, _session)]; sessionCopy = [_session copy]; + } else { + _errorsBeforeSession++; } } @@ -492,38 +494,12 @@ - (SentryId *)captureError:(NSError *)error - (SentryId *)captureError:(NSError *)error withScope:(SentryScope *)scope { - SentryId * (^captureClientBlock)(SentryClientInternal *) - = ^SentryId *(SentryClientInternal *clientParam) { - return [clientParam captureErrorIncrementingSessionErrorCount:error withScope:scope]; - }; - - SentryId * (^captureClientBlockSessionNil)(SentryClientInternal *) - = ^SentryId *(SentryClientInternal *clientParam) { - return [clientParam captureError:error withScope:scope]; - }; - - return [self captureEventIncrementingSessionErrors:scope - captureClientBlock:captureClientBlock - captureClientSessionNilBlock:captureClientBlockSessionNil]; -} - -- (SentryId *)captureErrorEvent:(SentryEvent *)event -{ - SentryScope *scope = self.scope; - - SentryId * (^captureClientBlock)(SentryClientInternal *) = ^SentryId *( - SentryClientInternal *clientParam) { - return [clientParam captureErrorEventIncrementingSessionErrorCount:event withScope:scope]; - }; - - SentryId * (^captureClientBlockSessionNil)(SentryClientInternal *) - = ^SentryId *(SentryClientInternal *clientParam) { - return [clientParam captureEvent:event withScope:scope]; - }; + SentryClientInternal *client = self.client; - return [self captureEventIncrementingSessionErrors:scope - captureClientBlock:captureClientBlock - captureClientSessionNilBlock:captureClientBlockSessionNil]; + if (client != nil) { + return [client captureError:error withScope:scope]; + } + return SentryId.empty; } - (SentryId *)captureException:(NSException *)exception @@ -533,38 +509,21 @@ - (SentryId *)captureException:(NSException *)exception - (SentryId *)captureException:(NSException *)exception withScope:(SentryScope *)scope { - SentryId * (^captureClientBlock)(SentryClientInternal *) - = ^SentryId *(SentryClientInternal *clientParam) { - return [clientParam captureExceptionIncrementingSessionErrorCount:exception - withScope:scope]; - }; - - SentryId * (^captureClientBlockSessionNil)(SentryClientInternal *) - = ^SentryId *(SentryClientInternal *clientParam) { - return [clientParam captureException:exception withScope:scope]; - }; + SentryClientInternal *client = self.client; - return [self captureEventIncrementingSessionErrors:scope - captureClientBlock:captureClientBlock - captureClientSessionNilBlock:captureClientBlockSessionNil]; + if (client != nil) { + return [client captureException:exception withScope:scope]; + } + return SentryId.empty; } -- (SentryId *)captureEventIncrementingSessionErrors:(SentryScope *)scope - captureClientBlock:(SentryId * (^)( - SentryClientInternal *))captureClientBlock - captureClientSessionNilBlock: - (SentryId * (^)(SentryClientInternal *))captureClientSessionNilBlock +- (SentryId *)captureErrorEvent:(SentryEvent *)event { - - SentrySession *currentSession = _session; + SentryScope *scope = self.scope; SentryClientInternal *client = self.client; + if (client != nil) { - if (currentSession != nil) { - return captureClientBlock(client); - } else { - _errorsBeforeSession++; - return captureClientSessionNilBlock(client); - } + return [client captureEventIncrementingSessionErrorCount:event withScope:scope]; } return SentryId.empty; } diff --git a/Sources/Sentry/include/SentryClient+Private.h b/Sources/Sentry/include/SentryClient+Private.h index e6cf8123802..ead3bf6b40d 100644 --- a/Sources/Sentry/include/SentryClient+Private.h +++ b/Sources/Sentry/include/SentryClient+Private.h @@ -35,12 +35,6 @@ NS_ASSUME_NONNULL_BEGIN @property (nonatomic, strong) SentryFileManager *fileManager; @property (nonatomic, weak, nullable) id sessionDelegate; -- (SentryId *)captureErrorIncrementingSessionErrorCount:(NSError *)error - withScope:(SentryScope *)scope; - -- (SentryId *)captureExceptionIncrementingSessionErrorCount:(NSException *)exception - withScope:(SentryScope *)scope; - - (SentryId *)captureFatalEvent:(SentryEvent *)event withScope:(SentryScope *)scope; - (SentryId *)captureFatalEvent:(SentryEvent *)event @@ -61,8 +55,8 @@ NS_ASSUME_NONNULL_BEGIN additionalEnvelopeItems:(NSArray *)additionalEnvelopeItems NS_SWIFT_NAME(capture(event:scope:additionalEnvelopeItems:)); -- (SentryId *)captureErrorEventIncrementingSessionErrorCount:(SentryEvent *)event - withScope:(SentryScope *)scope; +- (SentryId *)captureEventIncrementingSessionErrorCount:(SentryEvent *)event + withScope:(SentryScope *)scope; - (void)captureReplayEvent:(SentryReplayEvent *)replayEvent replayRecording:(SentryReplayRecording *)replayRecording diff --git a/Tests/SentryTests/Integrations/Session/SentrySessionTrackerTests.swift b/Tests/SentryTests/Integrations/Session/SentrySessionTrackerTests.swift index ee0ed31e4e4..263d6f42714 100644 --- a/Tests/SentryTests/Integrations/Session/SentrySessionTrackerTests.swift +++ b/Tests/SentryTests/Integrations/Session/SentrySessionTrackerTests.swift @@ -815,25 +815,21 @@ class SentrySessionTrackerTests: XCTestCase { private func assertNoInitSessionSent(file: StaticString = #file, line: UInt = #line) { let eventWithSessions = fixture.client.captureFatalEventWithSessionInvocations.invocations.map({ triple in triple.session }) - let errorWithSessions = fixture.client.captureErrorWithSessionInvocations.invocations.map({ triple in triple.session }) - let exceptionWithSessions = fixture.client.captureExceptionWithSessionInvocations.invocations.map({ triple in triple.session }) - var sessions = fixture.client.captureSessionInvocations.invocations + eventWithSessions + errorWithSessions + exceptionWithSessions + var sessions = fixture.client.captureSessionInvocations.invocations + eventWithSessions - sessions.sort { first, second in return first!.started < second!.started } + sessions.sort { first, second in return first.started < second.started } if let session = sessions.last { - XCTAssertFalse(session?.flagInit?.boolValue ?? false, file: file, line: line) + XCTAssertFalse(session.flagInit?.boolValue ?? false, file: file, line: line) } } private func assertSessionsSent(count: Int, file: StaticString = #file, line: UInt = #line) { let eventWithSessions = fixture.client.captureFatalEventWithSessionInvocations.count - let errorWithSessions = fixture.client.captureErrorWithSessionInvocations.count - let exceptionWithSessions = fixture.client.captureExceptionWithSessionInvocations.count let sessions = fixture.client.captureSessionInvocations.count - let sessionsSent = eventWithSessions + errorWithSessions + exceptionWithSessions + sessions + let sessionsSent = eventWithSessions + sessions XCTAssertEqual(count, sessionsSent, file: file, line: line) } diff --git a/Tests/SentryTests/SentryClientTests.swift b/Tests/SentryTests/SentryClientTests.swift index 2e7dd233818..a3f6cc58ed9 100644 --- a/Tests/SentryTests/SentryClientTests.swift +++ b/Tests/SentryTests/SentryClientTests.swift @@ -478,7 +478,7 @@ class SentryClientTests: XCTestCase { sut.addAttachmentProcessor(processor) let sessionDelegate = SentryTestSessionDelegate { self.fixture.session } sut.sessionDelegate = sessionDelegate - sut.captureErrorIncrementingSessionErrorCount(error, with: Scope()) + sut.capture(error: error, scope: Scope()) let sentAttachments = fixture.transportAdapter.sentEventsWithSessionTraceState.first?.attachments ?? [] @@ -502,8 +502,8 @@ class SentryClientTests: XCTestCase { SentrySession(releaseName: "", distinctId: "some-id") } sut.sessionDelegate = sessionDelegate - sut.captureErrorIncrementingSessionErrorCount(error, with: Scope()) - + sut.capture(error: error, scope: Scope()) + let sentAttachments = fixture.transportAdapter.sendEventWithTraceStateInvocations.first?.attachments ?? [] XCTAssertEqual(sentAttachments.count, 1) @@ -810,7 +810,7 @@ class SentryClientTests: XCTestCase { return self.fixture.session } sut.sessionDelegate = sessionDelegate - let eventId = sut.captureErrorIncrementingSessionErrorCount(error, with: scope) + let eventId = sut.capture(error: error, scope: scope) wait(for: [sessionBlockExpectation], timeout: 0.2) eventId.assertIsNotEmpty() @@ -838,7 +838,7 @@ class SentryClientTests: XCTestCase { return self.fixture.session } sut.sessionDelegate = sessionDelegate - let eventId = sut.captureErrorIncrementingSessionErrorCount(error, with: Scope()) + let eventId = sut.capture(error: error, scope: Scope()) wait(for: [sessionBlockExpectation], timeout: 0.2) eventId.assertIsEmpty() @@ -1156,11 +1156,11 @@ class SentryClientTests: XCTestCase { assertValidExceptionEvent(actual) } - func testCaptureExceptionWithSession() { + func testCaptureException_IncreasesSessionErrors() { let sut = fixture.getSut() let sessionDelegate = SentryTestSessionDelegate { self.fixture.session } sut.sessionDelegate = sessionDelegate - let eventId = sut.captureExceptionIncrementingSessionErrorCount(exception, with: fixture.scope) + let eventId = sut.capture(exception: exception, scope: fixture.scope) eventId.assertIsNotEmpty() XCTAssertNotNil(fixture.transportAdapter.sentEventsWithSessionTraceState.last) @@ -1171,7 +1171,7 @@ class SentryClientTests: XCTestCase { } } - func testCaptureExceptionWithSession_WithBeforeSendReturnsNil() throws { + func testCaptureException_WithBeforeSendReturnsNil() throws { let sessionBlockExpectation = expectation(description: "session block does not get called") sessionBlockExpectation.isInverted = true @@ -1184,7 +1184,7 @@ class SentryClientTests: XCTestCase { return self.fixture.session } sut.sessionDelegate = sessionDelegate - let eventId = sut.captureExceptionIncrementingSessionErrorCount(exception, with: fixture.scope) + let eventId = sut.capture(exception: exception, scope: fixture.scope) wait(for: [sessionBlockExpectation], timeout: 0.2) eventId.assertIsEmpty() @@ -1226,7 +1226,7 @@ class SentryClientTests: XCTestCase { let sessionDelegate = SentryTestSessionDelegate { session } sut.sessionDelegate = sessionDelegate - sut.captureExceptionIncrementingSessionErrorCount(exception, with: Scope()) + sut.capture(exception: exception, scope: Scope()) .assertIsNotEmpty() sut.captureFatalEvent(fixture.event, with: session, with: Scope()) .assertIsNotEmpty() @@ -1410,7 +1410,7 @@ class SentryClientTests: XCTestCase { }) let sessionDelegate = SentryTestSessionDelegate { self.fixture.session } sut.sessionDelegate = sessionDelegate - let eventId = sut.captureExceptionIncrementingSessionErrorCount(self.exception, with: fixture.scope) + let eventId = sut.capture(exception: self.exception, scope: fixture.scope) eventId.assertIsEmpty() assertNothingSent() @@ -1423,7 +1423,7 @@ class SentryClientTests: XCTestCase { }) let sessionDelegate = SentryTestSessionDelegate { self.fixture.session } sut.sessionDelegate = sessionDelegate - let eventId = sut.captureErrorIncrementingSessionErrorCount(self.error, with: fixture.scope) + let eventId = sut.capture(error: self.error, scope: fixture.scope) eventId.assertIsEmpty() assertNothingSent() diff --git a/Tests/SentryTests/SentryHubTests.swift b/Tests/SentryTests/SentryHubTests.swift index c6dbfdde826..bed0555abf4 100644 --- a/Tests/SentryTests/SentryHubTests.swift +++ b/Tests/SentryTests/SentryHubTests.swift @@ -27,7 +27,7 @@ class SentryHubTests: XCTestCase { let random = TestRandom(value: 0.5) let queue = DispatchQueue(label: "SentryHubTests", qos: .utility, attributes: [.concurrent]) let dispatchQueueWrapper = TestSentryDispatchQueueWrapper() - + init() { options = Options() options.dsn = SentryHubTests.dsnAsString @@ -673,24 +673,28 @@ class SentryHubTests: XCTestCase { XCTAssertEqual(fixture.scope, errorArguments.scope) } } - - func testCaptureErrorWithSessionWithScope() { + + func testCaptureErrorWithSessionWithScope() throws { + // Arrange let sut = fixture.getSut() sut.startSession() + + // Act sut.capture(error: fixture.error, scope: fixture.scope).assertIsNotEmpty() - - XCTAssertEqual(1, fixture.client.captureErrorWithSessionInvocations.count) - if let errorArguments = fixture.client.captureErrorWithSessionInvocations.first { + + // Assert + XCTAssertEqual(1, fixture.client.captureErrorWithScopeInvocations.count) + if let errorArguments = fixture.client.captureErrorWithScopeInvocations.first { XCTAssertEqual(fixture.error, errorArguments.error as NSError) - - XCTAssertEqual(1, errorArguments.session?.errors) - XCTAssertEqual(SentrySessionStatus.ok, errorArguments.session?.status) - XCTAssertEqual(fixture.scope, errorArguments.scope) } // only session init is sent - XCTAssertEqual(1, fixture.client.captureSessionInvocations.count) + XCTAssertEqual(fixture.client.captureSessionInvocations.count, 1) + + let actualSession = try XCTUnwrap(sut.session) + XCTAssertEqual(actualSession.errors, 1) + XCTAssertEqual(actualSession.status, SentrySessionStatus.ok) } func testCaptureErrorBeforeSessionStart() { @@ -745,23 +749,6 @@ class SentryHubTests: XCTestCase { } } - func testCaptureWithoutIncreasingErrorCount() { - let sut = fixture.getSut() - sut.startSession() - fixture.client.callSessionBlockWithIncrementSessionErrors = false - sut.capture(error: fixture.error, scope: fixture.scope).assertIsNotEmpty() - - XCTAssertEqual(1, fixture.client.captureErrorWithSessionInvocations.count) - if let errorArguments = fixture.client.captureErrorWithSessionInvocations.first { - XCTAssertEqual(fixture.error, errorArguments.error as NSError) - XCTAssertNil(errorArguments.session) - XCTAssertEqual(fixture.scope, errorArguments.scope) - } - - // only session init is sent - XCTAssertEqual(1, fixture.client.captureSessionInvocations.count) - } - func testCaptureErrorWithoutScope() { fixture.getSut(fixture.options, fixture.scope).capture(error: fixture.error).assertIsNotEmpty() @@ -791,112 +778,75 @@ class SentryHubTests: XCTestCase { XCTAssertEqual(fixture.scope, errorArguments.scope) } } - - func testCaptureExceptionWithSessionWithScope() { - let sut = fixture.getSut() - sut.startSession() - sut.capture(exception: fixture.exception, scope: fixture.scope).assertIsNotEmpty() - - XCTAssertEqual(1, fixture.client.captureExceptionWithSessionInvocations.count) - if let exceptionArguments = fixture.client.captureExceptionWithSessionInvocations.first { - XCTAssertEqual(fixture.exception, exceptionArguments.exception) - - XCTAssertEqual(1, exceptionArguments.session?.errors) - XCTAssertEqual(SentrySessionStatus.ok, exceptionArguments.session?.status) - - XCTAssertEqual(fixture.scope, exceptionArguments.scope) - } - - // only session init is sent - XCTAssertEqual(1, fixture.client.captureSessionInvocations.count) - } - - func testCaptureExceptionWithoutIncreasingErrorCount() { + + func testCaptureMultipleExceptionsInParallel_IncrementsSessionCount() throws { + // Arrange + let captureCount = 100 + let sut = fixture.getSut() + sut.startSession() - fixture.client.callSessionBlockWithIncrementSessionErrors = false - sut.capture(exception: fixture.exception, scope: fixture.scope).assertIsNotEmpty() - - XCTAssertEqual(1, fixture.client.captureExceptionWithSessionInvocations.count) - if let exceptionArguments = fixture.client.captureExceptionWithSessionInvocations.first { - XCTAssertEqual(fixture.exception, exceptionArguments.exception) - XCTAssertNil(exceptionArguments.session) - XCTAssertEqual(fixture.scope, exceptionArguments.scope) - } - - // only session init is sent - XCTAssertEqual(1, fixture.client.captureSessionInvocations.count) - } - - func testCaptureMultipleExceptionWithSessionInParallel() { - let captureCount = 100 - captureConcurrentWithSession(count: captureCount) { sut in - sut.capture(exception: self.fixture.exception, scope: self.fixture.scope) - } - - let invocations = fixture.client.captureExceptionWithSessionInvocations.invocations - XCTAssertEqual(captureCount, invocations.count) - for i in 1...captureCount { - // The session error count must not be in order as we use a concurrent DispatchQueue - XCTAssertTrue( - invocations.contains { $0.session!.errors == i }, - "No session captured with \(i) amount of errors." - ) + + let expectation = XCTestExpectation(description: "Capture error") + expectation.expectedFulfillmentCount = captureCount + expectation.assertForOverFulfill = true + + // Act + for _ in 0.. Void) { - let sut = fixture.getSut() - sut.startSession() - - let queue = fixture.queue - - let expectation = XCTestExpectation(description: "Capture should be called \(count) times") - expectation.expectedFulfillmentCount = count - - for _ in 0.. SentrySession? + + init(handler: @escaping () -> SentrySession?) { + self.handler = handler + } + + func incrementSessionErrors() -> SentrySession? { + handler() + } +} From 9ca950475f60c73813e49848d9165999918f1c03 Mon Sep 17 00:00:00 2001 From: Philipp Hofmann Date: Thu, 13 Nov 2025 10:41:39 +0100 Subject: [PATCH 5/7] fix changelog --- CHANGELOG.md | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 8fd71ffd9e2..0e3fa3329b0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,11 @@ # Changelog +## Unreleased + +### Breaking Changes + +- [HTTP Client errors](https://docs.sentry.io/platforms/apple/guides/ios/configuration/http-client-errors/) now mark sessions as errored (#6633) + ## 9.0.0-alpha.1 ### Breaking Changes @@ -103,7 +109,6 @@ - Change `value` and `type` of `SentryException` to be nullable (#6563) - Change the default trace context status to "ok" instead of "undefined" (#6611) - Remove `getHash` from SentryDsn (#6605) -- [HTTP Client errors](https://docs.sentry.io/platforms/apple/guides/ios/configuration/http-client-errors/) now mark sessions as errored (#6633) - The precompiled XCFramework is now built with Xcode 16. To submit to the App Store, [Apple now requires Xcode 16](https://developer.apple.com/news/upcoming-requirements/?id=02212025a). If you need a precompiled XCFramework built with Xcode 15, continue using Sentry SDK 8.x.x. - Set `SentryException.type` to `nil` when `NSException` has no `reason` (#6653). The backend then can provide a proper message when there is no reason. From fe3fb1d64c69139bdcd00194f05333263079649d Mon Sep 17 00:00:00 2001 From: Philipp Hofmann Date: Thu, 13 Nov 2025 11:00:37 +0100 Subject: [PATCH 6/7] cleanup --- Sources/Sentry/SentryHub.m | 7 ++--- Tests/SentryTests/SentryHubTests.swift | 27 +++++++++++++++++++ .../SentryTestSessionDelegate.swift | 13 --------- 3 files changed, 29 insertions(+), 18 deletions(-) delete mode 100644 Tests/SentryTests/SentryTestSessionDelegate.swift diff --git a/Sources/Sentry/SentryHub.m b/Sources/Sentry/SentryHub.m index 7818967af0d..e30e53cbccd 100644 --- a/Sources/Sentry/SentryHub.m +++ b/Sources/Sentry/SentryHub.m @@ -25,7 +25,7 @@ NS_ASSUME_NONNULL_BEGIN -@interface SentryHubInternal () +@interface SentryHubInternal () @property (nullable, atomic, strong) SentryClientInternal *client; @property (nullable, nonatomic, strong) SentryScope *scope; @@ -578,10 +578,7 @@ - (nullable SentryClientInternal *)getClient - (void)bindClient:(nullable SentryClientInternal *)client { - SentryClientInternal *currentClient = self.client; - if (currentClient != nil) { - currentClient.sessionDelegate = nil; - } + self.client.sessionDelegate = nil; self.client = client; diff --git a/Tests/SentryTests/SentryHubTests.swift b/Tests/SentryTests/SentryHubTests.swift index 57a6f07ceb9..f521ca61973 100644 --- a/Tests/SentryTests/SentryHubTests.swift +++ b/Tests/SentryTests/SentryHubTests.swift @@ -1437,6 +1437,33 @@ class SentryHubTests: XCTestCase { ])) } + func testBindClient_SetsSessionDelegate() throws { + // Arrange + let sut = fixture.getSut() + let currentClient = fixture.client + let newClient = SentryClientInternal(options: fixture.options) + + // Act + sut.bindClient(newClient) + + // Assert + XCTAssertNil(currentClient.sessionDelegate) + // We only assert if it's not nil for a safety check. Other unit tests validate the correctness of the delegate methods. + XCTAssertNotNil(newClient?.sessionDelegate) + } + + func testBindNilClient_SetsSessionDelegateToNil() { + // Arrange + let sut = fixture.getSut() + let client = fixture.client + + // Act + sut.bindClient(nil) + + // Assert + XCTAssertNil(client.sessionDelegate) + } + private func captureEventEnvelope(level: SentryLevel) { let event = TestData.event event.level = level diff --git a/Tests/SentryTests/SentryTestSessionDelegate.swift b/Tests/SentryTests/SentryTestSessionDelegate.swift deleted file mode 100644 index c580b491591..00000000000 --- a/Tests/SentryTests/SentryTestSessionDelegate.swift +++ /dev/null @@ -1,13 +0,0 @@ -@_spi(Private) @testable import Sentry - -final class SentryTestSessionDelegate: NSObject, SentrySessionDelegate { - private let handler: () -> SentrySession? - - init(handler: @escaping () -> SentrySession?) { - self.handler = handler - } - - func incrementSessionErrors() -> SentrySession? { - handler() - } -} From c846b72eb5c041df8ad39f6ad747304be92aaf26 Mon Sep 17 00:00:00 2001 From: Philipp Hofmann Date: Thu, 13 Nov 2025 11:08:16 +0100 Subject: [PATCH 7/7] polish --- Sources/Sentry/SentryClient.m | 21 +++++++++++--------- Tests/SentryTests/SentryClientTests.swift | 24 ++++++++++++++++++++++- 2 files changed, 35 insertions(+), 10 deletions(-) diff --git a/Sources/Sentry/SentryClient.m b/Sources/Sentry/SentryClient.m index fe8404ad531..d47ff59ab65 100644 --- a/Sources/Sentry/SentryClient.m +++ b/Sources/Sentry/SentryClient.m @@ -453,22 +453,25 @@ - (SentryId *)sendEvent:(SentryEvent *)event SentryTraceContext *traceContext = [self getTraceStateWithEvent:event withScope:scope]; - if (nil == session.releaseName || [session.releaseName length] == 0) { - SENTRY_LOG_DEBUG(DropSessionLogMessage); - + if (session == nil) { [self.transportAdapter sendEvent:event traceContext:traceContext attachments:attachments]; return event.eventId; } - if (session != nil) { - [self.transportAdapter sendEvent:event - withSession:SENTRY_UNWRAP_NULLABLE(SentrySession, session) - traceContext:traceContext - attachments:attachments]; - } else { + SentrySession *nonnullSession = SENTRY_UNWRAP_NULLABLE(SentrySession, session); + + if (nonnullSession.releaseName == nil || [nonnullSession.releaseName length] == 0) { + SENTRY_LOG_DEBUG(DropSessionLogMessage); + [self.transportAdapter sendEvent:event traceContext:traceContext attachments:attachments]; + return event.eventId; } + [self.transportAdapter sendEvent:event + withSession:nonnullSession + traceContext:traceContext + attachments:attachments]; + return event.eventId; } diff --git a/Tests/SentryTests/SentryClientTests.swift b/Tests/SentryTests/SentryClientTests.swift index e3ed32ce12c..5b380995742 100644 --- a/Tests/SentryTests/SentryClientTests.swift +++ b/Tests/SentryTests/SentryClientTests.swift @@ -824,7 +824,29 @@ class SentryClientTests: XCTestCase { expectedTraceContext.traceId) } } - + + func testCaptureErrorWithOutSession() throws { + let sessionBlockExpectation = expectation(description: "session block gets called") + let scope = Scope() + let sut = fixture.getSut() + let sessionDelegate = SentryTestSessionDelegate { + sessionBlockExpectation.fulfill() + return nil + } + sut.sessionDelegate = sessionDelegate + let eventId = sut.capture(error: error, scope: scope) + wait(for: [sessionBlockExpectation], timeout: 0.2) + + eventId.assertIsNotEmpty() + let eventWithSessionArguments = try XCTUnwrap(fixture.transportAdapter.sendEventWithTraceStateInvocations.last) + + try assertValidErrorEvent(eventWithSessionArguments.event, error) + + let expectedTraceContext = TraceContext(trace: scope.propagationContext.traceId, options: Options(), replayId: nil) + XCTAssertEqual(eventWithSessionArguments.traceContext?.traceId, + expectedTraceContext.traceId) + } + func testCaptureErrorWithSession_WithBeforeSendReturnsNil() throws { let sessionBlockExpectation = expectation(description: "session block does not get called") sessionBlockExpectation.isInverted = true