From 6596b8c652b8407c65a5beb550927ee4e1441cf4 Mon Sep 17 00:00:00 2001 From: Abdulaziz Albahar <67667005+azooz2003-bit@users.noreply.github.com> Date: Thu, 30 Jul 2026 23:21:12 -0700 Subject: [PATCH 1/5] Move Sentry scrubbing layer to shared CmuxSentryTelemetry package Co-Authored-By: Claude Fable 5 --- CLI/CLISocketSentryTelemetry.swift | 1 + .../CmuxSentryTelemetry/Package.resolved | 15 ++++ .../Shared/CmuxSentryTelemetry/Package.swift | 72 +++++++++++++++++++ .../SentryEventScrubber.swift | 14 ++-- .../ScrubberDenylists.swift | 0 .../SentryRegexMatch.swift | 0 .../SentryRegexPattern.swift | 0 .../CmuxSentryScrubbing}/SentryScrubber.swift | 0 .../SentryEventScrubberTests.swift | 71 +----------------- .../ScrubberDenylistsTests.swift | 2 +- .../SentryScrubberTests.swift | 2 +- .../CmuxMobileCrashReporting/Package.swift | 3 + Sources/AppDelegate.swift | 1 + cmux.xcodeproj/project.pbxproj | 41 ++++++++--- cmux.xcworkspace/contents.xcworkspacedata | 3 + cmuxTests/MacSentryStartupPolicyTests.swift | 70 ++++++++++++++++++ 16 files changed, 207 insertions(+), 88 deletions(-) create mode 100644 Packages/Shared/CmuxSentryTelemetry/Package.resolved create mode 100644 Packages/Shared/CmuxSentryTelemetry/Package.swift rename {Sources => Packages/Shared/CmuxSentryTelemetry/Sources/CmuxSentryReporting}/SentryEventScrubber.swift (97%) rename Packages/{macOS/CmuxFoundation/Sources/CmuxFoundation => Shared/CmuxSentryTelemetry/Sources/CmuxSentryScrubbing}/ScrubberDenylists.swift (100%) rename Packages/{macOS/CmuxFoundation/Sources/CmuxFoundation => Shared/CmuxSentryTelemetry/Sources/CmuxSentryScrubbing}/SentryRegexMatch.swift (100%) rename Packages/{macOS/CmuxFoundation/Sources/CmuxFoundation => Shared/CmuxSentryTelemetry/Sources/CmuxSentryScrubbing}/SentryRegexPattern.swift (100%) rename Packages/{macOS/CmuxFoundation/Sources/CmuxFoundation => Shared/CmuxSentryTelemetry/Sources/CmuxSentryScrubbing}/SentryScrubber.swift (100%) rename {cmuxTests => Packages/Shared/CmuxSentryTelemetry/Tests/CmuxSentryReportingTests}/SentryEventScrubberTests.swift (82%) rename Packages/{macOS/CmuxFoundation/Tests/CmuxFoundationTests => Shared/CmuxSentryTelemetry/Tests/CmuxSentryScrubbingTests}/ScrubberDenylistsTests.swift (99%) rename Packages/{macOS/CmuxFoundation/Tests/CmuxFoundationTests => Shared/CmuxSentryTelemetry/Tests/CmuxSentryScrubbingTests}/SentryScrubberTests.swift (99%) create mode 100644 cmuxTests/MacSentryStartupPolicyTests.swift diff --git a/CLI/CLISocketSentryTelemetry.swift b/CLI/CLISocketSentryTelemetry.swift index 336b5210d4ed..b3d81397fa39 100644 --- a/CLI/CLISocketSentryTelemetry.swift +++ b/CLI/CLISocketSentryTelemetry.swift @@ -1,4 +1,5 @@ import CmuxFoundation +import CmuxSentryReporting import Darwin import Foundation diff --git a/Packages/Shared/CmuxSentryTelemetry/Package.resolved b/Packages/Shared/CmuxSentryTelemetry/Package.resolved new file mode 100644 index 000000000000..ae4e5fd386db --- /dev/null +++ b/Packages/Shared/CmuxSentryTelemetry/Package.resolved @@ -0,0 +1,15 @@ +{ + "originHash" : "7ab320f29c392aea9676513bb1b86dfdbd7fb6816a7fa94051365fbff1192aaa", + "pins" : [ + { + "identity" : "sentry-cocoa", + "kind" : "remoteSourceControl", + "location" : "https://github.com/getsentry/sentry-cocoa.git", + "state" : { + "revision" : "d82bb1c5eb353a593573a5624bb370f5a1f500ce", + "version" : "9.24.0" + } + } + ], + "version" : 3 +} diff --git a/Packages/Shared/CmuxSentryTelemetry/Package.swift b/Packages/Shared/CmuxSentryTelemetry/Package.swift new file mode 100644 index 000000000000..adb1a9a154a1 --- /dev/null +++ b/Packages/Shared/CmuxSentryTelemetry/Package.swift @@ -0,0 +1,72 @@ +// swift-tools-version: 6.0 + +import PackageDescription + +// `CmuxSentryTelemetry` is the shared (macOS + iOS) Sentry privacy layer. +// `CmuxSentryScrubbing` is the pure-Foundation value scrubber (no Sentry +// dependency) so it stays testable without linking the SDK; `CmuxSentryReporting` +// is the thin glue that routes Sentry `Event` / `Breadcrumb` / `Span` fields +// through that scrubber and therefore links the Sentry SDK. +let package = Package( + name: "CmuxSentryTelemetry", + platforms: [ + .iOS(.v18), + .macOS(.v14), + ], + products: [ + .library( + name: "CmuxSentryScrubbing", + targets: ["CmuxSentryScrubbing"] + ), + .library( + name: "CmuxSentryReporting", + targets: ["CmuxSentryReporting"] + ), + ], + dependencies: [ + .package( + url: "https://github.com/getsentry/sentry-cocoa.git", + .upToNextMajor(from: "9.3.0") + ), + ], + targets: [ + .target( + name: "CmuxSentryScrubbing", + swiftSettings: [ + .swiftLanguageMode(.v6), + .enableUpcomingFeature("ExistentialAny"), + .enableUpcomingFeature("InternalImportsByDefault"), + ] + ), + .target( + name: "CmuxSentryReporting", + dependencies: [ + "CmuxSentryScrubbing", + .product(name: "Sentry", package: "sentry-cocoa"), + ], + swiftSettings: [ + .swiftLanguageMode(.v6), + .enableUpcomingFeature("ExistentialAny"), + .enableUpcomingFeature("InternalImportsByDefault"), + ] + ), + .testTarget( + name: "CmuxSentryScrubbingTests", + dependencies: ["CmuxSentryScrubbing"], + swiftSettings: [ + .swiftLanguageMode(.v6), + .enableUpcomingFeature("ExistentialAny"), + .enableUpcomingFeature("InternalImportsByDefault"), + ] + ), + .testTarget( + name: "CmuxSentryReportingTests", + dependencies: ["CmuxSentryReporting"], + swiftSettings: [ + .swiftLanguageMode(.v6), + .enableUpcomingFeature("ExistentialAny"), + .enableUpcomingFeature("InternalImportsByDefault"), + ] + ), + ] +) diff --git a/Sources/SentryEventScrubber.swift b/Packages/Shared/CmuxSentryTelemetry/Sources/CmuxSentryReporting/SentryEventScrubber.swift similarity index 97% rename from Sources/SentryEventScrubber.swift rename to Packages/Shared/CmuxSentryTelemetry/Sources/CmuxSentryReporting/SentryEventScrubber.swift index f5529287f161..5932a50ad086 100644 --- a/Sources/SentryEventScrubber.swift +++ b/Packages/Shared/CmuxSentryTelemetry/Sources/CmuxSentryReporting/SentryEventScrubber.swift @@ -1,5 +1,5 @@ -import CmuxFoundation -import Sentry +public import CmuxSentryScrubbing +public import Sentry /// Applies a ``SentryScrubber`` to outgoing Sentry events and breadcrumbs so /// file paths, emails, and secrets are redacted before they leave the device. @@ -29,14 +29,14 @@ import Sentry /// fields, `level`, `environment`, `releaseName`, `dist`, `modules`. The /// `event.error` reference is not serialized by the SDK (it is converted into /// `exceptions` / `mechanism.data` first), so it needs no handling here. -struct SentryEventScrubber { +public struct SentryEventScrubber: Sendable { /// The pure value scrubber that does the actual redaction. private let scrubber: SentryScrubber /// Creates an event scrubber. /// /// - Parameter scrubber: The underlying value scrubber. Defaults to one bound to the current home directory. - init(scrubber: SentryScrubber = SentryScrubber()) { + public init(scrubber: SentryScrubber = SentryScrubber()) { self.scrubber = scrubber } @@ -48,7 +48,7 @@ struct SentryEventScrubber { /// /// - Parameter event: The event Sentry is about to send. /// - Returns: The scrubbed event. - func scrub(_ event: Event) -> Event { + public func scrub(_ event: Event) -> Event { event.message = scrub(event.message) event.serverName = scrubber.scrub(optional: event.serverName) @@ -117,7 +117,7 @@ struct SentryEventScrubber { /// - Parameter breadcrumb: The breadcrumb Sentry is about to record. /// - Returns: The scrubbed breadcrumb. @discardableResult - func scrub(_ breadcrumb: Breadcrumb) -> Breadcrumb { + public func scrub(_ breadcrumb: Breadcrumb) -> Breadcrumb { breadcrumb.message = scrubber.scrub(optional: breadcrumb.message) if let data = breadcrumb.data { breadcrumb.data = scrubber.scrub(dictionary: data) @@ -138,7 +138,7 @@ struct SentryEventScrubber { /// - Parameter span: The span Sentry is about to send. /// - Returns: The scrubbed span. @discardableResult - func scrub(_ span: any Span) -> any Span { + public func scrub(_ span: any Span) -> any Span { span.spanDescription = scrubber.scrub(optional: span.spanDescription) // `data` / `tags` are read-only; scrub via the key-aware dictionary // scrubber and rewrite each entry through the per-key setters. diff --git a/Packages/macOS/CmuxFoundation/Sources/CmuxFoundation/ScrubberDenylists.swift b/Packages/Shared/CmuxSentryTelemetry/Sources/CmuxSentryScrubbing/ScrubberDenylists.swift similarity index 100% rename from Packages/macOS/CmuxFoundation/Sources/CmuxFoundation/ScrubberDenylists.swift rename to Packages/Shared/CmuxSentryTelemetry/Sources/CmuxSentryScrubbing/ScrubberDenylists.swift diff --git a/Packages/macOS/CmuxFoundation/Sources/CmuxFoundation/SentryRegexMatch.swift b/Packages/Shared/CmuxSentryTelemetry/Sources/CmuxSentryScrubbing/SentryRegexMatch.swift similarity index 100% rename from Packages/macOS/CmuxFoundation/Sources/CmuxFoundation/SentryRegexMatch.swift rename to Packages/Shared/CmuxSentryTelemetry/Sources/CmuxSentryScrubbing/SentryRegexMatch.swift diff --git a/Packages/macOS/CmuxFoundation/Sources/CmuxFoundation/SentryRegexPattern.swift b/Packages/Shared/CmuxSentryTelemetry/Sources/CmuxSentryScrubbing/SentryRegexPattern.swift similarity index 100% rename from Packages/macOS/CmuxFoundation/Sources/CmuxFoundation/SentryRegexPattern.swift rename to Packages/Shared/CmuxSentryTelemetry/Sources/CmuxSentryScrubbing/SentryRegexPattern.swift diff --git a/Packages/macOS/CmuxFoundation/Sources/CmuxFoundation/SentryScrubber.swift b/Packages/Shared/CmuxSentryTelemetry/Sources/CmuxSentryScrubbing/SentryScrubber.swift similarity index 100% rename from Packages/macOS/CmuxFoundation/Sources/CmuxFoundation/SentryScrubber.swift rename to Packages/Shared/CmuxSentryTelemetry/Sources/CmuxSentryScrubbing/SentryScrubber.swift diff --git a/cmuxTests/SentryEventScrubberTests.swift b/Packages/Shared/CmuxSentryTelemetry/Tests/CmuxSentryReportingTests/SentryEventScrubberTests.swift similarity index 82% rename from cmuxTests/SentryEventScrubberTests.swift rename to Packages/Shared/CmuxSentryTelemetry/Tests/CmuxSentryReportingTests/SentryEventScrubberTests.swift index 5cd2344eb8ab..7827ab6e05d6 100644 --- a/cmuxTests/SentryEventScrubberTests.swift +++ b/Packages/Shared/CmuxSentryTelemetry/Tests/CmuxSentryReportingTests/SentryEventScrubberTests.swift @@ -1,12 +1,8 @@ -import CmuxFoundation +import CmuxSentryScrubbing import Sentry import Testing -#if canImport(cmux_DEV) -@testable import cmux_DEV -#elseif canImport(cmux) -@testable import cmux -#endif +@testable import CmuxSentryReporting /// Verifies that ``SentryEventScrubber`` routes every sensitive Sentry field /// through the scrubber while leaving grouping-relevant fields intact. @@ -193,66 +189,3 @@ import Testing #expect(scrubbed.exceptions?.first?.type == "EXC_BAD_INSTRUCTION") } } - -@Suite struct MacSentryStartupPolicyTests { - @Test func xctestLaunchDoesNotStartSentry() { - #expect( - MacSentryStartupPolicy( - telemetryEnabled: true, - isRunningUnderXCTest: true, - allowUnderXCTest: false - ).shouldStart == false - ) - } - - @Test func explicitTestTelemetryOptInStartsSentry() { - #expect( - MacSentryStartupPolicy( - telemetryEnabled: true, - isRunningUnderXCTest: true, - allowUnderXCTest: true - ).shouldStart == true - ) - } - - @Test func normalTelemetryEnabledLaunchStartsSentry() { - #expect( - MacSentryStartupPolicy( - telemetryEnabled: true, - isRunningUnderXCTest: false, - allowUnderXCTest: false - ).shouldStart == true - ) - } - - @Test func telemetryOptOutStillPreventsSentryStartup() { - #expect( - MacSentryStartupPolicy( - telemetryEnabled: false, - isRunningUnderXCTest: false, - allowUnderXCTest: false - ).shouldStart == false - ) - } - - @Test func explicitUITestMarkerPreventsSentryStartup() { - #expect( - MacSentryStartupPolicy( - environment: ["CMUX_UI_TEST_PROCESS": "1"], - telemetryEnabled: true - ).shouldStart == false - ) - } - - @Test func explicitTestTelemetryOptInOverridesUITestMarker() { - #expect( - MacSentryStartupPolicy( - environment: [ - "CMUX_UI_TEST_PROCESS": "1", - "CMUX_TEST_SENTRY_ENABLED": "1" - ], - telemetryEnabled: true - ).shouldStart == true - ) - } -} diff --git a/Packages/macOS/CmuxFoundation/Tests/CmuxFoundationTests/ScrubberDenylistsTests.swift b/Packages/Shared/CmuxSentryTelemetry/Tests/CmuxSentryScrubbingTests/ScrubberDenylistsTests.swift similarity index 99% rename from Packages/macOS/CmuxFoundation/Tests/CmuxFoundationTests/ScrubberDenylistsTests.swift rename to Packages/Shared/CmuxSentryTelemetry/Tests/CmuxSentryScrubbingTests/ScrubberDenylistsTests.swift index b0cadc337dab..21e5f7f8fab1 100644 --- a/Packages/macOS/CmuxFoundation/Tests/CmuxFoundationTests/ScrubberDenylistsTests.swift +++ b/Packages/Shared/CmuxSentryTelemetry/Tests/CmuxSentryScrubbingTests/ScrubberDenylistsTests.swift @@ -1,7 +1,7 @@ import Foundation import Testing -@testable import CmuxFoundation +@testable import CmuxSentryScrubbing /// Table-driven behavior tests for the maintained denylists ported into /// ``ScrubberDenylists``. diff --git a/Packages/macOS/CmuxFoundation/Tests/CmuxFoundationTests/SentryScrubberTests.swift b/Packages/Shared/CmuxSentryTelemetry/Tests/CmuxSentryScrubbingTests/SentryScrubberTests.swift similarity index 99% rename from Packages/macOS/CmuxFoundation/Tests/CmuxFoundationTests/SentryScrubberTests.swift rename to Packages/Shared/CmuxSentryTelemetry/Tests/CmuxSentryScrubbingTests/SentryScrubberTests.swift index 21a24c224782..f4c79af79607 100644 --- a/Packages/macOS/CmuxFoundation/Tests/CmuxFoundationTests/SentryScrubberTests.swift +++ b/Packages/Shared/CmuxSentryTelemetry/Tests/CmuxSentryScrubbingTests/SentryScrubberTests.swift @@ -1,7 +1,7 @@ import Foundation import Testing -@testable import CmuxFoundation +@testable import CmuxSentryScrubbing @Suite struct SentryScrubberTests { /// A scrubber with a fixed home directory so path redaction is deterministic. diff --git a/Packages/iOS/CmuxMobileCrashReporting/Package.swift b/Packages/iOS/CmuxMobileCrashReporting/Package.swift index 3544d9a16909..78049489de63 100644 --- a/Packages/iOS/CmuxMobileCrashReporting/Package.swift +++ b/Packages/iOS/CmuxMobileCrashReporting/Package.swift @@ -21,6 +21,7 @@ let package = Package( ], dependencies: [ .package(path: "../CmuxMobileAnalytics"), + .package(path: "../../Shared/CmuxSentryTelemetry"), .package( url: "https://github.com/getsentry/sentry-cocoa.git", .upToNextMajor(from: "9.3.0") @@ -31,6 +32,8 @@ let package = Package( name: "CmuxMobileCrashReporting", dependencies: [ "CmuxMobileAnalytics", + .product(name: "CmuxSentryScrubbing", package: "CmuxSentryTelemetry"), + .product(name: "CmuxSentryReporting", package: "CmuxSentryTelemetry"), .product(name: "Sentry", package: "sentry-cocoa"), ], swiftSettings: [ diff --git a/Sources/AppDelegate.swift b/Sources/AppDelegate.swift index f5faddec3e95..775c0f7d0ba5 100644 --- a/Sources/AppDelegate.swift +++ b/Sources/AppDelegate.swift @@ -26,6 +26,7 @@ import Combine import ObjectiveC.runtime import Darwin import CmuxFoundation +import CmuxSentryReporting import CmuxSidebar import CmuxGit diff --git a/cmux.xcodeproj/project.pbxproj b/cmux.xcodeproj/project.pbxproj index 770d332fdb08..697510c91310 100644 --- a/cmux.xcodeproj/project.pbxproj +++ b/cmux.xcodeproj/project.pbxproj @@ -737,6 +737,10 @@ C0DE71B10000000000000001 /* AppDelegate+AgentChatNotifications.swift in Sources A9F200000000000000000006 /* CmuxRuntimeDebugCaptureConfiguration.swift in Sources */ = {isa = PBXBuildFile; fileRef = A9F100000000000000000006 /* CmuxRuntimeDebugCaptureConfiguration.swift */; }; A9F20000000000000000001A /* CmuxRuntimeDebugCaptureSender.swift in Sources */ = {isa = PBXBuildFile; fileRef = A9F10000000000000000001A /* CmuxRuntimeDebugCaptureSender.swift */; }; A9F200000000000000000007 /* CmuxRuntimeDebugCaptureSequence.swift in Sources */ = {isa = PBXBuildFile; fileRef = A9F100000000000000000007 /* CmuxRuntimeDebugCaptureSequence.swift */; }; + CE57E10000000000000000B3 /* CmuxSentryReporting in Frameworks */ = {isa = PBXBuildFile; productRef = CE57E10000000000000000B2 /* CmuxSentryReporting */; }; + CE57E10000000000000000B4 /* CmuxSentryReporting in Frameworks */ = {isa = PBXBuildFile; productRef = CE57E10000000000000000B2 /* CmuxSentryReporting */; }; + CE57E10000000000000000A3 /* CmuxSentryScrubbing in Frameworks */ = {isa = PBXBuildFile; productRef = CE57E10000000000000000A2 /* CmuxSentryScrubbing */; }; + CE57E10000000000000000A4 /* CmuxSentryScrubbing in Frameworks */ = {isa = PBXBuildFile; productRef = CE57E10000000000000000A2 /* CmuxSentryScrubbing */; }; CD0CFE5300000000CD0CFE53 /* CmuxSettings in Frameworks */ = {isa = PBXBuildFile; productRef = CD0CFE5200000000CD0CFE52 /* CmuxSettings */; }; CD0CFE5700000000CD0CFE57 /* CmuxSettings in Frameworks */ = {isa = PBXBuildFile; productRef = CD0CFE5200000000CD0CFE52 /* CmuxSettings */; }; A7984AC10000000000000001 /* CmuxSettingsFileStore+Live.swift in Sources */ = {isa = PBXBuildFile; fileRef = A7984AC10000000000000002 /* CmuxSettingsFileStore+Live.swift */; }; @@ -1210,6 +1214,7 @@ C0DE71B10000000000000001 /* AppDelegate+AgentChatNotifications.swift in Sources D1F0A00500000000000000E1 /* MacPresenceDecisionCache.swift in Sources */ = {isa = PBXBuildFile; fileRef = D1F0A00500000000000000E2 /* MacPresenceDecisionCache.swift */; }; D1F0A00200000000000000B1 /* MacPresenceMonitor.swift in Sources */ = {isa = PBXBuildFile; fileRef = D1F0A00200000000000000B2 /* MacPresenceMonitor.swift */; }; A78C41010000000000000001 /* MacSentryStartupPolicy.swift in Sources */ = {isa = PBXBuildFile; fileRef = A78C41010000000000000002 /* MacSentryStartupPolicy.swift */; }; + 5E2701030000000000000001 /* MacSentryStartupPolicyTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5E2701030000000000000002 /* MacSentryStartupPolicyTests.swift */; }; 875900000000000000000009 /* MainThreadHangCaptureStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = 87590000000000000000000A /* MainThreadHangCaptureStore.swift */; }; 87590000000000000000000B /* MainThreadHangSampleRunner.swift in Sources */ = {isa = PBXBuildFile; fileRef = 87590000000000000000000C /* MainThreadHangSampleRunner.swift */; }; 875900000000000000000003 /* MainThreadHangWatchdog.swift in Sources */ = {isa = PBXBuildFile; fileRef = 875900000000000000000004 /* MainThreadHangWatchdog.swift */; }; @@ -1698,9 +1703,6 @@ C0DE71B10000000000000001 /* AppDelegate+AgentChatNotifications.swift in Sources 5E2701040000000000000003 /* Sentry in Frameworks */ = {isa = PBXBuildFile; productRef = 5E2701040000000000000001 /* Sentry */; }; A5001250 /* Sentry in Frameworks */ = {isa = PBXBuildFile; productRef = A5001251 /* Sentry */; }; B9000024A1B2C3D4E5F60719 /* Sentry in Frameworks */ = {isa = PBXBuildFile; productRef = A5001251 /* Sentry */; }; - 5E2701020000000000000001 /* SentryEventScrubber.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5E2701020000000000000002 /* SentryEventScrubber.swift */; }; - 5E2701020000000000000003 /* SentryEventScrubber.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5E2701020000000000000002 /* SentryEventScrubber.swift */; }; - 5E2701030000000000000001 /* SentryEventScrubberTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5E2701030000000000000002 /* SentryEventScrubberTests.swift */; }; A5001601 /* SentryHelper.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5001600 /* SentryHelper.swift */; }; FE003106 /* SessionAgentPresentation.swift in Sources */ = {isa = PBXBuildFile; fileRef = FE003006 /* SessionAgentPresentation.swift */; }; A9F200000000000000000008 /* SessionAgentSessionPanelSnapshot.swift in Sources */ = {isa = PBXBuildFile; fileRef = A9F100000000000000000008 /* SessionAgentSessionPanelSnapshot.swift */; }; @@ -3754,6 +3756,7 @@ C0DE71B10000000000000002 /* AppDelegate+AgentChatNotifications.swift */ = {isa = D1F0A00500000000000000E2 /* MacPresenceDecisionCache.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MacPresenceDecisionCache.swift; sourceTree = ""; }; D1F0A00200000000000000B2 /* MacPresenceMonitor.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MacPresenceMonitor.swift; sourceTree = ""; }; A78C41010000000000000002 /* MacSentryStartupPolicy.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = App/MacSentryStartupPolicy.swift; sourceTree = ""; }; + 5E2701030000000000000002 /* MacSentryStartupPolicyTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = MacSentryStartupPolicyTests.swift; sourceTree = ""; }; 87590000000000000000000A /* MainThreadHangCaptureStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MainThreadHangCaptureStore.swift; sourceTree = ""; }; 87590000000000000000000C /* MainThreadHangSampleRunner.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MainThreadHangSampleRunner.swift; sourceTree = ""; }; 875900000000000000000004 /* MainThreadHangWatchdog.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MainThreadHangWatchdog.swift; sourceTree = ""; }; @@ -4234,8 +4237,6 @@ C0DE71B10000000000000002 /* AppDelegate+AgentChatNotifications.swift */ = {isa = 3865B0013865B0013865B001 /* SearchIndex.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Search/SearchIndex.swift; sourceTree = ""; }; 3865B0043865B0043865B004 /* SearchIndexTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SearchIndexTests.swift; sourceTree = ""; }; C0DEF0C20000000000000002 /* SemanticVersion.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SemanticVersion.swift; sourceTree = ""; }; - 5E2701020000000000000002 /* SentryEventScrubber.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SentryEventScrubber.swift; sourceTree = ""; }; - 5E2701030000000000000002 /* SentryEventScrubberTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = SentryEventScrubberTests.swift; sourceTree = ""; }; A5001600 /* SentryHelper.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SentryHelper.swift; sourceTree = ""; }; FE003006 /* SessionAgentPresentation.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SessionAgentPresentation.swift; sourceTree = ""; }; A9F100000000000000000008 /* SessionAgentSessionPanelSnapshot.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SessionAgentSessionPanelSnapshot.swift; sourceTree = ""; }; @@ -5112,6 +5113,8 @@ C0DE71B10000000000000002 /* AppDelegate+AgentChatNotifications.swift */ = {isa = CC0DE10000000000000000A3 /* CmuxRemoteDaemon in Frameworks */, CC0DE30000000000000000A3 /* CmuxRemoteSession in Frameworks */, CC0DE20000000000000000A3 /* CmuxRemoteWorkspace in Frameworks */, + CE57E10000000000000000B3 /* CmuxSentryReporting in Frameworks */, + CE57E10000000000000000A3 /* CmuxSentryScrubbing in Frameworks */, CD0CFE5300000000CD0CFE53 /* CmuxSettings in Frameworks */, CD0CFE5600000000CD0CFE56 /* CmuxSettingsUI in Frameworks */, E3B7A30000000000000000B3 /* CmuxSidebar in Frameworks */, @@ -5154,6 +5157,8 @@ C0DE71B10000000000000002 /* AppDelegate+AgentChatNotifications.swift */ = {isa = A5B00004A1B2C3D4E5F60718 /* CMUXAgentLaunch in Frameworks */, C79470010000000000000005 /* CmuxControlSocket in Frameworks */, CF00F00000000000000000A4 /* CmuxFoundation in Frameworks */, + CE57E10000000000000000B4 /* CmuxSentryReporting in Frameworks */, + CE57E10000000000000000A4 /* CmuxSentryScrubbing in Frameworks */, CD0CFE5700000000CD0CFE57 /* CmuxSettings in Frameworks */, C5A1FED200000000000000E2 /* CmuxSidebarInterpreterClient in Frameworks */, C51A700000000000000000A5 /* CmuxSimulator in Frameworks */, @@ -5465,7 +5470,6 @@ C0DE71B10000000000000002 /* AppDelegate+AgentChatNotifications.swift */ = {isa = CA5CADF00000000000000001 /* CanvasDebugMenuButtons.swift */, C46790000000000000000004 /* CLIForwardingLaunchRouter.swift */, C46790000000000000000006 /* RosettaNativeRelaunch.swift */, - 5E2701020000000000000002 /* SentryEventScrubber.swift */, D9CCF001D9CCF001D9CCF001 /* SettingsLazyLoadHelpers.swift */, E3309A08 /* cmuxApp+EqualizeSplitsMenu.swift */, C4160A010000000000000002 /* cmuxApp+HistoryMenu.swift */, @@ -7739,7 +7743,7 @@ C0DE71B10000000000000002 /* AppDelegate+AgentChatNotifications.swift */ = {isa = C0DE73840000000000000002 /* MobileHostWorkspaceTicketAuthorizationTests.swift */, B8B056D80000000000000002 /* MobileHostIdentityTests.swift */, A7C0F0010000000000000001 /* MacPairedMacBackupPublisherScopeTests.swift */, - 5E2701030000000000000002 /* SentryEventScrubberTests.swift */, + 5E2701030000000000000002 /* MacSentryStartupPolicyTests.swift */, ); path = cmuxTests; sourceTree = ""; @@ -7834,6 +7838,8 @@ C0DE71B10000000000000002 /* AppDelegate+AgentChatNotifications.swift */ = {isa = CDFEED0200000000CDFEED02 /* CmuxUpdater */, CDFEED0500000000CDFEED05 /* CmuxUpdaterUI */, CF00F00000000000000000A2 /* CmuxFoundation */, + CE57E10000000000000000B2 /* CmuxSentryReporting */, + CE57E10000000000000000A2 /* CmuxSentryScrubbing */, CC0DE00000000000000000A2 /* CmuxCore */, CC0DE10000000000000000A2 /* CmuxRemoteDaemon */, CC0DE20000000000000000A2 /* CmuxRemoteWorkspace */, @@ -7868,6 +7874,8 @@ C0DE71B10000000000000002 /* AppDelegate+AgentChatNotifications.swift */ = {isa = C51A700000000000000000A2 /* CmuxSimulator */, CD0CFE5200000000CD0CFE52 /* CmuxSettings */, CF00F00000000000000000A2 /* CmuxFoundation */, + CE57E10000000000000000B2 /* CmuxSentryReporting */, + CE57E10000000000000000A2 /* CmuxSentryScrubbing */, ); productName = cmux; productReference = B9000004A1B2C3D4E5F60719 /* cmux */; @@ -8027,6 +8035,7 @@ C0DE71B10000000000000002 /* AppDelegate+AgentChatNotifications.swift */ = {isa = CDFEED0100000000CDFEED01 /* XCLocalSwiftPackageReference "CmuxUpdater" */, CDFEED0400000000CDFEED04 /* XCLocalSwiftPackageReference "CmuxUpdaterUI" */, CF00F00000000000000000A1 /* XCLocalSwiftPackageReference "CmuxFoundation" */, + CE57E10000000000000000A1 /* XCLocalSwiftPackageReference "CmuxSentryTelemetry" */, CC0DE00000000000000000A1 /* XCLocalSwiftPackageReference "CmuxCore" */, CC0DE10000000000000000A1 /* XCLocalSwiftPackageReference "CmuxRemoteDaemon" */, CC0DE20000000000000000A1 /* XCLocalSwiftPackageReference "CmuxRemoteWorkspace" */, @@ -9227,7 +9236,6 @@ C0DE71B10000000000000002 /* AppDelegate+AgentChatNotifications.swift */ = {isa = D1F0A00600000000000000F1 /* ScreenLockObserver.swift in Sources */, 3865A0013865A0013865A001 /* SearchIndex.swift in Sources */, C0DEF0C20000000000000001 /* SemanticVersion.swift in Sources */, - 5E2701020000000000000001 /* SentryEventScrubber.swift in Sources */, A5001601 /* SentryHelper.swift in Sources */, FE003106 /* SessionAgentPresentation.swift in Sources */, A9F200000000000000000008 /* SessionAgentSessionPanelSnapshot.swift in Sources */, @@ -9973,7 +9981,6 @@ C0DE71B10000000000000002 /* AppDelegate+AgentChatNotifications.swift */ = {isa = 835000000000000000000002 /* RemoteInitialCommandBootstrap.swift in Sources */, B9000028A1B2C3D4E5F60719 /* RemoteInteractiveShellBootstrapBuilder.swift in Sources */, B9000027A1B2C3D4E5F60719 /* RemoteRelayZshBootstrap.swift in Sources */, - 5E2701020000000000000003 /* SentryEventScrubber.swift in Sources */, C510C1E00000000000000002 /* SocketOperationTelemetry.swift in Sources */, B816E948EC5E42AF967648AC /* SSHPTYAttachReconnectInputFilter.swift in Sources */, E60610200000000000000002 /* SSHPTYAttachReconnectInputFilterControl.swift in Sources */, @@ -10376,6 +10383,7 @@ C0DE71B10000000000000002 /* AppDelegate+AgentChatNotifications.swift */ = {isa = 8582A1000000000000000002 /* KimiResumeReviewRegressionTests.swift in Sources */, 87C609D63F03597AC12C8B0A /* LastSurfaceClosePreferenceTests.swift in Sources */, A7C0F0010000000000000002 /* MacPairedMacBackupPublisherScopeTests.swift in Sources */, + 5E2701030000000000000001 /* MacSentryStartupPolicyTests.swift in Sources */, 6512F0C06512F0C06512F002 /* MainWindowFocusRestoreTests.swift in Sources */, 40ED33F6C1CEB26EAE7C9476 /* MainWindowSelfSizingTests.swift in Sources */, 17C9F5BA0DD14EDC8C3E5001 /* MainWindowVisibilityControllerTests.swift in Sources */, @@ -10518,7 +10526,6 @@ C0DE71B10000000000000002 /* AppDelegate+AgentChatNotifications.swift */ = {isa = C0DE1A080000000000000001 /* SavedLayoutDefinitionTests.swift in Sources */, C0DE1A070000000000000001 /* SavedLayoutStoreTests.swift in Sources */, 3865A0043865A0043865A004 /* SearchIndexTests.swift in Sources */, - 5E2701030000000000000001 /* SentryEventScrubberTests.swift in Sources */, C71510010000000000000001 /* SessionContentWidthSettingsFileStoreTests.swift in Sources */, 850000000000000000000004 /* SessionIndexJSONLReaderTests.swift in Sources */, 850000000000000000000006 /* SessionIndexSnapshotLoaderTests.swift in Sources */, @@ -11294,6 +11301,10 @@ C0DE71B10000000000000002 /* AppDelegate+AgentChatNotifications.swift */ = {isa = isa = XCLocalSwiftPackageReference; relativePath = Packages/macOS/CmuxFoundation; }; + CE57E10000000000000000A1 /* XCLocalSwiftPackageReference "CmuxSentryTelemetry" */ = { + isa = XCLocalSwiftPackageReference; + relativePath = Packages/Shared/CmuxSentryTelemetry; + }; /* End XCLocalSwiftPackageReference section */ /* Begin XCRemoteSwiftPackageReference section */ @@ -11551,6 +11562,16 @@ C0DE71B10000000000000002 /* AppDelegate+AgentChatNotifications.swift */ = {isa = package = CF00F00000000000000000A1 /* XCLocalSwiftPackageReference "CmuxFoundation" */; productName = CmuxFoundation; }; + CE57E10000000000000000A2 /* CmuxSentryScrubbing */ = { + isa = XCSwiftPackageProductDependency; + package = CE57E10000000000000000A1 /* XCLocalSwiftPackageReference "CmuxSentryTelemetry" */; + productName = CmuxSentryScrubbing; + }; + CE57E10000000000000000B2 /* CmuxSentryReporting */ = { + isa = XCSwiftPackageProductDependency; + package = CE57E10000000000000000A1 /* XCLocalSwiftPackageReference "CmuxSentryTelemetry" */; + productName = CmuxSentryReporting; + }; A5001231 /* Sparkle */ = { isa = XCSwiftPackageProductDependency; package = A5001232 /* XCRemoteSwiftPackageReference "Sparkle" */; diff --git a/cmux.xcworkspace/contents.xcworkspacedata b/cmux.xcworkspace/contents.xcworkspacedata index 3277e5479237..8e4a48c4fd2f 100644 --- a/cmux.xcworkspace/contents.xcworkspacedata +++ b/cmux.xcworkspace/contents.xcworkspacedata @@ -31,6 +31,9 @@ + + diff --git a/cmuxTests/MacSentryStartupPolicyTests.swift b/cmuxTests/MacSentryStartupPolicyTests.swift new file mode 100644 index 000000000000..2d399cbd0c0e --- /dev/null +++ b/cmuxTests/MacSentryStartupPolicyTests.swift @@ -0,0 +1,70 @@ +import Testing + +#if canImport(cmux_DEV) +@testable import cmux_DEV +#elseif canImport(cmux) +@testable import cmux +#endif + +@Suite struct MacSentryStartupPolicyTests { + @Test func xctestLaunchDoesNotStartSentry() { + #expect( + MacSentryStartupPolicy( + telemetryEnabled: true, + isRunningUnderXCTest: true, + allowUnderXCTest: false + ).shouldStart == false + ) + } + + @Test func explicitTestTelemetryOptInStartsSentry() { + #expect( + MacSentryStartupPolicy( + telemetryEnabled: true, + isRunningUnderXCTest: true, + allowUnderXCTest: true + ).shouldStart == true + ) + } + + @Test func normalTelemetryEnabledLaunchStartsSentry() { + #expect( + MacSentryStartupPolicy( + telemetryEnabled: true, + isRunningUnderXCTest: false, + allowUnderXCTest: false + ).shouldStart == true + ) + } + + @Test func telemetryOptOutStillPreventsSentryStartup() { + #expect( + MacSentryStartupPolicy( + telemetryEnabled: false, + isRunningUnderXCTest: false, + allowUnderXCTest: false + ).shouldStart == false + ) + } + + @Test func explicitUITestMarkerPreventsSentryStartup() { + #expect( + MacSentryStartupPolicy( + environment: ["CMUX_UI_TEST_PROCESS": "1"], + telemetryEnabled: true + ).shouldStart == false + ) + } + + @Test func explicitTestTelemetryOptInOverridesUITestMarker() { + #expect( + MacSentryStartupPolicy( + environment: [ + "CMUX_UI_TEST_PROCESS": "1", + "CMUX_TEST_SENTRY_ENABLED": "1" + ], + telemetryEnabled: true + ).shouldStart == true + ) + } +} From dec77fce135ce3944fcc3c5493931be5b8f9e984 Mon Sep 17 00:00:00 2001 From: Abdulaziz Albahar <67667005+azooz2003-bit@users.noreply.github.com> Date: Thu, 30 Jul 2026 23:55:07 -0700 Subject: [PATCH 2/5] Transport diagnostics core: DiagnosticLog tap, presentation, incident policy DiagnosticLog gains a single settable event tap delivered on the drain task (after ring retention, so selected-path dedup is respected and the hot-path record() stays untouched). DiagnosticEventPresentation decodes events into stable case names and per-code fields for telemetry sinks. Pure TransportIncidentPolicy turns the failure stream into a bounded set of reportable incidents: per-signature cooldown with coalesced counts, hourly capture budget, sustained-streak outage escalation, and suppression of attributable noise (cancelled/superseded churn, offline-while-unreachable, idle timeout while backgrounded). pairFail now records the classified DiagnosticFailureKind in its b slot so pairing failures group by cause. Co-Authored-By: Claude Fable 5 --- .../CMUXMobileCore/DiagnosticEventCode.swift | 2 +- .../DiagnosticEventPresentation.swift | 205 ++++++++++ .../CMUXMobileCore/DiagnosticLog.swift | 58 ++- .../TransportIncidentPolicy.swift | 357 ++++++++++++++++++ .../DiagnosticEventPresentationTests.swift | 102 +++++ .../DiagnosticLogTests.swift | 61 +++ .../TransportIncidentPolicyTests.swift | 188 +++++++++ .../MobileShellComposite.swift | 5 +- 8 files changed, 972 insertions(+), 6 deletions(-) create mode 100644 Packages/Shared/CMUXMobileCore/Sources/CMUXMobileCore/DiagnosticEventPresentation.swift create mode 100644 Packages/Shared/CMUXMobileCore/Sources/CMUXMobileCore/TransportIncidentPolicy.swift create mode 100644 Packages/Shared/CMUXMobileCore/Tests/CMUXMobileCoreTests/DiagnosticEventPresentationTests.swift create mode 100644 Packages/Shared/CMUXMobileCore/Tests/CMUXMobileCoreTests/TransportIncidentPolicyTests.swift diff --git a/Packages/Shared/CMUXMobileCore/Sources/CMUXMobileCore/DiagnosticEventCode.swift b/Packages/Shared/CMUXMobileCore/Sources/CMUXMobileCore/DiagnosticEventCode.swift index 0201b75e3659..e55d2fb7bc65 100644 --- a/Packages/Shared/CMUXMobileCore/Sources/CMUXMobileCore/DiagnosticEventCode.swift +++ b/Packages/Shared/CMUXMobileCore/Sources/CMUXMobileCore/DiagnosticEventCode.swift @@ -16,7 +16,7 @@ public enum DiagnosticEventCode: UInt16, Sendable, Codable, CaseIterable { case connect = 1 /// Pairing / attach completed successfully. case pairOk = 2 - /// Pairing / attach failed. + /// Pairing / attach failed. `b`, when present, is ``DiagnosticFailureKind``. case pairFail = 3 /// The render-grid stream lagged behind (a bounded render-lag counter tick). /// diff --git a/Packages/Shared/CMUXMobileCore/Sources/CMUXMobileCore/DiagnosticEventPresentation.swift b/Packages/Shared/CMUXMobileCore/Sources/CMUXMobileCore/DiagnosticEventPresentation.swift new file mode 100644 index 000000000000..98a94dc6c6f0 --- /dev/null +++ b/Packages/Shared/CMUXMobileCore/Sources/CMUXMobileCore/DiagnosticEventPresentation.swift @@ -0,0 +1,205 @@ +import Foundation + +/// Decodes a ``DiagnosticEvent`` into stable, human-readable names and fields +/// for telemetry sinks (Sentry breadcrumbs, structured logs) and debug UI. +/// +/// The compact ring export stays integer-only; this presentation layer is for +/// consumers that ship or display individual events and want them legible +/// without the offline decoder. Everything here is derived from the fixed +/// integer taxonomy, so the output is privacy-safe by construction: no free +/// text from errors, peers, accounts, or terminal content can appear. +/// +/// Case names are part of the telemetry vocabulary (Sentry issue grouping and +/// search keys use them), so renaming a taxonomy case is a breaking telemetry +/// change; ``DiagnosticEventPresentationTests`` pins the names. +public enum DiagnosticEventPresentation { + /// One decoded key/value pair of a described event. + public struct Field: Sendable, Equatable { + public let key: String + public let value: String + + public init(key: String, value: String) { + self.key = key + self.value = value + } + } + + /// A described event: a stable dotted name plus decoded payload fields. + public struct DescribedEvent: Sendable, Equatable { + /// The stable event name, e.g. `transportDialFailed`. + public let name: String + /// Decoded payload fields in a stable order. + public let fields: [Field] + + public init(name: String, fields: [Field]) { + self.name = name + self.fields = fields + } + } + + /// The stable name of an event code (its case name). + public static func name(_ code: DiagnosticEventCode) -> String { + String(describing: code) + } + + /// The stable name of a failure kind (its case name). + public static func name(_ kind: DiagnosticFailureKind) -> String { + String(describing: kind) + } + + /// The stable name of a transport kind (its case name). + public static func name(_ kind: DiagnosticTransportKind) -> String { + String(describing: kind) + } + + /// The stable name of a path kind (its case name). + public static func name(_ kind: DiagnosticPathKind) -> String { + String(describing: kind) + } + + /// The stable name of a session lifecycle kind (its case name). + public static func name(_ kind: DiagnosticSessionLifecycleKind) -> String { + String(describing: kind) + } + + /// The stable name of an app lifecycle phase (its case name). + public static func name(_ phase: DiagnosticAppLifecyclePhase) -> String { + String(describing: phase) + } + + /// The stable name of a runtime role (its case name). + public static func name(_ role: DiagnosticRuntimeRole) -> String { + String(describing: role) + } + + /// Decodes an event's payload slots per its code's documented semantics. + /// + /// Unknown raw values render as their integer so a newer writer's event + /// still describes usefully on an older reader. + public static func describe(_ event: DiagnosticEvent) -> DescribedEvent { + var fields: [Field] = [] + if let surface = event.surface { + fields.append(Field(key: "surface", value: String(surface))) + } + if let ms = event.ms { + fields.append(Field(key: msKey(for: event.code), value: String(ms))) + } + if let a = event.a { + fields.append(decodeA(a, code: event.code)) + } + if let b = event.b { + fields.append(decodeB(b, code: event.code)) + } + if let c = event.c { + fields.append(Field(key: cKey(for: event.code), value: String(c))) + } + return DescribedEvent(name: name(event.code), fields: fields) + } + + /// The failure kind carried in an event's `b` slot, when its code uses `b` + /// for ``DiagnosticFailureKind``. + public static func failureKind(of event: DiagnosticEvent) -> DiagnosticFailureKind? { + guard codesWithFailureB.contains(event.code), let b = event.b else { return nil } + return DiagnosticFailureKind(rawValue: b) + } + + /// The transport kind carried in an event's `a` slot, when its code uses + /// `a` for ``DiagnosticTransportKind``. + public static func transportKind(of event: DiagnosticEvent) -> DiagnosticTransportKind? { + guard codesWithTransportA.contains(event.code), let a = event.a else { return nil } + return DiagnosticTransportKind(rawValue: a) + } + + /// Event codes whose `b` slot carries a ``DiagnosticFailureKind``. + static let codesWithFailureB: Set = [ + .pairFail, .transportDialFailed, .recoveryFailed, .endpointFailed, + .relayPolicyRefreshFailed, .sessionClosed, .routeUnavailable, + .discoveryFailed, .admissionFailed, .hostAuthenticationFailed, + .rpcFailed, .transportCloseAttribution, + ] + + /// Event codes whose `a` slot carries a ``DiagnosticTransportKind``. + static let codesWithTransportA: Set = [ + .transportDialStarted, .transportDialConnected, .transportDialFailed, + .sessionClosed, + ] + + private static func msKey(for code: DiagnosticEventCode) -> String { + switch code { + case .retryScheduled: + return "delay_ms" + case .transportCloseAttribution: + return "app_error_code" + case .composerActiveTransition: + return "keyboard_height" + default: + return "ms" + } + } + + private static func cKey(for code: DiagnosticEventCode) -> String { + switch code { + case .transportDialStarted, .transportDialConnected, .transportDialFailed: + return "attempt_id" + case .sessionClosed, .transportSessionLifecycle, + .transportCloseAttribution, .transportPathEvent: + return "session_id" + default: + return "c" + } + } + + private static func decodeA(_ a: Int, code: DiagnosticEventCode) -> Field { + switch code { + case .transportDialStarted, .transportDialConnected, .transportDialFailed, + .sessionClosed: + return enumField(key: "transport", raw: a) { DiagnosticTransportKind(rawValue: $0).map(name) } + case .selectedPathChanged: + return enumField(key: "path", raw: a) { DiagnosticPathKind(rawValue: $0).map(name) } + case .transportSessionLifecycle: + return enumField(key: "lifecycle", raw: a) { DiagnosticSessionLifecycleKind(rawValue: $0).map(name) } + case .appLifecycleChanged: + return enumField(key: "phase", raw: a) { DiagnosticAppLifecyclePhase(rawValue: $0).map(name) } + case .reachabilityChanged: + return Field(key: "reachable", value: a == 1 ? "true" : "false") + case .transportCloseAttribution: + return enumField(key: "initiator", raw: a) { closeInitiatorNames[$0] } + case .transportPathEvent: + return enumField(key: "path_event", raw: a) { pathEventNames[$0] } + default: + return Field(key: "a", value: String(a)) + } + } + + private static func decodeB(_ b: Int, code: DiagnosticEventCode) -> Field { + if codesWithFailureB.contains(code) { + return enumField(key: "failure", raw: b) { DiagnosticFailureKind(rawValue: $0).map(name) } + } + switch code { + case .transportSessionLifecycle: + return Field(key: "purpose", value: String(b)) + case .transportPathEvent: + return enumField(key: "path", raw: b) { DiagnosticPathKind(rawValue: $0).map(name) } + default: + return Field(key: "b", value: String(b)) + } + } + + private static func enumField( + key: String, + raw: Int, + name: (Int) -> String? + ) -> Field { + Field(key: key, value: name(raw) ?? String(raw)) + } + + /// Close-initiator names for ``DiagnosticEventCode/transportCloseAttribution``'s `a`. + private static let closeInitiatorNames: [Int: String] = [ + 0: "unknown", 1: "local", 2: "remote", 3: "timedOut", + ] + + /// Path-event names for ``DiagnosticEventCode/transportPathEvent``'s `a`. + private static let pathEventNames: [Int: String] = [ + 1: "opened", 2: "closed", 3: "selected", 4: "lagged", + ] +} diff --git a/Packages/Shared/CMUXMobileCore/Sources/CMUXMobileCore/DiagnosticLog.swift b/Packages/Shared/CMUXMobileCore/Sources/CMUXMobileCore/DiagnosticLog.swift index fbc959b3ef4d..7d6a67462f46 100644 --- a/Packages/Shared/CMUXMobileCore/Sources/CMUXMobileCore/DiagnosticLog.swift +++ b/Packages/Shared/CMUXMobileCore/Sources/CMUXMobileCore/DiagnosticLog.swift @@ -45,6 +45,9 @@ public final class DiagnosticLog: Sendable { /// The inner actor owning the ring buffer and the wall-clock anchor. private let store: Store + /// The optional live observer, delivered retained events on the drain task. + private let tap: TapBox + /// The drain task. Its closure captures only local stream/store values, so /// deinitialization can finish ingress and let accepted clear commands drain /// to their acknowledgements without retaining this log. @@ -94,12 +97,16 @@ public final class DiagnosticLog: Sendable { commandContinuation: commandContinuation ) self.ingress = ingress + let tap = TapBox() + self.tap = tap self.drainTask = Task { for await command in commandStream { switch command { case let .events(events): for await event in events { - await store.append(event) + if await store.append(event) { + tap.deliver(event) + } } case let .clear( anchorWallNanos, @@ -113,7 +120,9 @@ public final class DiagnosticLog: Sendable { ) acknowledgement.resume() for await event in nextEvents { - await store.append(event) + if await store.append(event) { + tap.deliver(event) + } } } } @@ -124,6 +133,23 @@ public final class DiagnosticLog: Sendable { ingress.finish() } + /// Sets the single live event observer, replacing any previous one. + /// + /// The observer runs on the internal drain task, after the event is retained + /// in the ring, so it adds no work to the hot-path ``record(_:)`` call and + /// sees events in ring order. Events consumed but not retained (the repeated + /// ``DiagnosticEventCode/selectedPathChanged`` dedup) are not delivered. + /// Events recorded before the observer is set are not replayed; a consumer + /// that needs history snapshots the ring via ``export()`` or ``snapshot(generatedAt:)``. + /// + /// The observer must be fast and must not block: it shares the drain task + /// with ring appends. Forward into your own queue or task for slow work. + /// + /// - Parameter observer: The observer, or `nil` to remove the current one. + public func setEventTap(_ observer: (@Sendable (DiagnosticEvent) -> Void)?) { + tap.set(observer) + } + /// Record one event. Non-blocking and safe from any thread. /// /// This is the hot-path API. It only yields the value onto the buffered @@ -216,6 +242,25 @@ public final class DiagnosticLog: Sendable { ) } + /// Holds the settable live observer without retaining the log, so the drain + /// task can capture it while ``DiagnosticLog/deinit`` stays reachable. + private final class TapBox: Sendable { + // lint:allow lock - deliver runs on the drain task and set is rare; the + // critical region only reads or writes one closure reference. + private let observer = OSAllocatedUnfairLock<(@Sendable (DiagnosticEvent) -> Void)?>( + initialState: nil + ) + + func set(_ newObserver: (@Sendable (DiagnosticEvent) -> Void)?) { + observer.withLock { $0 = newObserver } + } + + func deliver(_ event: DiagnosticEvent) { + let current = observer.withLock { $0 } + current?(event) + } + } + /// Serializes event-segment rotation without suspending callers. Event /// segments use `.bufferingNewest(capacity)` and therefore stay bounded; /// the command stream is unbounded only for rare clear controls, which must @@ -356,10 +401,14 @@ public final class DiagnosticLog: Sendable { self.slots = Array(repeating: nil, count: clamped) } - func append(_ event: DiagnosticEvent) { + /// Appends one event, returning whether it was retained (`false` for the + /// repeated selected-path dedup) so the drain task can skip observer + /// delivery for events the ring itself discards. + @discardableResult + func append(_ event: DiagnosticEvent) -> Bool { totalProcessed += 1 if let nextPathKind = event.diagnosticPathKind { - guard nextPathKind != selectedPathKind else { return } + guard nextPathKind != selectedPathKind else { return false } selectedPathKind = nextPathKind } slots[head] = event @@ -367,6 +416,7 @@ public final class DiagnosticLog: Sendable { if filled < capacity { filled += 1 } + return true } func count() -> Int { diff --git a/Packages/Shared/CMUXMobileCore/Sources/CMUXMobileCore/TransportIncidentPolicy.swift b/Packages/Shared/CMUXMobileCore/Sources/CMUXMobileCore/TransportIncidentPolicy.swift new file mode 100644 index 000000000000..2aa7f6fde566 --- /dev/null +++ b/Packages/Shared/CMUXMobileCore/Sources/CMUXMobileCore/TransportIncidentPolicy.swift @@ -0,0 +1,357 @@ +public import Foundation + +/// Pure decision logic that turns the transport diagnostic event stream into a +/// bounded set of reportable incidents. +/// +/// Every diagnostic event is cheap to breadcrumb, but capturing a telemetry +/// *event* per failure would let one retry storm (dial failures every few +/// seconds for hours) flood the project. This policy decides, per event, whether +/// the failure deserves a capture now, coalesces repeats behind a per-signature +/// cooldown, enforces a global hourly budget, and escalates sustained +/// no-connectivity windows into a single high-severity outage incident. +/// +/// The type is a value: callers thread it through a lock or actor and feed it +/// events in ring order. Time comes from each event's monotonic `tNanos`, so +/// decisions are deterministic and fully unit-testable with synthesized events. +/// +/// Suppression rules encode what an operator can already attribute without a +/// report: failures classified ``DiagnosticFailureKind/cancelled`` or +/// ``DiagnosticFailureKind/superseded`` are lifecycle churn; +/// ``DiagnosticFailureKind/offline`` failures and pairing preflight +/// unreachability while the device reports no network path are expected; +/// ``DiagnosticFailureKind/transportIdleTimedOut`` while backgrounded is +/// suspension, not a defect. +public struct TransportIncidentPolicy: Sendable { + /// Tunable thresholds. Defaults are chosen so one broken user produces a + /// handful of well-grouped events per hour, not thousands. + public struct Configuration: Sendable { + /// Minimum interval between captures of the same signature. + public var signatureCooldown: TimeInterval + /// Sliding-window cap on failure captures (outage incidents are exempt + /// so escalation is never starved by its own precursors). + public var hourlyCaptureLimit: Int + /// Consecutive failure candidates (without an intervening success) + /// required before an outage incident fires. + public var outageFailureThreshold: Int + /// Minimum span between the first and latest failure of the streak + /// before an outage incident fires. + public var outageMinimumDuration: TimeInterval + /// After an outage fires, another cannot fire until this much time + /// passes or a success resets the streak. + public var outageRearmInterval: TimeInterval + + public init( + signatureCooldown: TimeInterval = 600, + hourlyCaptureLimit: Int = 30, + outageFailureThreshold: Int = 5, + outageMinimumDuration: TimeInterval = 60, + outageRearmInterval: TimeInterval = 3600 + ) { + self.signatureCooldown = signatureCooldown + self.hourlyCaptureLimit = hourlyCaptureLimit + self.outageFailureThreshold = outageFailureThreshold + self.outageMinimumDuration = outageMinimumDuration + self.outageRearmInterval = outageRearmInterval + } + } + + /// A reportable incident distilled from the event stream. + public struct Incident: Sendable, Equatable { + public enum Kind: Sendable, Equatable { + /// One failure signature crossed its capture gate. + case failure + /// A sustained streak of failures with no success escalated. + case outage + } + + public enum Severity: Sendable, Equatable { + case warning + case error + } + + /// Stable grouping fingerprint, e.g. + /// `transportDialFailed/policyUnavailable/iroh`. + public let signature: String + /// Human-readable one-line summary suitable as an event title. + public let title: String + public let kind: Kind + public let severity: Severity + /// The event that triggered the incident. + public let event: DiagnosticEvent + /// The decoded failure kind of the triggering event, when present. + public let failure: DiagnosticFailureKind? + /// The decoded transport kind of the triggering event, when present. + public let transport: DiagnosticTransportKind? + /// Occurrences of this signature coalesced into this capture (>= 1), + /// including the triggering one. + public let coalescedCount: Int + /// Seconds since the first coalesced occurrence, when repeats were + /// coalesced. + public let secondsSinceFirstCoalesced: Double? + /// Length of the current no-success failure streak, including this + /// event. + public let consecutiveFailures: Int + /// Seconds since the last success-classified event, when one was seen. + public let secondsSinceLastSuccess: Double? + /// Captures dropped by the hourly budget since the last allowed one. + public let droppedByBudget: Int + /// Last observed device reachability, when known. + public let reachable: Bool? + /// Last observed app lifecycle phase, when known. + public let appPhase: DiagnosticAppLifecyclePhase? + } + + public init(configuration: Configuration = Configuration()) { + self.configuration = configuration + } + + private let configuration: Configuration + + // MARK: Streak and environment state (event-time domain, nanoseconds). + + private var lastSuccessTNanos: UInt64? + private var streakCount = 0 + private var streakFirstTNanos: UInt64? + private var outageFiredTNanos: UInt64? + private var reachable: Bool? + private var appPhase: DiagnosticAppLifecyclePhase? + + /// Per-signature capture gate state. + private struct SignatureState { + var lastCaptureTNanos: UInt64 + var pendingCount: Int + var firstPendingTNanos: UInt64? + } + + private var signatureStates: [String: SignatureState] = [:] + + /// Capture timestamps inside the sliding hourly budget window. + private var captureWindow: [UInt64] = [] + private var droppedByBudget = 0 + + /// Event codes that mark the transport as healthy and reset the streak. + public static let successCodes: Set = [ + .pairOk, .transportDialConnected, .hostAuthenticated, .rpcReady, + .recoverySucceeded, .endpointActive, .relayPolicyRefreshSucceeded, + .discoverySucceeded, .admissionSucceeded, + ] + + /// Event codes that are failure candidates (subject to suppression rules). + public static let failureCodes: Set = [ + .pairFail, .pairUnreachable, .error, .transportDialFailed, + .recoveryFailed, .endpointFailed, .relayPolicyRefreshFailed, + .sessionClosed, .routeUnavailable, .discoveryFailed, .admissionFailed, + .hostAuthenticationFailed, .rpcFailed, + ] + + /// Feed one event, in ring order. Returns an incident when the event + /// crosses a capture gate; `nil` means breadcrumb-only. + public mutating func decide(_ event: DiagnosticEvent) -> Incident? { + switch event.code { + case .reachabilityChanged: + reachable = event.a.map { $0 == 1 } + return nil + case .appLifecycleChanged: + appPhase = event.a.flatMap(DiagnosticAppLifecyclePhase.init(rawValue:)) + return nil + default: + break + } + + if Self.successCodes.contains(event.code) { + lastSuccessTNanos = event.tNanos + streakCount = 0 + streakFirstTNanos = nil + outageFiredTNanos = nil + return nil + } + + guard Self.failureCodes.contains(event.code) else { return nil } + + let failure = failureKind(of: event) + guard isReportable(event: event, failure: failure) else { return nil } + + let transport = DiagnosticEventPresentation.transportKind(of: event) + let signature = Self.signature(code: event.code, failure: failure, transport: transport) + + streakCount += 1 + if streakFirstTNanos == nil { + streakFirstTNanos = event.tNanos + } + + if let outage = decideOutage(event: event, signature: signature, failure: failure, transport: transport) { + return outage + } + + return decideFailureCapture( + event: event, + signature: signature, + failure: failure, + transport: transport + ) + } + + /// The stable grouping fingerprint for a failure event. + static func signature( + code: DiagnosticEventCode, + failure: DiagnosticFailureKind?, + transport: DiagnosticTransportKind? + ) -> String { + var parts = [DiagnosticEventPresentation.name(code)] + if let failure { + parts.append(DiagnosticEventPresentation.name(failure)) + } + if let transport { + parts.append(DiagnosticEventPresentation.name(transport)) + } + return parts.joined(separator: "/") + } + + private func failureKind(of event: DiagnosticEvent) -> DiagnosticFailureKind? { + if let kind = DiagnosticEventPresentation.failureKind(of: event) { + return kind + } + if event.code == .pairUnreachable { + return .offline + } + return nil + } + + private func isReportable(event: DiagnosticEvent, failure: DiagnosticFailureKind?) -> Bool { + switch failure { + case .some(.none), .some(.cancelled), .some(.superseded): + // Expected lifecycle churn: an intentional close, a dial replaced by + // a newer attempt, or a cooperative cancellation. + return false + case .some(.offline): + // Offline failures while the device itself reports no network path + // are environmental, not diagnosable defects. When reachability is + // unknown or claims a usable path, an offline classification IS + // interesting (e.g. a stale local socket). + return reachable != false + case .some(.transportIdleTimedOut): + // Idle expiry while backgrounded is scene suspension. In the + // foreground it is a real defect (sessions dying under the user). + return appPhase != .background + case nil: + // Codes that never carry a failure kind (pairFail, error) stay + // reportable; sessionClosed without one is documented as expected. + return event.code != .sessionClosed + default: + return true + } + } + + private mutating func decideOutage( + event: DiagnosticEvent, + signature: String, + failure: DiagnosticFailureKind?, + transport: DiagnosticTransportKind? + ) -> Incident? { + guard streakCount >= configuration.outageFailureThreshold, + let firstTNanos = streakFirstTNanos, + elapsedSeconds(from: firstTNanos, to: event.tNanos) >= configuration.outageMinimumDuration + else { return nil } + if let fired = outageFiredTNanos, + elapsedSeconds(from: fired, to: event.tNanos) < configuration.outageRearmInterval { + return nil + } + outageFiredTNanos = event.tNanos + let duration = Int(elapsedSeconds(from: firstTNanos, to: event.tNanos).rounded()) + return Incident( + signature: "transport-outage", + title: "Transport outage: \(streakCount) consecutive failures over \(duration)s, latest \(signature)", + kind: .outage, + severity: .error, + event: event, + failure: failure, + transport: transport, + coalescedCount: streakCount, + secondsSinceFirstCoalesced: elapsedSeconds(from: firstTNanos, to: event.tNanos), + consecutiveFailures: streakCount, + secondsSinceLastSuccess: lastSuccessTNanos.map { elapsedSeconds(from: $0, to: event.tNanos) }, + droppedByBudget: 0, + reachable: reachable, + appPhase: appPhase + ) + } + + private mutating func decideFailureCapture( + event: DiagnosticEvent, + signature: String, + failure: DiagnosticFailureKind?, + transport: DiagnosticTransportKind? + ) -> Incident? { + if var state = signatureStates[signature] { + let sinceCapture = elapsedSeconds(from: state.lastCaptureTNanos, to: event.tNanos) + if sinceCapture < configuration.signatureCooldown { + state.pendingCount += 1 + if state.firstPendingTNanos == nil { + state.firstPendingTNanos = event.tNanos + } + signatureStates[signature] = state + return nil + } + } + + guard admitCaptureWithinBudget(at: event.tNanos) else { + var state = signatureStates[signature] + ?? SignatureState(lastCaptureTNanos: event.tNanos, pendingCount: 0, firstPendingTNanos: nil) + state.pendingCount += 1 + if state.firstPendingTNanos == nil { + state.firstPendingTNanos = event.tNanos + } + signatureStates[signature] = state + droppedByBudget += 1 + return nil + } + + let previous = signatureStates[signature] + let coalescedCount = (previous?.pendingCount ?? 0) + 1 + let firstCoalescedTNanos = previous?.firstPendingTNanos + signatureStates[signature] = SignatureState( + lastCaptureTNanos: event.tNanos, + pendingCount: 0, + firstPendingTNanos: nil + ) + let dropped = droppedByBudget + droppedByBudget = 0 + + var title = "Transport failure: \(signature)" + if coalescedCount > 1 { + title += " (\(coalescedCount)x)" + } + return Incident( + signature: signature, + title: title, + kind: .failure, + severity: .warning, + event: event, + failure: failure, + transport: transport, + coalescedCount: coalescedCount, + secondsSinceFirstCoalesced: firstCoalescedTNanos.map { + elapsedSeconds(from: $0, to: event.tNanos) + }, + consecutiveFailures: streakCount, + secondsSinceLastSuccess: lastSuccessTNanos.map { elapsedSeconds(from: $0, to: event.tNanos) }, + droppedByBudget: dropped, + reachable: reachable, + appPhase: appPhase + ) + } + + /// Sliding-window budget admission for failure captures. + private mutating func admitCaptureWithinBudget(at tNanos: UInt64) -> Bool { + let windowNanos: UInt64 = 3_600_000_000_000 + captureWindow.removeAll { tNanos >= $0 && tNanos - $0 > windowNanos } + guard captureWindow.count < configuration.hourlyCaptureLimit else { return false } + captureWindow.append(tNanos) + return true + } + + private func elapsedSeconds(from: UInt64, to: UInt64) -> Double { + guard to > from else { return 0 } + return Double(to - from) / 1_000_000_000 + } +} diff --git a/Packages/Shared/CMUXMobileCore/Tests/CMUXMobileCoreTests/DiagnosticEventPresentationTests.swift b/Packages/Shared/CMUXMobileCore/Tests/CMUXMobileCoreTests/DiagnosticEventPresentationTests.swift new file mode 100644 index 000000000000..f32538a1aead --- /dev/null +++ b/Packages/Shared/CMUXMobileCore/Tests/CMUXMobileCoreTests/DiagnosticEventPresentationTests.swift @@ -0,0 +1,102 @@ +import Foundation +import Testing +@testable import CMUXMobileCore + +@Suite struct DiagnosticEventPresentationTests { + /// Case names are shipped telemetry vocabulary (Sentry grouping keys), so a + /// rename is a breaking change this test makes visible. + @Test func pinsEventCodeNames() { + #expect(DiagnosticEventPresentation.name(DiagnosticEventCode.transportDialFailed) == "transportDialFailed") + #expect(DiagnosticEventPresentation.name(DiagnosticEventCode.endpointFailed) == "endpointFailed") + #expect(DiagnosticEventPresentation.name(DiagnosticEventCode.pairFail) == "pairFail") + #expect(DiagnosticEventPresentation.name(DiagnosticEventCode.sessionClosed) == "sessionClosed") + #expect(DiagnosticEventPresentation.name(DiagnosticEventCode.retryScheduled) == "retryScheduled") + #expect(DiagnosticEventPresentation.name(DiagnosticEventCode.hostAuthenticationFailed) == "hostAuthenticationFailed") + } + + @Test func pinsTaxonomyNames() { + #expect(DiagnosticEventPresentation.name(DiagnosticFailureKind.policyUnavailable) == "policyUnavailable") + #expect(DiagnosticEventPresentation.name(DiagnosticFailureKind.identityMismatch) == "identityMismatch") + #expect(DiagnosticEventPresentation.name(DiagnosticFailureKind.authorizationFailed) == "authorizationFailed") + #expect(DiagnosticEventPresentation.name(DiagnosticTransportKind.iroh) == "iroh") + #expect(DiagnosticEventPresentation.name(DiagnosticPathKind.relay) == "relay") + #expect(DiagnosticEventPresentation.name(DiagnosticRuntimeRole.mobileClient) == "mobileClient") + #expect(DiagnosticEventPresentation.name(DiagnosticAppLifecyclePhase.background) == "background") + } + + @Test func describesDialFailure() { + let event = DiagnosticEvent( + code: .transportDialFailed, + tNanos: 1, + a: DiagnosticTransportKind.iroh.rawValue, + b: DiagnosticFailureKind.policyUnavailable.rawValue, + c: 42 + ) + let described = DiagnosticEventPresentation.describe(event) + #expect(described.name == "transportDialFailed") + #expect(described.fields == [ + .init(key: "transport", value: "iroh"), + .init(key: "failure", value: "policyUnavailable"), + .init(key: "attempt_id", value: "42"), + ]) + } + + @Test func describesRetryDelayAndCloseAttribution() { + let retry = DiagnosticEventPresentation.describe( + DiagnosticEvent(code: .retryScheduled, tNanos: 1, ms: 32_331) + ) + #expect(retry.fields == [.init(key: "delay_ms", value: "32331")]) + + let close = DiagnosticEventPresentation.describe( + DiagnosticEvent( + code: .transportCloseAttribution, + tNanos: 1, + ms: 7, + a: 3, + b: DiagnosticFailureKind.transportIdleTimedOut.rawValue, + c: 9 + ) + ) + #expect(close.fields == [ + .init(key: "app_error_code", value: "7"), + .init(key: "initiator", value: "timedOut"), + .init(key: "failure", value: "transportIdleTimedOut"), + .init(key: "session_id", value: "9"), + ]) + } + + @Test func describesLifecycleAndReachability() { + let phase = DiagnosticEventPresentation.describe( + DiagnosticEvent(code: .appLifecycleChanged, tNanos: 1, a: DiagnosticAppLifecyclePhase.background.rawValue) + ) + #expect(phase.fields == [.init(key: "phase", value: "background")]) + + let reachability = DiagnosticEventPresentation.describe( + DiagnosticEvent(code: .reachabilityChanged, tNanos: 1, a: 0) + ) + #expect(reachability.fields == [.init(key: "reachable", value: "false")]) + } + + @Test func unknownRawValuesFallBackToIntegers() { + let described = DiagnosticEventPresentation.describe( + DiagnosticEvent(code: .transportDialFailed, tNanos: 1, a: 999, b: 998) + ) + #expect(described.fields == [ + .init(key: "transport", value: "999"), + .init(key: "failure", value: "998"), + ]) + } + + @Test func extractsFailureAndTransportKinds() { + let event = DiagnosticEvent( + code: .endpointFailed, + tNanos: 1, + b: DiagnosticFailureKind.identityMismatch.rawValue + ) + #expect(DiagnosticEventPresentation.failureKind(of: event) == .identityMismatch) + #expect(DiagnosticEventPresentation.transportKind(of: event) == nil) + + let success = DiagnosticEvent(code: .rpcReady, tNanos: 1, b: 3) + #expect(DiagnosticEventPresentation.failureKind(of: success) == nil) + } +} diff --git a/Packages/Shared/CMUXMobileCore/Tests/CMUXMobileCoreTests/DiagnosticLogTests.swift b/Packages/Shared/CMUXMobileCore/Tests/CMUXMobileCoreTests/DiagnosticLogTests.swift index 0feee4961b3b..2f2b0824a712 100644 --- a/Packages/Shared/CMUXMobileCore/Tests/CMUXMobileCoreTests/DiagnosticLogTests.swift +++ b/Packages/Shared/CMUXMobileCore/Tests/CMUXMobileCoreTests/DiagnosticLogTests.swift @@ -1,5 +1,6 @@ import Foundation import Testing +import os @testable import CMUXMobileCore @Suite struct DiagnosticLogTests { @@ -596,4 +597,64 @@ import Testing #expect(report.events.last?.tNanos == UInt64(maximum)) #expect(!report.events.contains(where: { $0.tNanos == 99_999 || $0.tNanos == 88_888 })) } + + @Test func eventTapDeliversRetainedEventsInOrder() async { + let log = DiagnosticLog(capacity: 8) + let received = OSAllocatedUnfairLock<[DiagnosticEvent]>(initialState: []) + log.setEventTap { event in + received.withLock { $0.append(event) } + } + + let first = DiagnosticEvent(code: .connect, tNanos: 1) + let second = DiagnosticEvent(code: .pairOk, tNanos: 2) + log.record(first) + log.record(second) + await waitForProcessed(log, 2) + + #expect(received.withLock { $0 } == [first, second]) + } + + @Test func eventTapSkipsDedupedSelectedPathRepeats() async { + let log = DiagnosticLog(capacity: 8) + let received = OSAllocatedUnfairLock<[DiagnosticEvent]>(initialState: []) + log.setEventTap { event in + received.withLock { $0.append(event) } + } + + let relay = DiagnosticEvent( + code: .selectedPathChanged, + tNanos: 1, + a: DiagnosticPathKind.relay.rawValue + ) + let repeatRelay = DiagnosticEvent( + code: .selectedPathChanged, + tNanos: 2, + a: DiagnosticPathKind.relay.rawValue + ) + log.record(relay) + log.record(repeatRelay) + await waitForProcessed(log, 2) + + #expect(received.withLock { $0 } == [relay]) + } + + @Test func eventTapDoesNotReplayHistoryAndCanBeRemoved() async { + let log = DiagnosticLog(capacity: 8) + log.record(DiagnosticEvent(code: .connect, tNanos: 1)) + await waitForProcessed(log, 1) + + let received = OSAllocatedUnfairLock<[DiagnosticEvent]>(initialState: []) + log.setEventTap { event in + received.withLock { $0.append(event) } + } + let live = DiagnosticEvent(code: .pairOk, tNanos: 2) + log.record(live) + await waitForProcessed(log, 2) + #expect(received.withLock { $0 } == [live]) + + log.setEventTap(nil) + log.record(DiagnosticEvent(code: .pairFail, tNanos: 3)) + await waitForProcessed(log, 3) + #expect(received.withLock { $0 } == [live]) + } } diff --git a/Packages/Shared/CMUXMobileCore/Tests/CMUXMobileCoreTests/TransportIncidentPolicyTests.swift b/Packages/Shared/CMUXMobileCore/Tests/CMUXMobileCoreTests/TransportIncidentPolicyTests.swift new file mode 100644 index 000000000000..efea8f1c7ae9 --- /dev/null +++ b/Packages/Shared/CMUXMobileCore/Tests/CMUXMobileCoreTests/TransportIncidentPolicyTests.swift @@ -0,0 +1,188 @@ +import Foundation +import Testing +@testable import CMUXMobileCore + +@Suite struct TransportIncidentPolicyTests { + private static let second: UInt64 = 1_000_000_000 + + private func dialFailed( + at seconds: UInt64, + failure: DiagnosticFailureKind = .policyUnavailable, + transport: DiagnosticTransportKind = .iroh + ) -> DiagnosticEvent { + DiagnosticEvent( + code: .transportDialFailed, + tNanos: seconds * Self.second, + a: transport.rawValue, + b: failure.rawValue, + c: 1 + ) + } + + private func connected(at seconds: UInt64) -> DiagnosticEvent { + DiagnosticEvent(code: .transportDialConnected, tNanos: seconds * Self.second, a: 1, c: 1) + } + + @Test func firstFailureCaptures() { + var policy = TransportIncidentPolicy() + let incident = policy.decide(dialFailed(at: 10)) + #expect(incident?.kind == .failure) + #expect(incident?.severity == .warning) + #expect(incident?.signature == "transportDialFailed/policyUnavailable/iroh") + #expect(incident?.coalescedCount == 1) + #expect(incident?.consecutiveFailures == 1) + } + + @Test func repeatWithinCooldownCoalesces() { + var policy = TransportIncidentPolicy() + #expect(policy.decide(dialFailed(at: 10)) != nil) + #expect(policy.decide(dialFailed(at: 20)) == nil) + #expect(policy.decide(dialFailed(at: 30)) == nil) + // Past the 600s cooldown the next occurrence captures, carrying the + // two coalesced repeats. + let recapture = policy.decide(dialFailed(at: 700)) + #expect(recapture != nil) + #expect(recapture?.coalescedCount == 3) + #expect(recapture?.secondsSinceFirstCoalesced == 680) + } + + @Test func distinctSignaturesCaptureIndependently() { + var policy = TransportIncidentPolicy() + #expect(policy.decide(dialFailed(at: 10, failure: .policyUnavailable)) != nil) + #expect(policy.decide(dialFailed(at: 11, failure: .identityMismatch)) != nil) + #expect(policy.decide(dialFailed(at: 12, failure: .policyUnavailable)) == nil) + } + + @Test func benignFailureKindsAreSuppressed() { + var policy = TransportIncidentPolicy() + #expect(policy.decide(dialFailed(at: 10, failure: .cancelled)) == nil) + #expect(policy.decide(dialFailed(at: 11, failure: .superseded)) == nil) + #expect(policy.decide(dialFailed(at: 12, failure: .none)) == nil) + } + + @Test func offlineSuppressedOnlyWhileUnreachable() { + var policy = TransportIncidentPolicy() + _ = policy.decide(DiagnosticEvent(code: .reachabilityChanged, tNanos: 1, a: 0)) + #expect(policy.decide(dialFailed(at: 10, failure: .offline)) == nil) + _ = policy.decide(DiagnosticEvent(code: .reachabilityChanged, tNanos: 11 * Self.second, a: 1)) + #expect(policy.decide(dialFailed(at: 12, failure: .offline)) != nil) + } + + @Test func offlineReportedWhenReachabilityUnknown() { + var policy = TransportIncidentPolicy() + #expect(policy.decide(dialFailed(at: 10, failure: .offline)) != nil) + } + + @Test func idleTimeoutSuppressedInBackground() { + var policy = TransportIncidentPolicy() + _ = policy.decide(DiagnosticEvent( + code: .appLifecycleChanged, + tNanos: 1, + a: DiagnosticAppLifecyclePhase.background.rawValue + )) + let backgrounded = DiagnosticEvent( + code: .sessionClosed, + tNanos: 10 * Self.second, + a: DiagnosticTransportKind.iroh.rawValue, + b: DiagnosticFailureKind.transportIdleTimedOut.rawValue + ) + #expect(policy.decide(backgrounded) == nil) + + _ = policy.decide(DiagnosticEvent( + code: .appLifecycleChanged, + tNanos: 11 * Self.second, + a: DiagnosticAppLifecyclePhase.active.rawValue + )) + let foregrounded = DiagnosticEvent( + code: .sessionClosed, + tNanos: 12 * Self.second, + a: DiagnosticTransportKind.iroh.rawValue, + b: DiagnosticFailureKind.transportIdleTimedOut.rawValue + ) + #expect(policy.decide(foregrounded) != nil) + } + + @Test func expectedSessionCloseIsSuppressed() { + var policy = TransportIncidentPolicy() + let close = DiagnosticEvent(code: .sessionClosed, tNanos: Self.second, a: 1, c: 3) + #expect(policy.decide(close) == nil) + } + + @Test func pairFailWithoutKindStillCaptures() { + var policy = TransportIncidentPolicy() + let incident = policy.decide(DiagnosticEvent(code: .pairFail, tNanos: Self.second)) + #expect(incident?.signature == "pairFail") + } + + @Test func successResetsStreakAndCooldownKeepsCounting() { + var policy = TransportIncidentPolicy() + #expect(policy.decide(dialFailed(at: 10))?.consecutiveFailures == 1) + #expect(policy.decide(dialFailed(at: 20)) == nil) + _ = policy.decide(connected(at: 30)) + // New failure after a success starts a fresh streak but stays inside + // the signature cooldown, so it coalesces rather than captures. + #expect(policy.decide(dialFailed(at: 40)) == nil) + let recapture = policy.decide(dialFailed(at: 700)) + #expect(recapture?.consecutiveFailures == 2) + #expect(recapture?.secondsSinceLastSuccess == 670) + } + + @Test func outageEscalatesAfterThresholdAndDuration() { + var policy = TransportIncidentPolicy() + #expect(policy.decide(dialFailed(at: 10))?.kind == .failure) + #expect(policy.decide(dialFailed(at: 20)) == nil) + #expect(policy.decide(dialFailed(at: 30)) == nil) + #expect(policy.decide(dialFailed(at: 40)) == nil) + // 5th consecutive failure, 60s after the first: outage fires even + // though the signature is inside its cooldown. + let outage = policy.decide(dialFailed(at: 70)) + #expect(outage?.kind == .outage) + #expect(outage?.severity == .error) + #expect(outage?.signature == "transport-outage") + #expect(outage?.consecutiveFailures == 5) + + // While the outage is armed-off, further failures stay quiet. + #expect(policy.decide(dialFailed(at: 80)) == nil) + + // A success re-arms; a new sustained streak fires a new outage. + _ = policy.decide(connected(at: 100)) + var last: TransportIncidentPolicy.Incident? + for t in stride(from: UInt64(4000), through: 4080, by: 20) { + last = policy.decide(dialFailed(at: t)) ?? last + } + #expect(last?.kind == .outage) + } + + @Test func hourlyBudgetDropsAndReports() { + var policy = TransportIncidentPolicy( + configuration: .init( + signatureCooldown: 0, + hourlyCaptureLimit: 2, + outageFailureThreshold: 100, + outageMinimumDuration: 10_000 + ) + ) + #expect(policy.decide(dialFailed(at: 10)) != nil) + #expect(policy.decide(dialFailed(at: 20)) != nil) + #expect(policy.decide(dialFailed(at: 30)) == nil) + #expect(policy.decide(dialFailed(at: 40)) == nil) + // Window slides: the first two captures age out after an hour, and the + // next capture reports what the budget dropped. + let later = policy.decide(dialFailed(at: 10 + 3700)) + #expect(later != nil) + #expect(later?.droppedByBudget == 2) + } + + @Test func environmentRidesOnIncidents() { + var policy = TransportIncidentPolicy() + _ = policy.decide(DiagnosticEvent(code: .reachabilityChanged, tNanos: 1, a: 1)) + _ = policy.decide(DiagnosticEvent( + code: .appLifecycleChanged, + tNanos: 2, + a: DiagnosticAppLifecyclePhase.active.rawValue + )) + let incident = policy.decide(dialFailed(at: 10)) + #expect(incident?.reachable == true) + #expect(incident?.appPhase == .active) + } +} diff --git a/Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite.swift b/Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite.swift index 71a716f28b90..36b089dfe901 100644 --- a/Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite.swift +++ b/Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite.swift @@ -8231,7 +8231,10 @@ public final class MobileShellComposite: MobileTerminalOutputSinking { await client.disconnect() } - diagnosticLog?.record(DiagnosticEvent(.pairFail)) + diagnosticLog?.record(DiagnosticEvent( + .pairFail, + b: Self.diagnosticFailureKind(for: lastError).rawValue + )) diagnosticLog?.record(DiagnosticEvent( .rpcFailed, a: activeRoute.map { DiagnosticTransportKind($0.kind).rawValue } From d770055162bc258c8bb769e15cdb9f0e37ec6685 Mon Sep 17 00:00:00 2001 From: Abdulaziz Albahar <67667005+azooz2003-bit@users.noreply.github.com> Date: Fri, 31 Jul 2026 01:23:37 -0700 Subject: [PATCH 3/5] Bridge transport diagnostics into Sentry on iOS and macOS TransportSentryReporter (CmuxSentryReporting) consumes the DiagnosticLog tap: every retained event becomes a scrubbed breadcrumb and a budget-limited structured log line, and failures that cross TransportIncidentPolicy's gates become Sentry events fingerprinted by code/failure/transport signature with the compact diagnostic ring export attached, so one issue carries the full connection timeline that previously had to be pulled off the device by hand. iOS gains the shared last-mile scrubber it was waiting on: beforeSend now scrubs (in addition to the consent gate), beforeBreadcrumb and beforeSendLog are installed, and enableLogs is on; swizzling and automatic network capture stay off. macOS enables logs, scrubs them, and taps the Mac host's hostDiagnosticLog with role macHost after SentrySDK.start. Co-Authored-By: Claude Fable 5 --- .../Shared/CmuxSentryTelemetry/Package.swift | 14 +- .../SentryEventScrubber.swift | 30 +++ .../TransportSentryReporter.swift | 240 ++++++++++++++++++ .../TransportTelemetryLogBudget.swift | 35 +++ .../SentryEventScrubberTests.swift | 19 ++ .../TransportSentryReporterTests.swift | 217 ++++++++++++++++ .../CmuxMobileCrashReporting/Package.resolved | 6 +- .../MobileCrashReporter.swift | 46 ++-- .../MobileCrashReporterTests.swift | 57 +++++ Sources/AppDelegate.swift | 27 +- docs/transport-sentry-diagnostics.md | 58 +++++ ios/cmux/AppCompositionRoot.swift | 18 ++ 12 files changed, 742 insertions(+), 25 deletions(-) create mode 100644 Packages/Shared/CmuxSentryTelemetry/Sources/CmuxSentryReporting/TransportSentryReporter.swift create mode 100644 Packages/Shared/CmuxSentryTelemetry/Sources/CmuxSentryReporting/TransportTelemetryLogBudget.swift create mode 100644 Packages/Shared/CmuxSentryTelemetry/Tests/CmuxSentryReportingTests/TransportSentryReporterTests.swift create mode 100644 docs/transport-sentry-diagnostics.md diff --git a/Packages/Shared/CmuxSentryTelemetry/Package.swift b/Packages/Shared/CmuxSentryTelemetry/Package.swift index adb1a9a154a1..c38d0d8e563a 100644 --- a/Packages/Shared/CmuxSentryTelemetry/Package.swift +++ b/Packages/Shared/CmuxSentryTelemetry/Package.swift @@ -2,11 +2,13 @@ import PackageDescription -// `CmuxSentryTelemetry` is the shared (macOS + iOS) Sentry privacy layer. -// `CmuxSentryScrubbing` is the pure-Foundation value scrubber (no Sentry -// dependency) so it stays testable without linking the SDK; `CmuxSentryReporting` -// is the thin glue that routes Sentry `Event` / `Breadcrumb` / `Span` fields -// through that scrubber and therefore links the Sentry SDK. +// `CmuxSentryTelemetry` is the shared (macOS + iOS) Sentry privacy and +// transport-telemetry layer. `CmuxSentryScrubbing` is the pure-Foundation +// value scrubber (no Sentry dependency) so it stays testable without linking +// the SDK; `CmuxSentryReporting` is the glue that routes Sentry `Event` / +// `Breadcrumb` / `Span` / `SentryLog` fields through that scrubber and bridges +// the CMUXMobileCore transport diagnostic stream into Sentry, so it links the +// Sentry SDK. let package = Package( name: "CmuxSentryTelemetry", platforms: [ @@ -24,6 +26,7 @@ let package = Package( ), ], dependencies: [ + .package(path: "../CMUXMobileCore"), .package( url: "https://github.com/getsentry/sentry-cocoa.git", .upToNextMajor(from: "9.3.0") @@ -42,6 +45,7 @@ let package = Package( name: "CmuxSentryReporting", dependencies: [ "CmuxSentryScrubbing", + "CMUXMobileCore", .product(name: "Sentry", package: "sentry-cocoa"), ], swiftSettings: [ diff --git a/Packages/Shared/CmuxSentryTelemetry/Sources/CmuxSentryReporting/SentryEventScrubber.swift b/Packages/Shared/CmuxSentryTelemetry/Sources/CmuxSentryReporting/SentryEventScrubber.swift index 5932a50ad086..f14beff6084a 100644 --- a/Packages/Shared/CmuxSentryTelemetry/Sources/CmuxSentryReporting/SentryEventScrubber.swift +++ b/Packages/Shared/CmuxSentryTelemetry/Sources/CmuxSentryReporting/SentryEventScrubber.swift @@ -154,6 +154,36 @@ public struct SentryEventScrubber: Sendable { return span } + /// Redacts a structured log line's body and string attributes in place. + /// + /// Suitable inside `beforeSendLog`. Attribute values typed as strings (and + /// string arrays) go through the free-text scrubber; keys named like + /// secrets are redacted wholesale via the key-aware dictionary rules. + /// Numeric and boolean attributes cannot carry free text and pass through. + /// + /// - Parameter log: The log entry Sentry is about to send. + /// - Returns: The scrubbed log entry. + @discardableResult + public func scrub(_ log: SentryLog) -> SentryLog { + log.body = scrubber.scrub(log.body) + // Collect string-typed attribute values and route them through the + // key-aware dictionary scrubber, so a secret-like key is redacted by + // name and a free-text value by pattern, matching tags/extra handling. + var stringValues: [String: Any] = [:] + for (key, attribute) in log.attributes { + if let value = attribute.value as? String { + stringValues[key] = value + } + } + guard !stringValues.isEmpty else { return log } + for (key, value) in scrubber.scrub(dictionary: stringValues) { + if let scrubbed = value as? String { + log.setAttribute(SentryAttribute(string: scrubbed), forKey: key) + } + } + return log + } + /// Rebuilds a message with its rendered text, template, and params scrubbed. /// /// `SentryMessage.formatted` is read-only, so the message must be rebuilt diff --git a/Packages/Shared/CmuxSentryTelemetry/Sources/CmuxSentryReporting/TransportSentryReporter.swift b/Packages/Shared/CmuxSentryTelemetry/Sources/CmuxSentryReporting/TransportSentryReporter.swift new file mode 100644 index 000000000000..7fdf6f45c7bd --- /dev/null +++ b/Packages/Shared/CmuxSentryTelemetry/Sources/CmuxSentryReporting/TransportSentryReporter.swift @@ -0,0 +1,240 @@ +public import CMUXMobileCore +public import Foundation +public import Sentry +internal import os + +/// Bridges the transport diagnostic event stream into Sentry so any user's +/// connection failure is diagnosable remotely, on both the iOS client and the +/// macOS host. +/// +/// Wire an instance as the ``CMUXMobileCore/DiagnosticLog`` event tap from the +/// composition root. Each retained event becomes: +/// +/// 1. A Sentry **breadcrumb** (category `transport`), so every subsequent +/// event — including crashes, hangs, and watchdog kills — carries the +/// recent connection timeline. +/// 2. A budget-limited Sentry **structured log** line (when the SDK started +/// with `enableLogs`), searchable without waiting for an error. +/// 3. When it crosses ``CMUXMobileCore/TransportIncidentPolicy``'s capture +/// gates, a Sentry **event** fingerprinted by the failure signature and +/// carrying the compact diagnostic ring export as an attachment — the same +/// `cmuxdiag v1` blob that previously had to be pulled off the device by +/// hand. +/// +/// Privacy: everything sent derives from the fixed integer diagnostic +/// taxonomy, so no free text, peer identity, address, account, or terminal +/// content can appear. The SDK-level scrubbers still run over all of it. +/// +/// `ingest(_:)` is called on the diagnostic ring's drain task; it does its +/// synchronous work (breadcrumb, log, policy decision) inline and defers the +/// ring export + event capture to a task so the drain is never blocked. +public final class TransportSentryReporter: Sendable { + /// Delivery seams to the Sentry SDK, injectable for tests. + public struct Delivery: Sendable { + /// Whether telemetry is currently deliverable (SDK started, consent on). + public var isEnabled: @Sendable () -> Bool + /// Records one breadcrumb. + public var addBreadcrumb: @Sendable (Breadcrumb) -> Void + /// Captures one event with an optional attachment. + public var capture: @Sendable (Event, Attachment?) -> Void + /// Emits one structured log line. + public var log: @Sendable (LogLevel, String, [String: Any]) -> Void + + public init( + isEnabled: @escaping @Sendable () -> Bool, + addBreadcrumb: @escaping @Sendable (Breadcrumb) -> Void, + capture: @escaping @Sendable (Event, Attachment?) -> Void, + log: @escaping @Sendable (LogLevel, String, [String: Any]) -> Void + ) { + self.isEnabled = isEnabled + self.addBreadcrumb = addBreadcrumb + self.capture = capture + self.log = log + } + + /// The production delivery, talking to the live `SentrySDK`. + public static func sentry() -> Delivery { + Delivery( + isEnabled: { SentrySDK.isEnabled }, + addBreadcrumb: { SentrySDK.addBreadcrumb($0) }, + capture: { event, attachment in + SentrySDK.capture(event: event) { scope in + if let attachment { + scope.addAttachment(attachment) + } + } + }, + log: { level, message, attributes in + switch level { + case .info: + SentrySDK.logger.info(message, attributes: attributes) + case .warning: + SentrySDK.logger.warn(message, attributes: attributes) + case .error: + SentrySDK.logger.error(message, attributes: attributes) + } + } + ) + } + } + + /// Structured-log severity, decoupled from Sentry's type so tests need no SDK. + public enum LogLevel: Sendable, Equatable { + case info + case warning + case error + } + + private struct MutableState: Sendable { + var policy: TransportIncidentPolicy + var logBudget: TransportTelemetryLogBudget + } + + private let role: DiagnosticRuntimeRole + private let roleName: String + private let exportRing: @Sendable () async -> Data + private let delivery: Delivery + // lint:allow lock - ingest is synchronous on the diagnostic drain task; the + // critical region only advances the pure policy/budget state machines. + private let state: OSAllocatedUnfairLock + + /// Creates a reporter. + /// + /// - Parameters: + /// - role: The producing runtime (`mobileClient` on iOS, `macHost` on + /// macOS); rides as a tag and fingerprint component. + /// - exportRing: Snapshot of the diagnostic ring's compact export, + /// attached to captured incidents. Pass the owning log's `export`. + /// - incidentConfiguration: Capture-gate thresholds. + /// - logsPerHour: Sliding-hour budget for structured log lines. + /// - delivery: SDK seams; defaults to the live Sentry SDK. + public init( + role: DiagnosticRuntimeRole, + exportRing: @escaping @Sendable () async -> Data, + incidentConfiguration: TransportIncidentPolicy.Configuration = .init(), + logsPerHour: Int = 300, + delivery: Delivery = .sentry() + ) { + self.role = role + self.roleName = DiagnosticEventPresentation.name(role) + self.exportRing = exportRing + self.delivery = delivery + self.state = OSAllocatedUnfairLock(initialState: MutableState( + policy: TransportIncidentPolicy(configuration: incidentConfiguration), + logBudget: TransportTelemetryLogBudget(capacityPerHour: logsPerHour) + )) + } + + /// Ingests one diagnostic event, in ring order. Safe to install directly + /// as ``CMUXMobileCore/DiagnosticLog/setEventTap(_:)``'s observer. + public func ingest(_ event: DiagnosticEvent) { + guard delivery.isEnabled() else { return } + + let described = DiagnosticEventPresentation.describe(event) + let isFailure = TransportIncidentPolicy.failureCodes.contains(event.code) + + let (incident, logDropCount) = state.withLock { state in + (state.policy.decide(event), state.logBudget.admit(tNanos: event.tNanos)) + } + + deliverBreadcrumb(described, isFailure: isFailure) + if let logDropCount { + deliverLog(described, isFailure: isFailure, droppedBeforeThis: logDropCount) + } + if let incident { + captureIncident(incident) + } + } + + private func deliverBreadcrumb( + _ described: DiagnosticEventPresentation.DescribedEvent, + isFailure: Bool + ) { + let crumb = Breadcrumb(level: isFailure ? .warning : .info, category: "transport") + crumb.type = isFailure ? "error" : "default" + crumb.message = described.name + if !described.fields.isEmpty { + var data: [String: Any] = [:] + for field in described.fields { + data[field.key] = field.value + } + crumb.data = data + } + delivery.addBreadcrumb(crumb) + } + + private func deliverLog( + _ described: DiagnosticEventPresentation.DescribedEvent, + isFailure: Bool, + droppedBeforeThis: Int + ) { + var attributes: [String: Any] = ["transport.role": roleName] + for field in described.fields { + attributes["transport.\(field.key)"] = field.value + } + if droppedBeforeThis > 0 { + attributes["transport.log_dropped_before_this"] = droppedBeforeThis + } + delivery.log(isFailure ? .warning : .info, "transport.\(described.name)", attributes) + } + + private func captureIncident(_ incident: TransportIncidentPolicy.Incident) { + Task.detached(priority: .utility) { [self] in + let ring = await exportRing() + let attachment = ring.isEmpty ? nil : Attachment( + data: ring, + filename: "cmux-transport-diag.txt", + contentType: "text/plain" + ) + delivery.capture(makeEvent(incident), attachment) + } + } + + /// Builds the Sentry event for an incident. Grouping comes from the + /// explicit fingerprint (role + policy signature), never from the message, + /// so coalesced-count suffixes cannot split issues. + private nonisolated func makeEvent(_ incident: TransportIncidentPolicy.Incident) -> Event { + let event = Event(level: incident.severity == .error ? .error : .warning) + event.message = SentryMessage(formatted: incident.title) + event.logger = "cmux.transport" + event.fingerprint = ["cmux-transport", roleName, incident.signature] + + var tags: [String: String] = [ + "transport.event": DiagnosticEventPresentation.name(incident.event.code), + "transport.signature": incident.signature, + "transport.role": roleName, + "transport.incident": incident.kind == .outage ? "outage" : "failure", + ] + if let failure = incident.failure { + tags["transport.failure"] = DiagnosticEventPresentation.name(failure) + } + if let transport = incident.transport { + tags["transport.kind"] = DiagnosticEventPresentation.name(transport) + } + event.tags = tags + + var context: [String: Any] = [ + "coalesced_count": incident.coalescedCount, + "consecutive_failures": incident.consecutiveFailures, + "dropped_by_budget": incident.droppedByBudget, + ] + if let seconds = incident.secondsSinceFirstCoalesced { + context["seconds_since_first_coalesced"] = Int(seconds.rounded()) + } + if let seconds = incident.secondsSinceLastSuccess { + context["seconds_since_last_success"] = Int(seconds.rounded()) + } + if let reachable = incident.reachable { + context["reachable"] = reachable + } + if let phase = incident.appPhase { + context["app_phase"] = DiagnosticEventPresentation.name(phase) + } + let described = DiagnosticEventPresentation.describe(incident.event) + for field in described.fields { + context["event_\(field.key)"] = field.value + } + event.context = ["cmux.transport": context] + return event + } +} diff --git a/Packages/Shared/CmuxSentryTelemetry/Sources/CmuxSentryReporting/TransportTelemetryLogBudget.swift b/Packages/Shared/CmuxSentryTelemetry/Sources/CmuxSentryReporting/TransportTelemetryLogBudget.swift new file mode 100644 index 000000000000..c820751668fb --- /dev/null +++ b/Packages/Shared/CmuxSentryTelemetry/Sources/CmuxSentryReporting/TransportTelemetryLogBudget.swift @@ -0,0 +1,35 @@ +import Foundation + +/// A sliding-window admission budget for structured transport log lines. +/// +/// Sentry structured logs are quota-billed per entry, and a transport retry +/// storm can emit an event every few seconds for hours. This budget admits up +/// to `capacityPerHour` entries per sliding hour (measured in the event +/// stream's monotonic time) and counts what it drops, so the first admitted +/// entry after a drop window can report how much was suppressed. +struct TransportTelemetryLogBudget: Sendable { + private let capacityPerHour: Int + private var window: [UInt64] = [] + private var droppedSinceLastAdmit = 0 + + init(capacityPerHour: Int) { + self.capacityPerHour = max(1, capacityPerHour) + } + + /// Admits or drops one log line at the given monotonic timestamp. + /// + /// - Returns: `nil` when the line should be dropped; otherwise the number + /// of lines dropped since the previous admitted one (0 when none). + mutating func admit(tNanos: UInt64) -> Int? { + let windowNanos: UInt64 = 3_600_000_000_000 + window.removeAll { tNanos >= $0 && tNanos - $0 > windowNanos } + guard window.count < capacityPerHour else { + droppedSinceLastAdmit += 1 + return nil + } + window.append(tNanos) + let dropped = droppedSinceLastAdmit + droppedSinceLastAdmit = 0 + return dropped + } +} diff --git a/Packages/Shared/CmuxSentryTelemetry/Tests/CmuxSentryReportingTests/SentryEventScrubberTests.swift b/Packages/Shared/CmuxSentryTelemetry/Tests/CmuxSentryReportingTests/SentryEventScrubberTests.swift index 7827ab6e05d6..37ad6459048e 100644 --- a/Packages/Shared/CmuxSentryTelemetry/Tests/CmuxSentryReportingTests/SentryEventScrubberTests.swift +++ b/Packages/Shared/CmuxSentryTelemetry/Tests/CmuxSentryReportingTests/SentryEventScrubberTests.swift @@ -188,4 +188,23 @@ import Testing #expect(scrubbed.exceptions?.first?.value == "fatal error: Index out of range") #expect(scrubbed.exceptions?.first?.type == "EXC_BAD_INSTRUCTION") } + + @Test func scrubsStructuredLogBodyAndStringAttributes() { + let log = SentryLog( + level: .info, + body: "dial from /Users/lawrence/dev failed" + ) + log.setAttribute(SentryLog.Attribute(string: "/Users/lawrence/dev"), forKey: "cwd") + log.setAttribute(SentryLog.Attribute(string: "s3cr3ts3cr3ts3cr3t"), forKey: "access_token") + log.setAttribute(SentryLog.Attribute(string: "iroh"), forKey: "transport.kind") + log.setAttribute(SentryLog.Attribute(integer: 42), forKey: "attempt") + + let scrubbed = scrubber.scrub(log) + #expect(scrubbed.body == "dial from /Users//dev failed") + #expect(scrubbed.attributes["cwd"]?.value as? String == "/Users//dev") + // A secret-like attribute key is redacted by name. + #expect(scrubbed.attributes["access_token"]?.value as? String == "") + #expect(scrubbed.attributes["transport.kind"]?.value as? String == "iroh") + #expect(scrubbed.attributes["attempt"]?.value as? Int == 42) + } } diff --git a/Packages/Shared/CmuxSentryTelemetry/Tests/CmuxSentryReportingTests/TransportSentryReporterTests.swift b/Packages/Shared/CmuxSentryTelemetry/Tests/CmuxSentryReportingTests/TransportSentryReporterTests.swift new file mode 100644 index 000000000000..b99d349c7baf --- /dev/null +++ b/Packages/Shared/CmuxSentryTelemetry/Tests/CmuxSentryReportingTests/TransportSentryReporterTests.swift @@ -0,0 +1,217 @@ +import CMUXMobileCore +import Foundation +import Sentry +import Testing +import os +@testable import CmuxSentryReporting + +@Suite struct TransportSentryReporterTests { + private static let second: UInt64 = 1_000_000_000 + + /// Thread-safe recorder for the injected delivery seams. + private final class Recorder: Sendable { + struct LogLine: Sendable { + let level: TransportSentryReporter.LogLevel + let message: String + let attributeKeys: [String] + } + + struct CapturedEvent: Sendable { + let title: String + let level: SentryLevel + let fingerprint: [String]? + let tags: [String: String]? + let hasAttachment: Bool + let attachmentFilename: String? + } + + struct CapturedBreadcrumb: Sendable { + let category: String + let message: String? + let level: SentryLevel + } + + let breadcrumbs = OSAllocatedUnfairLock<[CapturedBreadcrumb]>(initialState: []) + let events = OSAllocatedUnfairLock<[CapturedEvent]>(initialState: []) + let logs = OSAllocatedUnfairLock<[LogLine]>(initialState: []) + let enabled = OSAllocatedUnfairLock(initialState: true) + + var delivery: TransportSentryReporter.Delivery { + TransportSentryReporter.Delivery( + isEnabled: { self.enabled.withLock { $0 } }, + addBreadcrumb: { crumb in + let captured = CapturedBreadcrumb( + category: crumb.category, + message: crumb.message, + level: crumb.level + ) + self.breadcrumbs.withLock { $0.append(captured) } + }, + capture: { event, attachment in + let captured = CapturedEvent( + title: event.message?.formatted ?? "", + level: event.level, + fingerprint: event.fingerprint, + tags: event.tags, + hasAttachment: attachment != nil, + attachmentFilename: attachment?.filename + ) + self.events.withLock { $0.append(captured) } + }, + log: { level, message, attributes in + let line = LogLine( + level: level, + message: message, + attributeKeys: attributes.keys.sorted() + ) + self.logs.withLock { $0.append(line) } + } + ) + } + } + + private func makeReporter( + recorder: Recorder, + logsPerHour: Int = 300, + ring: Data = Data("ring".utf8) + ) -> TransportSentryReporter { + TransportSentryReporter( + role: .mobileClient, + exportRing: { ring }, + logsPerHour: logsPerHour, + delivery: recorder.delivery + ) + } + + private func dialFailed(at seconds: UInt64) -> DiagnosticEvent { + DiagnosticEvent( + code: .transportDialFailed, + tNanos: seconds * Self.second, + a: DiagnosticTransportKind.iroh.rawValue, + b: DiagnosticFailureKind.policyUnavailable.rawValue, + c: 7 + ) + } + + /// Awaits the async incident-capture task without sleeping. + private func waitForEvents(_ recorder: Recorder, _ expected: Int) async { + for _ in 0..<1_000_000 { + if recorder.events.withLock({ $0.count }) >= expected { return } + await Task.yield() + } + #expect(recorder.events.withLock { $0.count } >= expected) + } + + @Test func everyEventBecomesABreadcrumbAndLog() { + let recorder = Recorder() + let reporter = makeReporter(recorder: recorder) + + reporter.ingest(DiagnosticEvent(code: .endpointStarting, tNanos: 1)) + reporter.ingest(DiagnosticEvent(code: .endpointActive, tNanos: 2)) + + let crumbs = recorder.breadcrumbs.withLock { $0 } + #expect(crumbs.count == 2) + #expect(crumbs.allSatisfy { $0.category == "transport" }) + #expect(crumbs.map(\.message) == ["endpointStarting", "endpointActive"]) + #expect(crumbs.allSatisfy { $0.level == .info }) + + let logs = recorder.logs.withLock { $0 } + #expect(logs.map(\.message) == ["transport.endpointStarting", "transport.endpointActive"]) + #expect(logs.allSatisfy { $0.level == .info }) + #expect(logs.allSatisfy { $0.attributeKeys.contains("transport.role") }) + } + + @Test func failureEventsAreWarningsAndCaptureIncidents() async { + let recorder = Recorder() + let reporter = makeReporter(recorder: recorder) + + reporter.ingest(dialFailed(at: 10)) + await waitForEvents(recorder, 1) + + let crumbs = recorder.breadcrumbs.withLock { $0 } + #expect(crumbs.first?.level == .warning) + + let events = recorder.events.withLock { $0 } + #expect(events.count == 1) + let event = events[0] + #expect(event.title == "Transport failure: transportDialFailed/policyUnavailable/iroh") + #expect(event.level == .warning) + #expect(event.fingerprint == [ + "cmux-transport", "mobileClient", "transportDialFailed/policyUnavailable/iroh", + ]) + #expect(event.tags?["transport.failure"] == "policyUnavailable") + #expect(event.tags?["transport.kind"] == "iroh") + #expect(event.tags?["transport.role"] == "mobileClient") + #expect(event.tags?["transport.incident"] == "failure") + #expect(event.hasAttachment) + #expect(event.attachmentFilename == "cmux-transport-diag.txt") + } + + @Test func repeatFailuresInsideCooldownDoNotCaptureAgain() async { + let recorder = Recorder() + let reporter = makeReporter(recorder: recorder) + + reporter.ingest(dialFailed(at: 10)) + reporter.ingest(dialFailed(at: 20)) + reporter.ingest(dialFailed(at: 30)) + await waitForEvents(recorder, 1) + + #expect(recorder.events.withLock { $0.count } == 1) + #expect(recorder.breadcrumbs.withLock { $0.count } == 3) + } + + @Test func sustainedFailureStreakEscalatesToOutage() async { + let recorder = Recorder() + let reporter = makeReporter(recorder: recorder) + + for t in stride(from: UInt64(10), through: 70, by: 15) { + reporter.ingest(dialFailed(at: t)) + } + await waitForEvents(recorder, 2) + + let events = recorder.events.withLock { $0 } + let outage = events.first { $0.tags?["transport.incident"] == "outage" } + #expect(outage != nil) + #expect(outage?.level == .error) + #expect(outage?.fingerprint == ["cmux-transport", "mobileClient", "transport-outage"]) + #expect(outage?.hasAttachment == true) + } + + @Test func emptyRingCapturesWithoutAttachment() async { + let recorder = Recorder() + let reporter = makeReporter(recorder: recorder, ring: Data()) + + reporter.ingest(dialFailed(at: 10)) + await waitForEvents(recorder, 1) + + #expect(recorder.events.withLock { $0.first?.hasAttachment } == false) + } + + @Test func logBudgetDropsExcessAndReportsOnReadmission() { + let recorder = Recorder() + let reporter = makeReporter(recorder: recorder, logsPerHour: 2) + + reporter.ingest(DiagnosticEvent(code: .endpointStarting, tNanos: 1 * Self.second)) + reporter.ingest(DiagnosticEvent(code: .endpointActive, tNanos: 2 * Self.second)) + reporter.ingest(DiagnosticEvent(code: .endpointStopped, tNanos: 3 * Self.second)) + reporter.ingest(DiagnosticEvent(code: .endpointStarting, tNanos: 3800 * Self.second)) + + let logs = recorder.logs.withLock { $0 } + #expect(logs.count == 3) + #expect(logs[2].attributeKeys.contains("transport.log_dropped_before_this")) + // Breadcrumbs are unbudgeted: they only ship attached to events. + #expect(recorder.breadcrumbs.withLock { $0.count } == 4) + } + + @Test func disabledDeliverySendsNothing() { + let recorder = Recorder() + recorder.enabled.withLock { $0 = false } + let reporter = makeReporter(recorder: recorder) + + reporter.ingest(dialFailed(at: 10)) + + #expect(recorder.breadcrumbs.withLock { $0.isEmpty }) + #expect(recorder.logs.withLock { $0.isEmpty }) + #expect(recorder.events.withLock { $0.isEmpty }) + } +} diff --git a/Packages/iOS/CmuxMobileCrashReporting/Package.resolved b/Packages/iOS/CmuxMobileCrashReporting/Package.resolved index e2299cf51f87..9217022741b5 100644 --- a/Packages/iOS/CmuxMobileCrashReporting/Package.resolved +++ b/Packages/iOS/CmuxMobileCrashReporting/Package.resolved @@ -1,13 +1,13 @@ { - "originHash" : "0d6a31d1448c6064fd2bf80f8c9c525deb0c2239739891af8b23a7524a05fa38", + "originHash" : "3d5baa8fec750c3f0abb2a3d74178bfee8cd3a44b31cd9b7449516157c278d99", "pins" : [ { "identity" : "sentry-cocoa", "kind" : "remoteSourceControl", "location" : "https://github.com/getsentry/sentry-cocoa.git", "state" : { - "revision" : "53eb9bd5da18e208cfd80e86863d3f4c7ba21b1d", - "version" : "9.21.0" + "revision" : "d82bb1c5eb353a593573a5624bb370f5a1f500ce", + "version" : "9.24.0" } } ], diff --git a/Packages/iOS/CmuxMobileCrashReporting/Sources/CmuxMobileCrashReporting/MobileCrashReporter.swift b/Packages/iOS/CmuxMobileCrashReporting/Sources/CmuxMobileCrashReporting/MobileCrashReporter.swift index 8798c5261ebb..2053ab6d46b4 100644 --- a/Packages/iOS/CmuxMobileCrashReporting/Sources/CmuxMobileCrashReporting/MobileCrashReporter.swift +++ b/Packages/iOS/CmuxMobileCrashReporting/Sources/CmuxMobileCrashReporting/MobileCrashReporter.swift @@ -1,4 +1,5 @@ public import CmuxMobileAnalytics +import CmuxSentryReporting import Foundation public import Sentry @@ -6,13 +7,16 @@ public import Sentry /// /// ``MobileCrashReporter`` intentionally reuses /// ``CmuxMobileAnalytics/AnalyticsConsentProviding`` so crash telemetry and -/// analytics obey one opt-out source. No custom iOS breadcrumbs or messages are -/// sent in this first pass: `sendDefaultPii` is disabled, and reports are -/// limited to crash, watchdog, MetricKit, app-hang, stack, and device context -/// until the macOS scrubber can be moved to a shared package. +/// analytics obey one opt-out source. `sendDefaultPii` is disabled and every +/// outgoing event, breadcrumb, and structured log is redacted by the shared +/// `SentryEventScrubber` (CmuxSentryReporting) before it leaves the device. +/// Structured logs are enabled so the transport diagnostics bridge can emit +/// searchable connection telemetry; swizzling and automatic network capture +/// stay off because URLSession traffic in this app carries auth. public struct MobileCrashReporter { private let transportSessionController: any MobileCrashTransportSessionControlling private let cachePurger: SentryCachePurger + private let scrubber = SentryEventScrubber() /// Creates a mobile crash reporter. public init() { @@ -68,12 +72,17 @@ public struct MobileCrashReporter { // attempts that flush; a zero timeout prevents shutdown waiting. options.urlSession = transportSessionController.makeSession() options.shutdownTimeInterval = 0 - // Consent is re-read per event, mirroring the analytics emitter's - // per-capture gate: flipping sendAnonymousTelemetry off mid-session - // drops every subsequent envelope (crash, hang, MetricKit) without - // requiring a relaunch. + // Consent is re-read per envelope, mirroring the analytics + // emitter's per-capture gate: flipping sendAnonymousTelemetry off + // mid-session drops every subsequent envelope (crash, hang, + // MetricKit, structured log) without requiring a relaunch. Events + // and logs that do ship are scrubbed last-mile. + let scrubber = self.scrubber options.beforeSend = { event in - consent.isTelemetryEnabled ? event : nil + consent.isTelemetryEnabled ? scrubber.scrub(event) : nil + } + options.beforeSendLog = { log in + consent.isTelemetryEnabled ? scrubber.scrub(log) : nil } start(options) @@ -128,12 +137,19 @@ public struct MobileCrashReporter { options.enableWatchdogTerminationTracking = true options.enableAppHangTracking = true options.appHangTimeoutInterval = 8.0 - // Crash/device-context ONLY until the macOS scrubber moves to a shared - // package: there is no beforeSend scrubber here, so every default that - // would record or mutate app traffic stays off. Swizzling injects - // sentry-trace/baggage headers into URLSession requests (which carry - // auth in this app) and network/auto breadcrumbs record request URLs - // into crash envelopes; sendDefaultPii does not cover those. + // Structured logs power the transport diagnostics bridge + // (TransportSentryReporter); each log line passes the consent gate and + // scrubber installed in `beforeSendLog`. + options.enableLogs = true + // Manual breadcrumbs (the transport bridge's) are scrubbed last-mile. + // Swizzling and automatic network capture stay OFF even with the + // scrubber in place: swizzling injects sentry-trace/baggage headers + // into URLSession requests, which carry auth in this app, and that + // egress is not something a beforeSend hook can redact. + let scrubber = self.scrubber + options.beforeBreadcrumb = { breadcrumb in + scrubber.scrub(breadcrumb) + } options.enableSwizzling = false options.enableNetworkTracking = false options.enableNetworkBreadcrumbs = false diff --git a/Packages/iOS/CmuxMobileCrashReporting/Tests/CmuxMobileCrashReportingTests/MobileCrashReporterTests.swift b/Packages/iOS/CmuxMobileCrashReporting/Tests/CmuxMobileCrashReportingTests/MobileCrashReporterTests.swift index a4ba59f48aa0..66cf2391324f 100644 --- a/Packages/iOS/CmuxMobileCrashReporting/Tests/CmuxMobileCrashReportingTests/MobileCrashReporterTests.swift +++ b/Packages/iOS/CmuxMobileCrashReporting/Tests/CmuxMobileCrashReportingTests/MobileCrashReporterTests.swift @@ -67,6 +67,8 @@ private struct FixedConsent: AnalyticsConsentProviding { #expect(options.enableAutoBreadcrumbTracking == false) #expect(options.tracePropagationTargets.isEmpty) #expect(options.enableAutoSessionTracking == false) + #expect(options.enableLogs == true) + #expect(options.beforeBreadcrumb != nil) #if canImport(MetricKit) && !os(tvOS) && !os(visionOS) #expect(options.enableMetricKit == true) #expect(options.enableMetricKitRawPayload == false) @@ -341,4 +343,59 @@ private struct FixedConsent: AnalyticsConsentProviding { consent.enabled = false #expect(beforeSend(Event()) == nil) } + + @Test func beforeSendScrubsEventsThatPassConsent() throws { + let consent = CrashTestToggleConsent(enabled: true) + var captured: Options? + + MobileCrashReporter().startIfEnabled( + consent: consent, + arguments: ["cmux"], + environment: [:], + revocationWatcher: MobileCrashReporter.RevocationWatcher(), + start: { captured = $0 }, + close: {}, + purgeCache: {}, + crash: {} + ) + + let beforeSend = try #require(captured?.beforeSend) + let event = Event() + event.message = SentryMessage(formatted: "dial from /Users/lawrence/dev failed") + let scrubbed = try #require(beforeSend(event)) + #expect(scrubbed.message?.formatted == "dial from /Users//dev failed") + } + + @Test func beforeSendLogGatesOnConsentAndScrubs() throws { + let consent = CrashTestToggleConsent(enabled: true) + var captured: Options? + + MobileCrashReporter().startIfEnabled( + consent: consent, + arguments: ["cmux"], + environment: [:], + revocationWatcher: MobileCrashReporter.RevocationWatcher(), + start: { captured = $0 }, + close: {}, + purgeCache: {}, + crash: {} + ) + + let beforeSendLog = try #require(captured?.beforeSendLog) + let log = SentryLog(level: .info, body: "retry from /Users/lawrence/dev") + let scrubbed = try #require(beforeSendLog(log)) + #expect(scrubbed.body == "retry from /Users//dev") + + consent.enabled = false + #expect(beforeSendLog(SentryLog(level: .info, body: "x")) == nil) + } + + @Test func breadcrumbHookScrubsData() throws { + let options = MobileCrashReporter().makeOptions() + let beforeBreadcrumb = try #require(options.beforeBreadcrumb) + let crumb = Breadcrumb(level: .info, category: "transport") + crumb.message = "token=abcdef0123456789zz" + let scrubbed = try #require(beforeBreadcrumb(crumb)) + #expect(scrubbed.message == "token=") + } } diff --git a/Sources/AppDelegate.swift b/Sources/AppDelegate.swift index 775c0f7d0ba5..760d6ae3f438 100644 --- a/Sources/AppDelegate.swift +++ b/Sources/AppDelegate.swift @@ -20,6 +20,7 @@ import CMUXAgentLaunch import CoreServices import CoreGraphics import UserNotifications +import CMUXMobileCore import Sentry import WebKit import Combine @@ -527,6 +528,11 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent private var isRunningUnderXCTestCached: Bool { Self.cachedIsRunningUnderXCTest } + /// Bridges the Mac host's transport diagnostic ring into Sentry + /// (breadcrumbs, structured logs, throttled failure events with the ring + /// export attached). Created after `SentrySDK.start`; delivery no-ops when + /// the SDK is off. + private var transportSentryReporter: TransportSentryReporter? private let cmuxThemePreviewReloadScheduler = MainActorDeferredActionScheduler() private func isRunningUnderXCTest(_ env: [String: String]) -> Bool { @@ -1410,13 +1416,30 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent options.attachStacktrace = true // Avoid recursively capturing failed requests from Sentry's own ingestion endpoint. options.enableCaptureFailedRequests = false + // Structured logs power the transport diagnostics bridge + // (TransportSentryReporter on the host diagnostic ring below). + options.enableLogs = true // Redact file paths, emails, and secrets from every outgoing - // event, breadcrumb, and (belt-and-suspenders, if tracing is ever - // re-enabled) child performance span before it leaves the device. + // event, breadcrumb, structured log, and (belt-and-suspenders, + // if tracing is ever re-enabled) child performance span before + // it leaves the device. let scrubber = SentryEventScrubber() options.beforeSend = { event in scrubber.scrub(event) } options.beforeBreadcrumb = { breadcrumb in scrubber.scrub(breadcrumb) } options.beforeSendSpan = { span in scrubber.scrub(span) } + options.beforeSendLog = { log in scrubber.scrub(log) } + } + // Bridge the Mac host's transport diagnostic ring into Sentry: + // every retained event becomes a breadcrumb + budget-limited log + // line, and gated failures become events carrying the ring export. + // The tap delivers on the ring's drain task, off the main thread. + let transportReporter = TransportSentryReporter( + role: .macHost, + exportRing: { await MobileHostIrohRuntime.hostDiagnosticLog.export() } + ) + transportSentryReporter = transportReporter + MobileHostIrohRuntime.hostDiagnosticLog.setEventTap { event in + transportReporter.ingest(event) } StartupBreadcrumbLog.append("appDelegate.didFinish.sentry.complete") } diff --git a/docs/transport-sentry-diagnostics.md b/docs/transport-sentry-diagnostics.md new file mode 100644 index 000000000000..e093e455ac7e --- /dev/null +++ b/docs/transport-sentry-diagnostics.md @@ -0,0 +1,58 @@ +# Transport Sentry diagnostics + +Every iroh/transport failure a user can hit is diagnosable from Sentry alone, +on both macOS (host) and iOS (client). The pipeline turns the existing +`DiagnosticLog` ring (`Packages/Shared/CMUXMobileCore`) into three Sentry +surfaces without adding any new PII egress: the ring's vocabulary is fixed +integer codes (`DiagnosticEventCode`, `DiagnosticFailureKind`, ...), so the +bridge ships decoded case names and integers, never error strings, peers, +addresses, accounts, or terminal content. + +## Pipeline + +`DiagnosticLog.setEventTap(_:)` delivers each retained event (on the ring's +drain task) to a `TransportSentryReporter` +(`Packages/Shared/CmuxSentryTelemetry`, target `CmuxSentryReporting`), which +emits: + +1. **Breadcrumbs** — every transport event, category `transport`, decoded via + `DiagnosticEventPresentation`. These ride on ALL Sentry events, including + crashes and hangs, so any report carries the recent connection timeline. +2. **Structured logs** — the same decoded events as searchable Sentry logs + (`options.enableLogs`), rate-limited by a sliding hourly budget so retry + storms cannot flood the quota. +3. **Error events** — failures that cross `TransportIncidentPolicy` + (`CMUXMobileCore`, pure and unit-tested) become Sentry events fingerprinted + by `code/failureKind/transportKind` signature, with the compact diagnostic + ring export attached (`cmux-transport-diag.txt`, the same `cmuxdiag v1` + blob the `iroh_diag` socket verb and iOS Settings export produce). + +The policy suppresses what an operator can already attribute (cancelled or +superseded dials, offline failures while reachability reports no network, +idle timeouts while backgrounded), coalesces repeats behind a 10-minute +per-signature cooldown, caps failure captures per hour, and escalates a +sustained no-success failure streak into one error-severity +`transport-outage` event. Environment (reachability, app lifecycle phase, +seconds since last success, consecutive-failure count) rides on every capture. + +## Wiring + +- iOS: `AppCompositionRoot` sets the tap on the injected `DiagnosticLog` + (role `mobileClient`). Consent is the same + `AnalyticsConsentProviding` gate crash reporting uses; the SDK's + `beforeSend`/`beforeSendLog` re-check it per envelope, and every outgoing + event, breadcrumb, and log is scrubbed by `SentryEventScrubber` + (target `CmuxSentryReporting`, pure core in `CmuxSentryScrubbing`). +- macOS: `AppDelegate` sets the tap on + `MobileHostIrohRuntime.hostDiagnosticLog` (role `macHost`) after + `SentrySDK.start`, gated by `MacSentryStartupPolicy` as before. + +## Reading an issue + +A transport issue's title is the policy signature (e.g. +`Transport failure: transportDialFailed/policyUnavailable/iroh`). Tags: +`transport.event`, `transport.failure`, `transport.kind`, `transport.role`, +`transport.incident` (`failure` | `outage`). The `cmux.transport` context +holds streak counts and suppression counters. The attachment holds the full +ring in `cmuxdiag v1` compact form (`tNanos,code,surface,ms,a,b,c` rows); the +breadcrumb trail holds the same events decoded, in order. diff --git a/ios/cmux/AppCompositionRoot.swift b/ios/cmux/AppCompositionRoot.swift index 3b8a22fb4ca9..295e4ea2a5e5 100644 --- a/ios/cmux/AppCompositionRoot.swift +++ b/ios/cmux/AppCompositionRoot.swift @@ -5,6 +5,7 @@ import CmuxMobileDiagnostics import CmuxMobileShellModel import CmuxMobileSupport import CmuxMobileTransport +import CmuxSentryReporting import Foundation import SwiftUI import cmuxFeature @@ -45,6 +46,12 @@ final class AppCompositionRoot { /// credentials, peer identities, addresses, or free-form errors. let diagnosticLog: DiagnosticLog + /// Bridges the diagnostic event stream into Sentry (breadcrumbs, structured + /// logs, and throttled failure events with the ring export attached). Held + /// for the process lifetime; delivery no-ops whenever the crash SDK is off + /// (consent revoked or crash reporting disabled for the build). + private let transportSentryReporter: TransportSentryReporter + init( runtime: CMUXMobileRuntime, auth: MobileAuthComposition, @@ -70,6 +77,17 @@ final class AppCompositionRoot { revocationWatcher: crashRevocationWatcher ) } + // The reporter checks `SentrySDK.isEnabled` per event, so it respects + // both the build-level kill switch above and mid-session consent + // revocation (which closes the SDK) without extra plumbing. + let transportSentryReporter = TransportSentryReporter( + role: .mobileClient, + exportRing: { [diagnosticLog] in await diagnosticLog.export() } + ) + self.transportSentryReporter = transportSentryReporter + diagnosticLog.setEventTap { event in + transportSentryReporter.ingest(event) + } self.analytics = MobileAnalyticsComposition( apiBaseURL: auth.config.apiBaseURL, tokenProvider: auth.coordinator, From 609ea8ca3c55646dc308b76be69ec659244e9653 Mon Sep 17 00:00:00 2001 From: Abdulaziz Albahar <67667005+azooz2003-bit@users.noreply.github.com> Date: Fri, 31 Jul 2026 02:52:49 -0700 Subject: [PATCH 4/5] Regenerate SwiftPM lockfiles for the CmuxSentryTelemetry dependency Co-Authored-By: Claude Fable 5 --- ios/cmuxPackage/Package.resolved | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/ios/cmuxPackage/Package.resolved b/ios/cmuxPackage/Package.resolved index 0e163c86aad5..fc802d2cd79b 100644 --- a/ios/cmuxPackage/Package.resolved +++ b/ios/cmuxPackage/Package.resolved @@ -1,5 +1,5 @@ { - "originHash" : "b5c61e211e7c320e669fccbbd4cbd15f33840cda5ce56ba5540b04d93c197259", + "originHash" : "3e2058bf90bf2c1caef8ad94956461e721f87976fa6c0d452a1c08b310c1d36d", "pins" : [ { "identity" : "highlightr", @@ -24,8 +24,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/getsentry/sentry-cocoa.git", "state" : { - "revision" : "53eb9bd5da18e208cfd80e86863d3f4c7ba21b1d", - "version" : "9.21.0" + "revision" : "d82bb1c5eb353a593573a5624bb370f5a1f500ce", + "version" : "9.24.0" } }, { From d0087f3be0b97d9b63478f80ff89e052f433ad7b Mon Sep 17 00:00:00 2001 From: Abdulaziz Albahar <67667005+azooz2003-bit@users.noreply.github.com> Date: Fri, 31 Jul 2026 14:05:20 -0700 Subject: [PATCH 5/5] Address review: tap admission floor, cooldown-on-drop bug, scrub arrays, single pairFail The event tap now gates on an ingress admission sequence: installing an observer while recorded events are still queued on the drain task no longer delivers those pre-installation events (regression test records a 500-event burst and installs the tap with no drain sync). A budget-dropped capture no longer stamps lastCaptureTNanos, so a brand-new failure signature arriving during budget exhaustion captures as soon as the window slides instead of serving a phantom cooldown. The structured-log scrubber now handles string-array attributes (previously bypassed) and writes back via SentryLog.Attribute. One exhausted connect now records a single pairFail carrying transport (a) and failure (b) instead of a pairFail+rpcFailed pair that double-counted the outage streak; pairFail and routeUnavailable decode their transport slot in presentation. The iOS workspace lockfile aligns sentry-cocoa to 9.24.0, matching the package-local pins (fixes the SwiftPM lockfile policy guard). Doc states coverage is policy-shaped, not per-event. Co-Authored-By: Claude Fable 5 --- .../CMUXMobileCore/DiagnosticEventCode.swift | 4 +- .../DiagnosticEventPresentation.swift | 8 +- .../CMUXMobileCore/DiagnosticLog.swift | 90 +++++++++++++------ .../TransportIncidentPolicy.swift | 12 +-- .../DiagnosticLogTests.swift | 26 ++++++ .../TransportIncidentPolicyTests.swift | 28 ++++++ .../SentryEventScrubber.swift | 23 +++-- .../SentryEventScrubberTests.swift | 20 +++++ .../MobileShellComposite.swift | 7 +- docs/transport-sentry-diagnostics.md | 19 ++-- .../xcshareddata/swiftpm/Package.resolved | 4 +- 11 files changed, 185 insertions(+), 56 deletions(-) diff --git a/Packages/Shared/CMUXMobileCore/Sources/CMUXMobileCore/DiagnosticEventCode.swift b/Packages/Shared/CMUXMobileCore/Sources/CMUXMobileCore/DiagnosticEventCode.swift index e55d2fb7bc65..1f711a3569be 100644 --- a/Packages/Shared/CMUXMobileCore/Sources/CMUXMobileCore/DiagnosticEventCode.swift +++ b/Packages/Shared/CMUXMobileCore/Sources/CMUXMobileCore/DiagnosticEventCode.swift @@ -16,7 +16,9 @@ public enum DiagnosticEventCode: UInt16, Sendable, Codable, CaseIterable { case connect = 1 /// Pairing / attach completed successfully. case pairOk = 2 - /// Pairing / attach failed. `b`, when present, is ``DiagnosticFailureKind``. + /// Pairing / attach failed. `a`, when present, is + /// ``DiagnosticTransportKind``; `b`, when present, is + /// ``DiagnosticFailureKind``. case pairFail = 3 /// The render-grid stream lagged behind (a bounded render-lag counter tick). /// diff --git a/Packages/Shared/CMUXMobileCore/Sources/CMUXMobileCore/DiagnosticEventPresentation.swift b/Packages/Shared/CMUXMobileCore/Sources/CMUXMobileCore/DiagnosticEventPresentation.swift index 98a94dc6c6f0..44be80da12f0 100644 --- a/Packages/Shared/CMUXMobileCore/Sources/CMUXMobileCore/DiagnosticEventPresentation.swift +++ b/Packages/Shared/CMUXMobileCore/Sources/CMUXMobileCore/DiagnosticEventPresentation.swift @@ -120,8 +120,8 @@ public enum DiagnosticEventPresentation { /// Event codes whose `a` slot carries a ``DiagnosticTransportKind``. static let codesWithTransportA: Set = [ - .transportDialStarted, .transportDialConnected, .transportDialFailed, - .sessionClosed, + .pairFail, .transportDialStarted, .transportDialConnected, + .transportDialFailed, .sessionClosed, .routeUnavailable, ] private static func msKey(for code: DiagnosticEventCode) -> String { @@ -151,8 +151,8 @@ public enum DiagnosticEventPresentation { private static func decodeA(_ a: Int, code: DiagnosticEventCode) -> Field { switch code { - case .transportDialStarted, .transportDialConnected, .transportDialFailed, - .sessionClosed: + case .pairFail, .transportDialStarted, .transportDialConnected, + .transportDialFailed, .sessionClosed, .routeUnavailable: return enumField(key: "transport", raw: a) { DiagnosticTransportKind(rawValue: $0).map(name) } case .selectedPathChanged: return enumField(key: "path", raw: a) { DiagnosticPathKind(rawValue: $0).map(name) } diff --git a/Packages/Shared/CMUXMobileCore/Sources/CMUXMobileCore/DiagnosticLog.swift b/Packages/Shared/CMUXMobileCore/Sources/CMUXMobileCore/DiagnosticLog.swift index 7d6a67462f46..25f5e367033e 100644 --- a/Packages/Shared/CMUXMobileCore/Sources/CMUXMobileCore/DiagnosticLog.swift +++ b/Packages/Shared/CMUXMobileCore/Sources/CMUXMobileCore/DiagnosticLog.swift @@ -103,9 +103,9 @@ public final class DiagnosticLog: Sendable { for await command in commandStream { switch command { case let .events(events): - for await event in events { - if await store.append(event) { - tap.deliver(event) + for await sequenced in events { + if await store.append(sequenced.event) { + tap.deliver(sequenced) } } case let .clear( @@ -119,9 +119,9 @@ public final class DiagnosticLog: Sendable { anchorMonotonicNanos: anchorMonotonicNanos ) acknowledgement.resume() - for await event in nextEvents { - if await store.append(event) { - tap.deliver(event) + for await sequenced in nextEvents { + if await store.append(sequenced.event) { + tap.deliver(sequenced) } } } @@ -139,15 +139,18 @@ public final class DiagnosticLog: Sendable { /// in the ring, so it adds no work to the hot-path ``record(_:)`` call and /// sees events in ring order. Events consumed but not retained (the repeated /// ``DiagnosticEventCode/selectedPathChanged`` dedup) are not delivered. - /// Events recorded before the observer is set are not replayed; a consumer - /// that needs history snapshots the ring via ``export()`` or ``snapshot(generatedAt:)``. + /// Events recorded before the observer is set are never delivered, even + /// when they are still queued on the drain task at install time (each + /// event carries an ingress admission sequence, and only events admitted + /// after installation pass the tap). A consumer that needs history + /// snapshots the ring via ``export()`` or ``snapshot(generatedAt:)``. /// /// The observer must be fast and must not block: it shares the drain task /// with ring appends. Forward into your own queue or task for slow work. /// /// - Parameter observer: The observer, or `nil` to remove the current one. public func setEventTap(_ observer: (@Sendable (DiagnosticEvent) -> Void)?) { - tap.set(observer) + tap.set(observer, notBefore: ingress.lastAdmittedSeq()) } /// Record one event. Non-blocking and safe from any thread. @@ -233,31 +236,50 @@ public final class DiagnosticLog: Sendable { /// once full and would starve the drain task during the exact lag bursts this /// log captures). private enum DrainCommand: Sendable { - case events(AsyncStream) + case events(AsyncStream) case clear( anchorWallNanos: UInt64, anchorMonotonicNanos: UInt64, - nextEvents: AsyncStream, + nextEvents: AsyncStream, acknowledgement: CheckedContinuation ) } + /// One admitted event with its ingress admission sequence number. The tap + /// compares the number against its activation floor so an observer never + /// receives an event that was admitted (recorded) before it was installed, + /// even when that event is still queued on the drain task at install time. + private struct SequencedEvent: Sendable { + let seq: UInt64 + let event: DiagnosticEvent + } + /// Holds the settable live observer without retaining the log, so the drain /// task can capture it while ``DiagnosticLog/deinit`` stays reachable. private final class TapBox: Sendable { + private struct State: Sendable { + var observer: (@Sendable (DiagnosticEvent) -> Void)? + /// Only events admitted after this ingress sequence are delivered. + var notBefore: UInt64 = 0 + } + // lint:allow lock - deliver runs on the drain task and set is rare; the - // critical region only reads or writes one closure reference. - private let observer = OSAllocatedUnfairLock<(@Sendable (DiagnosticEvent) -> Void)?>( - initialState: nil - ) + // critical region only reads or writes one closure reference + floor. + private let state = OSAllocatedUnfairLock(initialState: State()) - func set(_ newObserver: (@Sendable (DiagnosticEvent) -> Void)?) { - observer.withLock { $0 = newObserver } + func set(_ newObserver: (@Sendable (DiagnosticEvent) -> Void)?, notBefore: UInt64) { + state.withLock { + $0.observer = newObserver + $0.notBefore = notBefore + } } - func deliver(_ event: DiagnosticEvent) { - let current = observer.withLock { $0 } - current?(event) + func deliver(_ sequenced: SequencedEvent) { + let current = state.withLock { state -> (@Sendable (DiagnosticEvent) -> Void)? in + guard sequenced.seq > state.notBefore else { return nil } + return state.observer + } + current?(sequenced.event) } } @@ -269,15 +291,18 @@ public final class DiagnosticLog: Sendable { private struct State: Sendable { let capacity: Int let commandContinuation: AsyncStream.Continuation - var eventContinuation: AsyncStream.Continuation? + var eventContinuation: AsyncStream.Continuation? var isFinished = false + /// Monotonic admission counter; the last value handed to a + /// recorded event. Read at tap install time as the delivery floor. + var lastAdmittedSeq: UInt64 = 0 } private enum ClearEnqueueResult: Sendable { - case enqueued(previous: AsyncStream.Continuation?) + case enqueued(previous: AsyncStream.Continuation?) case terminated( - previous: AsyncStream.Continuation?, - next: AsyncStream.Continuation + previous: AsyncStream.Continuation?, + next: AsyncStream.Continuation ) } @@ -302,10 +327,21 @@ public final class DiagnosticLog: Sendable { func record(_ event: DiagnosticEvent) { state.withLock { state in guard !state.isFinished else { return } - state.eventContinuation?.yield(event) + state.lastAdmittedSeq += 1 + state.eventContinuation?.yield(SequencedEvent( + seq: state.lastAdmittedSeq, + event: event + )) } } + /// The admission sequence of the most recently recorded event, used as + /// the tap's activation floor so already-admitted events are never + /// delivered to a newly installed observer. + func lastAdmittedSeq() -> UInt64 { + state.withLock { $0.lastAdmittedSeq } + } + func clear( anchorWallNanos: UInt64, anchorMonotonicNanos: UInt64, @@ -351,7 +387,7 @@ public final class DiagnosticLog: Sendable { func finish() { let continuations: ( - AsyncStream.Continuation?, + AsyncStream.Continuation?, AsyncStream.Continuation )? = state.withLock { state in guard !state.isFinished else { return nil } @@ -366,7 +402,7 @@ public final class DiagnosticLog: Sendable { private static func makeEventSegment( capacity: Int - ) -> (AsyncStream, AsyncStream.Continuation) { + ) -> (AsyncStream, AsyncStream.Continuation) { AsyncStream.makeStream(bufferingPolicy: .bufferingNewest(capacity)) } } diff --git a/Packages/Shared/CMUXMobileCore/Sources/CMUXMobileCore/TransportIncidentPolicy.swift b/Packages/Shared/CMUXMobileCore/Sources/CMUXMobileCore/TransportIncidentPolicy.swift index 2aa7f6fde566..8e2a1a3db9bc 100644 --- a/Packages/Shared/CMUXMobileCore/Sources/CMUXMobileCore/TransportIncidentPolicy.swift +++ b/Packages/Shared/CMUXMobileCore/Sources/CMUXMobileCore/TransportIncidentPolicy.swift @@ -116,9 +116,11 @@ public struct TransportIncidentPolicy: Sendable { private var reachable: Bool? private var appPhase: DiagnosticAppLifecyclePhase? - /// Per-signature capture gate state. + /// Per-signature capture gate state. `lastCaptureTNanos` is `nil` while the + /// signature has been seen but never actually captured (for example when + /// the hourly budget dropped it), so a budget drop never starts a cooldown. private struct SignatureState { - var lastCaptureTNanos: UInt64 + var lastCaptureTNanos: UInt64? var pendingCount: Int var firstPendingTNanos: UInt64? } @@ -282,8 +284,8 @@ public struct TransportIncidentPolicy: Sendable { failure: DiagnosticFailureKind?, transport: DiagnosticTransportKind? ) -> Incident? { - if var state = signatureStates[signature] { - let sinceCapture = elapsedSeconds(from: state.lastCaptureTNanos, to: event.tNanos) + if var state = signatureStates[signature], let lastCapture = state.lastCaptureTNanos { + let sinceCapture = elapsedSeconds(from: lastCapture, to: event.tNanos) if sinceCapture < configuration.signatureCooldown { state.pendingCount += 1 if state.firstPendingTNanos == nil { @@ -296,7 +298,7 @@ public struct TransportIncidentPolicy: Sendable { guard admitCaptureWithinBudget(at: event.tNanos) else { var state = signatureStates[signature] - ?? SignatureState(lastCaptureTNanos: event.tNanos, pendingCount: 0, firstPendingTNanos: nil) + ?? SignatureState(lastCaptureTNanos: nil, pendingCount: 0, firstPendingTNanos: nil) state.pendingCount += 1 if state.firstPendingTNanos == nil { state.firstPendingTNanos = event.tNanos diff --git a/Packages/Shared/CMUXMobileCore/Tests/CMUXMobileCoreTests/DiagnosticLogTests.swift b/Packages/Shared/CMUXMobileCore/Tests/CMUXMobileCoreTests/DiagnosticLogTests.swift index 2f2b0824a712..6f71ab147a0f 100644 --- a/Packages/Shared/CMUXMobileCore/Tests/CMUXMobileCoreTests/DiagnosticLogTests.swift +++ b/Packages/Shared/CMUXMobileCore/Tests/CMUXMobileCoreTests/DiagnosticLogTests.swift @@ -638,6 +638,32 @@ import os #expect(received.withLock { $0 } == [relay]) } + @Test func eventTapNeverDeliversEventsQueuedBeforeInstallation() async { + // Regression: record() admits events onto the drain stream before the + // drain task delivers them. Installing the tap in that window must not + // deliver the already-admitted events: the tap floor is the ingress + // admission sequence, not the drain position. Recording a burst and + // installing the tap immediately (no drain sync) makes the pre-fix + // race overwhelmingly likely to deliver stale events. + let log = DiagnosticLog(capacity: 4096) + let burst = 500 + for index in 1...burst { + log.record(DiagnosticEvent(code: .connect, tNanos: UInt64(index))) + } + let received = OSAllocatedUnfairLock<[DiagnosticEvent]>(initialState: []) + log.setEventTap { event in + received.withLock { $0.append(event) } + } + await waitForProcessed(log, burst) + #expect(received.withLock { $0.isEmpty }) + + // Events admitted after installation still flow. + let live = DiagnosticEvent(code: .pairOk, tNanos: UInt64(burst + 1)) + log.record(live) + await waitForProcessed(log, burst + 1) + #expect(received.withLock { $0 } == [live]) + } + @Test func eventTapDoesNotReplayHistoryAndCanBeRemoved() async { let log = DiagnosticLog(capacity: 8) log.record(DiagnosticEvent(code: .connect, tNanos: 1)) diff --git a/Packages/Shared/CMUXMobileCore/Tests/CMUXMobileCoreTests/TransportIncidentPolicyTests.swift b/Packages/Shared/CMUXMobileCore/Tests/CMUXMobileCoreTests/TransportIncidentPolicyTests.swift index efea8f1c7ae9..b383ff93b1f5 100644 --- a/Packages/Shared/CMUXMobileCore/Tests/CMUXMobileCoreTests/TransportIncidentPolicyTests.swift +++ b/Packages/Shared/CMUXMobileCore/Tests/CMUXMobileCoreTests/TransportIncidentPolicyTests.swift @@ -173,6 +173,34 @@ import Testing #expect(later?.droppedByBudget == 2) } + @Test func budgetDropDoesNotStartASignatureCooldown() { + var policy = TransportIncidentPolicy( + configuration: .init( + signatureCooldown: 600, + hourlyCaptureLimit: 1, + outageFailureThreshold: 100, + outageMinimumDuration: 10_000 + ) + ) + // Exhaust the hourly budget with one signature... + #expect(policy.decide(dialFailed(at: 10)) != nil) + // ...then a brand-new signature arrives while the budget is empty. + let unreachable = DiagnosticEvent( + code: .pairUnreachable, + tNanos: 20 * Self.second + ) + #expect(policy.decide(unreachable) == nil) + // Once the window slides, the never-captured signature must capture + // immediately: a budget drop is not a capture, so no cooldown applies. + let afterWindow = DiagnosticEvent( + code: .pairUnreachable, + tNanos: (10 + 3700) * Self.second + ) + let captured = policy.decide(afterWindow) + #expect(captured != nil) + #expect(captured?.coalescedCount == 2) + } + @Test func environmentRidesOnIncidents() { var policy = TransportIncidentPolicy() _ = policy.decide(DiagnosticEvent(code: .reachabilityChanged, tNanos: 1, a: 1)) diff --git a/Packages/Shared/CmuxSentryTelemetry/Sources/CmuxSentryReporting/SentryEventScrubber.swift b/Packages/Shared/CmuxSentryTelemetry/Sources/CmuxSentryReporting/SentryEventScrubber.swift index f14beff6084a..6cfba6b7ea81 100644 --- a/Packages/Shared/CmuxSentryTelemetry/Sources/CmuxSentryReporting/SentryEventScrubber.swift +++ b/Packages/Shared/CmuxSentryTelemetry/Sources/CmuxSentryReporting/SentryEventScrubber.swift @@ -166,19 +166,30 @@ public struct SentryEventScrubber: Sendable { @discardableResult public func scrub(_ log: SentryLog) -> SentryLog { log.body = scrubber.scrub(log.body) - // Collect string-typed attribute values and route them through the - // key-aware dictionary scrubber, so a secret-like key is redacted by - // name and a free-text value by pattern, matching tags/extra handling. + // Route string-typed attribute values (including string arrays) + // through the key-aware dictionary scrubber, so a secret-like key is + // redacted by name and a free-text value by pattern, matching + // tags/extra handling. Numbers and booleans cannot carry free text. var stringValues: [String: Any] = [:] for (key, attribute) in log.attributes { - if let value = attribute.value as? String { + switch attribute.value { + case let value as String: stringValues[key] = value + case let values as [String]: + stringValues[key] = values + default: + break } } guard !stringValues.isEmpty else { return log } for (key, value) in scrubber.scrub(dictionary: stringValues) { - if let scrubbed = value as? String { - log.setAttribute(SentryAttribute(string: scrubbed), forKey: key) + switch value { + case let scrubbed as String: + log.setAttribute(SentryLog.Attribute(string: scrubbed), forKey: key) + case let scrubbed as [String]: + log.setAttribute(SentryLog.Attribute(stringArray: scrubbed), forKey: key) + default: + break } } return log diff --git a/Packages/Shared/CmuxSentryTelemetry/Tests/CmuxSentryReportingTests/SentryEventScrubberTests.swift b/Packages/Shared/CmuxSentryTelemetry/Tests/CmuxSentryReportingTests/SentryEventScrubberTests.swift index 37ad6459048e..2e6552a9927f 100644 --- a/Packages/Shared/CmuxSentryTelemetry/Tests/CmuxSentryReportingTests/SentryEventScrubberTests.swift +++ b/Packages/Shared/CmuxSentryTelemetry/Tests/CmuxSentryReportingTests/SentryEventScrubberTests.swift @@ -207,4 +207,24 @@ import Testing #expect(scrubbed.attributes["transport.kind"]?.value as? String == "iroh") #expect(scrubbed.attributes["attempt"]?.value as? Int == 42) } + + @Test func scrubsStringArrayLogAttributes() { + let log = SentryLog(level: .info, body: "roots") + log.setAttribute( + SentryLog.Attribute(stringArray: ["/Users/lawrence/a", "/Users/lawrence/b"]), + forKey: "paths" + ) + log.setAttribute( + SentryLog.Attribute(stringArray: ["c0ffeec0ffeec0ffee"]), + forKey: "session_cookie" + ) + + let scrubbed = scrubber.scrub(log) + #expect( + scrubbed.attributes["paths"]?.value as? [String] + == ["/Users//a", "/Users//b"] + ) + // A sensitive-keyed array collapses to the wholesale redaction string. + #expect(scrubbed.attributes["session_cookie"]?.value as? String == "") + } } diff --git a/Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite.swift b/Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite.swift index 36b089dfe901..59e82b792338 100644 --- a/Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite.swift +++ b/Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite.swift @@ -8231,12 +8231,11 @@ public final class MobileShellComposite: MobileTerminalOutputSinking { await client.disconnect() } + // One event per exhausted connect: a second `.rpcFailed` record here + // would double the incident policy's consecutive-failure streak and + // burn a second signature-cooldown gate for the same underlying error. diagnosticLog?.record(DiagnosticEvent( .pairFail, - b: Self.diagnosticFailureKind(for: lastError).rawValue - )) - diagnosticLog?.record(DiagnosticEvent( - .rpcFailed, a: activeRoute.map { DiagnosticTransportKind($0.kind).rawValue } ?? DiagnosticTransportKind.unknown.rawValue, b: Self.diagnosticFailureKind(for: lastError).rawValue diff --git a/docs/transport-sentry-diagnostics.md b/docs/transport-sentry-diagnostics.md index e093e455ac7e..9b525fa63e32 100644 --- a/docs/transport-sentry-diagnostics.md +++ b/docs/transport-sentry-diagnostics.md @@ -1,12 +1,17 @@ # Transport Sentry diagnostics -Every iroh/transport failure a user can hit is diagnosable from Sentry alone, -on both macOS (host) and iOS (client). The pipeline turns the existing -`DiagnosticLog` ring (`Packages/Shared/CMUXMobileCore`) into three Sentry -surfaces without adding any new PII egress: the ring's vocabulary is fixed -integer codes (`DiagnosticEventCode`, `DiagnosticFailureKind`, ...), so the -bridge ships decoded case names and integers, never error strings, peers, -addresses, accounts, or terminal content. +Iroh/transport failures are diagnosable from Sentry telemetry alone, on both +macOS (host) and iOS (client), without pulling logs off the device. Coverage +is policy-shaped, not a per-event guarantee: telemetry requires the SDK to be +started (telemetry consent on), error-event captures pass cooldown and hourly +budgets, and structured logs pass their own budget. Breadcrumbs are the widest +net (every retained transport event, attached to whatever ships next). The +pipeline turns the existing `DiagnosticLog` ring +(`Packages/Shared/CMUXMobileCore`) into three Sentry surfaces without adding +any new PII egress: the ring's vocabulary is fixed integer codes +(`DiagnosticEventCode`, `DiagnosticFailureKind`, ...), so the bridge ships +decoded case names and integers, never error strings, peers, addresses, +accounts, or terminal content. ## Pipeline diff --git a/ios/cmux.xcworkspace/xcshareddata/swiftpm/Package.resolved b/ios/cmux.xcworkspace/xcshareddata/swiftpm/Package.resolved index f40c002fb894..178b7c12ba6a 100644 --- a/ios/cmux.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ b/ios/cmux.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -24,8 +24,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/getsentry/sentry-cocoa.git", "state" : { - "revision" : "afa7510e05b99f35c1febe47566bfd868fa53fb9", - "version" : "9.23.0" + "revision" : "d82bb1c5eb353a593573a5624bb370f5a1f500ce", + "version" : "9.24.0" } }, {