diff --git a/CHANGELOG.md b/CHANGELOG.md index fc247912669..829439b4777 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,7 @@ ## unreleased +- feat: Add onCrashedLastRun #808 - feat: Add SentrySdkInfo to SentryOptions #859 ## 6.0.9 diff --git a/Sources/Sentry/Public/SentryDefines.h b/Sources/Sentry/Public/SentryDefines.h index ad33eeb08a7..b6e1affe373 100644 --- a/Sources/Sentry/Public/SentryDefines.h +++ b/Sources/Sentry/Public/SentryDefines.h @@ -48,6 +48,11 @@ typedef SentryBreadcrumb *_Nullable (^SentryBeforeBreadcrumbCallback)( */ typedef SentryEvent *_Nullable (^SentryBeforeSendEventCallback)(SentryEvent *_Nonnull event); +/** + * A callback to be notified when the last program execution terminated with a crash. + */ +typedef void (^SentryOnCrashedLastRunCallback)(SentryEvent *_Nonnull event); + /** * Block can be used to determine if an event should be queued and stored * locally. It will be tried to send again after next successful send. Note that diff --git a/Sources/Sentry/Public/SentryOptions.h b/Sources/Sentry/Public/SentryOptions.h index 18217fbe9be..7079d5b9a98 100644 --- a/Sources/Sentry/Public/SentryOptions.h +++ b/Sources/Sentry/Public/SentryOptions.h @@ -79,6 +79,17 @@ NS_SWIFT_NAME(Options) */ @property (nonatomic, copy) SentryBeforeBreadcrumbCallback _Nullable beforeBreadcrumb; +/** + * This gets called shortly after the initialization of the SDK when the last program execution + * terminated with a crash. It is not guaranteed that this is called on the main thread. + * + * @discussion This callback is only executed once during the entire run of the program to avoid + * multiple callbacks if there are multiple crash events to send. This can happen when the program + * terminates with a crash before the SDK can send the crash event. You can look into beforeSend if + * you prefer a callback for every event. + */ +@property (nonatomic, copy) SentryOnCrashedLastRunCallback _Nullable onCrashedLastRun; + /** * Array of integrations to install. */ diff --git a/Sources/Sentry/SentryClient.m b/Sources/Sentry/SentryClient.m index e6e17d8c594..a274338a086 100644 --- a/Sources/Sentry/SentryClient.m +++ b/Sources/Sentry/SentryClient.m @@ -17,6 +17,7 @@ #import "SentryMessage.h" #import "SentryMeta.h" #import "SentryOptions.h" +#import "SentrySDK+Private.h" #import "SentryScope.h" #import "SentryStacktraceBuilder.h" #import "SentryThreadInspector.h" @@ -175,13 +176,19 @@ - (SentryEvent *)buildErrorEvent:(NSError *)error return event; } -- (SentryId *)captureEvent:(SentryEvent *)event - withSession:(SentrySession *)session - withScope:(SentryScope *)scope +- (SentryId *)captureCrashEvent:(SentryEvent *)event withScope:(SentryScope *)scope +{ + return [self sendEvent:event withScope:scope alwaysAttachStacktrace:NO isCrashEvent:YES]; +} + +- (SentryId *)captureCrashEvent:(SentryEvent *)event + withSession:(SentrySession *)session + withScope:(SentryScope *)scope { SentryEvent *preparedEvent = [self prepareEvent:event withScope:scope - alwaysAttachStacktrace:NO]; + alwaysAttachStacktrace:NO + isCrashEvent:YES]; return [self sendEvent:preparedEvent withSession:session]; } @@ -198,10 +205,22 @@ - (SentryId *)captureEvent:(SentryEvent *)event withScope:(SentryScope *)scope - (SentryId *)sendEvent:(SentryEvent *)event withScope:(SentryScope *)scope alwaysAttachStacktrace:(BOOL)alwaysAttachStacktrace +{ + return [self sendEvent:event + withScope:scope + alwaysAttachStacktrace:alwaysAttachStacktrace + isCrashEvent:NO]; +} + +- (SentryId *)sendEvent:(SentryEvent *)event + withScope:(SentryScope *)scope + alwaysAttachStacktrace:(BOOL)alwaysAttachStacktrace + isCrashEvent:(BOOL)isCrashEvent { SentryEvent *preparedEvent = [self prepareEvent:event withScope:scope - alwaysAttachStacktrace:alwaysAttachStacktrace]; + alwaysAttachStacktrace:alwaysAttachStacktrace + isCrashEvent:isCrashEvent]; if (nil != preparedEvent) { [self.transport sendEvent:preparedEvent]; @@ -289,6 +308,17 @@ - (BOOL)checkSampleRate:(NSNumber *)sampleRate - (SentryEvent *_Nullable)prepareEvent:(SentryEvent *)event withScope:(SentryScope *)scope alwaysAttachStacktrace:(BOOL)alwaysAttachStacktrace +{ + return [self prepareEvent:event + withScope:scope + alwaysAttachStacktrace:alwaysAttachStacktrace + isCrashEvent:NO]; +} + +- (SentryEvent *_Nullable)prepareEvent:(SentryEvent *)event + withScope:(SentryScope *)scope + alwaysAttachStacktrace:(BOOL)alwaysAttachStacktrace + isCrashEvent:(BOOL)isCrashEvent { NSParameterAssert(event); if ([self isDisabled]) { @@ -340,12 +370,12 @@ - (SentryEvent *_Nullable)prepareEvent:(SentryEvent *)event || (nil != event.exceptions && [event.exceptions count] > 0); BOOL debugMetaNotAttached = !(nil != event.debugMeta && event.debugMeta.count > 0); - if (shouldAttachStacktrace && debugMetaNotAttached) { + if (!isCrashEvent && shouldAttachStacktrace && debugMetaNotAttached) { event.debugMeta = [self.debugMetaBuilder buildDebugMeta]; } BOOL threadsNotAttached = !(nil != event.threads && event.threads.count > 0); - if (shouldAttachStacktrace && threadsNotAttached) { + if (!isCrashEvent && shouldAttachStacktrace && threadsNotAttached) { event.threads = [self.threadInspector getCurrentThreads]; } @@ -366,6 +396,13 @@ - (SentryEvent *_Nullable)prepareEvent:(SentryEvent *)event event = self.options.beforeSend(event); } + if (isCrashEvent && nil != self.options.onCrashedLastRun && !SentrySDK.crashedLastRunCalled) { + // We only want to call the callback once. It can occur that multiple crash events are + // about to be sent. + self.options.onCrashedLastRun(event); + SentrySDK.crashedLastRunCalled = YES; + } + return event; } diff --git a/Sources/Sentry/SentryHub.m b/Sources/Sentry/SentryHub.m index 34bb9990a17..c90752a046f 100644 --- a/Sources/Sentry/SentryHub.m +++ b/Sources/Sentry/SentryHub.m @@ -203,13 +203,13 @@ - (void)captureCrashEvent:(SentryEvent *)event // It can be that there is no session yet, because autoSessionTracking was just enabled and // there is a previous crash on disk. In this case we just send the crash event. if (nil != crashedSession) { - [client captureEvent:event withSession:crashedSession withScope:self.scope]; + [client captureCrashEvent:event withSession:crashedSession withScope:self.scope]; [fileManager deleteCrashedSession]; return; } } - [self captureEvent:event withScope:self.scope]; + [client captureCrashEvent:event withScope:self.scope]; } - (SentryId *)captureEvent:(SentryEvent *)event diff --git a/Sources/Sentry/SentryOptions.m b/Sources/Sentry/SentryOptions.m index 8aaba3a906a..15e1447a032 100644 --- a/Sources/Sentry/SentryOptions.m +++ b/Sources/Sentry/SentryOptions.m @@ -128,6 +128,10 @@ - (void)validateOptions:(NSDictionary *)options self.beforeBreadcrumb = options[@"beforeBreadcrumb"]; } + if (nil != options[@"onCrashedLastRun"]) { + self.onCrashedLastRun = options[@"onCrashedLastRun"]; + } + if (nil != options[@"integrations"]) { self.integrations = options[@"integrations"]; } diff --git a/Sources/Sentry/SentrySDK.m b/Sources/Sentry/SentrySDK.m index 7abc697f8ef..a96750122ed 100644 --- a/Sources/Sentry/SentrySDK.m +++ b/Sources/Sentry/SentrySDK.m @@ -23,6 +23,7 @@ @implementation SentrySDK static SentryHub *currentHub; +static BOOL crashedLastRunCalled; @dynamic logLevel; @@ -43,6 +44,16 @@ + (void)setCurrentHub:(SentryHub *)hub } } ++ (BOOL)crashedLastRunCalled +{ + return crashedLastRunCalled; +} + ++ (void)setCrashedLastRunCalled:(BOOL)value +{ + crashedLastRunCalled = value; +} + + (void)startWithOptions:(NSDictionary *)optionsDict { NSError *error = nil; diff --git a/Sources/Sentry/include/SentryClient+Private.h b/Sources/Sentry/include/SentryClient+Private.h index a02b7729c1e..3e48b55d646 100644 --- a/Sources/Sentry/include/SentryClient+Private.h +++ b/Sources/Sentry/include/SentryClient+Private.h @@ -17,9 +17,11 @@ NS_ASSUME_NONNULL_BEGIN withSession:(SentrySession *)session withScope:(SentryScope *)scope; -- (SentryId *)captureEvent:(SentryEvent *)event - withSession:(SentrySession *)session - withScope:(SentryScope *)scope; +- (SentryId *)captureCrashEvent:(SentryEvent *)event withScope:(SentryScope *)scope; + +- (SentryId *)captureCrashEvent:(SentryEvent *)event + withSession:(SentrySession *)session + withScope:(SentryScope *)scope; @end diff --git a/Sources/Sentry/include/SentrySDK+Private.h b/Sources/Sentry/include/SentrySDK+Private.h index fd5b6db00fd..634d1f9f3a0 100644 --- a/Sources/Sentry/include/SentrySDK+Private.h +++ b/Sources/Sentry/include/SentrySDK+Private.h @@ -8,6 +8,11 @@ NS_ASSUME_NONNULL_BEGIN + (void)captureCrashEvent:(SentryEvent *)event; +/** + * SDK private field to store the state if onCrashedLastRun was called. + */ +@property (nonatomic, class) BOOL crashedLastRunCalled; + @end NS_ASSUME_NONNULL_END diff --git a/Tests/SentryTests/Integrations/SentrySessionTrackerTests.swift b/Tests/SentryTests/Integrations/SentrySessionTrackerTests.swift index 548de25e82a..537971a95f2 100644 --- a/Tests/SentryTests/Integrations/SentrySessionTrackerTests.swift +++ b/Tests/SentryTests/Integrations/SentrySessionTrackerTests.swift @@ -448,7 +448,7 @@ class SentrySessionTrackerTests: XCTestCase { } private func assertNoInitSessionSent() { - let eventWithSessions = fixture.client.captureEventWithSessionArguments.map({ triple in triple.second }) + let eventWithSessions = fixture.client.captureCrashEventWithSessionArguments.map({ triple in triple.second }) let errorWithSessions = fixture.client.captureErrorWithSessionArguments.map({ triple in triple.second }) let exceptionWithSessions = fixture.client.captureExceptionWithSessionArguments.map({ triple in triple.second }) @@ -462,7 +462,7 @@ class SentrySessionTrackerTests: XCTestCase { } private func assertSessionsSent(count: Int) { - let eventWithSessions = fixture.client.captureEventWithSessionArguments.count + let eventWithSessions = fixture.client.captureCrashEventWithSessionArguments.count let errorWithSessions = fixture.client.captureErrorWithSessionArguments.count let exceptionWithSessions = fixture.client.captureExceptionWithSessionArguments.count let sessions = fixture.client.sessions.count @@ -496,7 +496,7 @@ class SentrySessionTrackerTests: XCTestCase { sut.start() SentrySDK.captureCrash(Event()) - if let session = fixture.client.captureEventWithSessionArguments.last?.second { + if let session = fixture.client.captureCrashEventWithSessionArguments.last?.second { assertSession(session: session, started: sessionStartTime, status: SentrySessionStatus.crashed, duration: 5) } else { XCTFail("No session sent with event.") diff --git a/Tests/SentryTests/SentryClient+TestInit.h b/Tests/SentryTests/SentryClient+TestInit.h index 9d03af701d9..2e5a39c6617 100644 --- a/Tests/SentryTests/SentryClient+TestInit.h +++ b/Tests/SentryTests/SentryClient+TestInit.h @@ -1,6 +1,8 @@ #import "SentryTransport.h" #import +@class SentryCrashAdapter; + NS_ASSUME_NONNULL_BEGIN /** Expose the internal test init for testing. */ diff --git a/Tests/SentryTests/SentryClientTests.swift b/Tests/SentryTests/SentryClientTests.swift index 16ffc82e979..4df78257c81 100644 --- a/Tests/SentryTests/SentryClientTests.swift +++ b/Tests/SentryTests/SentryClientTests.swift @@ -24,7 +24,7 @@ class SentryClientTest: XCTestCase { let user: User let fileManager: SentryFileManager - + init() { session = SentrySession(releaseName: "release") session.incrementErrors() @@ -76,6 +76,16 @@ class SentryClientTest: XCTestCase { return scope } } + + var eventWithCrash: Event { + let event = TestData.event + let exception = Exception(value: "value", type: "type") + let mechanism = Mechanism(type: "mechanism") + mechanism.handled = false + exception.mechanism = mechanism + event.exceptions = [exception] + return event + } } private let error = NSError(domain: "domain", code: -20, userInfo: [NSLocalizedDescriptionKey: "Object does not exist"]) @@ -90,6 +100,11 @@ class SentryClientTest: XCTestCase { fixture.fileManager.deleteAllEnvelopes() } + override func tearDown() { + super.tearDown() + SentrySDK.crashedLastRunCalled = false + } + func testCaptureMessage() { let eventId = fixture.getSut().capture(message: fixture.messageAsString) @@ -275,20 +290,56 @@ class SentryClientTest: XCTestCase { assertLastSentEnvelopeIsASession() } - func testCaptureEventWithSession() { - let eventId = fixture.getSut().capture(fixture.event, with: fixture.session, with: fixture.scope) + func testCaptureCrashEventWithSession() { + let eventId = fixture.getSut().captureCrash(fixture.event, with: fixture.session, with: fixture.scope) eventId.assertIsNotEmpty() - XCTAssertNotNil(fixture.transport.sentEventsWithSession.last) - if let eventWithSessionArguments = fixture.transport.sentEventsWithSession.last { - let event = eventWithSessionArguments.first + + assertLastSentEventWithSession { event, session in XCTAssertEqual(fixture.event.eventId, event.eventId) XCTAssertEqual(fixture.event.message, event.message) XCTAssertEqual("value", event.tags?["key"] ?? "") - XCTAssertEqual(fixture.session, eventWithSessionArguments.second) + XCTAssertEqual(fixture.session, session) } + } + + func testCaptureCrashWithSession_DoesntOverideStacktrace() { + let event = TestData.event + event.threads = nil + event.debugMeta = nil + + fixture.getSut().captureCrash(event, with: fixture.session, with: fixture.scope) + + assertLastSentEventWithSession { event, _ in + XCTAssertNil(event.threads) + XCTAssertNil(event.debugMeta) + } + } + + func testCaptureCrashEvent() { + let eventId = fixture.getSut().captureCrash(fixture.event, with: fixture.scope) + eventId.assertIsNotEmpty() + + assertLastSentEvent { event in + XCTAssertEqual(fixture.event.eventId, event.eventId) + XCTAssertEqual(fixture.event.message, event.message) + XCTAssertEqual("value", event.tags?["key"] ?? "") + } + } + + func testCaptureCrash_DoesntOverideStacktraceFor() { + let event = TestData.event + event.threads = nil + event.debugMeta = nil + + fixture.getSut().captureCrash(event, with: fixture.scope) + + assertLastSentEvent { actual in + XCTAssertNil(actual.threads) + XCTAssertNil(actual.debugMeta) + } } func testCaptureErrorWithUserInfo() { @@ -366,7 +417,7 @@ class SentryClientTest: XCTestCase { fixture.getSut().capture(session: session) fixture.getSut().capture(exception, with: session, with: Scope()) .assertIsNotEmpty() - fixture.getSut().capture(fixture.event, with: session, with: Scope()) + fixture.getSut().captureCrash(fixture.event, with: session, with: Scope()) .assertIsNotEmpty() // No sessions sent @@ -459,7 +510,7 @@ class SentryClientTest: XCTestCase { _ = SentryEnvelope(event: Event()) let eventId = fixture.getSut(configureOptions: { options in options.dsn = nil - }).capture(Event(), with: fixture.session, with: Scope()) + }).captureCrash(Event(), with: fixture.session, with: Scope()) eventId.assertIsEmpty() assertNothingSent() @@ -615,6 +666,41 @@ class SentryClientTest: XCTestCase { XCTAssertEqual(1, fixture.fileManager.getAllEnvelopes().count) } + func testOnCrashedLastRun_OnCaptureCrashWithSession() { + let event = TestData.event + + var onCrashedLastRunCalled = false + fixture.getSut(configureOptions: { options in + options.onCrashedLastRun = { _ in + onCrashedLastRunCalled = true + } + }).captureCrash(event, with: fixture.session, with: fixture.scope) + + XCTAssertTrue(onCrashedLastRunCalled) + } + + func testOnCrashedLastRun_WithTwoCrashes_OnlyInvokeCallbackOnce() { + let event = TestData.event + + var onCrashedLastRunCalled = false + let client = fixture.getSut(configureOptions: { options in + options.onCrashedLastRun = { crashEvent in + onCrashedLastRunCalled = true + XCTAssertEqual(event.eventId, crashEvent.eventId) + } + }) + + client.captureCrash(event, with: fixture.scope) + client.captureCrash(TestData.event, with: fixture.scope) + + XCTAssertTrue(onCrashedLastRunCalled) + } + + func testOnCrashedLastRun_WithoutCallback_DoesNothing() { + let client = fixture.getSut() + client.captureCrash(TestData.event, with: fixture.scope) + } + private func givenEventWithDebugMeta() -> Event { let event = Event(level: SentryLevel.fatal) let debugMeta = DebugMeta() @@ -651,6 +737,13 @@ class SentryClientTest: XCTestCase { } } + private func assertLastSentEventWithSession(assert: (Event, SentrySession) -> Void) { + XCTAssertNotNil(fixture.transport.sentEventsWithSession.last) + if let args = fixture.transport.sentEventsWithSession.last { + assert(args.first, args.second) + } + } + private func assertValidErrorEvent(_ event: Event) { XCTAssertEqual(SentryLevel.error, event.level) XCTAssertEqual("\(error.domain) \(error.code)", event.message.formatted) diff --git a/Tests/SentryTests/SentryHubTests.swift b/Tests/SentryTests/SentryHubTests.swift index 77e8c9e3d40..9a8f3fdf721 100644 --- a/Tests/SentryTests/SentryHubTests.swift +++ b/Tests/SentryTests/SentryHubTests.swift @@ -365,7 +365,7 @@ class SentryHubTests: XCTestCase { // Make sure further crash events are sent sut.captureCrash(fixture.event) - assertEventSent() + assertCrashEventSent() } func testCaptureCrashEvent_CrashedSessionDoesNotExist() { @@ -373,7 +373,7 @@ class SentryHubTests: XCTestCase { sut.captureCrash(fixture.event) assertNoCrashedSessionSent() - assertEventSent() + assertCrashEventSent() } /** @@ -382,7 +382,7 @@ class SentryHubTests: XCTestCase { func testCatpureCrashEvent_CrashExistsButNoSessionExists() { sut.captureCrash(fixture.event) - assertEventSent() + assertCrashEventSent() } func testCaptureCrashEvent_WithoutExistingSessionAndAutoSessionTrackingEnabled() { @@ -390,7 +390,7 @@ class SentryHubTests: XCTestCase { sut.captureCrash(fixture.event) - assertEventSent() + assertCrashEventSent() } func testCaptureCrashEvent_SessionExistsButAutoSessionTrackingDisabled() { @@ -399,7 +399,7 @@ class SentryHubTests: XCTestCase { sut.captureCrash(fixture.event) - assertEventSent() + assertCrashEventSent() } func testCaptureCrashEvent_ClientIsNil() { @@ -478,7 +478,7 @@ class SentryHubTests: XCTestCase { private func assertNoEventsSent() { XCTAssertEqual(0, fixture.client.captureEventArguments.count) - XCTAssertEqual(0, fixture.client.captureEventWithSessionArguments.count) + XCTAssertEqual(0, fixture.client.captureCrashEventWithSessionArguments.count) } private func assertEventSent() { @@ -486,9 +486,15 @@ class SentryHubTests: XCTestCase { XCTAssertEqual(1, arguments.count) XCTAssertEqual(fixture.event, arguments.first?.first) } + + private func assertCrashEventSent() { + let arguments = fixture.client.captureCrashEventArguments + XCTAssertEqual(1, arguments.count) + XCTAssertEqual(fixture.event, arguments.first?.first) + } private func assertEventSentWithSession() { - let arguments = fixture.client.captureEventWithSessionArguments + let arguments = fixture.client.captureCrashEventWithSessionArguments XCTAssertEqual(1, arguments.count) let argument = arguments.first diff --git a/Tests/SentryTests/SentryOptionsTest.m b/Tests/SentryTests/SentryOptionsTest.m index af6bc1f2e43..4afe929b768 100644 --- a/Tests/SentryTests/SentryOptionsTest.m +++ b/Tests/SentryTests/SentryOptionsTest.m @@ -204,6 +204,28 @@ - (void)testDefaultBeforeBreadcrumb XCTAssertNil(options.beforeBreadcrumb); } +- (void)testOnCrashedLastRun +{ + __block BOOL onCrashedLastRunCalled = NO; + void (^callback)(SentryEvent *event) = ^(SentryEvent *event) { + onCrashedLastRunCalled = YES; + XCTAssertNotNil(event); + }; + SentryOptions *options = [self getValidOptions:@{ @"onCrashedLastRun" : callback }]; + + options.onCrashedLastRun([[SentryEvent alloc] init]); + + XCTAssertEqual(callback, options.onCrashedLastRun); + XCTAssertTrue(onCrashedLastRunCalled); +} + +- (void)testDefaultOnCrashedLastRun +{ + SentryOptions *options = [self getValidOptions:@{}]; + + XCTAssertNil(options.onCrashedLastRun); +} + - (void)testIntegrations { NSArray *integrations = @[ @"integration1", @"integration2" ]; diff --git a/Tests/SentryTests/TestClient.swift b/Tests/SentryTests/TestClient.swift index 62672b65d43..39c2f34bdb1 100644 --- a/Tests/SentryTests/TestClient.swift +++ b/Tests/SentryTests/TestClient.swift @@ -71,9 +71,15 @@ class TestClient: Client { return SentryId() } - var captureEventWithSessionArguments: [Triple] = [] - override func capture(_ event: Event, with session: SentrySession, with scope: Scope) -> SentryId { - captureEventWithSessionArguments.append(Triple(event, session, scope)) + var captureCrashEventArguments: [Pair] = [] + override func captureCrash(_ event: Event, with scope: Scope) -> SentryId { + captureCrashEventArguments.append(Pair(event, scope)) + return SentryId() + } + + var captureCrashEventWithSessionArguments: [Triple] = [] + override func captureCrash(_ event: Event, with session: SentrySession, with scope: Scope) -> SentryId { + captureCrashEventWithSessionArguments.append(Triple(event, session, scope)) return SentryId() }