From 2cbd13c634ebba74839617713722e0234531d350 Mon Sep 17 00:00:00 2001 From: Dustin Hilgaertner Date: Wed, 8 Apr 2026 14:56:11 -0500 Subject: [PATCH 1/4] Add session analytics via Claude Code OpenTelemetry (#136) Adds an in-process OTLP HTTP/JSON receiver so Crow can ingest telemetry from Claude Code sessions and surface per-session analytics in the UI. New CrowTelemetry package: - OTLP HTTP receiver using NWListener on localhost - SQLite storage for metrics and events - TelemetryService facade coordinating receiver + database Integration: - Injects OTEL env vars when launching Claude Code sessions - Maps sessions via OTEL_RESOURCE_ATTRIBUTES=crow.session.id - Analytics strip in session detail header (cost, tokens, tools, time) - Telemetry settings in preferences (enable/disable, port config) Co-Authored-By: Claude Opus 4.6 (1M context) --- Package.swift | 2 + .../CrowCore/Sources/CrowCore/AppState.swift | 1 + .../Sources/CrowCore/Models/AppConfig.swift | 21 +- .../CrowCore/Models/SessionAnalytics.swift | 66 ++++ Packages/CrowTelemetry/Package.swift | 23 ++ .../CrowTelemetry/Models/OTLPModels.swift | 178 +++++++++++ .../CrowTelemetry/Receiver/HTTPParser.swift | 143 +++++++++ .../CrowTelemetry/Receiver/OTLPReceiver.swift | 255 +++++++++++++++ .../Storage/TelemetryDatabase.swift | 298 ++++++++++++++++++ .../CrowTelemetry/TelemetryService.swift | 68 ++++ .../CrowUI/SessionAnalyticsStrip.swift | 85 +++++ .../Sources/CrowUI/SessionDetailView.swift | 8 + .../CrowUI/Sources/CrowUI/SettingsView.swift | 17 + Sources/Crow/App/AppDelegate.swift | 37 ++- Sources/Crow/App/SessionService.swift | 24 +- 15 files changed, 1220 insertions(+), 6 deletions(-) create mode 100644 Packages/CrowCore/Sources/CrowCore/Models/SessionAnalytics.swift create mode 100644 Packages/CrowTelemetry/Package.swift create mode 100644 Packages/CrowTelemetry/Sources/CrowTelemetry/Models/OTLPModels.swift create mode 100644 Packages/CrowTelemetry/Sources/CrowTelemetry/Receiver/HTTPParser.swift create mode 100644 Packages/CrowTelemetry/Sources/CrowTelemetry/Receiver/OTLPReceiver.swift create mode 100644 Packages/CrowTelemetry/Sources/CrowTelemetry/Storage/TelemetryDatabase.swift create mode 100644 Packages/CrowTelemetry/Sources/CrowTelemetry/TelemetryService.swift create mode 100644 Packages/CrowUI/Sources/CrowUI/SessionAnalyticsStrip.swift diff --git a/Package.swift b/Package.swift index 1a6834b..a8a4561 100644 --- a/Package.swift +++ b/Package.swift @@ -17,6 +17,7 @@ let package = Package( .package(path: "Packages/CrowPersistence"), .package(path: "Packages/CrowClaude"), .package(path: "Packages/CrowIPC"), + .package(path: "Packages/CrowTelemetry"), .package(path: "Packages/CrowCLI"), .package(url: "https://github.com/apple/swift-argument-parser.git", from: "1.5.0"), ], @@ -32,6 +33,7 @@ let package = Package( "CrowPersistence", "CrowClaude", "CrowIPC", + "CrowTelemetry", ], path: "Sources/Crow", resources: [ diff --git a/Packages/CrowCore/Sources/CrowCore/AppState.swift b/Packages/CrowCore/Sources/CrowCore/AppState.swift index 92de14c..9e72901 100644 --- a/Packages/CrowCore/Sources/CrowCore/AppState.swift +++ b/Packages/CrowCore/Sources/CrowCore/AppState.swift @@ -315,6 +315,7 @@ public final class SessionHookState { public var pendingNotification: HookNotification? public var lastToolActivity: ToolActivity? public var hookEvents: [HookEvent] = [] + public var analytics: SessionAnalytics? public init() {} } diff --git a/Packages/CrowCore/Sources/CrowCore/Models/AppConfig.swift b/Packages/CrowCore/Sources/CrowCore/Models/AppConfig.swift index c9644a5..f963143 100644 --- a/Packages/CrowCore/Sources/CrowCore/Models/AppConfig.swift +++ b/Packages/CrowCore/Sources/CrowCore/Models/AppConfig.swift @@ -11,19 +11,22 @@ public struct AppConfig: Codable, Sendable, Equatable { public var notifications: NotificationSettings public var sidebar: SidebarSettings public var remoteControlEnabled: Bool + public var telemetry: TelemetryConfig public init( workspaces: [WorkspaceInfo] = [], defaults: ConfigDefaults = ConfigDefaults(), notifications: NotificationSettings = NotificationSettings(), sidebar: SidebarSettings = SidebarSettings(), - remoteControlEnabled: Bool = false + remoteControlEnabled: Bool = false, + telemetry: TelemetryConfig = TelemetryConfig() ) { self.workspaces = workspaces self.defaults = defaults self.notifications = notifications self.sidebar = sidebar self.remoteControlEnabled = remoteControlEnabled + self.telemetry = telemetry } public init(from decoder: Decoder) throws { @@ -33,10 +36,11 @@ public struct AppConfig: Codable, Sendable, Equatable { notifications = try container.decodeIfPresent(NotificationSettings.self, forKey: .notifications) ?? NotificationSettings() sidebar = try container.decodeIfPresent(SidebarSettings.self, forKey: .sidebar) ?? SidebarSettings() remoteControlEnabled = try container.decodeIfPresent(Bool.self, forKey: .remoteControlEnabled) ?? false + telemetry = try container.decodeIfPresent(TelemetryConfig.self, forKey: .telemetry) ?? TelemetryConfig() } private enum CodingKeys: String, CodingKey { - case workspaces, defaults, notifications, sidebar, remoteControlEnabled + case workspaces, defaults, notifications, sidebar, remoteControlEnabled, telemetry } } @@ -146,3 +150,16 @@ public struct SidebarSettings: Codable, Sendable, Equatable { self.hideSessionDetails = hideSessionDetails } } + +/// Telemetry collection settings for Claude Code OTLP metrics. +public struct TelemetryConfig: Codable, Sendable, Equatable { + /// Whether the OTLP receiver is enabled. + public var enabled: Bool + /// Port for the OTLP HTTP receiver (default: 4318). + public var port: UInt16 + + public init(enabled: Bool = true, port: UInt16 = 4318) { + self.enabled = enabled + self.port = port + } +} diff --git a/Packages/CrowCore/Sources/CrowCore/Models/SessionAnalytics.swift b/Packages/CrowCore/Sources/CrowCore/Models/SessionAnalytics.swift new file mode 100644 index 0000000..b367150 --- /dev/null +++ b/Packages/CrowCore/Sources/CrowCore/Models/SessionAnalytics.swift @@ -0,0 +1,66 @@ +import Foundation + +/// Aggregated analytics data for a single Crow session, computed from OTLP telemetry. +public struct SessionAnalytics: Sendable { + /// Total cost in USD (from `claude_code.cost.usage`). + public var totalCost: Double + /// Input tokens (from `claude_code.token.usage` where type=input). + public var inputTokens: Int + /// Output tokens (from `claude_code.token.usage` where type=output). + public var outputTokens: Int + /// Cache read tokens (from `claude_code.token.usage` where type=cacheRead). + public var cacheReadTokens: Int + /// Cache creation tokens (from `claude_code.token.usage` where type=cacheCreation). + public var cacheCreationTokens: Int + /// Active time in seconds (from `claude_code.active_time.total`). + public var activeTimeSeconds: Double + /// Lines of code added (from `claude_code.lines_of_code.count` where type=added). + public var linesAdded: Int + /// Lines of code removed (from `claude_code.lines_of_code.count` where type=removed). + public var linesRemoved: Int + /// Git commits created (from `claude_code.commit.count`). + public var commitCount: Int + /// User prompts submitted (count of `claude_code.user_prompt` events). + public var promptCount: Int + /// Tool calls executed (count of `claude_code.tool_result` events). + public var toolCallCount: Int + /// API requests made (count of `claude_code.api_request` events). + public var apiRequestCount: Int + /// API errors encountered (count of `claude_code.api_error` events). + public var apiErrorCount: Int + + /// Total tokens across all types. + public var totalTokens: Int { + inputTokens + outputTokens + cacheReadTokens + cacheCreationTokens + } + + public init( + totalCost: Double = 0, + inputTokens: Int = 0, + outputTokens: Int = 0, + cacheReadTokens: Int = 0, + cacheCreationTokens: Int = 0, + activeTimeSeconds: Double = 0, + linesAdded: Int = 0, + linesRemoved: Int = 0, + commitCount: Int = 0, + promptCount: Int = 0, + toolCallCount: Int = 0, + apiRequestCount: Int = 0, + apiErrorCount: Int = 0 + ) { + self.totalCost = totalCost + self.inputTokens = inputTokens + self.outputTokens = outputTokens + self.cacheReadTokens = cacheReadTokens + self.cacheCreationTokens = cacheCreationTokens + self.activeTimeSeconds = activeTimeSeconds + self.linesAdded = linesAdded + self.linesRemoved = linesRemoved + self.commitCount = commitCount + self.promptCount = promptCount + self.toolCallCount = toolCallCount + self.apiRequestCount = apiRequestCount + self.apiErrorCount = apiErrorCount + } +} diff --git a/Packages/CrowTelemetry/Package.swift b/Packages/CrowTelemetry/Package.swift new file mode 100644 index 0000000..a403a77 --- /dev/null +++ b/Packages/CrowTelemetry/Package.swift @@ -0,0 +1,23 @@ +// swift-tools-version: 6.0 +import PackageDescription + +let package = Package( + name: "CrowTelemetry", + platforms: [.macOS(.v14)], + products: [ + .library(name: "CrowTelemetry", targets: ["CrowTelemetry"]), + ], + dependencies: [ + .package(path: "../CrowCore"), + ], + targets: [ + .target( + name: "CrowTelemetry", + dependencies: ["CrowCore"] + ), + .testTarget( + name: "CrowTelemetryTests", + dependencies: ["CrowTelemetry"] + ), + ] +) diff --git a/Packages/CrowTelemetry/Sources/CrowTelemetry/Models/OTLPModels.swift b/Packages/CrowTelemetry/Sources/CrowTelemetry/Models/OTLPModels.swift new file mode 100644 index 0000000..90de951 --- /dev/null +++ b/Packages/CrowTelemetry/Sources/CrowTelemetry/Models/OTLPModels.swift @@ -0,0 +1,178 @@ +import Foundation + +// MARK: - OTLP Attribute Types + +/// An OTLP key-value attribute. +public struct OTLPAttribute: Codable, Sendable { + public let key: String + public let value: OTLPAnyValue + + public init(key: String, value: OTLPAnyValue) { + self.key = key + self.value = value + } +} + +/// An OTLP value that can be a string, int, double, or bool. +public struct OTLPAnyValue: Codable, Sendable { + public var stringValue: String? + public var intValue: String? // OTLP encodes int64 as string + public var doubleValue: Double? + public var boolValue: Bool? + + /// Extract the value as a string regardless of type. + public var asString: String? { + if let s = stringValue { return s } + if let s = intValue { return s } + if let d = doubleValue { return String(describing: d) } + if let b = boolValue { return String(describing: b) } + return nil + } + + /// Extract the value as a double regardless of type. + public var asDouble: Double? { + if let d = doubleValue { return d } + if let s = intValue, let d = Double(s) { return d } + if let s = stringValue, let d = Double(s) { return d } + return nil + } + + public init(stringValue: String) { + self.stringValue = stringValue + } + + public init(intValue: String) { + self.intValue = intValue + } + + public init(doubleValue: Double) { + self.doubleValue = doubleValue + } +} + +/// Extract an attribute value by key from an array of attributes. +public func extractAttribute(_ key: String, from attributes: [OTLPAttribute]) -> String? { + attributes.first(where: { $0.key == key })?.value.asString +} + +/// Extract a numeric attribute value by key from an array of attributes. +public func extractNumericAttribute(_ key: String, from attributes: [OTLPAttribute]) -> Double? { + attributes.first(where: { $0.key == key })?.value.asDouble +} + +// MARK: - Metrics Payload + +/// Top-level OTLP metrics export request. +public struct OTLPMetricsPayload: Codable, Sendable { + public let resourceMetrics: [ResourceMetrics] +} + +/// A set of metrics from a single resource (e.g., a Claude Code process). +public struct ResourceMetrics: Codable, Sendable { + public let resource: OTLPResource? + public let scopeMetrics: [ScopeMetrics]? +} + +/// Metrics from a single instrumentation scope. +public struct ScopeMetrics: Codable, Sendable { + public let scope: InstrumentationScope? + public let metrics: [OTLPMetric]? +} + +/// A single metric with its name and data points. +public struct OTLPMetric: Codable, Sendable { + public let name: String + public let unit: String? + public let description: String? + // Metrics can be sum, gauge, or histogram — Claude Code uses sum (counters). + public let sum: OTLPSum? + public let gauge: OTLPGauge? +} + +/// A sum metric (monotonic counter or non-monotonic up-down counter). +public struct OTLPSum: Codable, Sendable { + public let dataPoints: [OTLPNumberDataPoint]? + public let isMonotonic: Bool? + public let aggregationTemporality: Int? // 1 = delta, 2 = cumulative +} + +/// A gauge metric (point-in-time value). +public struct OTLPGauge: Codable, Sendable { + public let dataPoints: [OTLPNumberDataPoint]? +} + +/// A single numeric data point in a metric. +public struct OTLPNumberDataPoint: Codable, Sendable { + public let attributes: [OTLPAttribute]? + public let timeUnixNano: String? + public let startTimeUnixNano: String? + public let asInt: String? // int64 encoded as string + public let asDouble: Double? + + /// Get the numeric value as a Double. + public var numericValue: Double { + if let d = asDouble { return d } + if let s = asInt, let d = Double(s) { return d } + return 0 + } +} + +// MARK: - Logs Payload + +/// Top-level OTLP logs export request. +public struct OTLPLogsPayload: Codable, Sendable { + public let resourceLogs: [ResourceLogs] +} + +/// Log records from a single resource. +public struct ResourceLogs: Codable, Sendable { + public let resource: OTLPResource? + public let scopeLogs: [ScopeLogs]? +} + +/// Log records from a single instrumentation scope. +public struct ScopeLogs: Codable, Sendable { + public let scope: InstrumentationScope? + public let logRecords: [OTLPLogRecord]? +} + +/// A single OTLP log record (used for events). +public struct OTLPLogRecord: Codable, Sendable { + public let timeUnixNano: String? + public let observedTimeUnixNano: String? + public let body: OTLPAnyValue? + public let severityNumber: Int? + public let severityText: String? + public let attributes: [OTLPAttribute]? + + /// Extract the event name from the `event.name` attribute. + public var eventName: String? { + guard let attrs = attributes else { return nil } + return extractAttribute("event.name", from: attrs) + } +} + +// MARK: - Shared Types + +/// An OTLP resource describing the entity producing telemetry. +public struct OTLPResource: Codable, Sendable { + public let attributes: [OTLPAttribute]? + + /// Extract `crow.session.id` from resource attributes. + public var crowSessionID: String? { + guard let attrs = attributes else { return nil } + return extractAttribute("crow.session.id", from: attrs) + } + + /// Extract `session.id` from resource attributes. + public var sessionID: String? { + guard let attrs = attributes else { return nil } + return extractAttribute("session.id", from: attrs) + } +} + +/// An instrumentation scope (library/module that produced the data). +public struct InstrumentationScope: Codable, Sendable { + public let name: String? + public let version: String? +} diff --git a/Packages/CrowTelemetry/Sources/CrowTelemetry/Receiver/HTTPParser.swift b/Packages/CrowTelemetry/Sources/CrowTelemetry/Receiver/HTTPParser.swift new file mode 100644 index 0000000..a25fbfe --- /dev/null +++ b/Packages/CrowTelemetry/Sources/CrowTelemetry/Receiver/HTTPParser.swift @@ -0,0 +1,143 @@ +import Foundation + +/// A parsed HTTP/1.1 request. +struct HTTPRequest: Sendable { + let method: String + let path: String + let headers: [String: String] + let body: Data + + /// Get a header value (case-insensitive lookup). + func header(_ name: String) -> String? { + let lower = name.lowercased() + for (key, value) in headers { + if key.lowercased() == lower { return value } + } + return nil + } + + /// Content-Length from headers, or 0 if absent. + var contentLength: Int { + guard let value = header("Content-Length"), let length = Int(value) else { return 0 } + return length + } +} + +/// Minimal HTTP/1.1 response builder. +struct HTTPResponse: Sendable { + let statusCode: Int + let statusText: String + let body: Data + + static func ok(body: Data = Data("{}".utf8)) -> HTTPResponse { + HTTPResponse(statusCode: 200, statusText: "OK", body: body) + } + + static func badRequest(message: String = "Bad Request") -> HTTPResponse { + HTTPResponse(statusCode: 400, statusText: "Bad Request", + body: Data("{\"error\":\"\(message)\"}".utf8)) + } + + static func notFound() -> HTTPResponse { + HTTPResponse(statusCode: 404, statusText: "Not Found", + body: Data("{\"error\":\"Not Found\"}".utf8)) + } + + /// Serialize to HTTP/1.1 response bytes. + func serialize() -> Data { + var response = "HTTP/1.1 \(statusCode) \(statusText)\r\n" + response += "Content-Type: application/json\r\n" + response += "Content-Length: \(body.count)\r\n" + response += "Connection: close\r\n" + response += "\r\n" + var data = Data(response.utf8) + data.append(body) + return data + } +} + +/// Minimal HTTP/1.1 request parser for OTLP payloads. +/// +/// Only handles simple POST requests with Content-Length (no chunked encoding, +/// no keep-alive). This is sufficient for OTLP HTTP/JSON clients. +enum HTTPParser { + + /// Parse result from feeding data into the parser. + enum ParseResult: Sendable { + /// A complete request was parsed. + case complete(HTTPRequest) + /// More data is needed (headers not complete or body incomplete). + case needsMore + /// The data is malformed. + case error(String) + } + + /// Attempt to parse a complete HTTP request from the accumulated data. + /// + /// - Parameter data: All data received so far on the connection. + /// - Returns: Parse result indicating complete, needs more data, or error. + static func parse(_ data: Data) -> ParseResult { + // Find the end of headers (double CRLF) + let separator = Data("\r\n\r\n".utf8) + guard let separatorRange = data.range(of: separator) else { + // Check for unreasonably large headers (> 8KB without end) + if data.count > 8192 { + return .error("Headers too large") + } + return .needsMore + } + + let headerData = data[data.startIndex..= 2 else { + return .error("Malformed request line") + } + + let method = String(parts[0]) + let path = String(parts[1]) + + var headers: [String: String] = [:] + for line in lines.dropFirst() { + if let colonIndex = line.firstIndex(of: ":") { + let key = String(line[line.startIndex.. Void + + public init( + port: UInt16, + database: TelemetryDatabase, + onDataReceived: @escaping @Sendable @MainActor (UUID) -> Void + ) throws { + self.port = port + self.database = database + self.onDataReceived = onDataReceived + + let params = NWParameters.tcp + params.requiredLocalEndpoint = NWEndpoint.hostPort( + host: .ipv4(.loopback), + port: NWEndpoint.Port(rawValue: port)! + ) + params.acceptLocalOnly = true + + self.listener = try NWListener(using: params) + } + + public func start() { + listener.stateUpdateHandler = { [weak self] state in + switch state { + case .ready: + NSLog("[OTLPReceiver] Listening on localhost:%d", self?.port ?? 0) + case .failed(let error): + NSLog("[OTLPReceiver] Listener failed: %@", error.localizedDescription) + case .cancelled: + NSLog("[OTLPReceiver] Listener cancelled") + default: + break + } + } + + listener.newConnectionHandler = { [weak self] connection in + self?.handleConnection(connection) + } + + listener.start(queue: queue) + } + + public func stop() { + listener.cancel() + } + + // MARK: - Connection Handling + + private func handleConnection(_ connection: NWConnection) { + connection.start(queue: queue) + receiveData(on: connection, buffer: Data()) + } + + private func receiveData(on connection: NWConnection, buffer: Data) { + connection.receive(minimumIncompleteLength: 1, maximumLength: 65536) { + [weak self] content, _, isComplete, error in + + guard let self else { + connection.cancel() + return + } + + var accumulated = buffer + if let content { + accumulated.append(content) + } + + if let error { + NSLog("[OTLPReceiver] Receive error: %@", error.localizedDescription) + connection.cancel() + return + } + + // Try to parse + switch HTTPParser.parse(accumulated) { + case .complete(let request): + self.handleRequest(request, connection: connection) + case .needsMore: + if isComplete { + // Connection closed before complete request + connection.cancel() + } else { + self.receiveData(on: connection, buffer: accumulated) + } + case .error(let message): + NSLog("[OTLPReceiver] Parse error: %@", message) + self.sendResponse(HTTPResponse.badRequest(message: message), on: connection) + } + } + } + + private func handleRequest(_ request: HTTPRequest, connection: NWConnection) { + guard request.method == "POST" else { + sendResponse(HTTPResponse.badRequest(message: "Only POST supported"), on: connection) + return + } + + let db = self.database + let onData = self.onDataReceived + + switch request.path { + case "/v1/metrics": + Task { + do { + let payload = try JSONDecoder().decode(OTLPMetricsPayload.self, from: request.body) + let affectedSessions = await self.processMetrics(payload, database: db) + self.sendResponse(HTTPResponse.ok(), on: connection) + for sessionID in affectedSessions { + await onData(sessionID) + } + } catch { + NSLog("[OTLPReceiver] Metrics decode error: %@", error.localizedDescription) + self.sendResponse(HTTPResponse.badRequest(message: "Invalid metrics payload"), on: connection) + } + } + + case "/v1/logs": + Task { + do { + let payload = try JSONDecoder().decode(OTLPLogsPayload.self, from: request.body) + let affectedSessions = await self.processLogs(payload, database: db) + self.sendResponse(HTTPResponse.ok(), on: connection) + for sessionID in affectedSessions { + await onData(sessionID) + } + } catch { + NSLog("[OTLPReceiver] Logs decode error: %@", error.localizedDescription) + self.sendResponse(HTTPResponse.badRequest(message: "Invalid logs payload"), on: connection) + } + } + + default: + sendResponse(HTTPResponse.notFound(), on: connection) + } + } + + private func sendResponse(_ response: HTTPResponse, on connection: NWConnection) { + let data = response.serialize() + connection.send(content: data, completion: .contentProcessed { _ in + connection.cancel() + }) + } + + // MARK: - Payload Processing + + private func processMetrics(_ payload: OTLPMetricsPayload, database: TelemetryDatabase) async -> Set { + var affectedSessions = Set() + + for resourceMetrics in payload.resourceMetrics { + let crowSessionIDStr = resourceMetrics.resource?.crowSessionID + guard let crowSessionIDStr, let crowSessionID = UUID(uuidString: crowSessionIDStr) else { + continue + } + + // Register session mapping if we have both IDs + if let claudeSessionID = resourceMetrics.resource?.sessionID { + await database.registerSessionMapping( + claudeSessionID: claudeSessionID, + crowSessionID: crowSessionID + ) + } + + guard let scopeMetrics = resourceMetrics.scopeMetrics else { continue } + + for scope in scopeMetrics { + guard let metrics = scope.metrics else { continue } + for metric in metrics { + let dataPoints = metric.sum?.dataPoints ?? metric.gauge?.dataPoints ?? [] + for point in dataPoints { + let attributesJSON = encodeAttributes(point.attributes) + await database.insertMetric( + crowSessionID: crowSessionID, + metricName: metric.name, + value: point.numericValue, + attributesJSON: attributesJSON, + timestampNs: point.timeUnixNano + ) + } + } + } + affectedSessions.insert(crowSessionID) + } + + return affectedSessions + } + + private func processLogs(_ payload: OTLPLogsPayload, database: TelemetryDatabase) async -> Set { + var affectedSessions = Set() + + for resourceLogs in payload.resourceLogs { + let crowSessionIDStr = resourceLogs.resource?.crowSessionID + guard let crowSessionIDStr, let crowSessionID = UUID(uuidString: crowSessionIDStr) else { + continue + } + + if let claudeSessionID = resourceLogs.resource?.sessionID { + await database.registerSessionMapping( + claudeSessionID: claudeSessionID, + crowSessionID: crowSessionID + ) + } + + guard let scopeLogs = resourceLogs.scopeLogs else { continue } + + for scope in scopeLogs { + guard let logRecords = scope.logRecords else { continue } + for record in logRecords { + let eventName = record.eventName ?? "unknown" + let body = record.body?.asString + let attributesJSON = encodeAttributes(record.attributes) + + await database.insertEvent( + crowSessionID: crowSessionID, + eventName: eventName, + body: body, + attributesJSON: attributesJSON, + severityNumber: record.severityNumber, + timestampNs: record.timeUnixNano + ) + } + } + affectedSessions.insert(crowSessionID) + } + + return affectedSessions + } + + private func encodeAttributes(_ attributes: [OTLPAttribute]?) -> String? { + guard let attributes, !attributes.isEmpty else { return nil } + var dict: [String: String] = [:] + for attr in attributes { + dict[attr.key] = attr.value.asString ?? "" + } + guard let data = try? JSONSerialization.data(withJSONObject: dict, options: .sortedKeys) else { + return nil + } + return String(data: data, encoding: .utf8) + } +} diff --git a/Packages/CrowTelemetry/Sources/CrowTelemetry/Storage/TelemetryDatabase.swift b/Packages/CrowTelemetry/Sources/CrowTelemetry/Storage/TelemetryDatabase.swift new file mode 100644 index 0000000..5ac910a --- /dev/null +++ b/Packages/CrowTelemetry/Sources/CrowTelemetry/Storage/TelemetryDatabase.swift @@ -0,0 +1,298 @@ +import Foundation +import CrowCore + +#if canImport(SQLite3) +import SQLite3 +#endif + +/// Thread-safe SQLite storage for telemetry data. +/// +/// Uses the system SQLite3 C API (available on macOS without external dependencies). +/// All access is serialized through the actor. +public actor TelemetryDatabase { + private var db: OpaquePointer? + private let path: String + + public init(path: String) { + self.path = path + } + + // MARK: - Lifecycle + + public func open() throws { + guard sqlite3_open(path, &db) == SQLITE_OK else { + let msg = db.flatMap { String(cString: sqlite3_errmsg($0)) } ?? "Unknown error" + throw TelemetryDatabaseError.openFailed(msg) + } + // Enable WAL mode for better concurrent read performance + execute("PRAGMA journal_mode=WAL") + try createTables() + } + + public func close() { + if let db { + sqlite3_close(db) + } + db = nil + } + + // MARK: - Schema + + private func createTables() throws { + let statements = [ + """ + CREATE TABLE IF NOT EXISTS session_map ( + claude_session_id TEXT PRIMARY KEY, + crow_session_id TEXT NOT NULL, + created_at REAL NOT NULL + ) + """, + """ + CREATE TABLE IF NOT EXISTS metrics ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + crow_session_id TEXT NOT NULL, + metric_name TEXT NOT NULL, + value REAL NOT NULL, + attributes_json TEXT, + timestamp_ns TEXT, + received_at REAL NOT NULL + ) + """, + "CREATE INDEX IF NOT EXISTS idx_metrics_session ON metrics(crow_session_id)", + "CREATE INDEX IF NOT EXISTS idx_metrics_name ON metrics(metric_name)", + """ + CREATE TABLE IF NOT EXISTS events ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + crow_session_id TEXT NOT NULL, + event_name TEXT NOT NULL, + body TEXT, + attributes_json TEXT, + severity_number INTEGER, + timestamp_ns TEXT, + received_at REAL NOT NULL + ) + """, + "CREATE INDEX IF NOT EXISTS idx_events_session ON events(crow_session_id)", + ] + + for sql in statements { + guard execute(sql) else { + let msg = db.flatMap { String(cString: sqlite3_errmsg($0)) } ?? "Unknown error" + throw TelemetryDatabaseError.schemaFailed(msg) + } + } + } + + // MARK: - Writes + + public func registerSessionMapping(claudeSessionID: String, crowSessionID: UUID) { + let sql = """ + INSERT OR IGNORE INTO session_map (claude_session_id, crow_session_id, created_at) + VALUES (?, ?, ?) + """ + var stmt: OpaquePointer? + guard sqlite3_prepare_v2(db, sql, -1, &stmt, nil) == SQLITE_OK else { return } + defer { sqlite3_finalize(stmt) } + + sqlite3_bind_text(stmt, 1, (claudeSessionID as NSString).utf8String, -1, nil) + sqlite3_bind_text(stmt, 2, (crowSessionID.uuidString as NSString).utf8String, -1, nil) + sqlite3_bind_double(stmt, 3, Date().timeIntervalSince1970) + sqlite3_step(stmt) + } + + public func insertMetric( + crowSessionID: UUID, + metricName: String, + value: Double, + attributesJSON: String?, + timestampNs: String? + ) { + let sql = """ + INSERT INTO metrics (crow_session_id, metric_name, value, attributes_json, timestamp_ns, received_at) + VALUES (?, ?, ?, ?, ?, ?) + """ + var stmt: OpaquePointer? + guard sqlite3_prepare_v2(db, sql, -1, &stmt, nil) == SQLITE_OK else { return } + defer { sqlite3_finalize(stmt) } + + sqlite3_bind_text(stmt, 1, (crowSessionID.uuidString as NSString).utf8String, -1, nil) + sqlite3_bind_text(stmt, 2, (metricName as NSString).utf8String, -1, nil) + sqlite3_bind_double(stmt, 3, value) + if let json = attributesJSON { + sqlite3_bind_text(stmt, 4, (json as NSString).utf8String, -1, nil) + } else { + sqlite3_bind_null(stmt, 4) + } + if let ts = timestampNs { + sqlite3_bind_text(stmt, 5, (ts as NSString).utf8String, -1, nil) + } else { + sqlite3_bind_null(stmt, 5) + } + sqlite3_bind_double(stmt, 6, Date().timeIntervalSince1970) + sqlite3_step(stmt) + } + + public func insertEvent( + crowSessionID: UUID, + eventName: String, + body: String?, + attributesJSON: String?, + severityNumber: Int?, + timestampNs: String? + ) { + let sql = """ + INSERT INTO events (crow_session_id, event_name, body, attributes_json, severity_number, timestamp_ns, received_at) + VALUES (?, ?, ?, ?, ?, ?, ?) + """ + var stmt: OpaquePointer? + guard sqlite3_prepare_v2(db, sql, -1, &stmt, nil) == SQLITE_OK else { return } + defer { sqlite3_finalize(stmt) } + + sqlite3_bind_text(stmt, 1, (crowSessionID.uuidString as NSString).utf8String, -1, nil) + sqlite3_bind_text(stmt, 2, (eventName as NSString).utf8String, -1, nil) + if let body { + sqlite3_bind_text(stmt, 3, (body as NSString).utf8String, -1, nil) + } else { + sqlite3_bind_null(stmt, 3) + } + if let json = attributesJSON { + sqlite3_bind_text(stmt, 4, (json as NSString).utf8String, -1, nil) + } else { + sqlite3_bind_null(stmt, 4) + } + if let severity = severityNumber { + sqlite3_bind_int(stmt, 5, Int32(severity)) + } else { + sqlite3_bind_null(stmt, 5) + } + if let ts = timestampNs { + sqlite3_bind_text(stmt, 6, (ts as NSString).utf8String, -1, nil) + } else { + sqlite3_bind_null(stmt, 6) + } + sqlite3_bind_double(stmt, 7, Date().timeIntervalSince1970) + sqlite3_step(stmt) + } + + // MARK: - Reads + + /// Compute aggregated analytics for a Crow session. + public func sessionAnalytics(for crowSessionID: UUID) -> SessionAnalytics { + let sid = crowSessionID.uuidString + var analytics = SessionAnalytics() + + // Aggregate metrics + analytics.totalCost = sumMetric("claude_code.cost.usage", session: sid) + + // Token breakdown by type attribute + analytics.inputTokens = Int(sumMetricWithAttribute("claude_code.token.usage", attrKey: "type", attrValue: "input", session: sid)) + analytics.outputTokens = Int(sumMetricWithAttribute("claude_code.token.usage", attrKey: "type", attrValue: "output", session: sid)) + analytics.cacheReadTokens = Int(sumMetricWithAttribute("claude_code.token.usage", attrKey: "type", attrValue: "cacheRead", session: sid)) + analytics.cacheCreationTokens = Int(sumMetricWithAttribute("claude_code.token.usage", attrKey: "type", attrValue: "cacheCreation", session: sid)) + + analytics.activeTimeSeconds = sumMetric("claude_code.active_time.total", session: sid) + + // Lines of code by type attribute + analytics.linesAdded = Int(sumMetricWithAttribute("claude_code.lines_of_code.count", attrKey: "type", attrValue: "added", session: sid)) + analytics.linesRemoved = Int(sumMetricWithAttribute("claude_code.lines_of_code.count", attrKey: "type", attrValue: "removed", session: sid)) + + analytics.commitCount = Int(sumMetric("claude_code.commit.count", session: sid)) + + // Count events by type + analytics.promptCount = countEvents("claude_code.user_prompt", session: sid) + analytics.toolCallCount = countEvents("claude_code.tool_result", session: sid) + analytics.apiRequestCount = countEvents("claude_code.api_request", session: sid) + analytics.apiErrorCount = countEvents("claude_code.api_error", session: sid) + + return analytics + } + + // MARK: - Cleanup + + /// Delete all telemetry data for a session. + public func deleteSessionData(for crowSessionID: UUID) { + let sid = crowSessionID.uuidString + execute("DELETE FROM metrics WHERE crow_session_id = '\(sid)'") + execute("DELETE FROM events WHERE crow_session_id = '\(sid)'") + execute("DELETE FROM session_map WHERE crow_session_id = '\(sid)'") + } + + // MARK: - Private Helpers + + @discardableResult + private func execute(_ sql: String) -> Bool { + sqlite3_exec(db, sql, nil, nil, nil) == SQLITE_OK + } + + private func sumMetric(_ name: String, session: String) -> Double { + let sql = "SELECT COALESCE(SUM(value), 0) FROM metrics WHERE crow_session_id = ? AND metric_name = ?" + var stmt: OpaquePointer? + guard sqlite3_prepare_v2(db, sql, -1, &stmt, nil) == SQLITE_OK else { return 0 } + defer { sqlite3_finalize(stmt) } + + sqlite3_bind_text(stmt, 1, (session as NSString).utf8String, -1, nil) + sqlite3_bind_text(stmt, 2, (name as NSString).utf8String, -1, nil) + + if sqlite3_step(stmt) == SQLITE_ROW { + return sqlite3_column_double(stmt, 0) + } + return 0 + } + + private func sumMetricWithAttribute( + _ name: String, + attrKey: String, + attrValue: String, + session: String + ) -> Double { + // Filter metrics where the JSON attributes contain the specified key-value pair. + // Uses json_extract for exact matching. + let sql = """ + SELECT COALESCE(SUM(value), 0) FROM metrics + WHERE crow_session_id = ? AND metric_name = ? + AND json_extract(attributes_json, '$.' || ?) = ? + """ + var stmt: OpaquePointer? + guard sqlite3_prepare_v2(db, sql, -1, &stmt, nil) == SQLITE_OK else { return 0 } + defer { sqlite3_finalize(stmt) } + + sqlite3_bind_text(stmt, 1, (session as NSString).utf8String, -1, nil) + sqlite3_bind_text(stmt, 2, (name as NSString).utf8String, -1, nil) + sqlite3_bind_text(stmt, 3, (attrKey as NSString).utf8String, -1, nil) + sqlite3_bind_text(stmt, 4, (attrValue as NSString).utf8String, -1, nil) + + if sqlite3_step(stmt) == SQLITE_ROW { + return sqlite3_column_double(stmt, 0) + } + return 0 + } + + private func countEvents(_ eventName: String, session: String) -> Int { + let sql = "SELECT COUNT(*) FROM events WHERE crow_session_id = ? AND event_name = ?" + var stmt: OpaquePointer? + guard sqlite3_prepare_v2(db, sql, -1, &stmt, nil) == SQLITE_OK else { return 0 } + defer { sqlite3_finalize(stmt) } + + sqlite3_bind_text(stmt, 1, (session as NSString).utf8String, -1, nil) + sqlite3_bind_text(stmt, 2, (eventName as NSString).utf8String, -1, nil) + + if sqlite3_step(stmt) == SQLITE_ROW { + return Int(sqlite3_column_int(stmt, 0)) + } + return 0 + } +} + +// MARK: - Errors + +public enum TelemetryDatabaseError: Error, LocalizedError { + case openFailed(String) + case schemaFailed(String) + + public var errorDescription: String? { + switch self { + case .openFailed(let msg): return "Failed to open telemetry database: \(msg)" + case .schemaFailed(let msg): return "Failed to create telemetry schema: \(msg)" + } + } +} diff --git a/Packages/CrowTelemetry/Sources/CrowTelemetry/TelemetryService.swift b/Packages/CrowTelemetry/Sources/CrowTelemetry/TelemetryService.swift new file mode 100644 index 0000000..948e723 --- /dev/null +++ b/Packages/CrowTelemetry/Sources/CrowTelemetry/TelemetryService.swift @@ -0,0 +1,68 @@ +import Foundation +import CrowCore + +/// Coordinates the OTLP receiver and telemetry database, providing the public API +/// for the rest of the app to interact with session analytics. +public final class TelemetryService: Sendable { + private let database: TelemetryDatabase + private let receiver: OTLPReceiver + public let port: UInt16 + + /// Create and initialize the telemetry service. + /// + /// - Parameters: + /// - port: The port to listen on for OTLP HTTP/JSON requests. + /// - dataDirectory: Directory for the SQLite database file. Defaults to app support dir. + /// - onDataReceived: Called on the main actor when new telemetry data arrives for a session. + public init( + port: UInt16, + dataDirectory: String? = nil, + onDataReceived: @escaping @Sendable @MainActor (UUID) -> Void + ) throws { + self.port = port + + let dir = dataDirectory ?? Self.defaultDataDirectory() + try FileManager.default.createDirectory(atPath: dir, withIntermediateDirectories: true) + let dbPath = (dir as NSString).appendingPathComponent("telemetry.db") + + self.database = TelemetryDatabase(path: dbPath) + self.receiver = try OTLPReceiver( + port: port, + database: database, + onDataReceived: onDataReceived + ) + } + + /// Start receiving telemetry. Opens the database and starts the HTTP listener. + public func start() async throws { + try await database.open() + receiver.start() + NSLog("[TelemetryService] Started on port %d", port) + } + + /// Stop receiving telemetry. Stops the listener and closes the database. + public func stop() async { + receiver.stop() + await database.close() + NSLog("[TelemetryService] Stopped") + } + + /// Get analytics for a Crow session. + public func analytics(for crowSessionID: UUID) async -> SessionAnalytics { + await database.sessionAnalytics(for: crowSessionID) + } + + /// Delete all telemetry data for a session (called when session is deleted). + public func deleteSessionData(for crowSessionID: UUID) async { + await database.deleteSessionData(for: crowSessionID) + } + + // MARK: - Private + + private static func defaultDataDirectory() -> String { + let appSupport = FileManager.default.urls( + for: .applicationSupportDirectory, in: .userDomainMask + ).first!.path + return (appSupport as NSString).appendingPathComponent("crow") + } +} diff --git a/Packages/CrowUI/Sources/CrowUI/SessionAnalyticsStrip.swift b/Packages/CrowUI/Sources/CrowUI/SessionAnalyticsStrip.swift new file mode 100644 index 0000000..52359c0 --- /dev/null +++ b/Packages/CrowUI/Sources/CrowUI/SessionAnalyticsStrip.swift @@ -0,0 +1,85 @@ +import SwiftUI +import CrowCore + +/// Compact horizontal strip of session analytics metrics shown in the session header. +struct SessionAnalyticsStrip: View { + let analytics: SessionAnalytics + + var body: some View { + HStack(spacing: 12) { + StatChip(icon: "dollarsign.circle", label: "Cost", value: formatCost(analytics.totalCost)) + StatChip(icon: "text.word.spacing", label: "Tokens", value: formatCount(analytics.totalTokens)) + StatChip(icon: "wrench", label: "Tools", value: "\(analytics.toolCallCount)") + StatChip(icon: "clock", label: "Active", value: formatTime(analytics.activeTimeSeconds)) + if analytics.linesAdded > 0 || analytics.linesRemoved > 0 { + StatChip( + icon: "chevron.left.forwardslash.chevron.right", + label: "Lines", + value: "+\(analytics.linesAdded) −\(analytics.linesRemoved)" + ) + } + if analytics.apiErrorCount > 0 { + StatChip( + icon: "exclamationmark.triangle", + label: "Errors", + value: "\(analytics.apiErrorCount)", + valueColor: .red + ) + } + Spacer() + } + } + + // MARK: - Formatting + + private func formatCost(_ cost: Double) -> String { + if cost < 0.01 && cost > 0 { + return "<$0.01" + } + return String(format: "$%.2f", cost) + } + + private func formatCount(_ count: Int) -> String { + if count >= 1_000_000 { + return String(format: "%.1fM", Double(count) / 1_000_000) + } else if count >= 1_000 { + return String(format: "%.1fK", Double(count) / 1_000) + } + return "\(count)" + } + + private func formatTime(_ seconds: Double) -> String { + let totalSeconds = Int(seconds) + if totalSeconds >= 3600 { + let hours = totalSeconds / 3600 + let mins = (totalSeconds % 3600) / 60 + return "\(hours)h \(mins)m" + } else if totalSeconds >= 60 { + return "\(totalSeconds / 60)m" + } else { + return "\(totalSeconds)s" + } + } +} + +/// A single stat chip with icon, label, and value. +private struct StatChip: View { + let icon: String + let label: String + let value: String + var valueColor: Color = CorveilTheme.gold + + var body: some View { + HStack(spacing: 3) { + Image(systemName: icon) + .font(.system(size: 9)) + .foregroundStyle(CorveilTheme.textMuted) + Text(label) + .font(.system(size: 10)) + .foregroundStyle(CorveilTheme.textMuted) + Text(value) + .font(.system(size: 10, weight: .medium)) + .foregroundStyle(valueColor) + } + } +} diff --git a/Packages/CrowUI/Sources/CrowUI/SessionDetailView.swift b/Packages/CrowUI/Sources/CrowUI/SessionDetailView.swift index d53ac2f..2568b01 100644 --- a/Packages/CrowUI/Sources/CrowUI/SessionDetailView.swift +++ b/Packages/CrowUI/Sources/CrowUI/SessionDetailView.swift @@ -189,6 +189,14 @@ public struct SessionDetailView: View { .padding(.horizontal, 16) .padding(.vertical, 8) } + + // Row 4: Session Analytics (if telemetry data exists) + if let analytics = appState.hookState(for: session.id).analytics { + Divider().overlay(CorveilTheme.borderSubtle).padding(.horizontal, 16) + SessionAnalyticsStrip(analytics: analytics) + .padding(.horizontal, 16) + .padding(.vertical, 6) + } } .background(CorveilTheme.bgSurface) } diff --git a/Packages/CrowUI/Sources/CrowUI/SettingsView.swift b/Packages/CrowUI/Sources/CrowUI/SettingsView.swift index 5352a73..f269fe9 100644 --- a/Packages/CrowUI/Sources/CrowUI/SettingsView.swift +++ b/Packages/CrowUI/Sources/CrowUI/SettingsView.swift @@ -143,6 +143,23 @@ public struct SettingsView: View { .font(.caption) .foregroundStyle(.secondary) } + + Section("Telemetry") { + Toggle("Enable session analytics", isOn: $config.telemetry.enabled) + .onChange(of: config.telemetry.enabled) { _, _ in save() } + Text("Collects cost, token, and tool usage metrics from Claude Code sessions via OpenTelemetry. Requires app restart.") + .font(.caption) + .foregroundStyle(.secondary) + + HStack { + Text("OTLP receiver port") + TextField("Port", value: $config.telemetry.port, format: .number) + .textFieldStyle(.roundedBorder) + .frame(width: 80) + .onSubmit { save() } + } + .disabled(!config.telemetry.enabled) + } } .formStyle(.grouped) } diff --git a/Sources/Crow/App/AppDelegate.swift b/Sources/Crow/App/AppDelegate.swift index 1126783..352a070 100644 --- a/Sources/Crow/App/AppDelegate.swift +++ b/Sources/Crow/App/AppDelegate.swift @@ -5,6 +5,7 @@ import CrowUI import CrowPersistence import CrowTerminal import CrowIPC +import CrowTelemetry @MainActor final class AppDelegate: NSObject, NSApplicationDelegate { @@ -18,6 +19,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate { private var issueTracker: IssueTracker? private var notificationManager: NotificationManager? private var allowListService: AllowListService? + private var telemetryService: TelemetryService? private var devRoot: String? private var appConfig: AppConfig? @@ -113,7 +115,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate { appState.remoteControlEnabled = config.remoteControlEnabled // Create session service and hydrate state - let service = SessionService(store: store, appState: appState) + let service = SessionService(store: store, appState: appState, telemetryPort: config.telemetry.enabled ? config.telemetry.port : nil) service.hydrateState() self.sessionService = service NSLog("[Crow] Session state hydrated (%d sessions)", appState.sessions.count) @@ -141,6 +143,9 @@ final class AppDelegate: NSObject, NSApplicationDelegate { // Wire closures for UI actions appState.onDeleteSession = { [weak self, weak service] id in self?.notificationManager?.clearSession(id) + if let telemetry = self?.telemetryService { + await telemetry.deleteSessionData(for: id) + } await service?.deleteSession(id: id) } appState.onCompleteSession = { [weak service] id in @@ -254,6 +259,28 @@ final class AppDelegate: NSObject, NSApplicationDelegate { // Start socket server startSocketServer(store: store, devRoot: devRoot, sessionService: service) + // Start telemetry receiver if enabled + if config.telemetry.enabled { + Task { + do { + let telemetry = try TelemetryService( + port: config.telemetry.port, + onDataReceived: { [weak self] sessionID in + guard let self else { return } + Task { + guard let analytics = await self.telemetryService?.analytics(for: sessionID) else { return } + self.appState.hookState(for: sessionID).analytics = analytics + } + } + ) + try await telemetry.start() + self.telemetryService = telemetry + } catch { + NSLog("[Crow] Failed to start telemetry service: %@", error.localizedDescription) + } + } + } + NSLog("[Crow] Main app launch complete — creating window") // Create main window @@ -955,6 +982,14 @@ final class AppDelegate: NSObject, NSApplicationDelegate { if let devRoot, let appConfig { try? ConfigStore.saveConfig(appConfig, devRoot: devRoot) } + if let telemetry = telemetryService { + let semaphore = DispatchSemaphore(value: 0) + Task { + await telemetry.stop() + semaphore.signal() + } + _ = semaphore.wait(timeout: .now() + 2) + } socketServer?.stop() GhosttyApp.shared.shutdown() NSLog("[Crow] Cleanup complete") diff --git a/Sources/Crow/App/SessionService.swift b/Sources/Crow/App/SessionService.swift index 09252a6..4a5940f 100644 --- a/Sources/Crow/App/SessionService.swift +++ b/Sources/Crow/App/SessionService.swift @@ -9,10 +9,12 @@ import CrowTerminal final class SessionService { private let store: JSONStore private let appState: AppState + private let telemetryPort: UInt16? - init(store: JSONStore, appState: AppState) { + init(store: JSONStore, appState: AppState, telemetryPort: UInt16? = nil) { self.store = store self.appState = appState + self.telemetryPort = telemetryPort } // MARK: - Hydrate State from Store @@ -202,15 +204,31 @@ final class SessionService { let rcEnabled = appState.remoteControlEnabled let rcArgs = ClaudeLaunchArgs.argsSuffix(remoteControl: rcEnabled, sessionName: sessionName) + // Build OTEL telemetry env var prefix if enabled + let envPrefix: String + if let port = telemetryPort, let sessionID { + let vars = [ + "CLAUDE_CODE_ENABLE_TELEMETRY=1", + "OTEL_METRICS_EXPORTER=otlp", + "OTEL_LOGS_EXPORTER=otlp", + "OTEL_EXPORTER_OTLP_PROTOCOL=http/json", + "OTEL_EXPORTER_OTLP_ENDPOINT=http://localhost:\(port)", + "OTEL_RESOURCE_ATTRIBUTES=crow.session.id=\(sessionID.uuidString)", + ].joined(separator: " ") + envPrefix = "export \(vars) && " + } else { + envPrefix = "" + } + // For review sessions, launch claude with the review prompt file if let sessionID, let session = appState.sessions.first(where: { $0.id == sessionID }), session.kind == .review, let worktree = appState.primaryWorktree(for: sessionID) { let promptPath = (worktree.worktreePath as NSString).appendingPathComponent(".crow-review-prompt.md") - TerminalManager.shared.send(id: terminalID, text: "\(claudePath)\(rcArgs) \"$(cat \(promptPath))\"\n") + TerminalManager.shared.send(id: terminalID, text: "\(envPrefix)\(claudePath)\(rcArgs) \"$(cat \(promptPath))\"\n") } else { - TerminalManager.shared.send(id: terminalID, text: "\(claudePath)\(rcArgs) --continue\n") + TerminalManager.shared.send(id: terminalID, text: "\(envPrefix)\(claudePath)\(rcArgs) --continue\n") } appState.terminalReadiness[terminalID] = .claudeLaunched From 766ba88ccf425c3ccb61a848323d952cabfbb06d Mon Sep 17 00:00:00 2001 From: Dustin Hilgaertner Date: Thu, 9 Apr 2026 00:19:12 -0500 Subject: [PATCH 2/4] Fix OTEL env var injection for both new and restored sessions Two bugs fixed: 1. New sessions via `crow send` were missing OTEL env vars entirely. The send RPC handler now injects them when it detects a managed terminal receiving a claude command (same path that writes hooks). 2. The telemetry onDataReceived callback referenced self.telemetryService which was nil because it was assigned after start(). Restructured so init + assignment happens synchronously before start() runs in a Task. Co-Authored-By: Claude Opus 4.6 (1M context) --- Sources/Crow/App/AppDelegate.swift | 47 ++++++++++++++++++--------- Sources/Crow/App/SessionService.swift | 2 +- 2 files changed, 33 insertions(+), 16 deletions(-) diff --git a/Sources/Crow/App/AppDelegate.swift b/Sources/Crow/App/AppDelegate.swift index 352a070..8459f56 100644 --- a/Sources/Crow/App/AppDelegate.swift +++ b/Sources/Crow/App/AppDelegate.swift @@ -261,23 +261,27 @@ final class AppDelegate: NSObject, NSApplicationDelegate { // Start telemetry receiver if enabled if config.telemetry.enabled { - Task { - do { - let telemetry = try TelemetryService( - port: config.telemetry.port, - onDataReceived: { [weak self] sessionID in - guard let self else { return } - Task { - guard let analytics = await self.telemetryService?.analytics(for: sessionID) else { return } - self.appState.hookState(for: sessionID).analytics = analytics - } + do { + let telemetry = try TelemetryService( + port: config.telemetry.port, + onDataReceived: { [weak self] sessionID in + guard let self else { return } + Task { + guard let analytics = await self.telemetryService?.analytics(for: sessionID) else { return } + self.appState.hookState(for: sessionID).analytics = analytics } - ) - try await telemetry.start() - self.telemetryService = telemetry - } catch { - NSLog("[Crow] Failed to start telemetry service: %@", error.localizedDescription) + } + ) + self.telemetryService = telemetry + Task { + do { + try await telemetry.start() + } catch { + NSLog("[Crow] Failed to start telemetry service: %@", error.localizedDescription) + } } + } catch { + NSLog("[Crow] Failed to create telemetry service: %@", error.localizedDescription) } } @@ -456,6 +460,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate { let capturedStore = store let capturedNotifManager = notificationManager let capturedService = sessionService + let capturedTelemetryPort = sessionService.telemetryPort let router = CommandRouter(handlers: [ "new-session": { @Sendable params in @@ -746,6 +751,18 @@ final class AppDelegate: NSObject, NSApplicationDelegate { sessionID.uuidString, error.localizedDescription) } } + // Inject OTEL telemetry env vars so analytics flow back to Crow + if let port = capturedTelemetryPort { + let vars = [ + "CLAUDE_CODE_ENABLE_TELEMETRY=1", + "OTEL_METRICS_EXPORTER=otlp", + "OTEL_LOGS_EXPORTER=otlp", + "OTEL_EXPORTER_OTLP_PROTOCOL=http/json", + "OTEL_EXPORTER_OTLP_ENDPOINT=http://localhost:\(port)", + "OTEL_RESOURCE_ATTRIBUTES=crow.session.id=\(sessionID.uuidString)", + ].joined(separator: " ") + text = "export \(vars) && \(text)" + } capturedAppState.terminalReadiness[terminalID] = .claudeLaunched } diff --git a/Sources/Crow/App/SessionService.swift b/Sources/Crow/App/SessionService.swift index 4a5940f..0813cb0 100644 --- a/Sources/Crow/App/SessionService.swift +++ b/Sources/Crow/App/SessionService.swift @@ -9,7 +9,7 @@ import CrowTerminal final class SessionService { private let store: JSONStore private let appState: AppState - private let telemetryPort: UInt16? + let telemetryPort: UInt16? init(store: JSONStore, appState: AppState, telemetryPort: UInt16? = nil) { self.store = store From 12a41a981dadda9fe1502e016cd3491407289f4c Mon Sep 17 00:00:00 2001 From: Dustin Hilgaertner Date: Tue, 14 Apr 2026 12:39:54 -0500 Subject: [PATCH 3/4] Default telemetry to disabled (opt-in) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Users enable session analytics via Settings → General → Telemetry. Co-Authored-By: Claude Opus 4.6 (1M context) --- Packages/CrowCore/Sources/CrowCore/Models/AppConfig.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Packages/CrowCore/Sources/CrowCore/Models/AppConfig.swift b/Packages/CrowCore/Sources/CrowCore/Models/AppConfig.swift index f963143..aa26697 100644 --- a/Packages/CrowCore/Sources/CrowCore/Models/AppConfig.swift +++ b/Packages/CrowCore/Sources/CrowCore/Models/AppConfig.swift @@ -158,7 +158,7 @@ public struct TelemetryConfig: Codable, Sendable, Equatable { /// Port for the OTLP HTTP receiver (default: 4318). public var port: UInt16 - public init(enabled: Bool = true, port: UInt16 = 4318) { + public init(enabled: Bool = false, port: UInt16 = 4318) { self.enabled = enabled self.port = port } From 3c5ed201172618730fbf8a69209aa9a73fdd6a64 Mon Sep 17 00:00:00 2001 From: Dustin Hilgaertner Date: Mon, 20 Apr 2026 10:54:27 -0500 Subject: [PATCH 4/4] Add configurable telemetry retention window Bounds telemetry database growth with a configurable retention window, defaulting to 6 months. Old metrics and events are pruned on app launch. Presets in Settings: 30 days, 90 days, 6 months, 1 year, Forever. Co-Authored-By: Claude Opus 4.6 (1M context) --- .../CrowCore/Sources/CrowCore/Models/AppConfig.swift | 5 ++++- .../CrowTelemetry/Storage/TelemetryDatabase.swift | 9 +++++++++ .../Sources/CrowTelemetry/TelemetryService.swift | 6 ++++++ Packages/CrowUI/Sources/CrowUI/SettingsView.swift | 10 ++++++++++ Sources/Crow/App/AppDelegate.swift | 2 ++ 5 files changed, 31 insertions(+), 1 deletion(-) diff --git a/Packages/CrowCore/Sources/CrowCore/Models/AppConfig.swift b/Packages/CrowCore/Sources/CrowCore/Models/AppConfig.swift index aa26697..1c84d7a 100644 --- a/Packages/CrowCore/Sources/CrowCore/Models/AppConfig.swift +++ b/Packages/CrowCore/Sources/CrowCore/Models/AppConfig.swift @@ -157,9 +157,12 @@ public struct TelemetryConfig: Codable, Sendable, Equatable { public var enabled: Bool /// Port for the OTLP HTTP receiver (default: 4318). public var port: UInt16 + /// Number of days to retain telemetry data. 0 disables pruning (keep forever). + public var retentionDays: Int - public init(enabled: Bool = false, port: UInt16 = 4318) { + public init(enabled: Bool = false, port: UInt16 = 4318, retentionDays: Int = 180) { self.enabled = enabled self.port = port + self.retentionDays = retentionDays } } diff --git a/Packages/CrowTelemetry/Sources/CrowTelemetry/Storage/TelemetryDatabase.swift b/Packages/CrowTelemetry/Sources/CrowTelemetry/Storage/TelemetryDatabase.swift index 5ac910a..93d4d3a 100644 --- a/Packages/CrowTelemetry/Sources/CrowTelemetry/Storage/TelemetryDatabase.swift +++ b/Packages/CrowTelemetry/Sources/CrowTelemetry/Storage/TelemetryDatabase.swift @@ -217,6 +217,15 @@ public actor TelemetryDatabase { execute("DELETE FROM session_map WHERE crow_session_id = '\(sid)'") } + /// Delete metrics and events older than the retention window. + /// `retentionDays == 0` is a no-op (retention disabled — keep forever). + public func pruneOldData(retentionDays: Int) { + guard retentionDays > 0 else { return } + let cutoff = Date().timeIntervalSince1970 - Double(retentionDays) * 86400 + execute("DELETE FROM metrics WHERE received_at < \(cutoff)") + execute("DELETE FROM events WHERE received_at < \(cutoff)") + } + // MARK: - Private Helpers @discardableResult diff --git a/Packages/CrowTelemetry/Sources/CrowTelemetry/TelemetryService.swift b/Packages/CrowTelemetry/Sources/CrowTelemetry/TelemetryService.swift index 948e723..abc23d6 100644 --- a/Packages/CrowTelemetry/Sources/CrowTelemetry/TelemetryService.swift +++ b/Packages/CrowTelemetry/Sources/CrowTelemetry/TelemetryService.swift @@ -57,6 +57,12 @@ public final class TelemetryService: Sendable { await database.deleteSessionData(for: crowSessionID) } + /// Delete metrics and events older than the retention window. + /// `retentionDays == 0` keeps data forever. + public func pruneOldData(retentionDays: Int) async { + await database.pruneOldData(retentionDays: retentionDays) + } + // MARK: - Private private static func defaultDataDirectory() -> String { diff --git a/Packages/CrowUI/Sources/CrowUI/SettingsView.swift b/Packages/CrowUI/Sources/CrowUI/SettingsView.swift index 67474f3..a08cf59 100644 --- a/Packages/CrowUI/Sources/CrowUI/SettingsView.swift +++ b/Packages/CrowUI/Sources/CrowUI/SettingsView.swift @@ -179,6 +179,16 @@ public struct SettingsView: View { .onSubmit { save() } } .disabled(!config.telemetry.enabled) + + Picker("Retention", selection: $config.telemetry.retentionDays) { + Text("30 days").tag(30) + Text("90 days").tag(90) + Text("6 months").tag(180) + Text("1 year").tag(365) + Text("Forever").tag(0) + } + .onChange(of: config.telemetry.retentionDays) { _, _ in save() } + .disabled(!config.telemetry.enabled) } } .formStyle(.grouped) diff --git a/Sources/Crow/App/AppDelegate.swift b/Sources/Crow/App/AppDelegate.swift index 8459f56..9b258e9 100644 --- a/Sources/Crow/App/AppDelegate.swift +++ b/Sources/Crow/App/AppDelegate.swift @@ -273,9 +273,11 @@ final class AppDelegate: NSObject, NSApplicationDelegate { } ) self.telemetryService = telemetry + let retentionDays = config.telemetry.retentionDays Task { do { try await telemetry.start() + await telemetry.pruneOldData(retentionDays: retentionDays) } catch { NSLog("[Crow] Failed to start telemetry service: %@", error.localizedDescription) }