Skip to content

Commit 0b7362d

Browse files
committed
fix: Add extension detector to disable App Hang tracking in extensions
1 parent 12b7c04 commit 0b7362d

File tree

14 files changed

+677
-23
lines changed

14 files changed

+677
-23
lines changed

CHANGELOG.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,8 @@
3333
- 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).
3434
If you need a precompiled XCFramework built with Xcode 15, continue using Sentry SDK 8.x.x.
3535
- Set `SentryException.type` to `nil` when `NSException` has no `reason` (#6653). The backend then can provide a proper message when there is no reason.
36+
- App hang tracking is now automatically disabled for Widgets, Live Activities, Intent Extensions, and Action Extensions (#3901).
37+
These components run in separate processes or sandboxes with different execution characteristics, which can cause false positive app hang reports.
3638

3739
### Features
3840

Sentry.xcodeproj/project.pbxproj

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -779,6 +779,8 @@
779779
D452FE6D2DDC873A00AFF56F /* SentryWatchdogTerminationAttributesProcessorTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = D452FE6C2DDC873900AFF56F /* SentryWatchdogTerminationAttributesProcessorTests.swift */; };
780780
D452FE6F2DDC890A00AFF56F /* TestFileManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = D452FE6E2DDC890A00AFF56F /* TestFileManager.swift */; };
781781
D452FE712DDC8C4400AFF56F /* SentryWatchdogTerminationBreadcrumbProcessorTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = D452FE702DDC8C4400AFF56F /* SentryWatchdogTerminationBreadcrumbProcessorTests.swift */; };
782+
D4563FA92EBA3B73005B33E2 /* SentryExtensionDetector.swift in Sources */ = {isa = PBXBuildFile; fileRef = D4563FA72EBA3B73005B33E2 /* SentryExtensionDetector.swift */; };
783+
D4563FAA2EBA3B73005B33E2 /* SentryExtensionType.swift in Sources */ = {isa = PBXBuildFile; fileRef = D4563FA82EBA3B73005B33E2 /* SentryExtensionType.swift */; };
782784
D456B4322D706BDF007068CB /* SentrySpanOperation.h in Headers */ = {isa = PBXBuildFile; fileRef = D456B4312D706BDD007068CB /* SentrySpanOperation.h */; };
783785
D456B4362D706BF2007068CB /* SentryTraceOrigin.h in Headers */ = {isa = PBXBuildFile; fileRef = D456B4352D706BEE007068CB /* SentryTraceOrigin.h */; };
784786
D456B4382D706BFE007068CB /* SentrySpanDataKey.h in Headers */ = {isa = PBXBuildFile; fileRef = D456B4372D706BFB007068CB /* SentrySpanDataKey.h */; };
@@ -2144,6 +2146,8 @@
21442146
D452FE702DDC8C4400AFF56F /* SentryWatchdogTerminationBreadcrumbProcessorTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SentryWatchdogTerminationBreadcrumbProcessorTests.swift; sourceTree = "<group>"; };
21452147
D452FE722DDC8DB700AFF56F /* TestSentryWatchdogTerminationAttributesProcessor.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TestSentryWatchdogTerminationAttributesProcessor.swift; sourceTree = "<group>"; };
21462148
D452FE742DDC8DC400AFF56F /* TestSentryWatchdogTerminationBreadcrumbProcessor.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TestSentryWatchdogTerminationBreadcrumbProcessor.swift; sourceTree = "<group>"; };
2149+
D4563FA72EBA3B73005B33E2 /* SentryExtensionDetector.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SentryExtensionDetector.swift; sourceTree = "<group>"; };
2150+
D4563FA82EBA3B73005B33E2 /* SentryExtensionType.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SentryExtensionType.swift; sourceTree = "<group>"; };
21472151
D456B4312D706BDD007068CB /* SentrySpanOperation.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; name = SentrySpanOperation.h; path = include/SentrySpanOperation.h; sourceTree = "<group>"; };
21482152
D456B4352D706BEE007068CB /* SentryTraceOrigin.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; name = SentryTraceOrigin.h; path = include/SentryTraceOrigin.h; sourceTree = "<group>"; };
21492153
D456B4372D706BFB007068CB /* SentrySpanDataKey.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; name = SentrySpanDataKey.h; path = include/SentrySpanDataKey.h; sourceTree = "<group>"; };
@@ -2678,6 +2682,8 @@
26782682
FAEEC04C2E75E55A00E79CA9 /* SentrySerializationSwift.swift */,
26792683
FAEEBFE92E74517800E79CA9 /* SentryFileManager.swift */,
26802684
FA94E7232E6F32FA00576666 /* SentryEnvelopeItemType.swift */,
2685+
D4563FA72EBA3B73005B33E2 /* SentryExtensionDetector.swift */,
2686+
D4563FA82EBA3B73005B33E2 /* SentryExtensionType.swift */,
26812687
FA458CBD2E691A6E0061B13D /* SentryProcessInfo.swift */,
26822688
F4A930222E65FDAF006DA6EF /* SentryMobileProvisionParser.swift */,
26832689
F4FE9DBC2E621F100014FED5 /* SentryRandom.swift */,
@@ -5860,6 +5866,8 @@
58605866
15360CED2433A15500112302 /* SentryInstallation.m in Sources */,
58615867
FAAB95FF2EA301670030A2DB /* SentryWatchdogTerminationScopeObserver.swift in Sources */,
58625868
D8370B6A273DF1E900F66E2D /* SentryNSURLSessionTaskSearch.m in Sources */,
5869+
D4563FA92EBA3B73005B33E2 /* SentryExtensionDetector.swift in Sources */,
5870+
D4563FAA2EBA3B73005B33E2 /* SentryExtensionType.swift in Sources */,
58635871
63FE711D20DA4C1000CDBAE8 /* SentryCrashCPU_arm64.c in Sources */,
58645872
844EDC77294144DB00C86F34 /* SentrySystemWrapper.mm in Sources */,
58655873
D451ED5F2D92ECDE00C9BEA8 /* SentryReplayFrame.swift in Sources */,

