diff --git a/Sources/Sentry/SentryHub.m b/Sources/Sentry/SentryHub.m index c0d9974ebdd..bd4941bc0c4 100644 --- a/Sources/Sentry/SentryHub.m +++ b/Sources/Sentry/SentryHub.m @@ -9,6 +9,7 @@ #import "SentryEvent+Private.h" #import "SentryFileManager.h" #import "SentryId.h" +#import "SentryLevelMapper.h" #import "SentryLog.h" #import "SentryNSTimerWrapper.h" #import "SentryPerformanceTracker.h" @@ -590,37 +591,66 @@ - (void)captureEnvelope:(SentryEnvelope *)envelope - (SentryEnvelope *)updateSessionState:(SentryEnvelope *)envelope { - if ([self envelopeContainsEventWithErrorOrHigher:envelope.items]) { - SentrySession *currentSession = [self incrementSessionErrors]; - - if (currentSession != nil) { - // Create a new envelope with the session update - NSMutableArray *itemsToSend = - [[NSMutableArray alloc] initWithArray:envelope.items]; - [itemsToSend addObject:[[SentryEnvelopeItem alloc] initWithSession:currentSession]]; - - return [[SentryEnvelope alloc] initWithHeader:envelope.header items:itemsToSend]; + BOOL handled = YES; + if ([self envelopeContainsEventWithErrorOrHigher:envelope.items wasHandled:&handled]) { + SentrySession *currentSession; + @synchronized(_sessionLock) { + currentSession = handled ? [self incrementSessionErrors] : [_session copy]; + if (currentSession == nil) { + return envelope; + } + if (!handled) { + [currentSession endSessionCrashedWithTimestamp:[_currentDateProvider date]]; + // Setting _session to nil so startSession doesn't capture it again + _session = nil; + [self startSession]; + } } - } + // Create a new envelope with the session update + NSMutableArray *itemsToSend = + [[NSMutableArray alloc] initWithArray:envelope.items]; + [itemsToSend addObject:[[SentryEnvelopeItem alloc] initWithSession:currentSession]]; + return [[SentryEnvelope alloc] initWithHeader:envelope.header items:itemsToSend]; + } return envelope; } - (BOOL)envelopeContainsEventWithErrorOrHigher:(NSArray *)items + wasHandled:(BOOL *)handled; { for (SentryEnvelopeItem *item in items) { if ([item.header.type isEqualToString:SentryEnvelopeItemTypeEvent]) { // If there is no level the default is error - SentryLevel level = [SentrySerialization levelFromData:item.data]; + NSDictionary *eventJson = [SentrySerialization deserializeEventEnvelopeItem:item.data]; + if (eventJson == nil) { + return NO; + } + + SentryLevel level = sentryLevelForString(eventJson[@"level"]); if (level >= kSentryLevelError) { + *handled = [self eventContainsUnhandledError:eventJson]; return YES; } } } - return NO; } +- (BOOL)eventContainsUnhandledError:(NSDictionary *)eventDictionary +{ + NSArray *exceptions = eventDictionary[@"exception"][@"values"]; + for (NSDictionary *exception in exceptions) { + NSDictionary *mechanism = exception[@"mechanism"]; + NSNumber *handled = mechanism[@"handled"]; + + if ([handled boolValue] == NO) { + return NO; + } + } + return YES; +} + - (void)reportFullyDisplayed { #if SENTRY_HAS_UIKIT diff --git a/Sources/Sentry/SentrySerialization.m b/Sources/Sentry/SentrySerialization.m index abebeaf9fa4..27fe09500f0 100644 --- a/Sources/Sentry/SentrySerialization.m +++ b/Sources/Sentry/SentrySerialization.m @@ -339,6 +339,23 @@ + (SentryAppState *_Nullable)appStateWithData:(NSData *)data return [[SentryAppState alloc] initWithJSONObject:appSateDictionary]; } ++ (NSDictionary *)deserializeEventEnvelopeItem:(NSData *)eventEnvelopeItemData +{ + NSError *error = nil; + NSDictionary *eventDictionary = [NSJSONSerialization JSONObjectWithData:eventEnvelopeItemData + options:0 + error:&error]; + if (nil != error) { + [SentryLog + logWithMessage:[NSString + stringWithFormat:@"Failed to deserialize envelope item data: %@", + error] + andLevel:kSentryLevelError]; + } + + return eventDictionary; +} + + (SentryLevel)levelFromData:(NSData *)eventEnvelopeItemData { NSError *error = nil; diff --git a/Sources/Sentry/include/SentryHub+Private.h b/Sources/Sentry/include/SentryHub+Private.h index 622911d03cc..025b3a12a1c 100644 --- a/Sources/Sentry/include/SentryHub+Private.h +++ b/Sources/Sentry/include/SentryHub+Private.h @@ -2,7 +2,7 @@ #import "SentryTracer.h" @class SentryEnvelopeItem, SentryId, SentryScope, SentryTransaction, SentryDispatchQueueWrapper, - SentryEnvelope, SentryNSTimerWrapper; + SentryEnvelope, SentryNSTimerWrapper, SentrySession; NS_ASSUME_NONNULL_BEGIN @@ -11,6 +11,7 @@ SentryHub (Private) @property (nonatomic, strong) NSArray> *installedIntegrations; @property (nonatomic, strong) NSSet *installedIntegrationNames; +@property (nullable, nonatomic, strong) SentrySession *session; - (void)addInstalledIntegration:(id)integration name:(NSString *)name; - (void)removeAllIntegrations; diff --git a/Sources/Sentry/include/SentrySerialization.h b/Sources/Sentry/include/SentrySerialization.h index 4c83c1ef110..fbfcec32e4d 100644 --- a/Sources/Sentry/include/SentrySerialization.h +++ b/Sources/Sentry/include/SentrySerialization.h @@ -24,6 +24,11 @@ static int const SENTRY_BAGGAGE_MAX_SIZE = 8192; + (SentryAppState *_Nullable)appStateWithData:(NSData *)sessionData; +/** + * Retrieves the json object from an event envelope item data. + */ ++ (NSDictionary *)deserializeEventEnvelopeItem:(NSData *)eventEnvelopeItemData; + /** * Extract the level from data of an envelopte item containing an event. Default is the 'error' * level, see https://develop.sentry.dev/sdk/event-payloads/#optional-attributes diff --git a/Tests/SentryTests/SentryHubTests.swift b/Tests/SentryTests/SentryHubTests.swift index ab7ed2f0b04..3fe7fd81fab 100644 --- a/Tests/SentryTests/SentryHubTests.swift +++ b/Tests/SentryTests/SentryHubTests.swift @@ -5,7 +5,7 @@ import XCTest class SentryHubTests: XCTestCase { private static let dsnAsString = TestConstants.dsnAsString(username: "SentryHubTests") - + private class Fixture { let options: Options let error = NSError(domain: "", code: 0, userInfo: [NSLocalizedDescriptionKey: "Object does not exist"]) @@ -78,7 +78,7 @@ class SentryHubTests: XCTestCase { fixture.fileManager.deleteTimestampLastInForeground() fixture.fileManager.deleteAllEnvelopes() } - + func testBeforeBreadcrumbWithoutCallbackStoresBreadcrumb() { let hub = fixture.getSut() @@ -107,24 +107,24 @@ class SentryHubTests: XCTestCase { func testBreadcrumbLimitThroughOptionsUsingHubAddBreadcrumb() { let hub = fixture.getSut(withMaxBreadcrumbs: 10) - + for _ in 0...10 { let crumb = Breadcrumb( level: .error, category: "default") hub.add(crumb) } - + assert(withScopeBreadcrumbsCount: 10, with: hub) } func testBreadcrumbLimitThroughOptionsUsingConfigureScope() { let hub = fixture.getSut(withMaxBreadcrumbs: 10) - + for _ in 0...10 { addBreadcrumbThroughConfigureScope(hub) } - + assert(withScopeBreadcrumbsCount: 10, with: hub) } @@ -133,11 +133,11 @@ class SentryHubTests: XCTestCase { SentryLog.configure(true, diagnosticLevel: .error) let hub = fixture.getSut() - + for _ in 0...100 { addBreadcrumbThroughConfigureScope(hub) } - + assert(withScopeBreadcrumbsCount: 100, with: hub) setTestDefaultLogLevel() @@ -145,11 +145,11 @@ class SentryHubTests: XCTestCase { func testBreadcrumbOverDefaultLimit() { let hub = fixture.getSut(withMaxBreadcrumbs: 200) - + for _ in 0...200 { addBreadcrumbThroughConfigureScope(hub) } - + assert(withScopeBreadcrumbsCount: 200, with: hub) } @@ -185,15 +185,15 @@ class SentryHubTests: XCTestCase { func testAddUserToTheScope() throws { let client = SentryClient(options: fixture.options, fileManager: try TestFileManager(options: fixture.options), deleteOldEnvelopeItems: false) let hub = SentryHub(client: client, andScope: Scope()) - + let user = User() user.userId = "123" hub.setUser(user) - + let scopeSerialized = hub.scope.serialize() let scopeUser = scopeSerialized["user"] as? [String: Any?] let scopeUserId = scopeUser?["id"] as? String - + XCTAssertEqual(scopeUserId, "123") } @@ -237,7 +237,7 @@ class SentryHubTests: XCTestCase { XCTAssertEqual(span.operation, fixture.transactionOperation) XCTAssertEqual("manual", tracer.transactionContext.origin) } - + func testStartTransactionWithNameSource() { let span = fixture.getSut().startTransaction(transactionContext: TransactionContext( name: fixture.transactionName, @@ -245,7 +245,7 @@ class SentryHubTests: XCTestCase { operation: fixture.transactionOperation, origin: fixture.traceOrigin )) - + let tracer = span as! SentryTracer XCTAssertEqual(tracer.transactionContext.name, fixture.transactionName) XCTAssertEqual(tracer.transactionContext.nameSource, SentryTransactionNameSource.url) @@ -303,7 +303,7 @@ class SentryHubTests: XCTestCase { options.tracesSampleRate = 0.50 } } - + func testStartTransactionSamplingUsingTracesSampler() { assertSampler(expected: .yes) { options in options.tracesSampler = { _ in return 0.51 } @@ -334,27 +334,27 @@ class SentryHubTests: XCTestCase { let span = hub.startTransaction(name: fixture.transactionName, operation: fixture.transactionOperation) XCTAssertEqual(span.sampled, .no) } - + func testCaptureSampledTransaction_ReturnsEmptyId() { let transaction = sut.startTransaction(transactionContext: TransactionContext(name: fixture.transactionName, operation: fixture.transactionOperation, sampled: .no)) - + let trans = Dynamic(transaction).toTransaction().asAnyObject let id = sut.capture(trans as! Transaction, with: Scope()) id.assertIsEmpty() } - + func testCaptureSampledTransaction_RecordsLostEvent() { let transaction = sut.startTransaction(transactionContext: TransactionContext(name: fixture.transactionName, operation: fixture.transactionOperation, sampled: .no)) - + let trans = Dynamic(transaction).toTransaction().asAnyObject sut.capture(trans as! Transaction, with: Scope()) - + XCTAssertEqual(1, fixture.client.recordLostEvents.count) let lostEvent = fixture.client.recordLostEvents.first XCTAssertEqual(.transaction, lostEvent?.category) XCTAssertEqual(.sampleRate, lostEvent?.reason) } - + func testCaptureMessageWithScope() { fixture.getSut().capture(message: fixture.message, scope: fixture.scope) @@ -403,72 +403,72 @@ class SentryHubTests: XCTestCase { // only session init is sent XCTAssertEqual(1, fixture.client.captureSessionInvocations.count) } - + func testCaptureErrorBeforeSessionStart() { let sut = fixture.getSut() sut.capture(error: fixture.error, scope: fixture.scope).assertIsNotEmpty() sut.startSession() - + XCTAssertEqual(fixture.client.captureErrorWithScopeInvocations.count, 1) XCTAssertEqual(fixture.client.captureSessionInvocations.count, 1) - + if let session = fixture.client.captureSessionInvocations.first { XCTAssertEqual(session.errors, 1) } } - + func testCaptureErrorBeforeSessionStart_DisabledAutoSessionTracking() { fixture.options.enableAutoSessionTracking = false let sut = fixture.getSut() sut.capture(error: fixture.error, scope: fixture.scope).assertIsNotEmpty() sut.startSession() - + XCTAssertEqual(fixture.client.captureErrorWithScopeInvocations.count, 1) XCTAssertEqual(fixture.client.captureSessionInvocations.count, 1) - + if let session = fixture.client.captureSessionInvocations.first { XCTAssertEqual(session.errors, 0) } } - + func testCaptureError_SessionWithDefaultEnvironment() { let sut = fixture.getSut() sut.startSession() sut.capture(error: fixture.error, scope: fixture.scope).assertIsNotEmpty() - + XCTAssertEqual(fixture.client.captureSessionInvocations.count, 1) - + if let session = fixture.client.captureSessionInvocations.first { XCTAssertEqual(session.environment, "production") } } - + func testCaptureError_SessionWithEnvironmentFromOptions() { fixture.options.environment = "test-env" let sut = fixture.getSut() sut.startSession() sut.capture(error: fixture.error, scope: fixture.scope).assertIsNotEmpty() - + XCTAssertEqual(fixture.client.captureSessionInvocations.count, 1) - + if let session = fixture.client.captureSessionInvocations.first { XCTAssertEqual(session.environment, "test-env") } } - + 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) } @@ -521,20 +521,20 @@ class SentryHubTests: XCTestCase { // only session init is sent XCTAssertEqual(1, fixture.client.captureSessionInvocations.count) } - + func testCaptureExceptionWithoutIncreasingErrorCount() { 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) } @@ -594,7 +594,7 @@ class SentryHubTests: XCTestCase { givenCrashedSession() assertNoCrashedSessionSent() - + sut.captureCrash(fixture.event) assertEventSentWithSession() @@ -607,7 +607,7 @@ class SentryHubTests: XCTestCase { func testCaptureCrashEvent_CrashedSessionDoesNotExist() { sut.startSession() // there is already an existing session sut.captureCrash(fixture.event) - + assertNoCrashedSessionSent() assertCrashEventSent() } @@ -632,7 +632,7 @@ class SentryHubTests: XCTestCase { func testCaptureCrashEvent_SessionExistsButAutoSessionTrackingDisabled() { givenAutoSessionTrackingDisabled() givenCrashedSession() - + sut.captureCrash(fixture.event) assertCrashEventSent() @@ -706,7 +706,7 @@ class SentryHubTests: XCTestCase { assertNoEnvelopesCaptured() } - + func testCaptureEnvelope_WithSession() { let envelope = SentryEnvelope(session: SentrySession(releaseName: "")) sut.capture(envelope) @@ -714,46 +714,82 @@ class SentryHubTests: XCTestCase { XCTAssertEqual(1, fixture.client.captureEnvelopeInvocations.count) XCTAssertEqual(envelope, fixture.client.captureEnvelopeInvocations.first) } + + func testCaptureEnvelope_WithUnhandledException() { + sut.startSession() + + fixture.currentDateProvider.setDate(date: Date(timeIntervalSince1970: 2)) + + let event = TestData.event + event.level = .error + event.exceptions = [TestData.exception] + event.exceptions?.first?.mechanism?.handled = false + sut.capture(SentryEnvelope(event: event)) + //Check whether session was finished as crashed + let envelope = fixture.client.captureEnvelopeInvocations.first + let sessionEnvelopeItem = envelope?.items.first(where: { $0.header.type == "session" }) + + let json = (try! JSONSerialization.jsonObject(with: sessionEnvelopeItem!.data)) as! [String: Any] + + XCTAssertEqual(json["timestamp"] as? String, "1970-01-01T00:00:02.000Z") + XCTAssertEqual(json["status"] as? String, "crashed") + } + + func testCaptureEnvelope_WithHandledException() { + sut.startSession() + + let beginSession = sut.session + + let event = TestData.event + event.level = .error + event.exceptions = [TestData.exception] + sut.capture(SentryEnvelope(event: event)) + + let endSession = sut.session + + XCTAssertEqual(beginSession, endSession) + } + #if os(iOS) || os(tvOS) || targetEnvironment(macCatalyst) func test_reportFullyDisplayed_enableTimeToFullDisplay_YES() { fixture.options.enableTimeToFullDisplay = true let sut = fixture.getSut(fixture.options) - + let testTTDTracker = TestTimeToDisplayTracker() - + Dynamic(SentryUIViewControllerPerformanceTracker.shared).currentTTDTracker = testTTDTracker - + sut.reportFullyDisplayed() - + XCTAssertTrue(testTTDTracker.registerFullDisplayCalled) - + } - + func test_reportFullyDisplayed_enableTimeToFullDisplay_NO() { fixture.options.enableTimeToFullDisplay = false let sut = fixture.getSut(fixture.options) - + let testTTDTracker = TestTimeToDisplayTracker() - + Dynamic(SentryUIViewControllerPerformanceTracker.shared).currentTTDTracker = testTTDTracker - + sut.reportFullyDisplayed() - + XCTAssertFalse(testTTDTracker.registerFullDisplayCalled) } #endif - + private func addBreadcrumbThroughConfigureScope(_ hub: SentryHub) { hub.configureScope({ scope in scope.addBreadcrumb(self.fixture.crumb) }) } - + private func captureConcurrentWithSession(count: Int, _ capture: @escaping (SentryHub) -> Void) { let sut = fixture.getSut() sut.startSession() - + let queue = fixture.queue let group = DispatchGroup() for _ in 0.. Void) { options(fixture.options) - + let hub = fixture.getSut() Dynamic(hub).tracesSampler.random = fixture.random - + let span = hub.startTransaction(name: fixture.transactionName, operation: fixture.transactionOperation) - + XCTAssertEqual(expected, span.sampled) } } #if os(iOS) || os(tvOS) || targetEnvironment(macCatalyst) class TestTimeToDisplayTracker: SentryTimeToDisplayTracker { - + init() { super.init(for: UIViewController(), framesTracker: SentryFramesTracker.sharedInstance(), waitForFullDisplay: false) } - + var registerFullDisplayCalled = false override func reportFullyDisplayed() { registerFullDisplayCalled = true } - + } #endif