From 58ed75702fe2cb89511dd08a68bdb69a66744673 Mon Sep 17 00:00:00 2001 From: Valentin Pertuisot Date: Thu, 13 Aug 2026 11:27:43 +0200 Subject: [PATCH 1/9] feat(crash-reporting): add appHangBacktraceEnabled to decouple App Hang backtraces Until now the only way to stop generating stack traces for App Hangs was to not link `DatadogCrashReporting` at all, which also gave up crash reports. Generating the backtrace snapshots all running threads while the main thread is still blocked, so its cost adds to the duration of the hang being measured - apps with a small `appHangThreshold` may not want to pay it. `CrashReporting.Configuration.appHangBacktraceEnabled` (default `true`) lets them opt out of App Hang stack traces only. `BacktraceReportingFeature` is still registered, so the other consumers of backtrace generation - crash reports, binary images attached to error logs and RUM view events, and the public `backtraceReporter` API - are unaffected. The flag travels through `BacktraceReportingFeature` in `DatadogInternal`, since feature modules cannot import each other. RUM reads it on each hang rather than capturing it at init, so the behaviour does not depend on whether Crash Reporting was enabled before or after RUM. App Hang errors reported with backtraces disabled carry a dedicated `error.stack` message and no threads, binary images or truncation flag. The new `.disabled` case is additive to `AppHang.BacktraceGenerationResult`, so fatal hangs persisted by an earlier version still decode on the next launch. Also stops dropping a hang when the main thread ID could not be determined and backtraces are disabled - the ID is only needed to generate a backtrace. --- CHANGELOG.md | 1 + .../GeneratingBacktraceTests.swift | 23 +++++ .../RUM/AppHangsMonitoringTests.swift | 33 ++++++++ .../Sources/CrashReporting.swift | 84 +++++++++++++++++-- .../Tests/CrashReportingFeatureTests.swift | 37 ++++++++ .../BacktraceReporter.swift | 19 ++++- .../BacktraceReportingFeature.swift | 13 ++- DatadogRUM/RUM_FEATURE.md | 2 +- DatadogRUM/Sources/Feature/RUMFeature.swift | 8 +- .../Instrumentation/AppHangs/AppHang.swift | 3 + .../AppHangs/AppHangsMonitor.swift | 8 +- .../AppHangs/AppHangsWatchdogThread.swift | 54 ++++++++---- .../AppHangs/NonFatalAppHangsHandler.swift | 7 +- .../Instrumentation/RUMInstrumentation.swift | 18 ++-- DatadogRUM/Sources/RUMConfiguration.swift | 1 + .../AppHangs/AppHangsMonitorTests.swift | 21 +++++ .../AppHangsWatchdogThreadTests.swift | 75 +++++++++++++++++ .../BacktraceReportingMocks.swift | 5 ++ .../Mocks/DatadogRUM/RUMFeatureMocks.swift | 3 +- api-surface-swift | 9 +- 20 files changed, 382 insertions(+), 42 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 5f9d345344..971e553e0a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,7 @@ - [FEATURE] Add an experimental Core Animation recording pipeline for Session Replay, available through the `compositionTreeRecording` feature flag. See [#3127][] - [FEATURE] Add `disallowList` to `RUM.Configuration.URLSessionTracking` to exclude URLs from automatic RUM resource tracking, with `*` wildcard support. [#3097][] +- [FEATURE] Add `CrashReporting.Configuration.appHangBacktraceEnabled` to opt out of stack trace collection in App Hang errors while keeping Crash Reporting enabled. - [IMPROVEMENT] Forward `local_cache_hit` signal on RUM resources [#3074][] - [FIX] Fix `EXC_BREAKPOINT` crash when a log or RUM attribute's `encode(to:)` throws after partially encoding a value. [#3134][] diff --git a/Datadog/IntegrationUnitTests/CrashReporting/GeneratingBacktraceTests.swift b/Datadog/IntegrationUnitTests/CrashReporting/GeneratingBacktraceTests.swift index 991e588454..6a429264d7 100644 --- a/Datadog/IntegrationUnitTests/CrashReporting/GeneratingBacktraceTests.swift +++ b/Datadog/IntegrationUnitTests/CrashReporting/GeneratingBacktraceTests.swift @@ -59,6 +59,29 @@ class GeneratingBacktraceTests: XCTestCase { ) } + func testGivenAppHangBacktracesDisabled_whenGeneratingBacktrace_itStillGeneratesIt() throws { + #if os(watchOS) + throw XCTSkip("Backtrace generation is not supported on watchOS") + #endif + // Given + CrashReporting.enable(with: .init(appHangBacktraceEnabled: false), in: core) + + // Then + XCTAssertNotNil(core.get(feature: BacktraceReportingFeature.self), "`BacktraceReportingFeature` must still be registered") + XCTAssertFalse(core.isAppHangBacktraceEnabled) + + // Only the App Hangs consumer is gated - other consumers must keep working: + let backtrace = try XCTUnwrap(core.backtraceReporter.generateBacktrace()) + XCTAssertGreaterThan(backtrace.threads.count, 0, "Some thread(s) should be recorded") + XCTAssertGreaterThan(backtrace.binaryImages.count, 0, "Some binary image(s) should be recorded") + } + + func testGivenCrashReportingNotEnabled_thenAppHangBacktracesAreNotDisabled() { + // Then (backtrace generation is *unavailable*, not *disabled*) + XCTAssertNil(core.get(feature: BacktraceReportingFeature.self)) + XCTAssertTrue(core.isAppHangBacktraceEnabled) + } + func testGeneratingBacktraceOfTheMainThread() throws { #if os(watchOS) throw XCTSkip("Backtrace generation is not supported on watchOS") diff --git a/Datadog/IntegrationUnitTests/RUM/AppHangsMonitoringTests.swift b/Datadog/IntegrationUnitTests/RUM/AppHangsMonitoringTests.swift index 12ae4bd382..829b9dd5c9 100644 --- a/Datadog/IntegrationUnitTests/RUM/AppHangsMonitoringTests.swift +++ b/Datadog/IntegrationUnitTests/RUM/AppHangsMonitoringTests.swift @@ -116,6 +116,39 @@ class AppHangsMonitoringTests: XCTestCase { #endif } + func testGivenAppHangBacktracesDisabledInCrashReporting_whenMainThreadHangs_itTracksAppHangWithNoStackTrace() throws { + // Given (initialize SDK on the main thread) + let crashReportingConfig = CrashReporting.Configuration(appHangBacktraceEnabled: false) + oneOf([ // no matter of RUM or CR initialization order + { + RUM.enable(with: self.rumConfig, in: self.core) + CrashReporting.enable(with: crashReportingConfig, in: self.core) + }, + { + CrashReporting.enable(with: crashReportingConfig, in: self.core) + RUM.enable(with: self.rumConfig, in: self.core) + }, + ]) + + // When + mainQueue.sync { + Thread.sleep(forTimeInterval: hangDuration) + } + + // Then + try flushHangsMonitoring() + let errors = core.waitAndReturnEvents(ofFeature: RUMFeature.name, ofType: RUMErrorEvent.self) + let appHangError = try XCTUnwrap(errors.first) + + XCTAssertEqual(appHangError.error.message, AppHangsMonitor.Constants.appHangErrorMessage) + XCTAssertEqual(appHangError.error.type, AppHangsMonitor.Constants.appHangErrorType) + XCTAssertEqual(appHangError.error.stack, AppHangsMonitor.Constants.appHangStackDisabledErrorMessage) + XCTAssertEqual(appHangError.error.source, .source) + XCTAssertNil(appHangError.error.threads, "Threads should be unavailable as App Hang backtraces were disabled") + XCTAssertNil(appHangError.error.binaryImages, "Binary Images should be unavailable as App Hang backtraces were disabled") + XCTAssertNil(appHangError.error.wasTruncated, "Truncation flag should be unavailable as App Hang backtraces were disabled") + } + func testGivenOnlyRUMEnabled_whenMainThreadHangs_itTracksAppHangWithNoStackTrace() throws { // Given mainQueue.sync { diff --git a/DatadogCrashReporting/Sources/CrashReporting.swift b/DatadogCrashReporting/Sources/CrashReporting.swift index e00c74523d..a741a8b96f 100644 --- a/DatadogCrashReporting/Sources/CrashReporting.swift +++ b/DatadogCrashReporting/Sources/CrashReporting.swift @@ -19,10 +19,42 @@ import DatadogInternal /// /// Your crash reports appear in [Error Tracking](https://app.datadoghq.com/rum/error-tracking). public final class CrashReporting { + /// The Crash Reporting configuration. + public struct Configuration { + /// Determines whether backtraces are generated for App Hangs detected by RUM. + /// + /// Set this to `false` to keep receiving App Hang errors without a stack trace. Crash reports and every other + /// stack trace collected by the SDK are unaffected. + /// + /// Generating the backtrace snapshots all running threads while the main thread is still blocked, so its cost + /// adds to the duration of the hang being measured. Turning it off trades stack traces in App Hang errors for a + /// smaller footprint, which matters most for apps setting a small `RUM.Configuration.appHangThreshold`. + /// + /// Default: `true`. + public var appHangBacktraceEnabled: Bool + + /// Creates a Crash Reporting configuration object. + /// + /// - Parameter appHangBacktraceEnabled: Whether backtraces are generated for App Hangs detected by RUM. + public init(appHangBacktraceEnabled: Bool = true) { + self.appHangBacktraceEnabled = appHangBacktraceEnabled + } + } + /// Initializes the Datadog Crash Reporter using the default /// `KSCrash` plugin. public static func enable(in core: DatadogCoreProtocol = CoreRegistry.default) { - enable(with: try KSCrashPlugin(telemetry: core.telemetry), in: core) + enable(with: Configuration(), in: core) + } + + /// Initializes the Datadog Crash Reporter using the default + /// `KSCrash` plugin. + /// + /// - Parameters: + /// - configuration: The Crash Reporting configuration. + /// - core: The instance of Datadog SDK to enable Crash Reporting in (global instance by default). + public static func enable(with configuration: Configuration, in core: DatadogCoreProtocol = CoreRegistry.default) { + enable(with: try KSCrashPlugin(telemetry: core.telemetry), configuration: configuration, in: core) } /// Initializes the Datadog Crash Reporter with a custom Crash Reporting Plugin. @@ -31,19 +63,27 @@ public final class CrashReporting { /// - Provide crash report /// - Store context data associated with crashes /// - Provide backtraces - public static func enable(with plugin: @autoclosure () throws -> CrashReportingPlugin, in core: DatadogCoreProtocol = CoreRegistry.default) { + public static func enable( + with plugin: @autoclosure () throws -> CrashReportingPlugin, + configuration: Configuration = .init(), + in core: DatadogCoreProtocol = CoreRegistry.default + ) { do { // To ensure the correct registration order between Core and Features, // the entire initialization flow is synchronized on the main thread. try runOnMainThreadSync { - try enableOrThrow(with: plugin(), in: core) + try enableOrThrow(with: plugin(), in: core, configuration: configuration) } } catch let error { consolePrint("\(error)", .error) } } - internal static func enableOrThrow(with plugin: CrashReportingPlugin, in core: DatadogCoreProtocol) throws { + internal static func enableOrThrow( + with plugin: CrashReportingPlugin, + in core: DatadogCoreProtocol, + configuration: Configuration = .init() + ) throws { guard !(core is NOPDatadogCore) else { throw ProgrammerError( description: "Datadog SDK must be initialized before calling `CrashReporting.enable()`." @@ -63,7 +103,10 @@ public final class CrashReporting { try core.register(feature: reporter) if let backtraceReporter = plugin.backtraceReporter { - try core.register(backtraceReporter: backtraceReporter) + try core.register( + backtraceReporter: backtraceReporter, + appHangBacktraceEnabled: configuration.appHangBacktraceEnabled + ) } reporter.sendCrashReportIfFound() @@ -91,4 +134,35 @@ public final class objc_CrashReporting: NSObject { public static func enable() { CrashReporting.enable() } + + /// Initializes the Datadog Crash Reporter with the given configuration. + /// - Parameter configuration: The Crash Reporting configuration. + @objc + public static func enable(with configuration: objc_CrashReportingConfiguration) { + CrashReporting.enable(with: configuration.configuration) + } +} + +/// The Crash Reporting configuration. +@available(swift, obsoleted: 1) +@objc(DDCrashReporterConfiguration) +@objcMembers +public final class objc_CrashReportingConfiguration: NSObject { + internal var configuration: CrashReporting.Configuration + + /// Determines whether backtraces are generated for App Hangs detected by RUM. + /// + /// Set this to `NO` to keep receiving App Hang errors without a stack trace. Crash reports and every other + /// stack trace collected by the SDK are unaffected. + /// + /// Default: `YES`. + public var appHangBacktraceEnabled: Bool { + get { configuration.appHangBacktraceEnabled } + set { configuration.appHangBacktraceEnabled = newValue } + } + + /// Creates a Crash Reporting configuration object. + override public init() { + configuration = .init() + } } diff --git a/DatadogCrashReporting/Tests/CrashReportingFeatureTests.swift b/DatadogCrashReporting/Tests/CrashReportingFeatureTests.swift index c2fdd5a152..4fba573725 100644 --- a/DatadogCrashReporting/Tests/CrashReportingFeatureTests.swift +++ b/DatadogCrashReporting/Tests/CrashReportingFeatureTests.swift @@ -81,6 +81,43 @@ class CrashReportingFeatureTests: XCTestCase { XCTAssertTrue(user["invalid"] is NSNull) } + // MARK: - Configuration Tests + + func testByDefault_itRegistersBacktraceReporterWithAppHangBacktracesEnabled() throws { + // Given + let core = FeatureRegistrationCoreMock() + let plugin = CrashReportingPluginMock() + plugin.injectedBacktraceReporter = BacktraceReporterMock() + + // When + try CrashReporting.enableOrThrow(with: plugin, in: core) + + // Then + XCTAssertNotNil(try core.backtraceReporter.generateBacktrace(), "Backtrace reporter must be registered") + XCTAssertTrue(core.isAppHangBacktraceEnabled) + } + + func testWhenAppHangBacktracesAreDisabled_itStillRegistersBacktraceReporter() throws { + // Given + let core = FeatureRegistrationCoreMock() + let plugin = CrashReportingPluginMock() + plugin.injectedBacktraceReporter = BacktraceReporterMock() + + // When + try CrashReporting.enableOrThrow( + with: plugin, + in: core, + configuration: .init(appHangBacktraceEnabled: false) + ) + + // Then + XCTAssertNotNil( + try core.backtraceReporter.generateBacktrace(), + "Other consumers of backtrace generation must keep working" + ) + XCTAssertFalse(core.isAppHangBacktraceEnabled) + } + // MARK: - Crash Report Reading Tests func testItSendsLaunchReportWhenNoPendingCrash() { diff --git a/DatadogInternal/Sources/BacktraceReporting/BacktraceReporter.swift b/DatadogInternal/Sources/BacktraceReporting/BacktraceReporter.swift index de076115c5..31a4f54c52 100644 --- a/DatadogInternal/Sources/BacktraceReporting/BacktraceReporter.swift +++ b/DatadogInternal/Sources/BacktraceReporting/BacktraceReporter.swift @@ -86,14 +86,16 @@ internal struct CoreBacktraceReporter: BacktraceReporting, @unchecked Sendable { /// Adds capability of reporting backtraces. extension DatadogCoreProtocol { /// Registers backtrace reporter in Core. - /// - Parameter backtraceReporter: the implementation of backtrace reporter. - public func register(backtraceReporter: BacktraceReporting) throws { + /// - Parameters: + /// - backtraceReporter: the implementation of backtrace reporter. + /// - appHangBacktraceEnabled: whether backtraces may be generated for App Hangs detected by RUM. Default: `true`. + public func register(backtraceReporter: BacktraceReporting, appHangBacktraceEnabled: Bool = true) throws { guard get(feature: BacktraceReportingFeature.self) == nil else { DD.logger.debug("Backtrace reporter is already registered to this core. Skipping registration of next one.") return } - let feature = BacktraceReportingFeature(reporter: backtraceReporter) + let feature = BacktraceReportingFeature(reporter: backtraceReporter, appHangBacktraceEnabled: appHangBacktraceEnabled) try register(feature: feature) } @@ -101,4 +103,15 @@ extension DatadogCoreProtocol { /// /// It requires `BacktraceReportingFeature` registered to Datadog core. Otherwise reported backtraces will be `nil`. public var backtraceReporter: BacktraceReporting { CoreBacktraceReporter(core: self) } + + /// Whether backtraces may be generated for App Hangs detected by RUM. + /// + /// It is `false` only when a backtrace reporter was registered with App Hang backtraces turned off. Before any + /// reporter is registered it is `true`: in that state backtrace generation is *unavailable* rather than + /// *disabled*, and callers must keep distinguishing the two. Read it at the moment a backtrace is needed, as + /// the reporter may be registered after the reading Feature was enabled. + public var isAppHangBacktraceEnabled: Bool { + // `self.` is required: a bare `get(...)` here parses as a `get` accessor. + self.get(feature: BacktraceReportingFeature.self)?.appHangBacktraceEnabled ?? true + } } diff --git a/DatadogInternal/Sources/BacktraceReporting/BacktraceReportingFeature.swift b/DatadogInternal/Sources/BacktraceReporting/BacktraceReportingFeature.swift index 2de09b2b52..4c8ff601b4 100644 --- a/DatadogInternal/Sources/BacktraceReporting/BacktraceReportingFeature.swift +++ b/DatadogInternal/Sources/BacktraceReporting/BacktraceReportingFeature.swift @@ -14,9 +14,18 @@ internal final class BacktraceReportingFeature: DatadogFeature { /// A type capable of generating backtrace reports. let reporter: BacktraceReporting + /// Determines whether backtraces may be generated for App Hangs detected by RUM. + /// + /// It only gates the App Hangs consumer. All other consumers of `reporter` (crash reports, binary images + /// attached to logs and RUM view events, the public `backtraceReporter` API) are unaffected by this value. + let appHangBacktraceEnabled: Bool + /// Creates `BacktraceReportingFeature`. - /// - Parameter reporter: An external implementation of a type capable of generating backtrace reports. - init(reporter: BacktraceReporting) { + /// - Parameters: + /// - reporter: An external implementation of a type capable of generating backtrace reports. + /// - appHangBacktraceEnabled: Whether backtraces may be generated for App Hangs. Default: `true`. + init(reporter: BacktraceReporting, appHangBacktraceEnabled: Bool = true) { self.reporter = reporter + self.appHangBacktraceEnabled = appHangBacktraceEnabled } } diff --git a/DatadogRUM/RUM_FEATURE.md b/DatadogRUM/RUM_FEATURE.md index 81605c51a2..a9d4650ea1 100644 --- a/DatadogRUM/RUM_FEATURE.md +++ b/DatadogRUM/RUM_FEATURE.md @@ -298,7 +298,7 @@ Event mappers allow modifying or dropping events before upload: ## Feature Interactions -- **Crash Reporting**: Enhances App Hang monitoring with stack traces +- **Crash Reporting**: Enhances App Hang monitoring with stack traces. Set `CrashReporting.Configuration.appHangBacktraceEnabled` to `false` to keep crash reports but drop App Hang stack traces - **Tracing**: Network resources can create distributed traces via `firstPartyHostsTracing` - **Session Replay**: RUM must be enabled for Session Replay to work - **WebView Tracking**: Enables RUM tracking in web views. Requires: diff --git a/DatadogRUM/Sources/Feature/RUMFeature.swift b/DatadogRUM/Sources/Feature/RUMFeature.swift index 96c33d7384..b0ae3f5f22 100644 --- a/DatadogRUM/Sources/Feature/RUMFeature.swift +++ b/DatadogRUM/Sources/Feature/RUMFeature.swift @@ -244,7 +244,9 @@ internal final class RUMFeature: DatadogRemoteFeature, RUMSessionSamplerProvider watchdogTermination: watchdogTermination, memoryWarningMonitor: memoryWarningMonitor, uuidGenerator: configuration.uuidGenerator, - heatmapIdentifierRegistry: heatmapIdentifierStore + heatmapIdentifierRegistry: heatmapIdentifierStore, + // Read on each hang, as Crash Reporting - which owns this setting - can be enabled after RUM: + isAppHangBacktraceEnabled: { [weak core] in core?.isAppHangBacktraceEnabled ?? true } ) #else self.instrumentation = RUMInstrumentation( @@ -260,7 +262,9 @@ internal final class RUMFeature: DatadogRemoteFeature, RUMSessionSamplerProvider bundleType: bundleType, watchdogTermination: watchdogTermination, memoryWarningMonitor: nil, - uuidGenerator: configuration.uuidGenerator + uuidGenerator: configuration.uuidGenerator, + // Read on each hang, as Crash Reporting - which owns this setting - can be enabled after RUM: + isAppHangBacktraceEnabled: { [weak core] in core?.isAppHangBacktraceEnabled ?? true } ) #endif self.requestBuilder = RequestBuilder( diff --git a/DatadogRUM/Sources/Instrumentation/AppHangs/AppHang.swift b/DatadogRUM/Sources/Instrumentation/AppHangs/AppHang.swift index d9a69075ee..60d7023f62 100644 --- a/DatadogRUM/Sources/Instrumentation/AppHangs/AppHang.swift +++ b/DatadogRUM/Sources/Instrumentation/AppHangs/AppHang.swift @@ -19,6 +19,9 @@ internal struct AppHang: Codable { /// Indicates that backtrace generation is unavailable. /// It is the case when `BacktraceReportingFeature` is not available in core (when Crash Reporting feature was not enabled). case notAvailable + /// Indicates that backtrace generation was turned off in `CrashReporting.Configuration`. + /// Unlike `notAvailable`, Crash Reporting **is** enabled - it was asked not to provide App Hang backtraces. + case disabled } /// The date of hang start. diff --git a/DatadogRUM/Sources/Instrumentation/AppHangs/AppHangsMonitor.swift b/DatadogRUM/Sources/Instrumentation/AppHangs/AppHangsMonitor.swift index 1a03eb1b73..d3b77a6a6b 100644 --- a/DatadogRUM/Sources/Instrumentation/AppHangs/AppHangsMonitor.swift +++ b/DatadogRUM/Sources/Instrumentation/AppHangs/AppHangsMonitor.swift @@ -17,6 +17,8 @@ internal final class AppHangsMonitor { static let appHangStackNotAvailableErrorMessage = "Stack trace was not collected because `DatadogCrashReporting` had not been enabled." /// The standardized `error.stack` when backtrace generation failed due to an internal error. static let appHangStackGenerationFailedErrorMessage = "Failed to collect the stack trace." + /// The standardized `error.stack` when backtrace generation was turned off in `CrashReporting.Configuration`. + static let appHangStackDisabledErrorMessage = "Stack trace was not collected because backtrace generation for App Hangs was disabled." } /// Watchdog thread that monitors the main queue for App Hangs. @@ -34,7 +36,8 @@ internal final class AppHangsMonitor { fatalErrorContext: FatalErrorContextNotifying, dateProvider: DateProvider, uuidGenerator: RUMUUIDGenerator, - processID: UUID + processID: UUID, + isAppHangBacktraceEnabled: @escaping @Sendable () -> Bool = { true } ) { self.init( featureScope: featureScope, @@ -43,7 +46,8 @@ internal final class AppHangsMonitor { queue: observedQueue, dateProvider: dateProvider, backtraceReporter: backtraceReporter, - telemetry: featureScope.telemetry + telemetry: featureScope.telemetry, + isAppHangBacktraceEnabled: isAppHangBacktraceEnabled ), fatalErrorContext: fatalErrorContext, processID: processID, diff --git a/DatadogRUM/Sources/Instrumentation/AppHangs/AppHangsWatchdogThread.swift b/DatadogRUM/Sources/Instrumentation/AppHangs/AppHangsWatchdogThread.swift index b37bbaebb9..860dd3609c 100644 --- a/DatadogRUM/Sources/Instrumentation/AppHangs/AppHangsWatchdogThread.swift +++ b/DatadogRUM/Sources/Instrumentation/AppHangs/AppHangsWatchdogThread.swift @@ -57,6 +57,11 @@ internal final class AppHangsWatchdogThread: Thread, AppHangsObservingThread { private let dateProvider: DateProvider /// Backtrace reporter for hang's stack trace generation. private let backtraceReporter: BacktraceReporting + /// Tells whether backtraces may be generated for App Hangs. + /// + /// It is read on each hang instead of once on initialization, because Crash Reporting - which owns this + /// setting - can be enabled after RUM. + private let isAppHangBacktraceEnabled: @Sendable () -> Bool /// The hang's duration threshold to consider it a false-positive. private let falsePositiveThreshold: TimeInterval /// An identifier of the main thread required for backtrace generation. @@ -83,19 +88,22 @@ internal final class AppHangsWatchdogThread: Thread, AppHangsObservingThread { /// - dateProvider: Date provider. /// - backtraceReporter: Backtrace reporter for hang's stack trace generation. /// - telemetry: The handler to report issues through RUM Telemetry. + /// - isAppHangBacktraceEnabled: Tells whether backtraces may be generated for App Hangs. It is queried on each hang. init( appHangThreshold: TimeInterval, queue: DispatchQueue, dateProvider: DateProvider, backtraceReporter: BacktraceReporting, telemetry: Telemetry, - falsePositiveThreshold: TimeInterval = Constants.falsePositiveThreshold + falsePositiveThreshold: TimeInterval = Constants.falsePositiveThreshold, + isAppHangBacktraceEnabled: @escaping @Sendable () -> Bool = { true } ) { self.appHangThreshold = appHangThreshold self.idleInterval = appHangThreshold * Constants.tolerance self.mainQueue = queue self.dateProvider = dateProvider self.backtraceReporter = backtraceReporter + self.isAppHangBacktraceEnabled = isAppHangBacktraceEnabled self.falsePositiveThreshold = falsePositiveThreshold self.telemetry = telemetry @@ -158,24 +166,10 @@ internal final class AppHangsWatchdogThread: Thread, AppHangsObservingThread { } // Capture the stack trace of all running threads with promoting the main thread stack. - guard let mainThreadID = mainThreadID else { - telemetry.error("Failed to determine main thread ID for backtrace generation") + guard let backtraceResult = generateBacktrace() else { continue // unexpected } - let backtraceResult: AppHang.BacktraceGenerationResult - do { - if let backtrace = try backtraceReporter.generateBacktrace(threadID: mainThreadID) { - backtraceResult = .succeeded(backtrace) - } else { - backtraceResult = .notAvailable - } - } catch let error { - backtraceResult = .failed - DD.logger.error("Encountered an error when generating App Hang backtrace", error: error) - telemetry.error("Failed to generate App Hang backtrace", error: error) - } - let hang = AppHang( startDate: hangStart, backtraceResult: backtraceResult @@ -200,6 +194,34 @@ internal final class AppHangsWatchdogThread: Thread, AppHangsObservingThread { } } + /// Generates the backtrace of all running threads, promoting the main thread stack. + /// + /// - Returns: The result of generation or `nil` if the hang must be skipped (main thread ID could not be determined). + private func generateBacktrace() -> AppHang.BacktraceGenerationResult? { + guard isAppHangBacktraceEnabled() else { + // Checked before reading `mainThreadID` and before calling the reporter, so no work is done for the + // hang and it is still reported when the main thread ID is unknown. + return .disabled + } + + guard let mainThreadID = mainThreadID else { + telemetry.error("Failed to determine main thread ID for backtrace generation") + return nil // unexpected + } + + do { + if let backtrace = try backtraceReporter.generateBacktrace(threadID: mainThreadID) { + return .succeeded(backtrace) + } else { + return .notAvailable + } + } catch let error { + DD.logger.error("Encountered an error when generating App Hang backtrace", error: error) + telemetry.error("Failed to generate App Hang backtrace", error: error) + return .failed + } + } + private func interval(from t1: DispatchTime, to t2: DispatchTime) -> TimeInterval { TimeInterval(t2.uptimeNanoseconds - t1.uptimeNanoseconds) / 1_000_000_000 } diff --git a/DatadogRUM/Sources/Instrumentation/AppHangs/NonFatalAppHangsHandler.swift b/DatadogRUM/Sources/Instrumentation/AppHangs/NonFatalAppHangsHandler.swift index 40ee0cdc9c..c2d44282aa 100644 --- a/DatadogRUM/Sources/Instrumentation/AppHangs/NonFatalAppHangsHandler.swift +++ b/DatadogRUM/Sources/Instrumentation/AppHangs/NonFatalAppHangsHandler.swift @@ -38,27 +38,28 @@ internal extension AppHang.BacktraceGenerationResult { case .succeeded(let backtrace): return backtrace.stack case .failed: return AppHangsMonitor.Constants.appHangStackGenerationFailedErrorMessage case .notAvailable: return AppHangsMonitor.Constants.appHangStackNotAvailableErrorMessage + case .disabled: return AppHangsMonitor.Constants.appHangStackDisabledErrorMessage } } var threads: [DDThread]? { switch self { case .succeeded(let backtrace): return backtrace.threads - case .failed, .notAvailable: return nil + case .failed, .notAvailable, .disabled: return nil } } var binaryImages: [BinaryImage]? { switch self { case .succeeded(let backtrace): return backtrace.binaryImages - case .failed, .notAvailable: return nil + case .failed, .notAvailable, .disabled: return nil } } var wasTruncated: Bool? { switch self { case .succeeded(let backtrace): return backtrace.wasTruncated - case .failed, .notAvailable: return nil + case .failed, .notAvailable, .disabled: return nil } } } diff --git a/DatadogRUM/Sources/Instrumentation/RUMInstrumentation.swift b/DatadogRUM/Sources/Instrumentation/RUMInstrumentation.swift index f9811c2bbe..9b269dd124 100644 --- a/DatadogRUM/Sources/Instrumentation/RUMInstrumentation.swift +++ b/DatadogRUM/Sources/Instrumentation/RUMInstrumentation.swift @@ -76,7 +76,8 @@ internal final class RUMInstrumentation: RUMCommandPublisher { watchdogTermination: WatchdogTerminationMonitor?, memoryWarningMonitor: MemoryWarningMonitor?, uuidGenerator: RUMUUIDGenerator, - heatmapIdentifierRegistry: any HeatmapIdentifierRegistry + heatmapIdentifierRegistry: any HeatmapIdentifierRegistry, + isAppHangBacktraceEnabled: @escaping @Sendable () -> Bool = { true } ) { // Always create views handler (we can't know if it will be used by SwiftUI manual instrumentation) // and only activate `UIViewControllerSwizzler` if automatic instrumentation for UIKit or SwiftUI is configured: @@ -182,7 +183,8 @@ internal final class RUMInstrumentation: RUMCommandPublisher { backtraceReporter: backtraceReporter, fatalErrorContext: fatalErrorContext, processID: processID, - uuidGenerator: uuidGenerator + uuidGenerator: uuidGenerator, + isAppHangBacktraceEnabled: isAppHangBacktraceEnabled ) self.watchdogTermination = watchdogTermination self.memoryWarningMonitor = memoryWarningMonitor @@ -214,7 +216,8 @@ internal final class RUMInstrumentation: RUMCommandPublisher { bundleType: BundleType, watchdogTermination: WatchdogTerminationMonitor?, memoryWarningMonitor: MemoryWarningMonitor?, - uuidGenerator: RUMUUIDGenerator + uuidGenerator: RUMUUIDGenerator, + isAppHangBacktraceEnabled: @escaping @Sendable () -> Bool = { true } ) { // Always create views handler (we can't know if it will be used by manual instrumentation) self.viewsHandler = RUMViewsHandler(dateProvider: dateProvider, notificationCenter: notificationCenter) @@ -230,7 +233,8 @@ internal final class RUMInstrumentation: RUMCommandPublisher { backtraceReporter: backtraceReporter, fatalErrorContext: fatalErrorContext, processID: processID, - uuidGenerator: uuidGenerator + uuidGenerator: uuidGenerator, + isAppHangBacktraceEnabled: isAppHangBacktraceEnabled ) self.watchdogTermination = watchdogTermination self.memoryWarningMonitor = memoryWarningMonitor @@ -297,7 +301,8 @@ private extension AppHangsMonitor { backtraceReporter: BacktraceReporting, fatalErrorContext: FatalErrorContextNotifying, processID: UUID, - uuidGenerator: RUMUUIDGenerator + uuidGenerator: RUMUUIDGenerator, + isAppHangBacktraceEnabled: @escaping @Sendable () -> Bool ) { guard bundleType == .iOSApp, var appHangThreshold = appHangThreshold else { return nil @@ -316,7 +321,8 @@ private extension AppHangsMonitor { fatalErrorContext: fatalErrorContext, dateProvider: dateProvider, uuidGenerator: uuidGenerator, - processID: processID + processID: processID, + isAppHangBacktraceEnabled: isAppHangBacktraceEnabled ) } } diff --git a/DatadogRUM/Sources/RUMConfiguration.swift b/DatadogRUM/Sources/RUMConfiguration.swift index 028ab08523..dadc5f74ad 100644 --- a/DatadogRUM/Sources/RUMConfiguration.swift +++ b/DatadogRUM/Sources/RUMConfiguration.swift @@ -190,6 +190,7 @@ extension RUM { /// some hangs lasting very close to this threshold may not be reported. /// /// - Note: App Hangs monitoring requires Datadog Crash Reporting to be enabled. Otherwise stack trace will be not reported in App Hang errors. + /// Stack traces can also be opted out of while keeping Crash Reporting enabled, with `CrashReporting.Configuration.appHangBacktraceEnabled`. /// /// - Default: `nil` (hangs monitoring disabled). public var appHangThreshold: TimeInterval? diff --git a/DatadogRUM/Tests/Instrumentation/AppHangs/AppHangsMonitorTests.swift b/DatadogRUM/Tests/Instrumentation/AppHangs/AppHangsMonitorTests.swift index 19f68d62cc..a05fcdca55 100644 --- a/DatadogRUM/Tests/Instrumentation/AppHangs/AppHangsMonitorTests.swift +++ b/DatadogRUM/Tests/Instrumentation/AppHangs/AppHangsMonitorTests.swift @@ -87,6 +87,27 @@ class AppHangsMonitorTests: XCTestCase { XCTAssertEqual(command.isStackTraceTruncated, hang.backtraceResult.wasTruncated) } + func testWhenAppHangEndsWithBacktraceGenerationDisabled_itSendsAppHangCommandWithNoStackTrace() throws { + // Given + let subscriber = RUMCommandSubscriberMock() + monitor.nonFatalHangsHandler.publish(to: subscriber) + monitor.start() + defer { monitor.stop() } + + // When + let hang: AppHang = .mockWith(backtraceResult: .disabled) + watchdogThread.delegate?.hangEnded(hang, duration: .mockRandom(min: 1, max: 4)) + + // Then + let command = try XCTUnwrap(subscriber.lastReceivedCommand as? RUMAddCurrentViewAppHangCommand) + XCTAssertEqual(command.message, AppHangsMonitor.Constants.appHangErrorMessage) + XCTAssertEqual(command.type, AppHangsMonitor.Constants.appHangErrorType) + XCTAssertEqual(command.stack, AppHangsMonitor.Constants.appHangStackDisabledErrorMessage) + XCTAssertNil(command.threads) + XCTAssertNil(command.binaryImages) + XCTAssertNil(command.isStackTraceTruncated) + } + // MARK: - Fatal App Hangs Monitoring func testGivenFatalErrorViewContextAvailable_whenAppHangStarts_itSavesPendingAppHangToDataStore() throws { diff --git a/DatadogRUM/Tests/Instrumentation/AppHangs/AppHangsWatchdogThreadTests.swift b/DatadogRUM/Tests/Instrumentation/AppHangs/AppHangsWatchdogThreadTests.swift index c12c643cdf..684dc381a1 100644 --- a/DatadogRUM/Tests/Instrumentation/AppHangs/AppHangsWatchdogThreadTests.swift +++ b/DatadogRUM/Tests/Instrumentation/AppHangs/AppHangsWatchdogThreadTests.swift @@ -212,6 +212,81 @@ class AppHangsWatchdogThreadTests: XCTestCase { watchdogThread.cancel() } + func testWhenBacktraceGenerationIsDisabled_itTracksAppHangWithErrorMessageAndDoesNotGenerateBacktrace() { + let trackHangStart = expectation(description: "track start of App Hang") + let trackHangEnd = expectation(description: "track end of App Hang") + + // Given + let appHangThreshold: TimeInterval = 0.25 + let hangDuration: TimeInterval = appHangThreshold * 2 + let queue = DispatchQueue(label: "main-queue", qos: .userInteractive) + let backtraceReporter = BacktraceReporterMock(backtrace: .mockWith(stack: "Main thread stack")) + + let watchdogThread = AppHangsWatchdogThread( + appHangThreshold: appHangThreshold, + queue: queue, + dateProvider: DateProviderMock(now: .mockDecember15th2019At10AMUTC()), + backtraceReporter: backtraceReporter, + telemetry: TelemetryMock(), + isAppHangBacktraceEnabled: { false } // backtrace generation disabled + ) + delegate.onHangStarted = { hang in + XCTAssertEqual(hang.backtraceResult.stack, AppHangsMonitor.Constants.appHangStackDisabledErrorMessage) + trackHangStart.fulfill() + } + delegate.onHangEnded = { hang, _ in + XCTAssertEqual(hang.backtraceResult.stack, AppHangsMonitor.Constants.appHangStackDisabledErrorMessage) + trackHangEnd.fulfill() + } + delegate.onHangCancelled = { _ in XCTFail("It should not cancel the hang") } + watchdogThread.start(with: delegate) + + // When + queue.async { + Thread.sleep(forTimeInterval: hangDuration) + } + + // Then + waitForExpectations(timeout: hangDuration * 10) + watchdogThread.cancel() + XCTAssertEqual(backtraceReporter.generateBacktraceCallsCount, 0, "It must not pay the cost of backtrace generation") + } + + func testWhenBacktraceGenerationIsEnabled_itGeneratesBacktrace() { + let trackHangEnd = expectation(description: "track end of App Hang") + + // Given + let appHangThreshold: TimeInterval = 0.25 + let hangDuration: TimeInterval = appHangThreshold * 2 + let queue = DispatchQueue(label: "main-queue", qos: .userInteractive) + let backtraceReporter = BacktraceReporterMock(backtrace: .mockWith(stack: "Main thread stack")) + + let watchdogThread = AppHangsWatchdogThread( + appHangThreshold: appHangThreshold, + queue: queue, + dateProvider: DateProviderMock(now: .mockDecember15th2019At10AMUTC()), + backtraceReporter: backtraceReporter, + telemetry: TelemetryMock(), + isAppHangBacktraceEnabled: { true } + ) + delegate.onHangEnded = { hang, _ in + XCTAssertEqual(hang.backtraceResult.stack, "Main thread stack") + trackHangEnd.fulfill() + } + delegate.onHangCancelled = { _ in XCTFail("It should not cancel the hang") } + watchdogThread.start(with: delegate) + + // When + queue.async { + Thread.sleep(forTimeInterval: hangDuration) + } + + // Then + waitForExpectations(timeout: hangDuration * 10) + watchdogThread.cancel() + XCTAssertGreaterThan(backtraceReporter.generateBacktraceCallsCount, 0) + } + func testWhenHangDurationExceedsFalsePositiveThreshold_itReportsHangCancellation() { let trackHangStart = expectation(description: "track start of App Hang") let trackHangCancel = expectation(description: "track cancellation of App Hang") diff --git a/TestUtilities/Sources/Mocks/CrashReporting/BacktraceReportingMocks.swift b/TestUtilities/Sources/Mocks/CrashReporting/BacktraceReportingMocks.swift index 86f1f91ea2..58941f1e8a 100644 --- a/TestUtilities/Sources/Mocks/CrashReporting/BacktraceReportingMocks.swift +++ b/TestUtilities/Sources/Mocks/CrashReporting/BacktraceReportingMocks.swift @@ -17,6 +17,9 @@ public struct BacktraceReporterMock: BacktraceReporting, @unchecked Sendable { /// The binary images returned by this mock. If not set, binary images are derived from `backtrace`. @ReadWriteLock public var binaryImagesList: [BinaryImage]? + /// The number of times `generateBacktrace(threadID:)` was called on this mock. + @ReadWriteLock + public var generateBacktraceCallsCount: Int /// Creates backtrace reporter mock. /// - Parameters: @@ -31,9 +34,11 @@ public struct BacktraceReporterMock: BacktraceReporting, @unchecked Sendable { self.backtrace = backtrace self.backtraceGenerationError = backtraceGenerationError self.binaryImagesList = binaryImages + self.generateBacktraceCallsCount = 0 } public func generateBacktrace(threadID: ThreadID) throws -> BacktraceReport? { + generateBacktraceCallsCount += 1 try throwIfNeeded() return backtrace } diff --git a/TestUtilities/Sources/Mocks/DatadogRUM/RUMFeatureMocks.swift b/TestUtilities/Sources/Mocks/DatadogRUM/RUMFeatureMocks.swift index 4f81c21b91..d2214be8f7 100644 --- a/TestUtilities/Sources/Mocks/DatadogRUM/RUMFeatureMocks.swift +++ b/TestUtilities/Sources/Mocks/DatadogRUM/RUMFeatureMocks.swift @@ -1707,7 +1707,8 @@ extension AppHang.BacktraceGenerationResult: AnyMockable, RandomMockable { return [ .succeeded(.mockRandom()), .failed, - .notAvailable + .notAvailable, + .disabled ].randomElement()! } } diff --git a/api-surface-swift b/api-surface-swift index bd7c47a152..a15e63deb7 100644 --- a/api-surface-swift +++ b/api-surface-swift @@ -576,9 +576,16 @@ public enum PerformanceMetric # ---------------------------------- public final class CrashReporting + public struct Configuration + public var appHangBacktraceEnabled: Bool + public init(appHangBacktraceEnabled: Bool = true) public static func enable(in core: DatadogCoreProtocol = CoreRegistry.default) - public static func enable(with plugin: @autoclosure () throws -> CrashReportingPlugin, in core: DatadogCoreProtocol = CoreRegistry.default) + public static func enable(with configuration: Configuration, in core: DatadogCoreProtocol = CoreRegistry.default) + public static func enable(with plugin: @autoclosure () throws -> CrashReportingPlugin,configuration: Configuration = .init(),in core: DatadogCoreProtocol = CoreRegistry.default) public static func enable() +public static func enable(with configuration: objc_CrashReportingConfiguration) +public var appHangBacktraceEnabled: Bool +override public init() public protocol CrashReportingPlugin: AnyObject func readPendingCrashReport(completion: @escaping (DDCrashReport?) -> Bool) func inject(context: Data) From 9d6ee2644b48c5f7f1683cac294925296da04fcb Mon Sep 17 00:00:00 2001 From: Valentin Pertuisot Date: Thu, 13 Aug 2026 15:35:23 +0200 Subject: [PATCH 2/9] feat(example): exercise appHangBacktraceEnabled from the Example app The Example app never set `appHangThreshold`, so App Hangs were not detected at all and the new `CrashReporting.Configuration.appHangBacktraceEnabled` flag had no reachable effect. Sets a 0.5s threshold and adds an "App Hang" section to the Crash Reporting debug screen with a button that blocks the main thread for 2s. The flag is fixed at `CrashReporting.enable` time, so it cannot be a runtime toggle - it reads a `DD_DISABLE_APP_HANG_BACKTRACES` launch argument instead, matching the existing `Environment.Argument` pattern. The screen shows which state is active so the two runs can be compared. --- .../Example/Base.lproj/Main iOS.storyboard | 60 +++++++++++++++++++ ...gCrashReportingWithRUMViewController.swift | 19 ++++++ Datadog/Example/Environment.swift | 9 +++ Datadog/Example/ExampleAppDelegate.swift | 7 ++- 4 files changed, 94 insertions(+), 1 deletion(-) diff --git a/Datadog/Example/Base.lproj/Main iOS.storyboard b/Datadog/Example/Base.lproj/Main iOS.storyboard index f2f1e7d959..cbc90fb03a 100644 --- a/Datadog/Example/Base.lproj/Main iOS.storyboard +++ b/Datadog/Example/Base.lproj/Main iOS.storyboard @@ -1369,6 +1369,65 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + @@ -1382,6 +1441,7 @@ + diff --git a/Datadog/Example/Debugging/DebugCrashReportingWithRUMViewController.swift b/Datadog/Example/Debugging/DebugCrashReportingWithRUMViewController.swift index 8c531f96ff..6e5d965796 100644 --- a/Datadog/Example/Debugging/DebugCrashReportingWithRUMViewController.swift +++ b/Datadog/Example/Debugging/DebugCrashReportingWithRUMViewController.swift @@ -14,6 +14,9 @@ class DebugCrashReportingWithRUMViewController: UIViewController { super.viewDidLoad() rumServiceNameTextField.text = serviceName viewNameTextField.placeholder = viewName + appHangBacktraceStatusLabel.text = Environment.isAppHangBacktraceEnabled() + ? "Backtraces: ON — launch with `DD_DISABLE_APP_HANG_BACKTRACES` to turn them off" + : "Backtraces: OFF — `appHangBacktraceEnabled: false`" } private func crash() { @@ -48,6 +51,22 @@ class DebugCrashReportingWithRUMViewController: UIViewController { } } + // MARK: - App Hang + + @IBOutlet weak var appHangBacktraceStatusLabel: UILabel! + + /// Blocks the main thread for longer than the `appHangThreshold` configured in `ExampleAppDelegate`, + /// so RUM reports an App Hang error. Whether that error carries a stack trace depends on + /// `CrashReporting.Configuration.appHangBacktraceEnabled`. + @IBAction func didTapHangMainThread(_ sender: Any) { + (sender as? UIButton)?.disableFor(seconds: 0.5) + + rumMonitor.startView(key: viewName, name: viewName, attributes: [:]) + DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { + Thread.sleep(forTimeInterval: 2) + } + } + // MARK: - OOM Crash @IBAction func didTapOOMCrash(_ sender: UIButton) { diff --git a/Datadog/Example/Environment.swift b/Datadog/Example/Environment.swift index 25efbd2d90..f95228fa2b 100644 --- a/Datadog/Example/Environment.swift +++ b/Datadog/Example/Environment.swift @@ -11,6 +11,8 @@ internal struct Environment { struct Argument { static let isRunningUnitTests = "IS_RUNNING_UNIT_TESTS" static let isRunningUITests = "IS_RUNNING_UI_TESTS" + /// Launches the app with `CrashReporting.Configuration.appHangBacktraceEnabled` set to `false`. + static let disableAppHangBacktraces = "DD_DISABLE_APP_HANG_BACKTRACES" } struct InfoPlistKey { @@ -37,6 +39,13 @@ internal struct Environment { return !isRunningUITests() && !isRunningUnitTests() } + /// Whether App Hangs detected by RUM should carry a stack trace. + /// + /// Add `DD_DISABLE_APP_HANG_BACKTRACES` to the scheme's launch arguments to exercise the opt-out. + static func isAppHangBacktraceEnabled() -> Bool { + return !ProcessInfo.processInfo.arguments.contains(Argument.disableAppHangBacktraces) + } + // MARK: - Info.plist static func readClientToken() -> String { diff --git a/Datadog/Example/ExampleAppDelegate.swift b/Datadog/Example/ExampleAppDelegate.swift index bf66628541..4ef242a0c9 100644 --- a/Datadog/Example/ExampleAppDelegate.swift +++ b/Datadog/Example/ExampleAppDelegate.swift @@ -61,7 +61,11 @@ class ExampleAppDelegate: UIResponder, UIApplicationDelegate { ) // Enable Crash Reporting - CrashReporting.enable() + CrashReporting.enable( + with: CrashReporting.Configuration( + appHangBacktraceEnabled: Environment.isAppHangBacktraceEnabled() + ) + ) // Set highest verbosity level to see debugging logs from the SDK Datadog.verbosityLevel = .debug @@ -87,6 +91,7 @@ class ExampleAppDelegate: UIResponder, UIApplicationDelegate { } ), trackBackgroundEvents: true, + appHangThreshold: 0.5, trackWatchdogTerminations: true, customEndpoint: Environment.readCustomRUMURL(), telemetrySampleRate: 100 From 1d5a8c6a133da4a67f885fa64029ce7bb927a8ad Mon Sep 17 00:00:00 2001 From: Valentin Pertuisot Date: Thu, 13 Aug 2026 18:01:41 +0200 Subject: [PATCH 3/9] docs(changelog): reference PR #3136 in the App Hang backtrace entry --- CHANGELOG.md | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 971e553e0a..0e5a70e274 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,7 +2,7 @@ - [FEATURE] Add an experimental Core Animation recording pipeline for Session Replay, available through the `compositionTreeRecording` feature flag. See [#3127][] - [FEATURE] Add `disallowList` to `RUM.Configuration.URLSessionTracking` to exclude URLs from automatic RUM resource tracking, with `*` wildcard support. [#3097][] -- [FEATURE] Add `CrashReporting.Configuration.appHangBacktraceEnabled` to opt out of stack trace collection in App Hang errors while keeping Crash Reporting enabled. +- [FEATURE] Add `CrashReporting.Configuration.appHangBacktraceEnabled` to opt out of stack trace collection in App Hang errors while keeping Crash Reporting enabled. See [#3136][] - [IMPROVEMENT] Forward `local_cache_hit` signal on RUM resources [#3074][] - [FIX] Fix `EXC_BREAKPOINT` crash when a log or RUM attribute's `encode(to:)` throws after partially encoding a value. [#3134][] @@ -1228,6 +1228,7 @@ Release `2.0` introduces breaking changes. Follow the [Migration Guide](MIGRATIO [#3127]: https://github.com/DataDog/dd-sdk-ios/pull/3127 [#3097]: https://github.com/DataDog/dd-sdk-ios/pull/3097 [#3134]: https://github.com/DataDog/dd-sdk-ios/pull/3134 +[#3136]: https://github.com/DataDog/dd-sdk-ios/pull/3136 [@00fa9a]: https://github.com/00FA9A [@britton-earnin]: https://github.com/Britton-Earnin From 9240952a0755ffd37d088cfb63d46d6051a778f8 Mon Sep 17 00:00:00 2001 From: Valentin Pertuisot Date: Fri, 14 Aug 2026 16:48:43 +0200 Subject: [PATCH 4/9] fix(crash-reporting): address review on App Hang backtrace decoupling - Restore `enable(with plugin:in:)` unchanged and add `enable(with plugin:configuration:in:)` as a separate overload with a required configuration, instead of adding a defaulted parameter to the existing one. Adding the parameter kept ordinary calls compiling but changed the exported symbol and broke unapplied references to `enable(with:in:)`. - Record `appHangBacktraceEnabled` even when a custom plugin provides no backtrace reporter. Previously the opt-out was dropped in that case and App Hangs reported the stack trace as "Crash Reporting had not been enabled". `BacktraceReportingFeature.reporter` is now optional and `register(appHangBacktraceEnabled:)` records the policy on its own; `CoreBacktraceReporter` warns and returns nil in exactly the same cases as before. - Increment `BacktraceReporterMock.generateBacktraceCallsCount` under a single write lock. `+=` through `@ReadWriteLock` took the read and write locks separately and could lose increments, which the "reporter never invoked" assertion depends on. --- .../Sources/CrashReporting.swift | 21 +++++++++++++++- .../Tests/CrashReportingFeatureTests.swift | 25 +++++++++++++++++++ .../BacktraceReporter.swift | 23 ++++++++++++++--- .../BacktraceReportingFeature.swift | 10 +++++--- .../BacktraceReportingMocks.swift | 4 ++- api-surface-swift | 3 ++- 6 files changed, 77 insertions(+), 9 deletions(-) diff --git a/DatadogCrashReporting/Sources/CrashReporting.swift b/DatadogCrashReporting/Sources/CrashReporting.swift index a741a8b96f..4de3c88a49 100644 --- a/DatadogCrashReporting/Sources/CrashReporting.swift +++ b/DatadogCrashReporting/Sources/CrashReporting.swift @@ -63,9 +63,24 @@ public final class CrashReporting { /// - Provide crash report /// - Store context data associated with crashes /// - Provide backtraces + public static func enable(with plugin: @autoclosure () throws -> CrashReportingPlugin, in core: DatadogCoreProtocol = CoreRegistry.default) { + enable(with: try plugin(), configuration: Configuration(), in: core) + } + + /// Initializes the Datadog Crash Reporter with a custom Crash Reporting Plugin. + /// + /// The custom plugin will be responsible for: + /// - Provide crash report + /// - Store context data associated with crashes + /// - Provide backtraces + /// + /// - Parameters: + /// - plugin: The custom Crash Reporting Plugin. + /// - configuration: The Crash Reporting configuration. + /// - core: The instance of Datadog SDK to enable Crash Reporting in (global instance by default). public static func enable( with plugin: @autoclosure () throws -> CrashReportingPlugin, - configuration: Configuration = .init(), + configuration: Configuration, in core: DatadogCoreProtocol = CoreRegistry.default ) { do { @@ -107,6 +122,10 @@ public final class CrashReporting { backtraceReporter: backtraceReporter, appHangBacktraceEnabled: configuration.appHangBacktraceEnabled ) + } else { + // A custom plugin may provide no backtrace reporter. Register the policy anyway, so that opting out of + // App Hang backtraces stays reportable as such rather than as "Crash Reporting was never enabled". + try core.register(appHangBacktraceEnabled: configuration.appHangBacktraceEnabled) } reporter.sendCrashReportIfFound() diff --git a/DatadogCrashReporting/Tests/CrashReportingFeatureTests.swift b/DatadogCrashReporting/Tests/CrashReportingFeatureTests.swift index 4fba573725..4295e5e82e 100644 --- a/DatadogCrashReporting/Tests/CrashReportingFeatureTests.swift +++ b/DatadogCrashReporting/Tests/CrashReportingFeatureTests.swift @@ -118,6 +118,31 @@ class CrashReportingFeatureTests: XCTestCase { XCTAssertFalse(core.isAppHangBacktraceEnabled) } + func testGivenPluginWithNoBacktraceReporter_whenAppHangBacktracesAreDisabled_itStillRecordsTheOptOut() throws { + // Given + let core = FeatureRegistrationCoreMock() + let plugin = CrashReportingPluginMock() + plugin.injectedBacktraceReporter = nil + + // When + try CrashReporting.enableOrThrow( + with: plugin, + in: core, + configuration: .init(appHangBacktraceEnabled: false) + ) + + // Then + XCTAssertFalse( + core.isAppHangBacktraceEnabled, + "Opting out must be recorded even when the plugin provides no backtrace reporter, so that App Hangs " + + "report the stack trace as disabled rather than as Crash Reporting never having been enabled" + ) + XCTAssertNil( + try core.backtraceReporter.generateBacktrace(), + "Backtrace generation stays unavailable when the plugin provides no reporter" + ) + } + // MARK: - Crash Report Reading Tests func testItSendsLaunchReportWhenNoPendingCrash() { diff --git a/DatadogInternal/Sources/BacktraceReporting/BacktraceReporter.swift b/DatadogInternal/Sources/BacktraceReporting/BacktraceReporter.swift index 31a4f54c52..8b93556b26 100644 --- a/DatadogInternal/Sources/BacktraceReporting/BacktraceReporter.swift +++ b/DatadogInternal/Sources/BacktraceReporting/BacktraceReporter.swift @@ -66,7 +66,7 @@ internal struct CoreBacktraceReporter: BacktraceReporting, @unchecked Sendable { return nil } - guard let backtraceFeature = core.get(feature: BacktraceReportingFeature.self) else { + guard let reporter = core.get(feature: BacktraceReportingFeature.self)?.reporter else { DD.logger.warn( """ Backtrace will not be generated as this capability is not available. @@ -75,11 +75,11 @@ internal struct CoreBacktraceReporter: BacktraceReporting, @unchecked Sendable { ) return nil } - return try backtraceFeature.reporter.generateBacktrace(threadID: threadID) + return try reporter.generateBacktrace(threadID: threadID) } func binaryImages() throws -> [BinaryImage]? { - try core?.get(feature: BacktraceReportingFeature.self)?.reporter.binaryImages() + try core?.get(feature: BacktraceReportingFeature.self)?.reporter?.binaryImages() } } @@ -99,6 +99,23 @@ extension DatadogCoreProtocol { try register(feature: feature) } + /// Registers the App Hang backtrace policy in Core when no backtrace reporter is available. + /// + /// Use it when Crash Reporting is enabled with a custom plugin that provides no backtrace reporter: backtraces + /// cannot be generated at all in that case, but recording the policy keeps "generation was turned off" reportable + /// as such instead of being mistaken for "Crash Reporting was never enabled". + /// + /// - Parameter appHangBacktraceEnabled: whether backtraces may be generated for App Hangs detected by RUM. + public func register(appHangBacktraceEnabled: Bool) throws { + guard get(feature: BacktraceReportingFeature.self) == nil else { + DD.logger.debug("Backtrace reporter is already registered to this core. Skipping registration of next one.") + return + } + + let feature = BacktraceReportingFeature(reporter: nil, appHangBacktraceEnabled: appHangBacktraceEnabled) + try register(feature: feature) + } + /// Backtrace reporter. Use it to snapshot all running threads in the current process. /// /// It requires `BacktraceReportingFeature` registered to Datadog core. Otherwise reported backtraces will be `nil`. diff --git a/DatadogInternal/Sources/BacktraceReporting/BacktraceReportingFeature.swift b/DatadogInternal/Sources/BacktraceReporting/BacktraceReportingFeature.swift index 4c8ff601b4..b9ba14c0bb 100644 --- a/DatadogInternal/Sources/BacktraceReporting/BacktraceReportingFeature.swift +++ b/DatadogInternal/Sources/BacktraceReporting/BacktraceReportingFeature.swift @@ -12,7 +12,11 @@ internal final class BacktraceReportingFeature: DatadogFeature { let messageReceiver: FeatureMessageReceiver = NOPFeatureMessageReceiver() /// A type capable of generating backtrace reports. - let reporter: BacktraceReporting + /// + /// It is `nil` when Crash Reporting was enabled with a custom plugin that provides no backtrace reporter. The + /// Feature is still registered in that case, so that `appHangBacktraceEnabled` is recorded and backtrace + /// generation stays distinguishable from Crash Reporting never having been enabled. + let reporter: BacktraceReporting? /// Determines whether backtraces may be generated for App Hangs detected by RUM. /// @@ -22,9 +26,9 @@ internal final class BacktraceReportingFeature: DatadogFeature { /// Creates `BacktraceReportingFeature`. /// - Parameters: - /// - reporter: An external implementation of a type capable of generating backtrace reports. + /// - reporter: An external implementation of a type capable of generating backtrace reports, if any. /// - appHangBacktraceEnabled: Whether backtraces may be generated for App Hangs. Default: `true`. - init(reporter: BacktraceReporting, appHangBacktraceEnabled: Bool = true) { + init(reporter: BacktraceReporting?, appHangBacktraceEnabled: Bool = true) { self.reporter = reporter self.appHangBacktraceEnabled = appHangBacktraceEnabled } diff --git a/TestUtilities/Sources/Mocks/CrashReporting/BacktraceReportingMocks.swift b/TestUtilities/Sources/Mocks/CrashReporting/BacktraceReportingMocks.swift index 58941f1e8a..6cba08e8b8 100644 --- a/TestUtilities/Sources/Mocks/CrashReporting/BacktraceReportingMocks.swift +++ b/TestUtilities/Sources/Mocks/CrashReporting/BacktraceReportingMocks.swift @@ -38,7 +38,9 @@ public struct BacktraceReporterMock: BacktraceReporting, @unchecked Sendable { } public func generateBacktrace(threadID: ThreadID) throws -> BacktraceReport? { - generateBacktraceCallsCount += 1 + // `+=` through the wrapper would take the read and the write lock separately, losing increments under + // concurrent calls. `mutate` acquires the write lock once. + _generateBacktraceCallsCount.mutate { $0 += 1 } try throwIfNeeded() return backtrace } diff --git a/api-surface-swift b/api-surface-swift index a15e63deb7..c117396b29 100644 --- a/api-surface-swift +++ b/api-surface-swift @@ -581,7 +581,8 @@ public final class CrashReporting public init(appHangBacktraceEnabled: Bool = true) public static func enable(in core: DatadogCoreProtocol = CoreRegistry.default) public static func enable(with configuration: Configuration, in core: DatadogCoreProtocol = CoreRegistry.default) - public static func enable(with plugin: @autoclosure () throws -> CrashReportingPlugin,configuration: Configuration = .init(),in core: DatadogCoreProtocol = CoreRegistry.default) + public static func enable(with plugin: @autoclosure () throws -> CrashReportingPlugin, in core: DatadogCoreProtocol = CoreRegistry.default) + public static func enable(with plugin: @autoclosure () throws -> CrashReportingPlugin,configuration: Configuration,in core: DatadogCoreProtocol = CoreRegistry.default) public static func enable() public static func enable(with configuration: objc_CrashReportingConfiguration) public var appHangBacktraceEnabled: Bool From 76dca4964b96c6aeeb12e90adbc590dd6bd0fa9c Mon Sep 17 00:00:00 2001 From: Valentin Pertuisot Date: Fri, 14 Aug 2026 17:08:16 +0200 Subject: [PATCH 5/9] docs(rum): re-verify RUM_FEATURE.md after the appHangThreshold doc-comment change `make feature-docs-verify` flagged RUM_FEATURE.md as stale: `RUMConfiguration.swift` is a tracked file and this branch amends the `appHangThreshold` doc-comment, so the baseline `verified_against_commit` no longer covers the public API surface. Mirror the source doc-comment in the App hangs configuration entry and bump the frontmatter baseline. The Feature Docs Verify CI job only runs on release/hotfix branches, so this would otherwise have surfaced at release time. --- DatadogRUM/RUM_FEATURE.md | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/DatadogRUM/RUM_FEATURE.md b/DatadogRUM/RUM_FEATURE.md index a9d4650ea1..871cb3fb8a 100644 --- a/DatadogRUM/RUM_FEATURE.md +++ b/DatadogRUM/RUM_FEATURE.md @@ -1,7 +1,7 @@ --- -last_updated: 2026-08-12 +last_updated: 2026-08-14 sdk_version: 3.15.0 -verified_against_commit: dc80cf268 +verified_against_commit: 83757b8fb tracked_files: - DatadogRUM/Sources/RUM.swift - DatadogRUM/Sources/RUMConfiguration.swift @@ -250,7 +250,7 @@ Requires configuration to be set, otherwise disabled by default: ### Performance Monitoring - **Long tasks**: `longTaskThreshold` (default: 0.1s) -- **App hangs**: `appHangThreshold` (default: nil/disabled) +- **App hangs**: `appHangThreshold` (default: nil/disabled) — stack traces require Crash Reporting, and can be opted out of with `CrashReporting.Configuration.appHangBacktraceEnabled` - **Vitals**: `vitalsUpdateFrequency` (default: .average) - **Slow frames**: `trackSlowFrames` (default: true) — captures view hitches and attaches them to the corresponding RUM view From a6e2b833d531b5f73b8f5efdebec7bbb3a0f99d6 Mon Sep 17 00:00:00 2001 From: Valentin Pertuisot Date: Fri, 14 Aug 2026 17:49:17 +0200 Subject: [PATCH 6/9] fix(crash-reporting): keep backtrace reporter registration open when not opting out MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Registering the policy-only `BacktraceReportingFeature` unconditionally claimed the single registration slot, so the `get(feature:) == nil` guard in `register(backtraceReporter:)` silently dropped any reporter registered afterwards — losing `binary_images` from logs and RUM view events for apps using a custom plugin with no backtrace reporter. Record the policy only when App Hang backtraces are actually turned off, leaving the default path behaving exactly as it did before. Restore `register(backtraceReporter:)` as its own symbol and add the `appHangBacktraceEnabled` variant as an overload, so the existing compound name and mangled symbol stay unchanged for XCFramework consumers. `DatadogInternal` is not part of `DATADOG_MODULES`, so `make api-surface-verify` does not catch this class of change. Tests: the App Hang integration test picked its RUM / Crash Reporting enablement order with `oneOf`, so the lazy per-hang read of the opt-out was only exercised on about half the runs; split it into two deterministic tests. Add coverage for the reporter-less default path, for the public plugin+configuration overload forwarding its argument, and for the Objective-C configuration default and setter. --- .../RUM/AppHangsMonitoringTests.swift | 33 ++++++++++------- .../ObjcAPITests/DDConfiguration+apiTests.m | 7 ++++ .../Sources/CrashReporting.swift | 12 ++++--- .../Tests/CrashReportingFeatureTests.swift | 36 +++++++++++++++++++ .../BacktraceReporter.swift | 18 ++++++---- 5 files changed, 84 insertions(+), 22 deletions(-) diff --git a/Datadog/IntegrationUnitTests/RUM/AppHangsMonitoringTests.swift b/Datadog/IntegrationUnitTests/RUM/AppHangsMonitoringTests.swift index 829b9dd5c9..4ea6f880c1 100644 --- a/Datadog/IntegrationUnitTests/RUM/AppHangsMonitoringTests.swift +++ b/Datadog/IntegrationUnitTests/RUM/AppHangsMonitoringTests.swift @@ -116,19 +116,28 @@ class AppHangsMonitoringTests: XCTestCase { #endif } - func testGivenAppHangBacktracesDisabledInCrashReporting_whenMainThreadHangs_itTracksAppHangWithNoStackTrace() throws { + func testGivenAppHangBacktracesDisabledInCrashReporting_whenRUMIsEnabledFirst_itTracksAppHangWithNoStackTrace() throws { + try assertAppHangIsTrackedWithNoStackTrace { crashReportingConfig in + RUM.enable(with: self.rumConfig, in: self.core) + CrashReporting.enable(with: crashReportingConfig, in: self.core) + } + } + + func testGivenAppHangBacktracesDisabledInCrashReporting_whenCrashReportingIsEnabledFirst_itTracksAppHangWithNoStackTrace() throws { + try assertAppHangIsTrackedWithNoStackTrace { crashReportingConfig in + CrashReporting.enable(with: crashReportingConfig, in: self.core) + RUM.enable(with: self.rumConfig, in: self.core) + } + } + + /// Asserts that a hang is tracked with no stack trace, with the SDK enabled by `enableSDK`. + /// + /// Both enablement orders get their own test rather than being picked at random: only the RUM-first order + /// proves that the opt-out is read per hang instead of being captured when RUM is enabled, so randomizing + /// would let that regression pass half of the runs. + private func assertAppHangIsTrackedWithNoStackTrace(enableSDK: (CrashReporting.Configuration) -> Void) throws { // Given (initialize SDK on the main thread) - let crashReportingConfig = CrashReporting.Configuration(appHangBacktraceEnabled: false) - oneOf([ // no matter of RUM or CR initialization order - { - RUM.enable(with: self.rumConfig, in: self.core) - CrashReporting.enable(with: crashReportingConfig, in: self.core) - }, - { - CrashReporting.enable(with: crashReportingConfig, in: self.core) - RUM.enable(with: self.rumConfig, in: self.core) - }, - ]) + enableSDK(CrashReporting.Configuration(appHangBacktraceEnabled: false)) // When mainQueue.sync { diff --git a/DatadogCore/Tests/Objc/ObjcAPITests/DDConfiguration+apiTests.m b/DatadogCore/Tests/Objc/ObjcAPITests/DDConfiguration+apiTests.m index 130ee08e1f..9d6706d071 100644 --- a/DatadogCore/Tests/Objc/ObjcAPITests/DDConfiguration+apiTests.m +++ b/DatadogCore/Tests/Objc/ObjcAPITests/DDConfiguration+apiTests.m @@ -73,6 +73,13 @@ - (void)testDDConfigurationBuilderAPI { - (void)testDatadogCrashReporterAPI { [DDCrashReporter enable]; + + DDCrashReporterConfiguration *configuration = [DDCrashReporterConfiguration new]; + XCTAssertTrue(configuration.appHangBacktraceEnabled, @"App Hang backtraces are enabled by default"); + configuration.appHangBacktraceEnabled = NO; + XCTAssertFalse(configuration.appHangBacktraceEnabled, @"The setter must write through to the wrapped configuration"); + + [DDCrashReporter enableWith:configuration]; } #pragma clang diagnostic pop diff --git a/DatadogCrashReporting/Sources/CrashReporting.swift b/DatadogCrashReporting/Sources/CrashReporting.swift index 4de3c88a49..ce065f1fc5 100644 --- a/DatadogCrashReporting/Sources/CrashReporting.swift +++ b/DatadogCrashReporting/Sources/CrashReporting.swift @@ -122,10 +122,14 @@ public final class CrashReporting { backtraceReporter: backtraceReporter, appHangBacktraceEnabled: configuration.appHangBacktraceEnabled ) - } else { - // A custom plugin may provide no backtrace reporter. Register the policy anyway, so that opting out of - // App Hang backtraces stays reportable as such rather than as "Crash Reporting was never enabled". - try core.register(appHangBacktraceEnabled: configuration.appHangBacktraceEnabled) + } else if !configuration.appHangBacktraceEnabled { + // A custom plugin may provide no backtrace reporter. Record the opt-out anyway, so that it stays + // reportable as such rather than as "Crash Reporting was never enabled". + // + // Only when opting out: registering unconditionally would claim the single `BacktraceReportingFeature` + // slot with a reporter-less Feature, and the `get(feature:) == nil` guard in `register(backtraceReporter:)` + // would then silently drop any reporter registered later. + try core.register(appHangBacktraceEnabled: false) } reporter.sendCrashReportIfFound() diff --git a/DatadogCrashReporting/Tests/CrashReportingFeatureTests.swift b/DatadogCrashReporting/Tests/CrashReportingFeatureTests.swift index 4295e5e82e..0988a72405 100644 --- a/DatadogCrashReporting/Tests/CrashReportingFeatureTests.swift +++ b/DatadogCrashReporting/Tests/CrashReportingFeatureTests.swift @@ -143,6 +143,42 @@ class CrashReportingFeatureTests: XCTestCase { ) } + func testGivenPluginWithNoBacktraceReporter_whenAppHangBacktracesAreEnabled_itLeavesRegistrationOpenForALaterReporter() throws { + // Given + let core = FeatureRegistrationCoreMock() + let plugin = CrashReportingPluginMock() + plugin.injectedBacktraceReporter = nil + + // When + try CrashReporting.enableOrThrow(with: plugin, in: core) + + // Then + XCTAssertTrue(core.isAppHangBacktraceEnabled) + + try core.register(backtraceReporter: BacktraceReporterMock()) + XCTAssertNotNil( + try core.backtraceReporter.generateBacktrace(), + "Nothing must claim the backtrace reporter registration when there is no opt-out to record, otherwise " + + "the `already registered` guard silently drops a reporter registered later" + ) + } + + func testWhenEnablingWithPluginThroughThePublicAPI_itForwardsTheConfiguration() throws { + // Given + let core = FeatureRegistrationCoreMock() + let plugin = CrashReportingPluginMock() + plugin.injectedBacktraceReporter = BacktraceReporterMock() + + // When (through the public API, so that the overload's own argument forwarding is covered) + CrashReporting.enable(with: plugin, configuration: .init(appHangBacktraceEnabled: false), in: core) + + // Then + XCTAssertFalse( + core.isAppHangBacktraceEnabled, + "The public overload must forward its `configuration`, not a default-constructed one" + ) + } + // MARK: - Crash Report Reading Tests func testItSendsLaunchReportWhenNoPendingCrash() { diff --git a/DatadogInternal/Sources/BacktraceReporting/BacktraceReporter.swift b/DatadogInternal/Sources/BacktraceReporting/BacktraceReporter.swift index 8b93556b26..a22ff19246 100644 --- a/DatadogInternal/Sources/BacktraceReporting/BacktraceReporter.swift +++ b/DatadogInternal/Sources/BacktraceReporting/BacktraceReporter.swift @@ -85,11 +85,17 @@ internal struct CoreBacktraceReporter: BacktraceReporting, @unchecked Sendable { /// Adds capability of reporting backtraces. extension DatadogCoreProtocol { + /// Registers backtrace reporter in Core. + /// - Parameter backtraceReporter: the implementation of backtrace reporter. + public func register(backtraceReporter: BacktraceReporting) throws { + try register(backtraceReporter: backtraceReporter, appHangBacktraceEnabled: true) + } + /// Registers backtrace reporter in Core. /// - Parameters: /// - backtraceReporter: the implementation of backtrace reporter. - /// - appHangBacktraceEnabled: whether backtraces may be generated for App Hangs detected by RUM. Default: `true`. - public func register(backtraceReporter: BacktraceReporting, appHangBacktraceEnabled: Bool = true) throws { + /// - appHangBacktraceEnabled: whether backtraces may be generated for App Hangs detected by RUM. + public func register(backtraceReporter: BacktraceReporting, appHangBacktraceEnabled: Bool) throws { guard get(feature: BacktraceReportingFeature.self) == nil else { DD.logger.debug("Backtrace reporter is already registered to this core. Skipping registration of next one.") return @@ -123,10 +129,10 @@ extension DatadogCoreProtocol { /// Whether backtraces may be generated for App Hangs detected by RUM. /// - /// It is `false` only when a backtrace reporter was registered with App Hang backtraces turned off. Before any - /// reporter is registered it is `true`: in that state backtrace generation is *unavailable* rather than - /// *disabled*, and callers must keep distinguishing the two. Read it at the moment a backtrace is needed, as - /// the reporter may be registered after the reading Feature was enabled. + /// It is `false` only when Crash Reporting was enabled with App Hang backtraces turned off — whether or not a + /// backtrace reporter came with it. Until then it is `true`: in that state backtrace generation may still be + /// *unavailable* rather than *disabled*, and callers must keep distinguishing the two. Read it at the moment a + /// backtrace is needed, as the reporter may be registered after the reading Feature was enabled. public var isAppHangBacktraceEnabled: Bool { // `self.` is required: a bare `get(...)` here parses as a `get` accessor. self.get(feature: BacktraceReportingFeature.self)?.appHangBacktraceEnabled ?? true From a4aeb05cd817ec7213824c88ac8dcd19b7b97168 Mon Sep 17 00:00:00 2001 From: Valentin Pertuisot Date: Mon, 17 Aug 2026 12:16:12 +0200 Subject: [PATCH 7/9] fix(crash-reporting): apply the App Hang backtrace opt-out when a reporter is already registered MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `register(backtraceReporter:appHangBacktraceEnabled:)` discarded the requested policy along with the duplicate reporter: its `get(feature:) == nil` guard returned before the flag was recorded. So when a `BacktraceReportingFeature` was already registered — an integration calling the public `register(backtraceReporter:)` before enabling Crash Reporting, or a second `CrashReporting.enable` call — `CrashReporting.enable(with: .init(appHangBacktraceEnabled: false))` silently left the policy at `true` and RUM kept snapshotting all threads during App Hangs. Same class of hole as the reporter-less plugin case fixed earlier on this branch, from the opposite direction: there the opt-out had no Feature to land on, here the Feature exists but the opt-out never reached it. The first reporter still keeps the registration — that part was pre-existing and is what makes a later reporter a no-op. Only the policy is now applied on top, through `BacktraceReportingFeature.disableAppHangBacktrace()`. Opting out is deliberately one-way. `register(backtraceReporter:)` forwards `true` as a compatibility default rather than as an explicit request, so honouring it symmetrically would let a bare reporter registration silently revert an opt-out. Being one-way also makes the outcome independent of the order in which reporters are registered, matching the per-hang, lazy read the watchdog thread already does. `appHangBacktraceEnabled` therefore becomes `@ReadWriteLock private(set) var` — it is read from the App Hangs watchdog thread, once per detected hang rather than in the polling loop. No API surface change: `disableAppHangBacktrace()` is internal to `DatadogInternal` and `make api-surface-verify` is unchanged. Two tests, both confirmed failing/passing as expected before and after: - `testGivenBacktraceReporterAlreadyRegistered_whenAppHangBacktracesAreDisabled_itStillRecordsTheOptOut` reproduces the reported bug. - `testGivenAppHangBacktracesDisabled_whenRegisteringAnotherBacktraceReporter_itKeepsTheOptOut` guards the one-way direction, so a later bare registration cannot revert the opt-out. --- .../Tests/CrashReportingFeatureTests.swift | 46 +++++++++++++++++++ .../BacktraceReporter.swift | 36 +++++++++------ .../BacktraceReportingFeature.swift | 19 +++++++- 3 files changed, 86 insertions(+), 15 deletions(-) diff --git a/DatadogCrashReporting/Tests/CrashReportingFeatureTests.swift b/DatadogCrashReporting/Tests/CrashReportingFeatureTests.swift index 0988a72405..6f08dade77 100644 --- a/DatadogCrashReporting/Tests/CrashReportingFeatureTests.swift +++ b/DatadogCrashReporting/Tests/CrashReportingFeatureTests.swift @@ -163,6 +163,52 @@ class CrashReportingFeatureTests: XCTestCase { ) } + func testGivenBacktraceReporterAlreadyRegistered_whenAppHangBacktracesAreDisabled_itStillRecordsTheOptOut() throws { + // Given (a reporter claimed the single registration slot before Crash Reporting was enabled) + let core = FeatureRegistrationCoreMock() + try core.register(backtraceReporter: BacktraceReporterMock()) + XCTAssertTrue(core.isAppHangBacktraceEnabled) + + let plugin = CrashReportingPluginMock() + plugin.injectedBacktraceReporter = BacktraceReporterMock() + + // When + try CrashReporting.enableOrThrow( + with: plugin, + in: core, + configuration: .init(appHangBacktraceEnabled: false) + ) + + // Then + XCTAssertFalse( + core.isAppHangBacktraceEnabled, + "The opt-out must be applied even though the first reporter keeps the registration, otherwise App Hangs " + + "keep snapshotting all threads after the app explicitly asked them not to" + ) + } + + func testGivenAppHangBacktracesDisabled_whenRegisteringAnotherBacktraceReporter_itKeepsTheOptOut() throws { + // Given + let core = FeatureRegistrationCoreMock() + let plugin = CrashReportingPluginMock() + plugin.injectedBacktraceReporter = BacktraceReporterMock() + try CrashReporting.enableOrThrow( + with: plugin, + in: core, + configuration: .init(appHangBacktraceEnabled: false) + ) + + // When + try core.register(backtraceReporter: BacktraceReporterMock()) + + // Then + XCTAssertFalse( + core.isAppHangBacktraceEnabled, + "`register(backtraceReporter:)` passes `true` as a compatibility default, not as an explicit request, " + + "so it must not revert an opt-out" + ) + } + func testWhenEnablingWithPluginThroughThePublicAPI_itForwardsTheConfiguration() throws { // Given let core = FeatureRegistrationCoreMock() diff --git a/DatadogInternal/Sources/BacktraceReporting/BacktraceReporter.swift b/DatadogInternal/Sources/BacktraceReporting/BacktraceReporter.swift index a22ff19246..a0f558a858 100644 --- a/DatadogInternal/Sources/BacktraceReporting/BacktraceReporter.swift +++ b/DatadogInternal/Sources/BacktraceReporting/BacktraceReporter.swift @@ -96,13 +96,18 @@ extension DatadogCoreProtocol { /// - backtraceReporter: the implementation of backtrace reporter. /// - appHangBacktraceEnabled: whether backtraces may be generated for App Hangs detected by RUM. public func register(backtraceReporter: BacktraceReporting, appHangBacktraceEnabled: Bool) throws { - guard get(feature: BacktraceReportingFeature.self) == nil else { - DD.logger.debug("Backtrace reporter is already registered to this core. Skipping registration of next one.") - return + guard let registered = get(feature: BacktraceReportingFeature.self) else { + let feature = BacktraceReportingFeature(reporter: backtraceReporter, appHangBacktraceEnabled: appHangBacktraceEnabled) + return try register(feature: feature) } - let feature = BacktraceReportingFeature(reporter: backtraceReporter, appHangBacktraceEnabled: appHangBacktraceEnabled) - try register(feature: feature) + DD.logger.debug("Backtrace reporter is already registered to this core. Skipping registration of next one.") + + // The first reporter keeps the registration, but an opt-out arriving with a later one must not be dropped + // along with it - see `BacktraceReportingFeature.disableAppHangBacktrace()`. + if !appHangBacktraceEnabled { + registered.disableAppHangBacktrace() + } } /// Registers the App Hang backtrace policy in Core when no backtrace reporter is available. @@ -113,13 +118,15 @@ extension DatadogCoreProtocol { /// /// - Parameter appHangBacktraceEnabled: whether backtraces may be generated for App Hangs detected by RUM. public func register(appHangBacktraceEnabled: Bool) throws { - guard get(feature: BacktraceReportingFeature.self) == nil else { - DD.logger.debug("Backtrace reporter is already registered to this core. Skipping registration of next one.") - return + guard let registered = get(feature: BacktraceReportingFeature.self) else { + let feature = BacktraceReportingFeature(reporter: nil, appHangBacktraceEnabled: appHangBacktraceEnabled) + return try register(feature: feature) } - let feature = BacktraceReportingFeature(reporter: nil, appHangBacktraceEnabled: appHangBacktraceEnabled) - try register(feature: feature) + // A reporter already holds the registration - keep it and only record the policy, as above. + if !appHangBacktraceEnabled { + registered.disableAppHangBacktrace() + } } /// Backtrace reporter. Use it to snapshot all running threads in the current process. @@ -129,10 +136,11 @@ extension DatadogCoreProtocol { /// Whether backtraces may be generated for App Hangs detected by RUM. /// - /// It is `false` only when Crash Reporting was enabled with App Hang backtraces turned off — whether or not a - /// backtrace reporter came with it. Until then it is `true`: in that state backtrace generation may still be - /// *unavailable* rather than *disabled*, and callers must keep distinguishing the two. Read it at the moment a - /// backtrace is needed, as the reporter may be registered after the reading Feature was enabled. + /// It is `false` once Crash Reporting was enabled with App Hang backtraces turned off — whether or not a + /// backtrace reporter came with it, and whichever order registrations happened in. Until then it is `true`: in + /// that state backtrace generation may still be *unavailable* rather than *disabled*, and callers must keep + /// distinguishing the two. Read it at the moment a backtrace is needed, as the reporter may be registered after + /// the reading Feature was enabled. public var isAppHangBacktraceEnabled: Bool { // `self.` is required: a bare `get(...)` here parses as a `get` accessor. self.get(feature: BacktraceReportingFeature.self)?.appHangBacktraceEnabled ?? true diff --git a/DatadogInternal/Sources/BacktraceReporting/BacktraceReportingFeature.swift b/DatadogInternal/Sources/BacktraceReporting/BacktraceReportingFeature.swift index b9ba14c0bb..f6e2849553 100644 --- a/DatadogInternal/Sources/BacktraceReporting/BacktraceReportingFeature.swift +++ b/DatadogInternal/Sources/BacktraceReporting/BacktraceReportingFeature.swift @@ -22,7 +22,11 @@ internal final class BacktraceReportingFeature: DatadogFeature { /// /// It only gates the App Hangs consumer. All other consumers of `reporter` (crash reports, binary images /// attached to logs and RUM view events, the public `backtraceReporter` API) are unaffected by this value. - let appHangBacktraceEnabled: Bool + /// + /// Only `disableAppHangBacktrace()` can change it after initialization. It is read from the App Hangs watchdog + /// thread, hence the lock. + @ReadWriteLock + private(set) var appHangBacktraceEnabled: Bool /// Creates `BacktraceReportingFeature`. /// - Parameters: @@ -32,4 +36,17 @@ internal final class BacktraceReportingFeature: DatadogFeature { self.reporter = reporter self.appHangBacktraceEnabled = appHangBacktraceEnabled } + + /// Turns off backtrace generation for App Hangs. + /// + /// Needed because only the first registration installs its `reporter`, while an opt-out arriving with a later one + /// must still take effect — otherwise an app that registered a backtrace reporter before enabling Crash Reporting + /// would keep snapshotting all threads after explicitly asking it not to. + /// + /// Turning it back on is deliberately not offered: `register(backtraceReporter:)` passes `true` as a compatibility + /// default rather than as an explicit request, so honouring it would silently revert an opt-out. Opting out is + /// therefore one-way, which also makes the outcome independent of registration order. + func disableAppHangBacktrace() { + _appHangBacktraceEnabled.mutate { $0 = false } + } } From 007ff4396d1e5046311d1f1cdc4693377bf9463a Mon Sep 17 00:00:00 2001 From: Valentin Pertuisot Date: Mon, 17 Aug 2026 12:16:49 +0200 Subject: [PATCH 8/9] docs(rum): point the RUM_FEATURE.md baseline at a reachable commit MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `verified_against_commit` was `83757b8fb`, the pre-rebase SHA of what is now `9240952a0`. That object does not exist in a fresh clone, so `tools/feature-docs-verify.sh` failed on `git diff 83757b8fb..HEAD` rather than reporting drift: ❌ RUM_FEATURE.md: failed to diff against 83757b8fb. fatal: bad revision '83757b8fb..HEAD' Exactly the failure mode the update-feature-docs skill warns about in step 9 — the SHA was written before the branch was rebased, and the rebase orphaned it. The Feature Docs Verify job only runs on release/hotfix branches, so this would have surfaced at release time. Re-point it at `a4aeb05cd` and re-date the verification. No content change: this branch's only edit to a tracked file is the `appHangThreshold` doc-comment in `RUMConfiguration.swift`, already mirrored into the App hangs entry by 76dca4964, and RUM's public API is untouched by the commits since. `make feature-docs-verify` now reports RUM_FEATURE.md up to date. Note that no baseline choice is rebase-proof here: the check requires a SHA at or after this branch's change to `RUMConfiguration.swift`, and every such commit is branch-local until merge. Re-run the skill if this branch is rebased or amended again before merging. --- DatadogRUM/RUM_FEATURE.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/DatadogRUM/RUM_FEATURE.md b/DatadogRUM/RUM_FEATURE.md index 871cb3fb8a..94df833ed5 100644 --- a/DatadogRUM/RUM_FEATURE.md +++ b/DatadogRUM/RUM_FEATURE.md @@ -1,7 +1,7 @@ --- -last_updated: 2026-08-14 +last_updated: 2026-08-17 sdk_version: 3.15.0 -verified_against_commit: 83757b8fb +verified_against_commit: a4aeb05cd tracked_files: - DatadogRUM/Sources/RUM.swift - DatadogRUM/Sources/RUMConfiguration.swift From 96cfc96057159c5325bb8993d4e0e9ac4f42b190 Mon Sep 17 00:00:00 2001 From: Valentin Pertuisot Date: Mon, 17 Aug 2026 12:34:10 +0200 Subject: [PATCH 9/9] fix(crash-reporting): let an opt-out-only feature adopt a reporter registered later MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Enabling Crash Reporting with `appHangBacktraceEnabled: false` and a custom plugin whose `backtraceReporter` is `nil` registered a `BacktraceReportingFeature` carrying only the policy. That Feature still occupied the single registration slot, so a reporter offered afterwards through the public `register(backtraceReporter:)` was dropped by the "already registered" rule and `core.backtraceReporter` stayed `nil` for the rest of the process — taking crash reports, `error.binary_images` on RUM view events and `binaryImages` on error logs with it. That directly contradicts what the option promises: it gates the App Hangs consumer only. The hazard was known — the comment at the `register(appHangBacktraceEnabled:)` call site described it as the reason the reporter-less Feature is registered *only* when opting out. This removes the hazard rather than working around it: `reporter` becomes adopt-once via `adoptReporterIfAbsent(_:)`, so the empty slot accepts the first reporter to arrive while the opt-out is retained. `BacktraceReportingFeature` is now monotonic in both of the things it carries — the reporter fills in once, the policy turns off once — so neither can be lost to registration order. That is the whole family of "single slot silently discards information" bugs closed, after the reporter-less case (9240952a0) and the already-registered-reporter case (a4aeb05cd). The narrowed reason for the `else if !configuration.appHangBacktraceEnabled` guard is now recorded at the call site: with the default there is simply no policy to record, so a reporter-less Feature would carry no information at all. Registering one is no longer harmful, just pointless. Covered by `testGivenPluginWithNoBacktraceReporter_whenAppHangBacktracesAreDisabled_itStillAcceptsALaterReporter`, confirmed failing before the fix on the `backtraceReporter` assertion and passing after. It also asserts the opt-out survives adoption. No API surface change. --- .../Sources/CrashReporting.swift | 6 ++-- .../Tests/CrashReportingFeatureTests.swift | 28 +++++++++++++++++++ .../BacktraceReporter.swift | 11 ++++++-- .../BacktraceReportingFeature.swift | 28 ++++++++++++++++++- 4 files changed, 66 insertions(+), 7 deletions(-) diff --git a/DatadogCrashReporting/Sources/CrashReporting.swift b/DatadogCrashReporting/Sources/CrashReporting.swift index ce065f1fc5..1d267c4b5a 100644 --- a/DatadogCrashReporting/Sources/CrashReporting.swift +++ b/DatadogCrashReporting/Sources/CrashReporting.swift @@ -126,9 +126,9 @@ public final class CrashReporting { // A custom plugin may provide no backtrace reporter. Record the opt-out anyway, so that it stays // reportable as such rather than as "Crash Reporting was never enabled". // - // Only when opting out: registering unconditionally would claim the single `BacktraceReportingFeature` - // slot with a reporter-less Feature, and the `get(feature:) == nil` guard in `register(backtraceReporter:)` - // would then silently drop any reporter registered later. + // Only when opting out: with the default there is no policy to record, so a reporter-less + // `BacktraceReportingFeature` would carry no information at all. A reporter registered later still + // installs either way, through `BacktraceReportingFeature.adoptReporterIfAbsent(_:)`. try core.register(appHangBacktraceEnabled: false) } diff --git a/DatadogCrashReporting/Tests/CrashReportingFeatureTests.swift b/DatadogCrashReporting/Tests/CrashReportingFeatureTests.swift index 6f08dade77..0daa6b3273 100644 --- a/DatadogCrashReporting/Tests/CrashReportingFeatureTests.swift +++ b/DatadogCrashReporting/Tests/CrashReportingFeatureTests.swift @@ -163,6 +163,34 @@ class CrashReportingFeatureTests: XCTestCase { ) } + func testGivenPluginWithNoBacktraceReporter_whenAppHangBacktracesAreDisabled_itStillAcceptsALaterReporter() throws { + // Given (the opt-out is recorded by a Feature that carries no reporter) + let core = FeatureRegistrationCoreMock() + let plugin = CrashReportingPluginMock() + plugin.injectedBacktraceReporter = nil + try CrashReporting.enableOrThrow( + with: plugin, + in: core, + configuration: .init(appHangBacktraceEnabled: false) + ) + XCTAssertNil(try core.backtraceReporter.generateBacktrace()) + + // When + try core.register(backtraceReporter: BacktraceReporterMock()) + + // Then + XCTAssertNotNil( + try core.backtraceReporter.generateBacktrace(), + "Recording the opt-out must not permanently block backtrace generation for the consumers this " + + "configuration promises are unaffected - crash reports, binary images on logs and RUM view events, " + + "and the public `backtraceReporter` API" + ) + XCTAssertFalse( + core.isAppHangBacktraceEnabled, + "Adopting a reporter must not revert the opt-out" + ) + } + func testGivenBacktraceReporterAlreadyRegistered_whenAppHangBacktracesAreDisabled_itStillRecordsTheOptOut() throws { // Given (a reporter claimed the single registration slot before Crash Reporting was enabled) let core = FeatureRegistrationCoreMock() diff --git a/DatadogInternal/Sources/BacktraceReporting/BacktraceReporter.swift b/DatadogInternal/Sources/BacktraceReporting/BacktraceReporter.swift index a0f558a858..f2c45b97b8 100644 --- a/DatadogInternal/Sources/BacktraceReporting/BacktraceReporter.swift +++ b/DatadogInternal/Sources/BacktraceReporting/BacktraceReporter.swift @@ -101,7 +101,11 @@ extension DatadogCoreProtocol { return try register(feature: feature) } - DD.logger.debug("Backtrace reporter is already registered to this core. Skipping registration of next one.") + // A Feature registered only to record an App Hang opt-out holds no reporter yet, so let this one fill the + // empty slot instead of being dropped - see `BacktraceReportingFeature.adoptReporterIfAbsent(_:)`. + if !registered.adoptReporterIfAbsent(backtraceReporter) { + DD.logger.debug("Backtrace reporter is already registered to this core. Skipping registration of next one.") + } // The first reporter keeps the registration, but an opt-out arriving with a later one must not be dropped // along with it - see `BacktraceReportingFeature.disableAppHangBacktrace()`. @@ -113,8 +117,9 @@ extension DatadogCoreProtocol { /// Registers the App Hang backtrace policy in Core when no backtrace reporter is available. /// /// Use it when Crash Reporting is enabled with a custom plugin that provides no backtrace reporter: backtraces - /// cannot be generated at all in that case, but recording the policy keeps "generation was turned off" reportable - /// as such instead of being mistaken for "Crash Reporting was never enabled". + /// cannot be generated until some other component registers one, but recording the policy keeps "generation was + /// turned off" reportable as such instead of being mistaken for "Crash Reporting was never enabled". A reporter + /// registered afterwards still installs, through `BacktraceReportingFeature.adoptReporterIfAbsent(_:)`. /// /// - Parameter appHangBacktraceEnabled: whether backtraces may be generated for App Hangs detected by RUM. public func register(appHangBacktraceEnabled: Bool) throws { diff --git a/DatadogInternal/Sources/BacktraceReporting/BacktraceReportingFeature.swift b/DatadogInternal/Sources/BacktraceReporting/BacktraceReportingFeature.swift index f6e2849553..bebd5cdd8e 100644 --- a/DatadogInternal/Sources/BacktraceReporting/BacktraceReportingFeature.swift +++ b/DatadogInternal/Sources/BacktraceReporting/BacktraceReportingFeature.swift @@ -16,7 +16,11 @@ internal final class BacktraceReportingFeature: DatadogFeature { /// It is `nil` when Crash Reporting was enabled with a custom plugin that provides no backtrace reporter. The /// Feature is still registered in that case, so that `appHangBacktraceEnabled` is recorded and backtrace /// generation stays distinguishable from Crash Reporting never having been enabled. - let reporter: BacktraceReporting? + /// + /// Only `adoptReporterIfAbsent(_:)` can change it after initialization. It is read from every thread that + /// generates a backtrace, hence the lock. + @ReadWriteLock + private(set) var reporter: BacktraceReporting? /// Determines whether backtraces may be generated for App Hangs detected by RUM. /// @@ -37,6 +41,28 @@ internal final class BacktraceReportingFeature: DatadogFeature { self.appHangBacktraceEnabled = appHangBacktraceEnabled } + /// Installs `reporter` if this Feature does not have one yet. + /// + /// A Feature registered only to record an App Hang opt-out carries no reporter, yet it occupies the single + /// registration slot. Without adoption, a reporter offered afterwards would be dropped by the "already + /// registered" rule and crash reports, binary images on logs and RUM view events, and the public + /// `backtraceReporter` API would stay unavailable for the rest of the process — none of which the App Hang + /// opt-out promises to affect. + /// + /// - Parameter reporter: The reporter to install. + /// - Returns: `true` if it was installed, `false` if one was already present and has been kept. + func adoptReporterIfAbsent(_ reporter: BacktraceReporting) -> Bool { + var adopted = false + _reporter.mutate { current in + guard current == nil else { + return // the first reporter wins + } + current = reporter + adopted = true + } + return adopted + } + /// Turns off backtrace generation for App Hangs. /// /// Needed because only the first registration installs its `reporter`, while an opt-out arriving with a later one