SentryTestUtils/Sources/TestInfoPlistWrapper.swift

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,9 @@ import XCTest
99
public var getAppValueBooleanInvocations = Invocations<(String, NSErrorPointer)>()
1010
private var mockedGetAppValueBooleanReturnValue: [String: Result<Bool, NSError>] = [:]
1111

12+
public var getAppValueDictionaryInvocations = Invocations<String>()
13+
private var mockedGetAppValueDictionaryReturnValue: [String: Result<[String: Any], Error>] = [:]
14+
1215
public init() {}
1316

1417
public func mockGetAppValueStringReturnValue(forKey key: String, value: String) {
@@ -55,4 +58,26 @@ import XCTest
5558
return false
5659
}
5760
}
61+
62+
public func mockGetAppValueDictionaryReturnValue(forKey key: String, value: [String: Any]) {
63+
mockedGetAppValueDictionaryReturnValue[key] = .success(value)
64+
}
65+
66+
public func mockGetAppValueDictionaryThrowError(forKey key: String, error: Error) {
67+
mockedGetAppValueDictionaryReturnValue[key] = .failure(error)
68+
}
69+
70+
public func getAppValueDictionary(for key: String) throws -> [String: Any] {
71+
getAppValueDictionaryInvocations.record(key)
72+
guard let result = mockedGetAppValueDictionaryReturnValue[key] else {
73+
XCTFail("TestInfoPlistWrapper: No mocked return value set for getAppValueDictionary(for:) for key: \(key)")
74+
return [:]
75+
}
76+
switch result {
77+
case .success(let value):
78+
return value
79+
case .failure(let error):
80+
throw error
81+
}
82+
}
5883
}

SentryTestUtilsTests/Sources/TestInfoPlistWrapperTests.swift

