Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions Package.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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"),
],
Expand All @@ -32,6 +33,7 @@ let package = Package(
"CrowPersistence",
"CrowClaude",
"CrowIPC",
"CrowTelemetry",
],
path: "Sources/Crow",
resources: [
Expand Down
1 change: 1 addition & 0 deletions Packages/CrowCore/Sources/CrowCore/AppState.swift
Original file line number Diff line number Diff line change
Expand Up @@ -343,6 +343,7 @@ public final class SessionHookState {
public var pendingNotification: HookNotification?
public var lastToolActivity: ToolActivity?
public var hookEvents: [HookEvent] = []
public var analytics: SessionAnalytics?

public init() {}
}
24 changes: 22 additions & 2 deletions Packages/CrowCore/Sources/CrowCore/Models/AppConfig.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -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
}
}

Expand Down Expand Up @@ -146,3 +150,19 @@ 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
/// Number of days to retain telemetry data. 0 disables pruning (keep forever).
public var retentionDays: Int

public init(enabled: Bool = false, port: UInt16 = 4318, retentionDays: Int = 180) {
self.enabled = enabled
self.port = port
self.retentionDays = retentionDays
}
}
Original file line number Diff line number Diff line change
@@ -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
}
}
23 changes: 23 additions & 0 deletions Packages/CrowTelemetry/Package.swift
Original file line number Diff line number Diff line change
@@ -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"]
),
]
)
178 changes: 178 additions & 0 deletions Packages/CrowTelemetry/Sources/CrowTelemetry/Models/OTLPModels.swift
Original file line number Diff line number Diff line change
@@ -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?
}
Loading
Loading