Automatic crash and error reporting for native macOS apps — with GitHub Issues as the backend. No SDK, no server, no dashboard to pay for.
GitBeacon watches your app for crashes and errors, groups them by the underlying bug (not by which machine hit it or when), and quietly files or updates a GitHub Issue — no user interaction, no popups, no "send crash report?" dialog. You find out about problems the same place you already track them.
Built entirely on native Apple frameworks (Foundation, CryptoKit, URLSession, Swift Concurrency) and the GitHub REST API. Nothing else. Apple Silicon only.
Every third-party crash reporter asks for the same trade: drop their SDK into your app, and in exchange, your crash data lives on their servers, behind their dashboard, under their pricing tier.
If you already triage bugs in GitHub Issues, that trade doesn't buy you anything — it just adds a dependency and a second place to check. GitBeacon skips the middleman: your app talks straight to the GitHub REST API, and a crash becomes an Issue, in the same repo where the fix will eventually land.
- Captures crashes and errors automatically. Install a crash handler once at launch; report non-fatal errors from any
catchblock with one line. - Groups by bug, not by occurrence. A fingerprint derived from the error's type, message, context, and stack trace — with machine-specific noise (memory addresses, user paths, dynamic IDs) normalized away — means the same bug on ten Macs becomes one GitHub Issue with ten comments, not ten Issues.
- Never blocks your app. Crash capture writes a small file and gets out of the way. Delivery to GitHub happens later, in the background, on the next launch.
- Never crashes your app. Reporting is strictly best-effort. If GitBeacon itself fails, your app doesn't notice.
- Never phones home more than it has to. No telemetry beyond what you explicitly send. No analytics. No "anonymous usage data."
- Also takes bug reports and feature requests. Build your own in-app form, call one function, and it becomes a labeled GitHub Issue — same plumbing, no fingerprinting needed since it's not an automatic capture.
- No dashboard, no web UI, no hosted service — GitHub is the dashboard.
- No symbolication server, no source maps, no build-time upload step.
- No user prompts, ever. If you want opt-in consent flows, build them around GitBeacon; it doesn't have an opinion.
- No Intel support. This is a from-scratch library for Apple Silicon Macs.
Swift Package Manager:
.package(url: "https://github.com/marcops/git-beacon-mac", from: "1.0.0")Requires macOS 14+ on Apple Silicon.
import GitBeacon
// Once, at launch — before anything else touches GitBeacon:
GitBeacon.configure(
owner: "your-org",
repo: "your-repo",
token: embeddedToken, // see "About the token" below
appVersion: "1.4.2",
build: "142"
)
GitBeacon.installCrashHandler()
Task {
await GitBeacon.processPendingReports()
}Report a non-fatal error anywhere in your code:
do {
try loadApplicantDocuments()
} catch {
ErrorReporter.report(error, context: "Loading applicant documents")
throw error
}Take a bug report or feature request from your own UI:
let issueNumber = try await UserReportReporter.submit(
UserReport(kind: .feature, title: "Dark mode", description: "Would love a dark theme.")
)That's the entire integration surface: two setup calls, and one call per report site.
Three identifiers, three different jobs — conflating any two of them breaks the system:
| Identifier | Identifies | Lives in |
|---|---|---|
| Report ID | one occurrence | the local JSON file, the GitHub comment |
| Installation ID | one Mac | a UUID generated on first launch, persisted to Application Support |
| Fingerprint | one bug | SHA256 of the normalized error signature |
The fingerprint is what a GitHub Issue is keyed on — via a fp:<hash> label, not a fuzzy text search, so lookups are exact and immediate (no search-index lag to race against). The same bug, on any number of Macs, across any number of app versions, converges on the same Issue:
Machine A, v1.4.0, NetworkError: HTTP 500 ─┐
Machine B, v1.4.1, NetworkError: HTTP 500 ├─→ Issue #142 "[Crash] NetworkError - a1b2c3d4"
Machine C, v1.4.2, NetworkError: HTTP 500 ─┘ 37 occurrences · 8 machines · v1.3–1.4
Each occurrence becomes a comment on that Issue, carrying its own Report ID (so retried deliveries never double-post) and its own Installation ID (so the Issue body can show how many machines hit it, not just how many times). The Issue's summary — occurrence count, machine count, version breakdown, first/last seen — updates automatically as new comments land.
Installation ID is a random UUID, not a hardware identifier — no serial number, no MAC address. If someone reinstalls, they get a new one; that's fine, it only tracks "how many distinct installs saw this," not "who."
struct ErrorReport: Codable {
let id: UUID // this occurrence
let fingerprint: String // this bug
let type: ReportType // .crash or .error
let timestamp: Date
let installationID: String // this machine
let appVersion: String
let build: String
let osVersion: String
let architecture: String
let context: String?
let errorType: String?
let message: String?
let stackTrace: String?
let metadata: [String: String]
}Everything is sanitized before it ever touches disk: passwords, tokens, API keys, cookies, and authorization headers are redacted from free text; oversized fields are truncated; the GitHub token itself is never included anywhere in a report, log, or Issue.
crash / error
↓
local JSON report → Application Support/CrashReports/pending/
↓
(next launch, background task)
↓
search for an Issue with this fingerprint's label
↓
exists? ──yes──→ already has this Report ID? ──yes──→ no-op, mark sent
│no │no
↓ ↓
create Issue add occurrence comment, update summary
↓ ↓
mark report sent, move to sent/
If the app dies between "GitHub confirmed the Issue was created" and "mark sent" — the report is still pending on the next launch, and the fingerprint lookup finds the Issue that already exists instead of creating a duplicate. Delivery is idempotent by design, not by luck.
Retries back off gently on transient failures (no connection, timeout, DNS failure, 429, 500, 502, 503): immediate → 30s → 2min → 10min, then the report stays pending for the next opportunity rather than looping.
There's no backend here — GitBeacon talks to the GitHub REST API directly from the app, using a fine-grained Personal Access Token embedded in the binary, scoped to exactly one repository with exactly one permission: Issues: Read and write.
That token can be extracted from a shipped .app by anyone who goes looking. This is a known, accepted trade-off of the "no backend" design — not an oversight. If that's not an acceptable risk for your project, GitBeacon isn't the right fit; a real backend proxying authenticated requests is the alternative, and this library deliberately doesn't try to paper over that with a fake sense of security.
Crash capture installs both NSSetUncaughtExceptionHandler and POSIX signal handlers (SIGABRT, SIGILL, SIGSEGV, SIGFPE, SIGBUS, SIGTRAP). The handler writes a local file and gets out of the way — no networking, no URLSession, no NSWorkspace, nothing that could itself hang inside a crash.
One thing stated plainly rather than glossed over: true POSIX signal handlers are only supposed to call async-signal-safe functions, and the JSONEncoder/String formatting used to write that file technically aren't. Going fully async-signal-safe means hand-rolling C-level buffer writes with no Swift runtime involved at all — a meaningfully larger, harder-to-audit implementation. GitBeacon takes the same pragmatic trade-off most lightweight, dependency-free crash reporters make: a small residual risk of the handler itself misbehaving on an already-crashing process, in exchange for staying plain, readable Swift.
MIT — see LICENSE.