Lines changed: 145 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,5 @@
1+
// swiftlint:disable file_length type_body_length
2+
13
@_spi(Private) @testable import Sentry
24
@_spi(Private) @testable import SentryTestUtils
35
import XCTest
@@ -279,4 +281,147 @@ class TestInfoPlistWrapperTests: XCTestCase {
279281
XCTAssertFalse(result2, "Should return false for key2")
280282
XCTAssertNil(error2, "Should not set error for key2")
281283
}
284+
285+
// MARK: - getAppValueDictionary(for:)
286+
287+
func testGetAppValueDictionary_withoutMockedValue_shouldFail() throws {
288+
// -- Arrange --
289+
let sut = TestInfoPlistWrapper()
290+
// Don't mock any value for this key
291+
292+
// -- Act & Assert --
293+
XCTExpectFailure("We are expecting a failure when accessing an unmocked key, as it indicates the test setup is incomplete")
294+
_ = try sut.getAppValueDictionary(for: "unmockedKey")
295+
}
296+
297+
func testGetAppValueDictionary_withMockedValue_withSingleInvocations_shouldReturnMockedValue() throws {
298+
// -- Arrange --
299+
let sut = TestInfoPlistWrapper()
300+
let expectedDict = ["key1": "value1", "key2": 123] as [String: Any]
301+
sut.mockGetAppValueDictionaryReturnValue(forKey: "dictKey", value: expectedDict)
302+
303+
// -- Act --
304+
let result = try sut.getAppValueDictionary(for: "dictKey")
305+
306+
// -- Assert --
307+
XCTAssertEqual(result["key1"] as? String, "value1", "Should return the mocked dictionary")
308+
XCTAssertEqual(result["key2"] as? Int, 123, "Should return the mocked dictionary")
309+
}
310+
311+
func testGetAppValueDictionary_withMockedValue_withMultipleInvocations_shouldReturnSameValue() throws {
312+
// -- Arrange --
313+
let sut = TestInfoPlistWrapper()
314+
let expectedDict = ["test": "value"] as [String: Any]
315+
sut.mockGetAppValueDictionaryReturnValue(forKey: "key1", value: expectedDict)
316+
317+
// -- Act --
318+
let result1 = try sut.getAppValueDictionary(for: "key1")
319+
let result2 = try sut.getAppValueDictionary(for: "key1")
320+
321+
// -- Assert --
322+
XCTAssertEqual(result1["test"] as? String, "value", "First invocation should return mocked value")
323+
XCTAssertEqual(result2["test"] as? String, "value", "Second invocation should return same mocked value")
324+
}
325+
326+
func testGetAppValueDictionary_shouldRecordInvocations() throws {
327+
// -- Arrange --
328+
let sut = TestInfoPlistWrapper()
329+
sut.mockGetAppValueDictionaryReturnValue(forKey: "key1", value: ["a": 1])
330+
sut.mockGetAppValueDictionaryReturnValue(forKey: "key2", value: ["b": 2])
331+
sut.mockGetAppValueDictionaryReturnValue(forKey: "key3", value: ["c": 3])
332+
333+
// -- Act --
334+
_ = try sut.getAppValueDictionary(for: "key1")
335+
_ = try sut.getAppValueDictionary(for: "key2")
336+
_ = try sut.getAppValueDictionary(for: "key3")
337+
338+
// -- Assert --
339+
XCTAssertEqual(sut.getAppValueDictionaryInvocations.count, 3, "Should record all three invocations")
340+
XCTAssertEqual(sut.getAppValueDictionaryInvocations.invocations.element(at: 0), "key1", "First invocation should be for key1")
341+
XCTAssertEqual(sut.getAppValueDictionaryInvocations.invocations.element(at: 1), "key2", "Second invocation should be for key2")
342+
XCTAssertEqual(sut.getAppValueDictionaryInvocations.invocations.element(at: 2), "key3", "Third invocation should be for key3")
343+
}
344+
345+
func testGetAppValueDictionary_withDifferentKeys_shouldReturnDifferentValues() throws {
346+
// -- Arrange --
347+
let sut = TestInfoPlistWrapper()
348+
sut.mockGetAppValueDictionaryReturnValue(forKey: "key1", value: ["value": "one"])
349+
sut.mockGetAppValueDictionaryReturnValue(forKey: "key2", value: ["value": "two"])
350+
351+
// -- Act --
352+
let result1 = try sut.getAppValueDictionary(for: "key1")
353+
let result2 = try sut.getAppValueDictionary(for: "key2")
354+
355+
// -- Assert --
356+
XCTAssertEqual(result1["value"] as? String, "one", "Should return 'one' for key1")
357+
XCTAssertEqual(result2["value"] as? String, "two", "Should return 'two' for key2")
358+
XCTAssertEqual(sut.getAppValueDictionaryInvocations.count, 2, "Should record both invocations")
359+
}
360+
361+
func testGetAppValueDictionary_withFailureResult_shouldThrowError() {
362+
// -- Arrange --
363+
let sut = TestInfoPlistWrapper()
364+
sut.mockGetAppValueDictionaryThrowError(forKey: "key", error: SentryInfoPlistError.keyNotFound(key: "testKey"))
365+
366+
// -- Act & Assert --
367+
XCTAssertThrowsError(try sut.getAppValueDictionary(for: "key")) { error in
368+
guard case SentryInfoPlistError.keyNotFound(let key) = error else {
369+
XCTFail("Expected SentryInfoPlistError.keyNotFound, got \(error)")
370+
return
371+
}
372+
XCTAssertEqual(key, "testKey", "Error should contain the expected key")
373+
}
374+
}
375+
376+
func testGetAppValueDictionary_withDifferentErrorTypes_shouldThrowCorrectError() {
377+
// -- Arrange --
378+
let sut = TestInfoPlistWrapper()
379+
380+
// Test mainInfoPlistNotFound
381+
sut.mockGetAppValueDictionaryThrowError(forKey: "key1", error: SentryInfoPlistError.mainInfoPlistNotFound)
382+
XCTAssertThrowsError(try sut.getAppValueDictionary(for: "key1")) { error in
383+
guard case SentryInfoPlistError.mainInfoPlistNotFound = error else {
384+
XCTFail("Expected SentryInfoPlistError.mainInfoPlistNotFound, got \(error)")
385+
return
386+
}
387+
}
388+
389+
// Test unableToCastValue
390+
sut.mockGetAppValueDictionaryThrowError(forKey: "key2", error: SentryInfoPlistError.unableToCastValue(key: "castKey", value: "not a dict", type: [String: Any].self))
391+
XCTAssertThrowsError(try sut.getAppValueDictionary(for: "key2")) { error in
392+
guard case SentryInfoPlistError.unableToCastValue(let key, let value, let type) = error else {
393+
XCTFail("Expected SentryInfoPlistError.unableToCastValue, got \(error)")
394+
return
395+
}
396+
XCTAssertEqual(key, "castKey", "Error should contain the correct key")
397+
XCTAssertEqual(value as? String, "not a dict", "Error should contain the correct value")
398+
XCTAssertTrue(type == [String: Any].self, "Error should contain the correct type")
399+
}
400+
}
401+
402+
func testGetAppValueDictionary_afterThrowingError_shouldRecordInvocation() {
403+
// -- Arrange --
404+
let sut = TestInfoPlistWrapper()
405+
sut.mockGetAppValueDictionaryThrowError(forKey: "key1", error: SentryInfoPlistError.keyNotFound(key: "testKey"))
406+
407+
// -- Act --
408+
_ = try? sut.getAppValueDictionary(for: "key1")
409+
410+
// -- Assert --
411+
XCTAssertEqual(sut.getAppValueDictionaryInvocations.count, 1, "Should record invocation even when throwing error")
412+
XCTAssertEqual(sut.getAppValueDictionaryInvocations.invocations.element(at: 0), "key1", "Should record the correct key")
413+
}
414+
415+
func testGetAppValueDictionary_withEmptyDictionary_shouldReturnEmptyDictionary() throws {
416+
// -- Arrange --
417+
let sut = TestInfoPlistWrapper()
418+
sut.mockGetAppValueDictionaryReturnValue(forKey: "key", value: [:])
419+
420+
// -- Act --
421+
let result = try sut.getAppValueDictionary(for: "key")
422+
423+
// -- Assert --
424+
XCTAssertTrue(result.isEmpty, "Should return empty dictionary when mocked with empty dictionary")
425+
}
282426
}
427+
// swiftlint:enable file_length type_body_length

