From 1a290f0242c629aa58d3928cfd330720027eda70 Mon Sep 17 00:00:00 2001 From: harryisfish Date: Fri, 12 Jun 2026 17:49:44 +0800 Subject: [PATCH] feat(daemon): add optional Sentry reporting --- Package.resolved | 11 ++- Package.swift | 4 +- README.md | 10 ++- Sources/SMCtlDaemonCore/Daemon.swift | 47 ++++++++++- Sources/SMCtlDaemonCore/SentryReporting.swift | 77 +++++++++++++++++++ .../SmctlDaemonTests.swift | 51 ++++++++++++ docs/README.zh-CN.md | 10 ++- docs/smctl.1 | 13 +++- 8 files changed, 209 insertions(+), 14 deletions(-) create mode 100644 Sources/SMCtlDaemonCore/SentryReporting.swift diff --git a/Package.resolved b/Package.resolved index fac5c8b..24e5d8d 100644 --- a/Package.resolved +++ b/Package.resolved @@ -1,6 +1,15 @@ { - "originHash" : "c99b7280596a755428906e643d216760f9200f8560fb82fe8d83bc06a73e3a64", + "originHash" : "74cf2af2704bcafa7b43f853a2256e920a5dff829fcfb71ec8426a7613239b78", "pins" : [ + { + "identity" : "sentry-cocoa", + "kind" : "remoteSourceControl", + "location" : "https://github.com/getsentry/sentry-cocoa.git", + "state" : { + "revision" : "2c420d31d0c31b525fa9e7c552ef0fc9d924befc", + "version" : "9.17.1" + } + }, { "identity" : "swift-argument-parser", "kind" : "remoteSourceControl", diff --git a/Package.swift b/Package.swift index bc95006..5a6e7cb 100644 --- a/Package.swift +++ b/Package.swift @@ -16,7 +16,8 @@ let package = Package( ], dependencies: [ .package(url: "https://github.com/apple/swift-argument-parser.git", from: "1.5.0"), - .package(url: "https://github.com/LebJe/TOMLKit.git", from: "0.6.0") + .package(url: "https://github.com/LebJe/TOMLKit.git", from: "0.6.0"), + .package(url: "https://github.com/getsentry/sentry-cocoa.git", from: "9.17.1") ], targets: [ .target( @@ -33,6 +34,7 @@ let package = Package( "SMCCore", "PolicyEngine", "SMCtlProtocol", + .product(name: "Sentry", package: "sentry-cocoa"), .product(name: "TOMLKit", package: "TOMLKit") ], linkerSettings: [ diff --git a/README.md b/README.md index 04e4ba8..5dd3694 100644 --- a/README.md +++ b/README.md @@ -87,19 +87,23 @@ Controlling fans and charging from userspace demands paranoia. smctl treats thes ## Privacy -The daemon makes outbound network requests in exactly two cases, both under your control: +The daemon makes outbound network requests in these cases, all under your control: 1. **Update check** — a once-a-day check of the GitHub releases API to learn the latest version, surfaced by the CLI as an upgrade hint. On by default; turn it off with `[update] check = false`. 2. **Alert webhooks** — only the webhook URLs *you* configure in `[[alert]]` rules. No alerts configured (the default) means no such requests. +3. **Sentry crash/error reporting** — only when you configure a DSN under `[sentry]`. The default empty DSN disables the SDK entirely. smctl sets `sendDefaultPii = false` and does not enable performance tracing unless you opt in with `traces_sample_rate`. -There is no telemetry and no analytics. The CLI itself never makes network requests — all outbound traffic originates in the daemon, from the two opt-in/opt-out cases above. To go fully offline, set: +There is no product analytics. The CLI itself never makes network requests — all outbound traffic originates in the daemon, from the opt-in/opt-out cases above. To go fully offline, set: ```toml [update] check = false + +[sentry] +dsn = "" ``` -in `/etc/smctl/config.toml` (then `sudo smctl daemon restart`) and configure no webhook alerts. +in `/etc/smctl/config.toml` (then `sudo smctl daemon restart`) and configure no webhook alerts. You can also hard-disable Sentry for the daemon process with `SMCTL_SENTRY_DISABLED=1`. ## Alerts diff --git a/Sources/SMCtlDaemonCore/Daemon.swift b/Sources/SMCtlDaemonCore/Daemon.swift index 2fdc1e8..21f94ef 100644 --- a/Sources/SMCtlDaemonCore/Daemon.swift +++ b/Sources/SMCtlDaemonCore/Daemon.swift @@ -21,6 +21,7 @@ struct DaemonConfig: Codable, Equatable, Sendable { var fan: FanConfig var safety: SafetyConfig var update: UpdateConfig + var sentry: SentryConfig var alerts: [AlertConfig] init( @@ -28,17 +29,19 @@ struct DaemonConfig: Codable, Equatable, Sendable { fan: FanConfig = FanConfig(), safety: SafetyConfig = SafetyConfig(), update: UpdateConfig = UpdateConfig(), + sentry: SentryConfig = SentryConfig(), alerts: [AlertConfig] = [] ) { self.battery = battery self.fan = fan self.safety = safety self.update = update + self.sentry = sentry self.alerts = alerts } enum CodingKeys: String, CodingKey { - case battery, fan, safety, update + case battery, fan, safety, update, sentry case alerts = "alert" } @@ -48,14 +51,15 @@ struct DaemonConfig: Codable, Equatable, Sendable { fan = try container.decodeIfPresent(FanConfig.self, forKey: .fan) ?? FanConfig() safety = try container.decodeIfPresent(SafetyConfig.self, forKey: .safety) ?? SafetyConfig() update = try container.decodeIfPresent(UpdateConfig.self, forKey: .update) ?? UpdateConfig() + sentry = try container.decodeIfPresent(SentryConfig.self, forKey: .sentry) ?? SentryConfig() alerts = try container.decodeIfPresent([AlertConfig].self, forKey: .alerts) ?? [] } } struct UpdateConfig: Codable, Equatable, Sendable { /// When true, the daemon contacts the GitHub releases API once a day to learn the - /// latest version, surfaced as a hint in the CLI. This is the only outbound network - /// request smctl makes; set to false for a fully offline daemon. + /// latest version, surfaced as a hint in the CLI. Set to false for a fully offline + /// daemon when Sentry and alert webhooks are also unconfigured. var check: Bool init(check: Bool = true) { @@ -68,6 +72,31 @@ struct UpdateConfig: Codable, Equatable, Sendable { } } +struct SentryConfig: Codable, Equatable, Sendable { + /// Empty means disabled. The project DSN is intentionally config-owned so public + /// builds do not phone home unless the operator opts in. + var dsn: String + var environment: String + var debug: Bool + var traces_sample_rate: Double + + init(dsn: String = "", environment: String = "production", debug: Bool = false, traces_sample_rate: Double = 0) { + self.dsn = dsn + self.environment = environment + self.debug = debug + self.traces_sample_rate = traces_sample_rate + } + + init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + dsn = try container.decodeIfPresent(String.self, forKey: .dsn) ?? "" + environment = try container.decodeIfPresent(String.self, forKey: .environment) ?? "production" + debug = try container.decodeIfPresent(Bool.self, forKey: .debug) ?? false + let sampleRate = try container.decodeIfPresent(Double.self, forKey: .traces_sample_rate) ?? 0 + traces_sample_rate = min(max(sampleRate, 0), 1) + } +} + struct BatteryConfig: Codable, Equatable, Sendable { var limit: String var sleep_policy: String @@ -383,6 +412,7 @@ public final class SmctlDaemon: @unchecked Sendable { queue.sync { let wasForcing = config.battery.force_discharge config = Self.loadConfig(path: configPath) + SentryReporter.startIfConfigured(config: config.sentry) let policy = (try? SleepPolicy.parse(config.battery.sleep_policy)) ?? .strict sleepMachine.setPolicy(policy) if wasForcing, !config.battery.force_discharge, readAdapterEnabledLocked() == false { @@ -515,6 +545,7 @@ public final class SmctlDaemon: @unchecked Sendable { } catch { lastError = String(describing: error) logger.error("Sleep event handling failed: \(String(describing: error), privacy: .public)") + SentryReporter.capture(error, context: "Sleep event handling failed") } return evaluation } @@ -552,6 +583,7 @@ public final class SmctlDaemon: @unchecked Sendable { lastEvaluation = now lastError = String(describing: error) logger.error("Battery policy evaluation failed: \(String(describing: error), privacy: .public)") + SentryReporter.capture(error, context: "Battery policy evaluation failed") } } @@ -738,6 +770,7 @@ public final class SmctlDaemon: @unchecked Sendable { } catch { lastError = String(describing: error) logger.error("Fan safety restore failed: \(String(describing: error), privacy: .public)") + SentryReporter.capture(error, context: "Fan safety restore failed") } return } @@ -755,6 +788,7 @@ public final class SmctlDaemon: @unchecked Sendable { } catch { lastError = String(describing: error) logger.error("Fan policy evaluation failed: \(String(describing: error), privacy: .public)") + SentryReporter.capture(error, context: "Fan policy evaluation failed") } } @@ -961,6 +995,7 @@ public final class SmctlDaemon: @unchecked Sendable { // (e.g. Macs Fan Control); surface it — never swallow a failed restore. lastError = String(describing: error) logger.error("Startup fan restore failed (external fan controller running?): \(String(describing: error), privacy: .public)") + SentryReporter.capture(error, context: "Startup fan restore failed") } } } @@ -1110,6 +1145,12 @@ public final class SmctlDaemon: @unchecked Sendable { [update] check = \(config.update.check ? "true" : "false") + [sentry] + dsn = "\(config.sentry.dsn)" + environment = "\(config.sentry.environment)" + debug = \(config.sentry.debug ? "true" : "false") + traces_sample_rate = \(config.sentry.traces_sample_rate) + """ for curve in config.fan.curves { text += """ diff --git a/Sources/SMCtlDaemonCore/SentryReporting.swift b/Sources/SMCtlDaemonCore/SentryReporting.swift new file mode 100644 index 0000000..f665707 --- /dev/null +++ b/Sources/SMCtlDaemonCore/SentryReporting.swift @@ -0,0 +1,77 @@ +import Foundation +import OSLog +import Sentry +import SMCtlProtocol + +private let sentryLogger = Logger(subsystem: "one.leaper.smctl", category: "sentry") + +enum SentryReporter { + private static let lock = NSLock() + private nonisolated(unsafe) static var started = false + + static func startIfConfigured(config: SentryConfig) { + let environment = ProcessInfo.processInfo.environment + guard environment["SMCTL_SENTRY_DISABLED"] != "1" else { return } + + let dsn = firstNonEmpty(environment["SMCTL_SENTRY_DSN"], config.dsn) + + guard let dsn else { return } + + lock.lock() + if started { + lock.unlock() + return + } + started = true + lock.unlock() + + let sentryEnvironment = firstNonEmpty(environment["SMCTL_SENTRY_ENVIRONMENT"], config.environment) ?? "production" + let tracesSampleRate = min(max(config.traces_sample_rate, 0), 1) + + SentrySDK.start { options in + options.dsn = dsn + options.environment = sentryEnvironment + options.releaseName = "smctl@\(SMCtlProtocolInfo.version)" + options.debug = config.debug + options.sendDefaultPii = false + options.tracesSampleRate = NSNumber(value: tracesSampleRate) + options.beforeSend = { event in + event.user = nil + event.serverName = nil + if var tags = event.tags { + tags.removeValue(forKey: "server_name") + tags.removeValue(forKey: "user") + event.tags = tags + } + return event + } + } + + sentryLogger.notice("Sentry crash/error reporting enabled for environment \(sentryEnvironment, privacy: .public)") + } + + static func capture(_ error: Error, context: String) { + guard isStarted else { return } + let report = NSError( + domain: "one.leaper.smctl.daemon", + code: 1, + userInfo: [NSLocalizedDescriptionKey: "\(context): \(String(describing: error))"] + ) + SentrySDK.capture(error: report) + } + + private static var isStarted: Bool { + lock.lock() + defer { lock.unlock() } + return started + } + + private static func firstNonEmpty(_ values: String?...) -> String? { + values + .map { $0?.trimmingCharacters(in: .whitespacesAndNewlines) } + .first { value in + guard let value else { return false } + return !value.isEmpty + } ?? nil + } +} diff --git a/Tests/SMCtlDaemonCoreTests/SmctlDaemonTests.swift b/Tests/SMCtlDaemonCoreTests/SmctlDaemonTests.swift index 2ab4f4b..86a29f8 100644 --- a/Tests/SMCtlDaemonCoreTests/SmctlDaemonTests.swift +++ b/Tests/SMCtlDaemonCoreTests/SmctlDaemonTests.swift @@ -1,4 +1,5 @@ import Foundation +import Darwin import XCTest @testable import SMCtlDaemonCore @testable import SMCCore @@ -9,6 +10,7 @@ final class SmctlDaemonTests: XCTestCase { override func setUp() { super.setUp() + setenv("SMCTL_SENTRY_DISABLED", "1", 1) configPath = NSTemporaryDirectory() + "smctl-test-\(UUID().uuidString).toml" } @@ -16,6 +18,7 @@ final class SmctlDaemonTests: XCTestCase { if let configPath { try? FileManager.default.removeItem(atPath: configPath) } + unsetenv("SMCTL_SENTRY_DISABLED") super.tearDown() } @@ -187,6 +190,54 @@ final class SmctlDaemonTests: XCTestCase { XCTAssertEqual(alert?.rule?.trigger.kind, .temp) } + func testSentryConfigDecodesFromToml() throws { + try """ + [sentry] + dsn = "https://public@example.invalid/1" + environment = "staging" + debug = true + traces_sample_rate = 0.25 + """.write(toFile: configPath, atomically: true, encoding: .utf8) + + let daemon = makeDaemon(backend: RecordingBackend(), capabilities: .oneFan) + XCTAssertEqual(daemon.config.sentry.dsn, "https://public@example.invalid/1") + XCTAssertEqual(daemon.config.sentry.environment, "staging") + XCTAssertTrue(daemon.config.sentry.debug) + XCTAssertEqual(daemon.config.sentry.traces_sample_rate, 0.25) + } + + func testSentrySampleRateIsClamped() throws { + try """ + [sentry] + traces_sample_rate = 2.0 + """.write(toFile: configPath, atomically: true, encoding: .utf8) + + let daemon = makeDaemon(backend: RecordingBackend(), capabilities: .oneFan) + XCTAssertEqual(daemon.config.sentry.traces_sample_rate, 1) + } + + func testWriteConfigPreservesSentry() throws { + try """ + [battery] + limit = "80" + + [sentry] + dsn = "https://public@example.invalid/1" + environment = "staging" + debug = true + traces_sample_rate = 0.5 + """.write(toFile: configPath, atomically: true, encoding: .utf8) + + let daemon = makeDaemon(backend: RecordingBackend(), capabilities: .oneFan) + try daemon.setChargeLimit("70-80") + + let reloaded = makeDaemon(backend: RecordingBackend(), capabilities: .oneFan) + XCTAssertEqual(reloaded.config.sentry.dsn, "https://public@example.invalid/1") + XCTAssertEqual(reloaded.config.sentry.environment, "staging") + XCTAssertTrue(reloaded.config.sentry.debug) + XCTAssertEqual(reloaded.config.sentry.traces_sample_rate, 0.5) + } + func testWriteConfigPreservesAlerts() throws { try """ [battery] diff --git a/docs/README.zh-CN.md b/docs/README.zh-CN.md index ed79057..f5f3cae 100644 --- a/docs/README.zh-CN.md +++ b/docs/README.zh-CN.md @@ -85,19 +85,23 @@ Homebrew 安装会自动配置 shell 补全(bash/zsh/fish)和 `man smctl` ## 隐私 -daemon 仅在两种情况下发起对外网络请求,且都由你掌控: +daemon 仅在以下情况下发起对外网络请求,且都由你掌控: 1. **更新检查**——每天一次查询 GitHub releases API 获取最新版本号,由 CLI 提示你升级。默认开启,可用 `[update] check = false` 关闭。 2. **告警 webhook**——只发往*你自己*在 `[[alert]]` 规则里配置的 URL。不配告警(默认)就没有这类请求。 +3. **Sentry 崩溃/错误上报**——只有当你在 `[sentry]` 里配置 DSN 时才启用。默认空 DSN 会让 SDK 完全不启动。smctl 设置 `sendDefaultPii = false`,也不会启用性能追踪,除非你显式配置 `traces_sample_rate`。 -无遥测、无统计。CLI 本身从不发起网络请求——所有对外流量都来自 daemon 的上述两种可开关情况。完全离线:在 `/etc/smctl/config.toml` 写入 +无产品分析。CLI 本身从不发起网络请求——所有对外流量都来自 daemon 的上述可开关情况。完全离线:在 `/etc/smctl/config.toml` 写入 ```toml [update] check = false + +[sentry] +dsn = "" ``` -然后 `sudo smctl daemon restart`,并且不配置任何 webhook 告警。 +然后 `sudo smctl daemon restart`,并且不配置任何 webhook 告警。也可以用 `SMCTL_SENTRY_DISABLED=1` 在 daemon 进程层硬关闭 Sentry。 ## 告警 diff --git a/docs/smctl.1 b/docs/smctl.1 index dcd6848..e260c15 100644 --- a/docs/smctl.1 +++ b/docs/smctl.1 @@ -126,11 +126,18 @@ Declarative policy configuration. .I /Library/LaunchDaemons/one.leaper.smctl.daemon.plist The installed LaunchDaemon. .SH PRIVACY -The daemon makes one outbound request: a daily check of the GitHub releases API -for the latest version, surfaced by the CLI as an upgrade hint. Disable it with +The CLI itself does not make network requests. The daemon can make outbound +requests for the daily GitHub releases update check, user-configured alert +webhooks, and optional Sentry crash/error reporting when +.B [sentry] +.B dsn +is configured. Disable the update check with .B check = false under -.BR [update] . +.BR [update] , +leave +.B [sentry] dsn +empty, and configure no webhook alerts for a fully offline daemon. .SH EXIT STATUS .B smctl exits 0 on success and non-zero on error (64 for usage and validation errors).