From dc34f40d9a9628f87e3ebfe1b19d21af86b23baa Mon Sep 17 00:00:00 2001
From: Alex Kroman
Date: Wed, 1 Jul 2026 12:15:35 -0400
Subject: [PATCH 1/5] Point updater + all repo links at AssemblyAI/blurt
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
The project now lives at github.com/AssemblyAI/blurt, so:
- AutoUpdater fetches releases from owner "AssemblyAI" (was "alexkroman"),
so the in-app self-update pulls from this repo's GitHub Releases.
- All github.com/alexkroman/blurt links → github.com/AssemblyAI/blurt
across README, site, SECURITY, CONTRIBUTING, issue templates, and the
launch-metrics skill's default repo.
Left CODE_OF_CONDUCT's @alexkroman link alone — that's the maintainer's
personal contact for reporting, not a repository link.
Co-Authored-By: Claude Opus 4.8 (1M context)
---
.claude/skills/launch-metrics/metrics.sh | 2 +-
.github/ISSUE_TEMPLATE/bug_report.yml | 2 +-
.github/ISSUE_TEMPLATE/config.yml | 4 ++--
.github/ISSUE_TEMPLATE/feature_request.yml | 2 +-
App/Blurt/Blurt/Update/AutoUpdater.swift | 4 ++--
CONTRIBUTING.md | 2 +-
README.md | 6 +++---
SECURITY.md | 4 ++--
site/index.html | 10 +++++-----
9 files changed, 18 insertions(+), 18 deletions(-)
diff --git a/.claude/skills/launch-metrics/metrics.sh b/.claude/skills/launch-metrics/metrics.sh
index 5993d813..e4ecbcbc 100755
--- a/.claude/skills/launch-metrics/metrics.sh
+++ b/.claude/skills/launch-metrics/metrics.sh
@@ -7,7 +7,7 @@
# shellcheck disable=SC2016
set -euo pipefail
-REPO="${BLURT_REPO:-alexkroman/blurt}"
+REPO="${BLURT_REPO:-AssemblyAI/blurt}"
if ! command -v gh >/dev/null 2>&1; then
echo "gh CLI not found — install it and run 'gh auth login'." >&2
diff --git a/.github/ISSUE_TEMPLATE/bug_report.yml b/.github/ISSUE_TEMPLATE/bug_report.yml
index 992f8293..c15c9519 100644
--- a/.github/ISSUE_TEMPLATE/bug_report.yml
+++ b/.github/ISSUE_TEMPLATE/bug_report.yml
@@ -5,7 +5,7 @@ body:
- type: markdown
attributes:
value: |
- For security issues, don't file a public issue — see [SECURITY.md](https://github.com/alexkroman/blurt/blob/main/SECURITY.md).
+ For security issues, don't file a public issue — see [SECURITY.md](https://github.com/AssemblyAI/blurt/blob/main/SECURITY.md).
- type: textarea
id: what-happened
attributes:
diff --git a/.github/ISSUE_TEMPLATE/config.yml b/.github/ISSUE_TEMPLATE/config.yml
index d579f4e6..1abaabc4 100644
--- a/.github/ISSUE_TEMPLATE/config.yml
+++ b/.github/ISSUE_TEMPLATE/config.yml
@@ -1,8 +1,8 @@
blank_issues_enabled: false
contact_links:
- name: Question or discussion
- url: https://github.com/alexkroman/blurt/discussions
+ url: https://github.com/AssemblyAI/blurt/discussions
about: Ask a question or share an idea that isn't a bug or feature request.
- name: Security vulnerability
- url: https://github.com/alexkroman/blurt/security/advisories/new
+ url: https://github.com/AssemblyAI/blurt/security/advisories/new
about: Report a security issue privately — please don't open a public issue.
diff --git a/.github/ISSUE_TEMPLATE/feature_request.yml b/.github/ISSUE_TEMPLATE/feature_request.yml
index a77b89d3..37ecf746 100644
--- a/.github/ISSUE_TEMPLATE/feature_request.yml
+++ b/.github/ISSUE_TEMPLATE/feature_request.yml
@@ -5,7 +5,7 @@ body:
- type: markdown
attributes:
value: |
- Blurt aims to stay small and focused — see ["Things deliberately not here"](https://github.com/alexkroman/blurt/blob/main/AGENTS.md) for what's intentionally out of scope.
+ Blurt aims to stay small and focused — see ["Things deliberately not here"](https://github.com/AssemblyAI/blurt/blob/main/AGENTS.md) for what's intentionally out of scope.
- type: textarea
id: idea
attributes:
diff --git a/App/Blurt/Blurt/Update/AutoUpdater.swift b/App/Blurt/Blurt/Update/AutoUpdater.swift
index 6c3d9a54..2df3ee67 100644
--- a/App/Blurt/Blurt/Update/AutoUpdater.swift
+++ b/App/Blurt/Blurt/Update/AutoUpdater.swift
@@ -7,7 +7,7 @@ import OSLog
/// AppUpdater ships no UI of its own — it just fetches GitHub Releases, verifies
/// the download was signed by the same Developer ID as the running app, and (on
/// request) swaps the bundle in place and relaunches. So this type supplies the
-/// UI: on launch it checks for a newer release of `alexkroman/blurt` and, when
+/// UI: on launch it checks for a newer release of `AssemblyAI/blurt` and, when
/// one exists, presents a Sparkle-style modal offering **Install and Relaunch**
/// or **Later** (mirroring `SPUStandardUserDriver`'s update-available alert).
///
@@ -16,7 +16,7 @@ import OSLog
/// which matches `blurt-`. See `scripts/release-publish.sh`.
@MainActor
final class AutoUpdater {
- private let updater = AppUpdater(owner: "alexkroman", repo: "blurt")
+ private let updater = AppUpdater(owner: "AssemblyAI", repo: "blurt")
private let log = Logger(subsystem: "dev.alex.blurt", category: "update")
/// Supplies the window to host the update prompt as a sheet (see `runAlert`).
diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md
index f6eb2195..bbcc92ab 100644
--- a/CONTRIBUTING.md
+++ b/CONTRIBUTING.md
@@ -43,7 +43,7 @@ If you install the git hooks, `check.sh` runs automatically on every commit.
## Reporting bugs and ideas
-Open an [issue](https://github.com/alexkroman/blurt/issues) using one of the
+Open an [issue](https://github.com/AssemblyAI/blurt/issues) using one of the
templates. For bugs, include your macOS version and steps to reproduce. For a
security issue, don't open a public issue — see [`SECURITY.md`](./SECURITY.md).
diff --git a/README.md b/README.md
index c35da6e2..56cea243 100644
--- a/README.md
+++ b/README.md
@@ -14,7 +14,7 @@
Powered by AssemblyAI.
From 40a2764ba27c3fd22d295d98a7fb21070a1fc29e Mon Sep 17 00:00:00 2001
From: Alex Kroman
Date: Wed, 1 Jul 2026 12:38:33 -0400
Subject: [PATCH 2/5] Replace Sentry with Datadog monitoring (dd-sdk-ios)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Swap the crash/error-reporting stack from sentry-cocoa to the Datadog
Swift SDK (dd-sdk-ios 3.13.0).
New `Monitoring` module (Release-only, no PII, US1) wires:
- DatadogCore + DatadogCrashReporting — crash reports (Sentry crash equiv).
- DatadogLogs — handled faults reported as error logs via reportError(),
preserving AppCoordinator's fault filtering + underlying_error attribute
(Sentry capture(error:) equiv). Both feed Datadog Error Tracking.
RUM is intentionally not used: DatadogRUM 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.
Also: remove the Sentry SPM dependency + release-build.sh sentry-cli
(dSYM/release) steps, replaced with best-effort `datadog-ci dsyms upload`
(guarded on DATADOG_API_KEY); update README/SECURITY/release skill wording
and the codeql cache comment.
Verified: app builds on macos (arch=arm64, warnings-as-errors); shellcheck,
swift-format, actionlint, prettier, markdownlint all pass.
Co-Authored-By: Claude Opus 4.8 (1M context)
---
.claude/skills/release/SKILL.md | 21 ++---
.github/workflows/codeql.yml | 2 +-
App/Blurt/Blurt.xcodeproj/project.pbxproj | 54 +++++++++----
.../xcshareddata/swiftpm/Package.resolved | 37 +++++++--
App/Blurt/Blurt/AppCoordinator.swift | 19 ++---
App/Blurt/Blurt/AppDelegate.swift | 24 ++----
App/Blurt/Blurt/Monitoring/Monitoring.swift | 59 ++++++++++++++
App/Blurt/project.yml | 14 ++--
README.md | 4 +-
SECURITY.md | 2 +-
.../Pipeline/DictationSession.swift | 2 +-
scripts/release-build.sh | 78 +++++++------------
12 files changed, 200 insertions(+), 116 deletions(-)
create mode 100644 App/Blurt/Blurt/Monitoring/Monitoring.swift
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/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..0834d73f 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`
@@ -96,22 +95,15 @@ final class AppDelegate: NSObject, NSApplicationDelegate {
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.
+ // 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.
#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
- }
+ Monitoring.start()
#endif
// No permission prompts fire at launch. Accessibility (and Microphone) are
diff --git a/App/Blurt/Blurt/Monitoring/Monitoring.swift b/App/Blurt/Blurt/Monitoring/Monitoring.swift
new file mode 100644
index 00000000..279c1413
--- /dev/null
+++ b/App/Blurt/Blurt/Monitoring/Monitoring.swift
@@ -0,0 +1,59 @@
+import DatadogCore
+import DatadogCrashReporting
+import DatadogLogs
+import Foundation
+
+/// Datadog crash + error monitoring. Replaces the former Sentry integration.
+///
+/// Started Release-only from `AppDelegate` (local dev runs the Debug config, and
+/// we don't want developer crashes/usage polluting production data). Blurt never
+/// sends dictation text or transcripts, and no PII is attached — 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.
+///
+/// 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"
+
+ /// 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 and enables crash reporting + logging. Call once, at launch.
+ static func start() {
+ Datadog.initialize(
+ with: Datadog.Configuration(
+ clientToken: clientToken,
+ env: environment,
+ site: .us1,
+ service: service
+ ),
+ trackingConsent: .granted
+ )
+ CrashReporting.enable()
+ Logs.enable()
+ logger = Logger.create(
+ with: Logger.Configuration(name: service, networkInfoEnabled: true)
+ )
+ }
+
+ /// 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)
+ }
+}
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..6cae6029 100644
--- a/README.md
+++ b/README.md
@@ -80,8 +80,8 @@ 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.
Because transcription is processed by AssemblyAI, their policies apply to that
audio:
diff --git a/SECURITY.md b/SECURITY.md
index 2aacba05..6b1b88f1 100644
--- a/SECURITY.md
+++ b/SECURITY.md
@@ -33,7 +33,7 @@ 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
nothing.
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"
From 9ea6f74d59c97585e43145388efa7170930ba63e Mon Sep 17 00:00:00 2001
From: Alex Kroman
Date: Wed, 1 Jul 2026 12:55:48 -0400
Subject: [PATCH 3/5] Remove the Sentry MCP server from .mcp.json
Completes the Sentry removal: drop the now-defunct Sentry MCP server
config, leaving only assemblyai-docs.
Co-Authored-By: Claude Opus 4.8 (1M context)
---
.mcp.json | 4 ----
1 file changed, 4 deletions(-)
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"
From bd742489c6eaacdcb7ac94111d45ad5b5ea72c69 Mon Sep 17 00:00:00 2001
From: Alex Kroman
Date: Wed, 1 Jul 2026 13:33:38 -0400
Subject: [PATCH 4/5] Add anonymous install id + launch event to Datadog
monitoring
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Set a random, locally-generated install UUID as Datadog's @usr.id (no
name/email/PII) and emit one "app launched" log per run, so unique-install
and version-adoption metrics have baseline volume — not just installs that
hit an error. Powers the Unique users / Adoption widgets on the Blurt
dashboard.
Using UUID + UserDefaults also makes `import Foundation` required again,
fixing the swiftlint analyze unused_import failure. Disclosed in
README.md and SECURITY.md.
Co-Authored-By: Claude Opus 4.8 (1M context)
---
App/Blurt/Blurt/Monitoring/Monitoring.swift | 33 ++++++++++++++++++---
README.md | 4 ++-
SECURITY.md | 4 ++-
3 files changed, 35 insertions(+), 6 deletions(-)
diff --git a/App/Blurt/Blurt/Monitoring/Monitoring.swift b/App/Blurt/Blurt/Monitoring/Monitoring.swift
index 279c1413..bf99a80f 100644
--- a/App/Blurt/Blurt/Monitoring/Monitoring.swift
+++ b/App/Blurt/Blurt/Monitoring/Monitoring.swift
@@ -7,13 +7,17 @@ import Foundation
///
/// Started Release-only from `AppDelegate` (local dev runs the Debug config, and
/// we don't want developer crashes/usage polluting production data). Blurt never
-/// sends dictation text or transcripts, and no PII is attached — the diagnostics
-/// this ships are disclosed in README.md and SECURITY.md.
+/// 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
@@ -29,11 +33,15 @@ enum Monitoring {
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"
+
/// 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 and enables crash reporting + logging. Call once, at launch.
+ /// 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() {
Datadog.initialize(
with: Datadog.Configuration(
@@ -44,11 +52,18 @@ enum Monitoring {
),
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()
- logger = Logger.create(
+ 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
@@ -56,4 +71,14 @@ enum Monitoring {
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/README.md b/README.md
index 6cae6029..7e6d8c87 100644
--- a/README.md
+++ b/README.md
@@ -81,7 +81,9 @@ stores no audio and no transcripts.
Release builds send crash reports and a few handled errors to
[Datadog](https://www.datadoghq.com). Those reports are diagnostic only: stack
-traces and error details, never your audio, transcripts, or API key.
+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 6b1b88f1..4da60103 100644
--- a/SECURITY.md
+++ b/SECURITY.md
@@ -35,7 +35,9 @@ permissions it uses are in scope here.
Release builds also send crash reports and a few handled errors to
[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
From 617756f674f610aeae7c5cb76cf1ce78bf21b0a5 Mon Sep 17 00:00:00 2001
From: Alex Kroman
Date: Wed, 1 Jul 2026 13:52:49 -0400
Subject: [PATCH 5/5] Gate Datadog start at runtime so periphery sees it as
used
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Monitoring.start() was only called inside `#if !DEBUG` in AppDelegate, so
periphery (which scans a Debug build) saw it — and everything it uses
(clientToken/service/environment/installIDKey/installID) — as unreachable
dead code. Call start() unconditionally and move the release gate to a
runtime `isReleaseBuild` guard inside it: same behavior (no-op in Debug, no
dev telemetry) but the config stays reachable, so periphery is clean.
Co-Authored-By: Claude Opus 4.8 (1M context)
---
App/Blurt/Blurt/AppDelegate.swift | 8 ++++----
App/Blurt/Blurt/Monitoring/Monitoring.swift | 17 +++++++++++++++--
2 files changed, 19 insertions(+), 6 deletions(-)
diff --git a/App/Blurt/Blurt/AppDelegate.swift b/App/Blurt/Blurt/AppDelegate.swift
index 0834d73f..fa4827ad 100644
--- a/App/Blurt/Blurt/AppDelegate.swift
+++ b/App/Blurt/Blurt/AppDelegate.swift
@@ -94,7 +94,9 @@ 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
+ // 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
@@ -102,9 +104,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate {
// 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.
- #if !DEBUG
- Monitoring.start()
- #endif
+ 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
index bf99a80f..8b641053 100644
--- a/App/Blurt/Blurt/Monitoring/Monitoring.swift
+++ b/App/Blurt/Blurt/Monitoring/Monitoring.swift
@@ -5,8 +5,9 @@ import Foundation
/// Datadog crash + error monitoring. Replaces the former Sentry integration.
///
-/// Started Release-only from `AppDelegate` (local dev runs the Debug config, and
-/// we don't want developer crashes/usage polluting production data). Blurt never
+/// 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
@@ -36,6 +37,17 @@ enum Monitoring {
// 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)?
@@ -43,6 +55,7 @@ enum Monitoring {
/// 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,