Sources/Sentry/SentryANRTrackingIntegration.m

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,18 @@ - (BOOL)installWithOptions:(SentryOptions *)options
4343
return NO;
4444
}
4545

46+
// Disable app hang tracking for Widgets, Live Activities, and certain extensions
47+
// where app hang detection might report false positives. These components run
48+
// in separate processes or sandboxes with different execution characteristics.
49+
SentryExtensionDetector *extensionDetector = SentryDependencies.extensionDetector;
50+
if ([extensionDetector shouldDisableAppHangTracking]) {
51+
NSString *extensionType = [extensionDetector getExtensionPointIdentifier];
52+
SENTRY_LOG_WARN(@"Not enabling app hang tracking for extension: %@", extensionType);
53+
[self logWithReason:[NSString stringWithFormat:@"because it's running in an extension (%@)",
54+
extensionType]];
55+
return NO;
56+
}
57+
4658
#if SENTRY_HAS_UIKIT
4759
self.tracker =
4860
[SentryDependencyContainer.sharedInstance getANRTracker:options.appHangTimeoutInterval];

Sources/Swift/Helper/Dependencies.swift

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,9 @@
88
@objc public static let sessionReplayEnvironmentChecker: SentrySessionReplayEnvironmentChecker = {
99
SentrySessionReplayEnvironmentChecker(infoPlistWrapper: Dependencies.infoPlistWrapper)
1010
}()
11+
@objc public static let extensionDetector: SentryExtensionDetector = {
12+
SentryExtensionDetector(infoPlistWrapper: Dependencies.infoPlistWrapper)
13+
}()
1114
@objc public static let dispatchQueueWrapper = SentryDispatchQueueWrapper()
1215
@objc public static let notificationCenterWrapper: SentryNSNotificationCenterWrapper = NotificationCenter.default
1316
@objc public static let crashWrapper = SentryCrashWrapper(processInfoWrapper: Dependencies.processInfoWrapper)

