diff --git a/.claude/skills/release/SKILL.md b/.claude/skills/release/SKILL.md index ca1a1e15..9d4cf4b3 100644 --- a/.claude/skills/release/SKILL.md +++ b/.claude/skills/release/SKILL.md @@ -1,6 +1,6 @@ --- name: release -description: Cut and publish a Blurt release (build, sign, notarize, staple, DMG, Sentry, GitHub). Use when the user asks to ship/release a new version. User-invoked only — it has real side effects (notarization, git tags, GitHub release, Sentry release). +description: Cut and publish a Blurt release (build, sign, notarize, staple, DMG, Datadog, GitHub). Use when the user asks to ship/release a new version. User-invoked only — it has real side effects (notarization, git tags, GitHub release, Datadog dSYM upload). disable-model-invocation: true --- @@ -12,9 +12,9 @@ the target version with the user before publishing** — publishing is hard to u ## Preconditions (verify first) - Clean working tree on an up-to-date `main` (releases ship from `main`). -- `sentry-cli` **authenticated** — `sentry-cli login` session or `SENTRY_AUTH_TOKEN` - in the env. `release-build.sh` preflight fails fast otherwise. The Sentry org is - pinned as the **slug** (`alex-kroman`), not the numeric ID, or sentry-cli warns. +- (Optional) `DATADOG_API_KEY` in the env for dSYM upload — needs Node/`npx` + (datadog-ci). `DATADOG_SITE` defaults to `datadoghq.com` (US1). If unset, the + build skips symbol upload with a warning rather than failing. - Notary profile `blurt-notary` exists (`xcrun notarytool store-credentials`). - Developer ID signing identity present in the keychain. @@ -24,11 +24,11 @@ the target version with the user before publishing** — publishing is hard to u and build number in `App/Blurt/project.yml`, regenerates the project, and opens a PR (versions land on `main` via PR — `main` is branch-protected). 2. **Build + sign + notarize** — `scripts/release-build.sh` does the whole Apple - path: `xcodebuild` Release → sign nested code **including embedded - frameworks** (`Sentry.framework` must get `--options runtime --timestamp`, or - notarization rejects it) → notarize → staple → DMG → verify. It also uploads - the dSYM to Sentry and creates/finalizes the matching Sentry release - (`dev.alex.blurt@+`). + path: `xcodebuild` Release → sign nested code **including any embedded + frameworks** (each must get `--options runtime --timestamp`, or notarization + rejects it) → notarize → staple → DMG → verify. If `DATADOG_API_KEY` is set it + also uploads the dSYM to Datadog (via `npx @datadog/datadog-ci`) so crashes + symbolicate. 3. **Publish** — `scripts/release-publish.sh` creates the GitHub release and uploads `Blurt.dmg` (the README's download link points at `releases/latest/download/Blurt.dmg`). @@ -41,7 +41,8 @@ the target version with the user before publishing** — publishing is hard to u - Notarization rejects any nested mach-o/framework lacking a **secure timestamp**; the build re-signs frameworks for this reason — don't remove that. - After a release, verify the DMG mounts, the app is stapled - (`xcrun stapler validate`), and the Sentry release shows the dSYM. + (`xcrun stapler validate`), and — if `DATADOG_API_KEY` was set — Datadog shows + the uploaded dSYM. - The post-build install step copies to `/Applications` (TCC needs a stable path); don't redirect it to DerivedData/`/tmp`. diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml index 3ac56585..9b1b898e 100644 --- a/.github/workflows/codeql.yml +++ b/.github/workflows/codeql.yml @@ -69,7 +69,7 @@ jobs: with: persist-credentials: false - # Cache the resolved SwiftPM checkouts so we don't re-clone sentry-cocoa's + # Cache the resolved SwiftPM checkouts so we don't re-clone the SDKs' # full git history on every run. Keyed on the pinned Package.resolved, so a # dependency bump busts the cache. restore-keys lets an older cache seed a # partial hit when the pins change. diff --git a/.mcp.json b/.mcp.json index 74142651..c502b12b 100644 --- a/.mcp.json +++ b/.mcp.json @@ -1,9 +1,5 @@ { "mcpServers": { - "sentry": { - "type": "http", - "url": "https://mcp.sentry.dev/mcp" - }, "assemblyai-docs": { "type": "http", "url": "https://mcp.assemblyai.com/docs" diff --git a/App/Blurt/Blurt.xcodeproj/project.pbxproj b/App/Blurt/Blurt.xcodeproj/project.pbxproj index 0bfcc269..a3ab763a 100644 --- a/App/Blurt/Blurt.xcodeproj/project.pbxproj +++ b/App/Blurt/Blurt.xcodeproj/project.pbxproj @@ -161,6 +161,7 @@ 5E42262A8153912B13356293 /* juno-96-start.m4a in Resources */ = {isa = PBXBuildFile; fileRef = 63AA5C73111EA1F4E03A8C24 /* juno-96-start.m4a */; }; 5EF49E6114C9C7BF79F857D9 /* juno-8-stop.m4a in Resources */ = {isa = PBXBuildFile; fileRef = 2FFA326076660C91492A6CE1 /* juno-8-stop.m4a */; }; 60CE1FFB2833C564F335DFF3 /* juno-47-stop.m4a in Resources */ = {isa = PBXBuildFile; fileRef = 11F215137F50A2D4AC61E414 /* juno-47-stop.m4a */; }; + 6179B457E48877787CE028DA /* DatadogCrashReporting in Frameworks */ = {isa = PBXBuildFile; productRef = 45CD7152E18BFEE0F598216D /* DatadogCrashReporting */; }; 623F42C6E50E5B9EBF9F207A /* juno-22-start.m4a in Resources */ = {isa = PBXBuildFile; fileRef = AD303A24D4133CE3F1F1DE23 /* juno-22-start.m4a */; }; 643FE1E9922319122B1C087D /* rom1a-17-stop.m4a in Resources */ = {isa = PBXBuildFile; fileRef = ED3C851EF55F6BBEBFDAE53A /* rom1a-17-stop.m4a */; }; 645A40A3B0E75729A0E3D1AC /* juno-66-start.m4a in Resources */ = {isa = PBXBuildFile; fileRef = 0CDBED88D2D7CE84776B7574 /* juno-66-start.m4a */; }; @@ -205,6 +206,7 @@ 8061E52E8A874E1BA584A2E7 /* juno-48-stop.m4a in Resources */ = {isa = PBXBuildFile; fileRef = ED5D54496B6973C9F7AE05F3 /* juno-48-stop.m4a */; }; 80D5E7A6D72094E9FC31CF7C /* juno-92-stop.m4a in Resources */ = {isa = PBXBuildFile; fileRef = 3C54A0D394CE71150BB0DBA8 /* juno-92-stop.m4a */; }; 82B97735B1714774843153EF /* juno-90-start.m4a in Resources */ = {isa = PBXBuildFile; fileRef = 79F8F36D7C5D074BC5135FEA /* juno-90-start.m4a */; }; + 82FCEE1383634F9B88976992 /* DatadogLogs in Frameworks */ = {isa = PBXBuildFile; productRef = 056BF2B5954A44C3AEEC6DD2 /* DatadogLogs */; }; 84958721E6DEB4D06E1A01B6 /* juno-42-stop.m4a in Resources */ = {isa = PBXBuildFile; fileRef = F77424E89C3E4B5B787BDFA4 /* juno-42-stop.m4a */; }; 8736CF7025B1EE7C295218D7 /* juno-9-start.m4a in Resources */ = {isa = PBXBuildFile; fileRef = 8887DCE124B065F8313AC59F /* juno-9-start.m4a */; }; 87B7038A5385E149C978908B /* juno-15-start.m4a in Resources */ = {isa = PBXBuildFile; fileRef = 0386BF281886EDBFE9EC8C61 /* juno-15-start.m4a */; }; @@ -229,6 +231,7 @@ 8E70ED1163B872F8D691CDC5 /* juno-118-start.m4a in Resources */ = {isa = PBXBuildFile; fileRef = E084A62CF8F167165466AE30 /* juno-118-start.m4a */; }; 8E89114ADA5FCE4AF9DFB3C0 /* juno-116-start.m4a in Resources */ = {isa = PBXBuildFile; fileRef = 486F5FE65FF8C13A35C28271 /* juno-116-start.m4a */; }; 8FE4B9B139BB9557FBC5589B /* OverlayWindowController.swift in Sources */ = {isa = PBXBuildFile; fileRef = A27D7F43295DE2A81EFFDDBC /* OverlayWindowController.swift */; }; + 9108B9B80FE4395A4D6D4F2C /* Monitoring.swift in Sources */ = {isa = PBXBuildFile; fileRef = B48B1FA4FF13F4A4B56BD27D /* Monitoring.swift */; }; 91C1257866F6E2D4EE65BD09 /* WindowAccessor.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6B2312DE4BC0F4F070830AD7 /* WindowAccessor.swift */; }; 91D775ACB2351468D47731FF /* rom1a-12-start.m4a in Resources */ = {isa = PBXBuildFile; fileRef = E2BCA8E305B596F960CEE1AE /* rom1a-12-start.m4a */; }; 9266EA4F50B9D46E9181FBAF /* rom1a-5-start.m4a in Resources */ = {isa = PBXBuildFile; fileRef = 2E64BCDB280FD0A27D965AA9 /* rom1a-5-start.m4a */; }; @@ -354,7 +357,7 @@ D4782F83F43693371724011D /* rom1b-23-start.m4a in Resources */ = {isa = PBXBuildFile; fileRef = EAB0871FD8CB94A4DCF837CC /* rom1b-23-start.m4a */; }; D5927A391D26CAB212D6FBB4 /* rom1b-3-start.m4a in Resources */ = {isa = PBXBuildFile; fileRef = 49CD6A6815EDDB16BBBCCCB2 /* rom1b-3-start.m4a */; }; D5A84975628548A076CEB3D7 /* juno-118-stop.m4a in Resources */ = {isa = PBXBuildFile; fileRef = 92387F60666853C0AA1BF0B5 /* juno-118-stop.m4a */; }; - D6DFF322638007525EBA02B2 /* Sentry in Frameworks */ = {isa = PBXBuildFile; productRef = 39EB6BB06E04EA9CD2CDC3BA /* Sentry */; }; + D6DFF322638007525EBA02B2 /* DatadogCore in Frameworks */ = {isa = PBXBuildFile; productRef = A0E6571699034CB1C08CB0C9 /* DatadogCore */; }; D750C1BF16502EEC92182ECD /* PermissionsStepView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E8DE60178ABA2CC7F4EB6401 /* PermissionsStepView.swift */; }; D779D9FD904DA9DDC241DA5F /* juno-70-start.m4a in Resources */ = {isa = PBXBuildFile; fileRef = AD5F1BDEB1DB3658DDB96247 /* juno-70-start.m4a */; }; D7CCAEAF551027586E39A032 /* juno-4-start.m4a in Resources */ = {isa = PBXBuildFile; fileRef = 63189CC345106B1D56960B1E /* juno-4-start.m4a */; }; @@ -742,6 +745,7 @@ B401FA111CA49F5A535366AC /* rom1b-15-stop.m4a */ = {isa = PBXFileReference; path = "rom1b-15-stop.m4a"; sourceTree = ""; }; B432346450961555004A2BDE /* juno-99-stop.m4a */ = {isa = PBXFileReference; path = "juno-99-stop.m4a"; sourceTree = ""; }; B460358B22184090D1CCD18E /* juno-68-stop.m4a */ = {isa = PBXFileReference; path = "juno-68-stop.m4a"; sourceTree = ""; }; + B48B1FA4FF13F4A4B56BD27D /* Monitoring.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Monitoring.swift; sourceTree = ""; }; B5F2D2902DFABF0A7F65E712 /* juno-113-stop.m4a */ = {isa = PBXFileReference; path = "juno-113-stop.m4a"; sourceTree = ""; }; B63CC1EF220EB3463D5A4BDF /* rom1b-12-start.m4a */ = {isa = PBXFileReference; path = "rom1b-12-start.m4a"; sourceTree = ""; }; B67F392ADB8F0C2D4BB6827F /* juno-114-start.m4a */ = {isa = PBXFileReference; path = "juno-114-start.m4a"; sourceTree = ""; }; @@ -865,13 +869,23 @@ files = ( 0A7FB8A90BB7E7A5110A4F4D /* BlurtEngine in Frameworks */, A34A51EDE26BC24EB08A3900 /* AppUpdater in Frameworks */, - D6DFF322638007525EBA02B2 /* Sentry in Frameworks */, + D6DFF322638007525EBA02B2 /* DatadogCore in Frameworks */, + 82FCEE1383634F9B88976992 /* DatadogLogs in Frameworks */, + 6179B457E48877787CE028DA /* DatadogCrashReporting in Frameworks */, ); runOnlyForDeploymentPostprocessing = 0; }; /* End PBXFrameworksBuildPhase section */ /* Begin PBXGroup section */ + 0093069C3061D45801274652 /* Monitoring */ = { + isa = PBXGroup; + children = ( + B48B1FA4FF13F4A4B56BD27D /* Monitoring.swift */, + ); + path = Monitoring; + sourceTree = ""; + }; 10E2E11380C974390308C1D2 /* Products */ = { isa = PBXGroup; children = ( @@ -896,6 +910,7 @@ 7D6DE85EA50E7CD4CC977211 /* Branding */, DF92BBDB4B9C15FAE100B783 /* Hotkey */, 8C67CBF637D85C0F642E53B5 /* MenuBar */, + 0093069C3061D45801274652 /* Monitoring */, 912505E1D0FBD5D5CA591BA2 /* Overlay */, DB755555AE4E89FE376399BF /* Resources */, C9A477A17B95181CF6FCCB13 /* Shared */, @@ -1448,7 +1463,9 @@ packageProductDependencies = ( 371215E93A9C2CE60C6347BB /* BlurtEngine */, B7FF7A7170C36BAABD1ECC90 /* AppUpdater */, - 39EB6BB06E04EA9CD2CDC3BA /* Sentry */, + A0E6571699034CB1C08CB0C9 /* DatadogCore */, + 056BF2B5954A44C3AEEC6DD2 /* DatadogLogs */, + 45CD7152E18BFEE0F598216D /* DatadogCrashReporting */, ); productName = Blurt; productReference = 5E2309BDEF30ED9EFED5B849 /* Blurt.app */; @@ -1485,7 +1502,7 @@ minimizedProjectReferenceProxies = 1; packageReferences = ( 1B0B6EAFD1174B4B9140D250 /* XCRemoteSwiftPackageReference "AppUpdater" */, - A70C65ACE504420011952EBA /* XCRemoteSwiftPackageReference "sentry-cocoa" */, + 0F8ABF4083EB1DDD289350B7 /* XCRemoteSwiftPackageReference "dd-sdk-ios" */, BFFF3588F0D8C2D0EEF7BC75 /* XCLocalSwiftPackageReference "../.." */, ); preferredProjectObjectVersion = 77; @@ -1955,6 +1972,7 @@ B5A7CEA6517E5A1E4D77623F /* FloatingPanel.swift in Sources */, F42E6D4E62345772C5A49A08 /* HotkeyStepView.swift in Sources */, AD32046355138298E7FC40D0 /* MenuBarScene.swift in Sources */, + 9108B9B80FE4395A4D6D4F2C /* Monitoring.swift in Sources */, 9437C292A6D7FE533338073D /* OverlayView.swift in Sources */, 8FE4B9B139BB9557FBC5589B /* OverlayWindowController.swift in Sources */, D750C1BF16502EEC92182ECD /* PermissionsStepView.swift in Sources */, @@ -2231,33 +2249,43 @@ /* End XCLocalSwiftPackageReference section */ /* Begin XCRemoteSwiftPackageReference section */ - 1B0B6EAFD1174B4B9140D250 /* XCRemoteSwiftPackageReference "AppUpdater" */ = { + 0F8ABF4083EB1DDD289350B7 /* XCRemoteSwiftPackageReference "dd-sdk-ios" */ = { isa = XCRemoteSwiftPackageReference; - repositoryURL = "https://github.com/mxcl/AppUpdater"; + repositoryURL = "https://github.com/DataDog/dd-sdk-ios"; requirement = { kind = upToNextMajorVersion; - minimumVersion = 2.1.1; + minimumVersion = 3.13.0; }; }; - A70C65ACE504420011952EBA /* XCRemoteSwiftPackageReference "sentry-cocoa" */ = { + 1B0B6EAFD1174B4B9140D250 /* XCRemoteSwiftPackageReference "AppUpdater" */ = { isa = XCRemoteSwiftPackageReference; - repositoryURL = "https://github.com/getsentry/sentry-cocoa"; + repositoryURL = "https://github.com/mxcl/AppUpdater"; requirement = { kind = upToNextMajorVersion; - minimumVersion = 9.19.0; + minimumVersion = 2.1.1; }; }; /* End XCRemoteSwiftPackageReference section */ /* Begin XCSwiftPackageProductDependency section */ + 056BF2B5954A44C3AEEC6DD2 /* DatadogLogs */ = { + isa = XCSwiftPackageProductDependency; + package = 0F8ABF4083EB1DDD289350B7 /* XCRemoteSwiftPackageReference "dd-sdk-ios" */; + productName = DatadogLogs; + }; 371215E93A9C2CE60C6347BB /* BlurtEngine */ = { isa = XCSwiftPackageProductDependency; productName = BlurtEngine; }; - 39EB6BB06E04EA9CD2CDC3BA /* Sentry */ = { + 45CD7152E18BFEE0F598216D /* DatadogCrashReporting */ = { + isa = XCSwiftPackageProductDependency; + package = 0F8ABF4083EB1DDD289350B7 /* XCRemoteSwiftPackageReference "dd-sdk-ios" */; + productName = DatadogCrashReporting; + }; + A0E6571699034CB1C08CB0C9 /* DatadogCore */ = { isa = XCSwiftPackageProductDependency; - package = A70C65ACE504420011952EBA /* XCRemoteSwiftPackageReference "sentry-cocoa" */; - productName = Sentry; + package = 0F8ABF4083EB1DDD289350B7 /* XCRemoteSwiftPackageReference "dd-sdk-ios" */; + productName = DatadogCore; }; B7FF7A7170C36BAABD1ECC90 /* AppUpdater */ = { isa = XCSwiftPackageProductDependency; diff --git a/App/Blurt/Blurt.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved b/App/Blurt/Blurt.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved index 4e2e6b0a..27f545c7 100644 --- a/App/Blurt/Blurt.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ b/App/Blurt/Blurt.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -1,5 +1,5 @@ { - "originHash" : "9d04cd778850e60aa40cee24d02882a4d5ff2794b24c0d2b11957e38a50b5b3d", + "originHash" : "d32b2af4166f93daf367d58fe94ee784347f77b1316b1ac4ad350fb4a26b3d50", "pins" : [ { "identity" : "appupdater", @@ -11,12 +11,39 @@ } }, { - "identity" : "sentry-cocoa", + "identity" : "dd-sdk-ios", "kind" : "remoteSourceControl", - "location" : "https://github.com/getsentry/sentry-cocoa", + "location" : "https://github.com/DataDog/dd-sdk-ios", "state" : { - "revision" : "00a2fec32ea665b81e406419e078bb713238e23f", - "version" : "9.19.0" + "revision" : "8446c244769af48f3fdd7e10bcebb3012371511c", + "version" : "3.13.0" + } + }, + { + "identity" : "kscrash", + "kind" : "remoteSourceControl", + "location" : "https://github.com/kstenerud/KSCrash.git", + "state" : { + "revision" : "95a8895d75f3c22aa9ad9f2a15d2fbd97b0a55e2", + "version" : "2.5.1" + } + }, + { + "identity" : "opentelemetry-swift-core", + "kind" : "remoteSourceControl", + "location" : "https://github.com/open-telemetry/opentelemetry-swift-core", + "state" : { + "revision" : "d77f1400f4c5738f04a1aec0a1246aec5240e9fa", + "version" : "2.5.0" + } + }, + { + "identity" : "swift-atomics", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-atomics.git", + "state" : { + "revision" : "0442cb5a3f98ab802acb777929fdb446bda11a34", + "version" : "1.3.1" } }, { diff --git a/App/Blurt/Blurt/AppCoordinator.swift b/App/Blurt/Blurt/AppCoordinator.swift index 8b3515cd..dcb8c9da 100644 --- a/App/Blurt/Blurt/AppCoordinator.swift +++ b/App/Blurt/Blurt/AppCoordinator.swift @@ -2,7 +2,6 @@ import AVFoundation import BlurtEngine import Foundation import Observation -import Sentry @MainActor @Observable @@ -328,18 +327,18 @@ final class AppCoordinator { } wasRecording = isRecording - // A .failed phase is a handled error (it doesn't crash the app), so Sentry's - // automatic crash reporting never sees it — report the genuine faults here. + // A .failed phase is a handled error (it doesn't crash the app), so the + // crash reporter never sees it — report the genuine faults here. if case .failed(let error) = phase { - reportFaultToSentry(error) + reportFault(error) } } - /// Reports a pipeline failure to Sentry, but only the genuine faults. The + /// Reports a pipeline failure to Datadog, but only the genuine faults. The /// permission- and key-missing cases are expected setup states the wizard /// already guides the user through, so reporting them would just be noise. The /// underlying error rides along as context for the wrapped cases. - private func reportFaultToSentry(_ error: BlurtError) { + private func reportFault(_ error: BlurtError) { switch error { case .microphonePermissionDenied, .accessibilityPermissionMissing, .apiKeyMissing: return @@ -348,15 +347,13 @@ final class AppCoordinator { // but it's not a fault to report regardless — there was simply nowhere to type. return case .targetAppLost: - SentrySDK.capture(error: error) + Monitoring.reportError(error) case .sttFailed(let underlying), .audioCaptureFailed(let underlying): // A dropped/absent network connection isn't a Blurt fault — it's the // user being offline mid-dictation. The pipeline still surfaces a .failed - // phase to them; we just don't page Sentry about it. + // phase to them; we just don't report it. if Self.isConnectivityError(underlying) { return } - SentrySDK.capture(error: error) { scope in - scope.setExtra(value: String(describing: underlying), key: "underlying_error") - } + Monitoring.reportError(error, attributes: ["underlying_error": String(describing: underlying)]) } } diff --git a/App/Blurt/Blurt/AppDelegate.swift b/App/Blurt/Blurt/AppDelegate.swift index 7b1e6bc7..fa4827ad 100644 --- a/App/Blurt/Blurt/AppDelegate.swift +++ b/App/Blurt/Blurt/AppDelegate.swift @@ -2,7 +2,6 @@ import AppKit import BlurtEngine import Foundation import Observation -import Sentry /// Owns the long-lived models and runs launch-time setup. The setup wizard and /// settings UI are SwiftUI `Window` scenes (see `BlurtApp` / `MainWindowRoot` @@ -95,24 +94,17 @@ final class AppDelegate: NSObject, NSApplicationDelegate { @MainActor func applicationDidFinishLaunching(_ notification: Notification) { // Crash/error reporting. Started as early as possible so launch-time failures - // are captured. Release-only: local dev runs the Debug configuration, and we - // don't want developer crashes/usage polluting production Sentry data. (The - // `SentrySDK.capture` calls in `AppCoordinator` are safe no-ops when the SDK - // was never started, so they self-disable in Debug too.) Blurt never sends - // dictation text or transcripts to Sentry; `sendDefaultPii` is left off so we - // don't attach the reporter's IP either — crash grouping doesn't need it, and - // a dictation app shouldn't ship identifying data it doesn't use. The - // diagnostics this does send are disclosed in README.md and SECURITY.md. - #if !DEBUG - SentrySDK.start { options in - options.dsn = "https://8191e48535bcc3fd861f912ae8735e18@o4509792651902976.ingest.us.sentry.io/4511634026004480" - // Pinned rather than left to the SDK's auto-detection: we only start - // Sentry in Release builds (see the `#if !DEBUG`), so every reporting - // session is production, and release-health charts filter on this. - options.environment = "production" - options.sendDefaultPii = false - } - #endif + // are captured. `start()` is release-only — it no-ops in Debug via a runtime + // guard (called unconditionally, rather than wrapped in `#if !DEBUG`, so the + // config stays reachable for the dead-code scan). Local dev runs Debug, and we + // don't want developer crashes/usage polluting production data. (The + // `Monitoring.reportError` calls in `AppCoordinator` are safe no-ops when the + // SDK was never started, so they self-disable in Debug too.) Blurt never sends + // dictation text or transcripts to Datadog, and no PII is attached — crash + // grouping doesn't need it, and a dictation app shouldn't ship identifying + // data it doesn't use. The diagnostics this does send (env "production") are + // disclosed in README.md and SECURITY.md. + Monitoring.start() // No permission prompts fire at launch. Accessibility (and Microphone) are // requested only when the user taps the matching button in the setup diff --git a/App/Blurt/Blurt/Monitoring/Monitoring.swift b/App/Blurt/Blurt/Monitoring/Monitoring.swift new file mode 100644 index 00000000..8b641053 --- /dev/null +++ b/App/Blurt/Blurt/Monitoring/Monitoring.swift @@ -0,0 +1,97 @@ +import DatadogCore +import DatadogCrashReporting +import DatadogLogs +import Foundation + +/// Datadog crash + error monitoring. Replaces the former Sentry integration. +/// +/// Started from `AppDelegate` at launch; `start()` no-ops in Debug via the +/// `isReleaseBuild` runtime guard (local dev runs the Debug config, and we don't +/// want developer crashes/usage polluting production data). Blurt never +/// sends dictation text or transcripts. The only identifier attached is a random, +/// locally-generated install id (`@usr.id`) used to count unique installs — it +/// carries no personal information and isn't tied to the user's identity. The +/// diagnostics this ships are disclosed in README.md and SECURITY.md. +/// +/// - `CrashReporting` captures crashes — the Sentry crash-reporting equivalent. +/// - A `Logs` logger records handled faults via ``reportError(_:attributes:)`` — +/// the Sentry `capture(error:)` equivalent. Both crashes and error logs feed +/// Datadog Error Tracking. +/// - `start()` also emits one "app launched" log per run so adoption / unique- +/// install counts have baseline volume (not just installs that hit an error). +/// +/// Datadog RUM is intentionally not used: it links UIKit and does not build for +/// native (AppKit) macOS, only Mac Catalyst. Logs + Crash Reporting are the +/// supported best-practice stack for a native macOS app. +/// +/// `@MainActor` so the one-time `start()` and the `logger` it sets are isolated +/// (both callers — `AppDelegate` launch and `AppCoordinator` — are main-actor). +@MainActor +enum Monitoring { + // Public identifiers — safe to ship in the binary, like the old Sentry DSN. + // The client token is from Datadog (US1, datadoghq.com). + private static let clientToken = "pub13781bb00c1c14ae35f16d2b98550454" + private static let service = "blurt" + private static let environment = "production" + + // UserDefaults key holding the anonymous, per-install id set as `@usr.id`. + private static let installIDKey = "dev.alex.blurt.anonymous-install-id" + + // Release-only gate. A runtime flag (not `#if` at the call site) so `start()` + // and its config stay reachable for the dead-code scan while execution is still + // limited to Release builds — dev crashes/usage must never hit production. + private static var isReleaseBuild: Bool { + #if DEBUG + false + #else + true + #endif + } + + /// Set by `start()`. `reportError` is a no-op until then, so it self-disables + /// in Debug (where the SDK is never started), just like the old Sentry calls. + private static var logger: (any LoggerProtocol)? + + /// Initializes the SDK, enables crash reporting + logging, tags telemetry with + /// the anonymous install id, and logs one launch event. Call once, at launch. + static func start() { + guard isReleaseBuild else { return } + Datadog.initialize( + with: Datadog.Configuration( + clientToken: clientToken, + env: environment, + site: .us1, + service: service + ), + trackingConsent: .granted + ) + // Anonymous install id only — no name/email/PII — so unique-install and + // version-adoption counts work without identifying anyone. + Datadog.setUserInfo(id: installID) + CrashReporting.enable() + Logs.enable() + let logger = Logger.create( + with: Logger.Configuration(name: service, networkInfoEnabled: true) + ) + Self.logger = logger + // Baseline telemetry: one launch event per run, so adoption / unique-install + // metrics see every install, not only the ones that hit an error. + logger.info("app launched") + } + + /// Reports a handled fault (the app didn't crash) as an error log, with optional + /// context attributes. A no-op when the SDK was never started (Debug). + static func reportError(_ error: Error, attributes: [String: any Encodable] = [:]) { + logger?.error("dictation pipeline fault", error: error, attributes: attributes) + } + + /// A random UUID minted once per install and persisted, used as the anonymous + /// `@usr.id`. Not tied to the user's identity; regenerates on a fresh install. + private static var installID: String { + let defaults = UserDefaults.standard + if let existing = defaults.string(forKey: installIDKey) { return existing } + let generated = UUID().uuidString + defaults.set(generated, forKey: installIDKey) + return generated + } +} diff --git a/App/Blurt/project.yml b/App/Blurt/project.yml index a7e1102e..24361f38 100644 --- a/App/Blurt/project.yml +++ b/App/Blurt/project.yml @@ -24,9 +24,9 @@ packages: AppUpdater: url: https://github.com/mxcl/AppUpdater from: "2.1.1" - Sentry: - url: https://github.com/getsentry/sentry-cocoa - from: "9.19.0" + Datadog: + url: https://github.com/DataDog/dd-sdk-ios + from: "3.13.0" targets: Blurt: type: application @@ -78,8 +78,12 @@ targets: product: BlurtEngine - package: AppUpdater product: AppUpdater - - package: Sentry - product: Sentry + - package: Datadog + product: DatadogCore + - package: Datadog + product: DatadogLogs + - package: Datadog + product: DatadogCrashReporting postBuildScripts: - name: Install to /Applications script: | diff --git a/README.md b/README.md index 56cea243..7e6d8c87 100644 --- a/README.md +++ b/README.md @@ -80,8 +80,10 @@ you are dictating, then sent over HTTPS to AssemblyAI for transcription. Blurt stores no audio and no transcripts. Release builds send crash reports and a few handled errors to -[Sentry](https://sentry.io). Those reports are diagnostic only: stack traces and -error details, never your audio, transcripts, or API key. +[Datadog](https://www.datadoghq.com). Those reports are diagnostic only: stack +traces and error details, never your audio, transcripts, or API key. Blurt also +sends an anonymous, random install identifier and app-launch events so we can +measure unique installs and version adoption — neither is tied to your identity. Because transcription is processed by AssemblyAI, their policies apply to that audio: diff --git a/SECURITY.md b/SECURITY.md index 2aacba05..4da60103 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -33,9 +33,11 @@ how Blurt handles audio, the API key, the clipboard, or the accessibility permissions it uses are in scope here. Release builds also send crash reports and a few handled errors to -[Sentry](https://sentry.io) for stability monitoring. These are diagnostic only +[Datadog](https://www.datadoghq.com) for stability monitoring. These are diagnostic only (stack traces and error details) — they never include your audio, transcripts, -or API key, and Blurt does not attach your IP address. Debug builds send +or API key, and Blurt does not attach your IP address. To count unique installs +and version adoption, Blurt also attaches a random, anonymous install identifier +(not tied to your identity) and logs an app-launch event. Debug builds send nothing. ## Supported versions diff --git a/Sources/BlurtEngine/Pipeline/DictationSession.swift b/Sources/BlurtEngine/Pipeline/DictationSession.swift index d6a9b009..94bf5344 100644 --- a/Sources/BlurtEngine/Pipeline/DictationSession.swift +++ b/Sources/BlurtEngine/Pipeline/DictationSession.swift @@ -308,7 +308,7 @@ public actor DictationSession { } catch BlurtError.noEditableTarget { // Not a failure: transcription worked, there was just nowhere to type. The // injector left the text on the clipboard — show the quiet "copied" notice - // rather than the red error flash (and don't page Sentry). + // rather than the red error flash (and don't report it). setPhase(.noTarget) } catch let err as BlurtError { // Surface the injector's real error (e.g. `.targetAppLost`) rather than diff --git a/scripts/release-build.sh b/scripts/release-build.sh index 728d55d0..44882b6c 100755 --- a/scripts/release-build.sh +++ b/scripts/release-build.sh @@ -2,16 +2,12 @@ # Build, sign, notarize, and staple a release DMG into build/release/. # Reproducible; safe to re-run. The publish step is a separate script. # -# Sentry (required): `sentry-cli` must be installed and authenticated — either a -# `sentry-cli login` session or a SENTRY_AUTH_TOKEN in the environment. The auth -# token is the ONLY secret and is never stored in this repo. The org slug + -# project ID below are NOT secrets (the project ID is the same one in the public -# DSN committed in AppDelegate.swift — a DSN ships inside the app binary by -# design), so they're hard-coded here. The build uploads the dSYM (so Release -# crashes symbolicate) -# and creates + finalizes the matching Sentry release (so release-health adoption -# / crash-free-by-release charts populate). Unauthenticated or no sentry-cli → -# preflight fails before building. +# Datadog (optional): if DATADOG_API_KEY is set, the build uploads the dSYM via +# datadog-ci (run through npx, so Node/npm is needed) so Release crashes +# symbolicate in Datadog. The API key is the only secret and is never stored in +# this repo; DATADOG_SITE defaults to datadoghq.com (US1). With no key the upload +# is skipped with a warning — crashes still report, just unsymbolicated — so a +# release never fails on telemetry. set -euo pipefail @@ -26,15 +22,6 @@ readonly IDENTITY="640A7F5A9754400D4A0491E7A6FB30542D907806" readonly TEAM_ID="Y54ZB9JF63" readonly NOTARY_PROFILE="blurt-notary" -# Sentry org slug + project ID (not secrets — see the header note). Exported so -# sentry-cli picks them up without per-command --org/--project flags. The org -# must be the *slug*, not the numeric ID from the DSN: sentry-cli matches the -# slug embedded in the auth token, and a numeric org ID makes it warn and fall -# back to the token's org. (The org is inferable from the token, but pinning it -# keeps the target explicit.) -export SENTRY_ORG="alex-kroman" -export SENTRY_PROJECT="4511634026004480" - SKIP_CHECKS=0 for arg in "$@"; do case "$arg" in @@ -80,16 +67,15 @@ else fi step "Preflight" -for cmd in xcodegen xcodebuild xcrun hdiutil codesign spctl create-dmg awk shasum sentry-cli; do - command -v "$cmd" >/dev/null 2>&1 || die "missing required tool: $cmd (brew install create-dmg / getsentry/tools/sentry-cli if needed)" +for cmd in xcodegen xcodebuild xcrun hdiutil codesign spctl create-dmg awk shasum; do + command -v "$cmd" >/dev/null 2>&1 || die "missing required tool: $cmd (brew install create-dmg if needed)" done -# Sentry auth must be present — the dSYM upload + release creation later depend -# on it, so fail now rather than after the expensive build/notarization. We don't -# dictate *how* it's supplied (a `sentry-cli login` session or a SENTRY_AUTH_TOKEN -# in the environment both work), only that sentry-cli is authenticated. -if sentry-cli info 2>&1 | grep -q "Method: Unauthorized"; then - die "sentry-cli not authenticated — run 'sentry-cli login' (or export SENTRY_AUTH_TOKEN)" +# Datadog dSYM upload is best-effort: warn now if DATADOG_API_KEY is unset so the +# maintainer knows this build's crashes won't symbolicate, but don't block the +# release on it (unlike the Apple signing/notarization steps, which are required). +if [ -z "${DATADOG_API_KEY:-}" ]; then + info "note: DATADOG_API_KEY unset — dSYM upload will be skipped (crashes unsymbolicated)" fi xcrun notarytool history --keychain-profile "$NOTARY_PROFILE" >/dev/null 2>&1 \ @@ -167,7 +153,7 @@ while IFS= read -r -d '' f; do codesign --force --sign "$IDENTITY" --options runtime --timestamp "$f" NESTED_COUNT=$((NESTED_COUNT + 1)) done < <(find "$APP_STAGED" -type f \( -name "*.dylib" -o -name "*.so" \) -print0) -# 2. Embedded framework bundles (e.g. Sentry.framework). Their mach-o binary has +# 2. Embedded framework bundles, if any. Their mach-o binary has # no dylib/so suffix, so step 1 misses it — sign the bundle so its signature is # refreshed. `-depth` yields the deepest frameworks first, so a nested framework # is signed before any framework that contains it. @@ -261,29 +247,19 @@ rmdir "$MOUNT_POINT" >/dev/null 2>&1 || true trap - EXIT info "dmg contents verified (Blurt.app $MOUNTED_VERSION, signed + stapled)" -step "Sentry (dSYM + release)" -# Runs after all the Apple steps so a Sentry hiccup never wastes notarization. -# Auth + sentry-cli are validated in preflight and org/project are exported -# above, so the commands here need no credentials or --org/--project flags. -# The Sentry release name must match what the in-app SDK auto-tags events with: -# {CFBundleIdentifier}@{CFBundleShortVersionString}+{CFBundleVersion}. -BUILD_NUM="$(awk '/CFBundleVersion:/ {gsub(/"/,"",$2); print $2; exit}' "$APP_DIR/project.yml")" -[ -n "$BUILD_NUM" ] || die "could not parse CFBundleVersion from $APP_DIR/project.yml" -SENTRY_RELEASE="dev.alex.blurt@${VERSION}+${BUILD_NUM}" -info "sentry release: $SENTRY_RELEASE" - -# Symbolicates crashes from the optimized/stripped Release build. -sentry-cli debug-files upload "$DSYM_DST" - -# Create + finalize so adoption / crash-free-by-release / regression charts -# light up. Commit association is best-effort — it needs either a Sentry repo -# integration (--auto) or local git history (--local). -sentry-cli releases new "$SENTRY_RELEASE" -sentry-cli releases set-commits "$SENTRY_RELEASE" --auto \ - || sentry-cli releases set-commits "$SENTRY_RELEASE" --local \ - || info "commit association skipped (no repo integration / git history)" -sentry-cli releases finalize "$SENTRY_RELEASE" -info "sentry: dSYM uploaded + release $SENTRY_RELEASE finalized" +step "Datadog (dSYM upload)" +# Runs after all the Apple steps so a Datadog hiccup never wastes notarization. +# Uploads the Release build's dSYM so optimized/stripped crashes symbolicate in +# Datadog. Best-effort: skipped (with a note) when DATADOG_API_KEY is unset, so a +# release never fails on telemetry. datadog-ci reads DATADOG_API_KEY and +# DATADOG_SITE from the environment; DATADOG_SITE defaults to datadoghq.com (US1). +if [ -n "${DATADOG_API_KEY:-}" ]; then + DATADOG_SITE="${DATADOG_SITE:-datadoghq.com}" \ + npx --yes @datadog/datadog-ci dsyms upload "$DSYM_DST" + info "datadog: dSYM uploaded from $DSYM_DST" +else + info "datadog: DATADOG_API_KEY unset — skipped dSYM upload" +fi step "Provenance" PROVENANCE="$BUILD_ROOT/build-info.txt"