Sources/Swift/Helper/InfoPlist/SentryInfoPlistKey.swift

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,8 +8,21 @@ enum SentryInfoPlistKey: String {
88
///
99
/// If `NO`, the system uses the UI design of the running OS, with no compatibility mode. Absence of the key, or NO, is the default value for apps linking against the latest SDKs.
1010
///
11-
/// - Warning: This key is used temporarily while reviewing and refining an apps UI for the design in the latest SDKs (i.e. Liquid Glass).
11+
/// - Warning: This key is used temporarily while reviewing and refining an app's UI for the design in the latest SDKs (i.e. Liquid Glass).
1212
///
1313
/// - SeeAlso: [Apple Documentation](https://developer.apple.com/documentation/BundleResources/Information-Property-List/UIDesignRequiresCompatibility)
1414
case designRequiresCompatibility = "UIDesignRequiresCompatibility"
15+
16+
/// The extension configuration dictionary for app extensions
17+
///
18+
/// - SeeAlso: [Apple Documentation](https://developer.apple.com/documentation/bundleresources/information_property_list/nsextension)
19+
case `extension` = "NSExtension"
20+
21+
/// Keys within the NSExtension dictionary
22+
enum Extension: String {
23+
/// The extension point identifier that specifies the type of app extension
24+
///
25+
/// - SeeAlso: [Apple Documentation](https://developer.apple.com/documentation/bundleresources/information_property_list/nsextension/nsextensionpointidentifier)
26+
case pointIdentifier = "NSExtensionPointIdentifier"
27+
}
1528
}

Sources/Swift/Helper/InfoPlist/SentryInfoPlistWrapper.swift

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,13 @@ final class SentryInfoPlistWrapper: SentryInfoPlistWrapperProvider {
2828
return value
2929
}
3030

31+
public func getAppValueDictionary(for key: String) throws -> [String: Any] {
32+
guard let value = try getAppValue(for: key, type: [String: Any].self) else {
33+
throw SentryInfoPlistError.keyNotFound(key: key)
34+
}
35+
return value
36+
}
37+
3138
// MARK: - Swift Implementation
3239

3340
private func getAppValue<T>(for key: String, type: T.Type) throws -> T? {

0 commit comments

Comments
 (0)