From 6c3f7934169a67430faef1772272fd745beb79c1 Mon Sep 17 00:00:00 2001 From: Philip Niedertscheider Date: Tue, 15 Sep 2026 14:35:33 +0200 Subject: [PATCH] ref(telemetry): Introduce an injectable telemetry service --- .../agent/skills/AgentSkillsGetCommand.swift | 43 +----- .../agent/skills/AgentSkillsListCommand.swift | 43 +----- Sources/CLI/cmd/cache/CacheCleanCommand.swift | 43 +----- .../TechnologiesListCommand.swift | 54 +------ Sources/CLI/cmd/types/TypesListCommand.swift | 57 +------ .../CLI/cmd/types/TypesSearchCommand.swift | 59 +------- Sources/CLI/cmd/types/TypesViewCommand.swift | 76 +--------- Sources/CLI/main/AppleDocs.swift | 65 +------- Sources/CLI/main/Dependencies.swift | 6 + Sources/CLI/main/LoggingConfiguration.swift | 16 +- Sources/CLI/telemetry/NoOpTelemetry.swift | 11 ++ Sources/CLI/telemetry/SentryTelemetry.swift | 114 ++++++++++++++ Sources/CLI/telemetry/Telemetry.swift | 31 ++++ ...xt.swift => TelemetryCommandContext.swift} | 50 +++---- .../telemetry/SentryCommandContextTests.swift | 139 ------------------ .../TelemetryCommandContextTests.swift | 137 +++++++++++++++++ Tests/CLITests/telemetry/TelemetryTests.swift | 90 ++++++++++++ 17 files changed, 456 insertions(+), 578 deletions(-) create mode 100644 Sources/CLI/telemetry/NoOpTelemetry.swift create mode 100644 Sources/CLI/telemetry/SentryTelemetry.swift create mode 100644 Sources/CLI/telemetry/Telemetry.swift rename Sources/CLI/telemetry/{SentryCommandContext.swift => TelemetryCommandContext.swift} (68%) delete mode 100644 Tests/CLITests/telemetry/SentryCommandContextTests.swift create mode 100644 Tests/CLITests/telemetry/TelemetryCommandContextTests.swift create mode 100644 Tests/CLITests/telemetry/TelemetryTests.swift diff --git a/Sources/CLI/cmd/agent/skills/AgentSkillsGetCommand.swift b/Sources/CLI/cmd/agent/skills/AgentSkillsGetCommand.swift index d302620..f87fa8a 100644 --- a/Sources/CLI/cmd/agent/skills/AgentSkillsGetCommand.swift +++ b/Sources/CLI/cmd/agent/skills/AgentSkillsGetCommand.swift @@ -1,17 +1,8 @@ import ArgumentParser -import Logging - -#if canImport(SentrySwift) - @preconcurrency import SentrySwift -#endif struct AgentSkillsGetCommand: ParsableCommand, GlobalOptionsProviding { @OptionGroup var global: GlobalOptions - private static let logger = Logger( - label: "com.techprimate.apple-docs.agent-skills-get" - ) - static let configuration = CommandConfiguration( commandName: "get", abstract: "Print a bundled Agent Skill." @@ -21,37 +12,11 @@ struct AgentSkillsGetCommand: ParsableCommand, GlobalOptionsProviding { var name: String mutating func run() throws { - #if canImport(SentrySwift) - if SentrySDK.isEnabled { - let context = SentryCommandContext.agentSkillsGet - let transaction = SentrySDK.startTransaction( - name: context.transactionName, - operation: "console.command", - bindToScope: true - ) - for (key, value) in context.attributes { - transaction.setData(value: value, key: key) - } - SentrySDK.configureScope { scope in - scope.setContext(value: context.attributes, key: "cli") - } - let breadcrumb = Breadcrumb( - level: .info, - category: SentryConfiguration.breadcrumbCategory - ) - breadcrumb.type = "user" - breadcrumb.message = "CLI command invoked" - for (key, value) in context.attributes { - breadcrumb.setData(value: value, key: key) - } - SentrySDK.addBreadcrumb(breadcrumb) - Self.logger.info( - "CLI command started", - metadata: context.logMetadata - ) - } - #endif + try run(telemetry: Dependencies.telemetry) + } + func run(telemetry: Telemetry) throws { + telemetry.startCommand(.agentSkillsGet) guard let skill = BundledAgentSkills.skill(named: name) else { throw ValidationError("Unknown bundled Agent Skill '\(name)'.") } diff --git a/Sources/CLI/cmd/agent/skills/AgentSkillsListCommand.swift b/Sources/CLI/cmd/agent/skills/AgentSkillsListCommand.swift index 25b68ac..2a2350b 100644 --- a/Sources/CLI/cmd/agent/skills/AgentSkillsListCommand.swift +++ b/Sources/CLI/cmd/agent/skills/AgentSkillsListCommand.swift @@ -1,54 +1,19 @@ import ArgumentParser -import Logging - -#if canImport(SentrySwift) - @preconcurrency import SentrySwift -#endif struct AgentSkillsListCommand: ParsableCommand, GlobalOptionsProviding { @OptionGroup var global: GlobalOptions - private static let logger = Logger( - label: "com.techprimate.apple-docs.agent-skills-list" - ) - static let configuration = CommandConfiguration( commandName: "list", abstract: "List Agent Skills bundled with apple-docs." ) mutating func run() throws { - #if canImport(SentrySwift) - if SentrySDK.isEnabled { - let context = SentryCommandContext.agentSkillsList - let transaction = SentrySDK.startTransaction( - name: context.transactionName, - operation: "console.command", - bindToScope: true - ) - for (key, value) in context.attributes { - transaction.setData(value: value, key: key) - } - SentrySDK.configureScope { scope in - scope.setContext(value: context.attributes, key: "cli") - } - let breadcrumb = Breadcrumb( - level: .info, - category: SentryConfiguration.breadcrumbCategory - ) - breadcrumb.type = "user" - breadcrumb.message = "CLI command invoked" - for (key, value) in context.attributes { - breadcrumb.setData(value: value, key: key) - } - SentrySDK.addBreadcrumb(breadcrumb) - Self.logger.info( - "CLI command started", - metadata: context.logMetadata - ) - } - #endif + run(telemetry: Dependencies.telemetry) + } + func run(telemetry: Telemetry) { + telemetry.startCommand(.agentSkillsList) for skill in BundledAgentSkills.all { print("\(skill.name)\t\(skill.shortDescription)") } diff --git a/Sources/CLI/cmd/cache/CacheCleanCommand.swift b/Sources/CLI/cmd/cache/CacheCleanCommand.swift index 267d6af..0719017 100644 --- a/Sources/CLI/cmd/cache/CacheCleanCommand.swift +++ b/Sources/CLI/cmd/cache/CacheCleanCommand.swift @@ -1,54 +1,19 @@ import ArgumentParser -import Logging - -#if canImport(SentrySwift) - @preconcurrency import SentrySwift -#endif struct CacheCleanCommand: ParsableCommand, GlobalOptionsProviding { @OptionGroup var global: GlobalOptions - private static let logger = Logger( - label: "com.techprimate.apple-docs.cache-clean" - ) - static let configuration = CommandConfiguration( commandName: "clean", abstract: "Clear cached Apple documentation." ) mutating func run() throws { - #if canImport(SentrySwift) - if SentrySDK.isEnabled { - let context = SentryCommandContext.cacheClean - let transaction = SentrySDK.startTransaction( - name: context.transactionName, - operation: "console.command", - bindToScope: true - ) - for (key, value) in context.attributes { - transaction.setData(value: value, key: key) - } - SentrySDK.configureScope { scope in - scope.setContext(value: context.attributes, key: "cli") - } - let breadcrumb = Breadcrumb( - level: .info, - category: SentryConfiguration.breadcrumbCategory - ) - breadcrumb.type = "user" - breadcrumb.message = "CLI command invoked" - for (key, value) in context.attributes { - breadcrumb.setData(value: value, key: key) - } - SentrySDK.addBreadcrumb(breadcrumb) - Self.logger.info( - "CLI command started", - metadata: context.logMetadata - ) - } - #endif + run(telemetry: Dependencies.telemetry) + } + func run(telemetry: Telemetry) { + telemetry.startCommand(.cacheClean) let result = CacheCleanCommandRunner( cache: Dependencies.documentationCache ).run() diff --git a/Sources/CLI/cmd/technologies/TechnologiesListCommand.swift b/Sources/CLI/cmd/technologies/TechnologiesListCommand.swift index 1f8f2f7..dd0ba81 100644 --- a/Sources/CLI/cmd/technologies/TechnologiesListCommand.swift +++ b/Sources/CLI/cmd/technologies/TechnologiesListCommand.swift @@ -1,17 +1,8 @@ import ArgumentParser -import Logging - -#if canImport(SentrySwift) - @preconcurrency import SentrySwift -#endif struct TechnologiesListCommand: AsyncParsableCommand, GlobalOptionsProviding { @OptionGroup var global: GlobalOptions - private static let logger = Logger( - label: "com.techprimate.apple-docs.technologies-list" - ) - static let configuration = CommandConfiguration( commandName: "list", abstract: "List Apple documentation technologies." @@ -24,50 +15,17 @@ struct TechnologiesListCommand: AsyncParsableCommand, GlobalOptionsProviding { var json = false mutating func run() async throws { - let context = SentryCommandContext.technologiesList(json: json) - #if canImport(SentrySwift) - if SentrySDK.isEnabled { - let transaction = SentrySDK.startTransaction( - name: context.transactionName, - operation: "console.command", - bindToScope: true - ) - for (key, value) in context.attributes { - transaction.setData(value: value, key: key) - } - SentrySDK.configureScope { scope in - scope.setContext(value: context.attributes, key: "cli") - } - let breadcrumb = Breadcrumb( - level: .info, - category: SentryConfiguration.breadcrumbCategory - ) - breadcrumb.type = "user" - breadcrumb.message = "CLI command invoked" - for (key, value) in context.attributes { - breadcrumb.setData(value: value, key: key) - } - SentrySDK.addBreadcrumb(breadcrumb) - Self.logger.info( - "CLI command started", - metadata: context.logMetadata - ) - } - #endif + try await run(telemetry: Dependencies.telemetry) + } + func run(telemetry: Telemetry) async throws { + let context = TelemetryCommandContext.technologiesList(json: json) + telemetry.startCommand(context) let result = try await TechnologiesListCommandRunner( client: Dependencies.documentationClient, renderer: Dependencies.technologyListRenderer(json: json) ).run() - #if canImport(SentrySwift) - if SentrySDK.isEnabled { - SentrySDK.metrics.gauge( - key: "apple_docs.technology.catalog.count", - value: Double(result.technologyCount), - attributes: context.metricAttributes - ) - } - #endif + telemetry.record(.technologyCatalog(count: result.technologyCount), context: context) print(result.output) } } diff --git a/Sources/CLI/cmd/types/TypesListCommand.swift b/Sources/CLI/cmd/types/TypesListCommand.swift index 4132942..4c83f60 100644 --- a/Sources/CLI/cmd/types/TypesListCommand.swift +++ b/Sources/CLI/cmd/types/TypesListCommand.swift @@ -1,17 +1,8 @@ import ArgumentParser -import Logging - -#if canImport(SentrySwift) - @preconcurrency import SentrySwift -#endif struct TypesListCommand: AsyncParsableCommand, GlobalOptionsProviding { @OptionGroup var global: GlobalOptions - private static let logger = Logger( - label: "com.techprimate.apple-docs.types-list" - ) - static let configuration = CommandConfiguration( commandName: "list", abstract: "List types in an Apple documentation technology." @@ -27,53 +18,17 @@ struct TypesListCommand: AsyncParsableCommand, GlobalOptionsProviding { var json = false mutating func run() async throws { - let context = SentryCommandContext.typesList( - technology: technology, - json: json - ) - #if canImport(SentrySwift) - if SentrySDK.isEnabled { - let transaction = SentrySDK.startTransaction( - name: context.transactionName, - operation: "console.command", - bindToScope: true - ) - for (key, value) in context.attributes { - transaction.setData(value: value, key: key) - } - SentrySDK.configureScope { scope in - scope.setContext(value: context.attributes, key: "cli") - } - let breadcrumb = Breadcrumb( - level: .info, - category: SentryConfiguration.breadcrumbCategory - ) - breadcrumb.type = "user" - breadcrumb.message = "CLI command invoked" - for (key, value) in context.attributes { - breadcrumb.setData(value: value, key: key) - } - SentrySDK.addBreadcrumb(breadcrumb) - Self.logger.info( - "CLI command started", - metadata: context.logMetadata - ) - } - #endif + try await run(telemetry: Dependencies.telemetry) + } + func run(telemetry: Telemetry) async throws { + let context = TelemetryCommandContext.typesList(technology: technology, json: json) + telemetry.startCommand(context) let result = try await TypesListCommandRunner( client: Dependencies.documentationClient, renderer: Dependencies.documentationTypeListRenderer(json: json) ).run(technology: technology) - #if canImport(SentrySwift) - if SentrySDK.isEnabled { - SentrySDK.metrics.gauge( - key: "apple_docs.type.catalog.count", - value: Double(result.typeCount), - attributes: context.metricAttributes - ) - } - #endif + telemetry.record(.typeCatalog(count: result.typeCount), context: context) print(result.output) } } diff --git a/Sources/CLI/cmd/types/TypesSearchCommand.swift b/Sources/CLI/cmd/types/TypesSearchCommand.swift index 28cbe68..e8c8420 100644 --- a/Sources/CLI/cmd/types/TypesSearchCommand.swift +++ b/Sources/CLI/cmd/types/TypesSearchCommand.swift @@ -1,17 +1,8 @@ import ArgumentParser -import Logging - -#if canImport(SentrySwift) - @preconcurrency import SentrySwift -#endif struct TypesSearchCommand: AsyncParsableCommand, GlobalOptionsProviding { @OptionGroup var global: GlobalOptions - private static let logger = Logger( - label: "com.techprimate.apple-docs.types-search" - ) - static let configuration = CommandConfiguration( commandName: "search", abstract: "Search types in an Apple documentation technology." @@ -30,54 +21,18 @@ struct TypesSearchCommand: AsyncParsableCommand, GlobalOptionsProviding { var json = false mutating func run() async throws { - // Search text can be user-authored, so it is deliberately excluded from telemetry context. - let context = SentryCommandContext.typesSearch( - technology: technology, - json: json - ) - #if canImport(SentrySwift) - if SentrySDK.isEnabled { - let transaction = SentrySDK.startTransaction( - name: context.transactionName, - operation: "console.command", - bindToScope: true - ) - for (key, value) in context.attributes { - transaction.setData(value: value, key: key) - } - SentrySDK.configureScope { scope in - scope.setContext(value: context.attributes, key: "cli") - } - let breadcrumb = Breadcrumb( - level: .info, - category: SentryConfiguration.breadcrumbCategory - ) - breadcrumb.type = "user" - breadcrumb.message = "CLI command invoked" - for (key, value) in context.attributes { - breadcrumb.setData(value: value, key: key) - } - SentrySDK.addBreadcrumb(breadcrumb) - Self.logger.info( - "CLI command started", - metadata: context.logMetadata - ) - } - #endif + try await run(telemetry: Dependencies.telemetry) + } + func run(telemetry: Telemetry) async throws { + // Search text can be user-authored, so it is deliberately excluded from telemetry context. + let context = TelemetryCommandContext.typesSearch(technology: technology, json: json) + telemetry.startCommand(context) let result = try await TypesSearchCommandRunner( client: Dependencies.documentationClient, renderer: Dependencies.documentationTypeListRenderer(json: json) ).run(query: query, technology: technology) - #if canImport(SentrySwift) - if SentrySDK.isEnabled { - SentrySDK.metrics.distribution( - key: "apple_docs.type.search.result.count", - value: Double(result.matchCount), - attributes: context.metricAttributes - ) - } - #endif + telemetry.record(.typeSearch(matches: result.matchCount), context: context) print(result.output) } } diff --git a/Sources/CLI/cmd/types/TypesViewCommand.swift b/Sources/CLI/cmd/types/TypesViewCommand.swift index df7eae0..f07f004 100644 --- a/Sources/CLI/cmd/types/TypesViewCommand.swift +++ b/Sources/CLI/cmd/types/TypesViewCommand.swift @@ -1,17 +1,8 @@ import ArgumentParser -import Logging - -#if canImport(SentrySwift) - @preconcurrency import SentrySwift -#endif struct TypesViewCommand: AsyncParsableCommand, GlobalOptionsProviding { @OptionGroup var global: GlobalOptions - private static let logger = Logger( - label: "com.techprimate.apple-docs.types-view" - ) - static let configuration = CommandConfiguration( commandName: "view", abstract: "Show documentation for a type." @@ -30,72 +21,17 @@ struct TypesViewCommand: AsyncParsableCommand, GlobalOptionsProviding { var json = false mutating func run() async throws { - let context = SentryCommandContext.typesView( - name: name, - technology: technology, - json: json - ) - #if canImport(SentrySwift) - if SentrySDK.isEnabled { - let transaction = SentrySDK.startTransaction( - name: context.transactionName, - operation: "console.command", - bindToScope: true - ) - for (key, value) in context.attributes { - transaction.setData(value: value, key: key) - } - SentrySDK.configureScope { scope in - scope.setContext(value: context.attributes, key: "cli") - } - let breadcrumb = Breadcrumb( - level: .info, - category: SentryConfiguration.breadcrumbCategory - ) - breadcrumb.type = "user" - breadcrumb.message = "CLI command invoked" - for (key, value) in context.attributes { - breadcrumb.setData(value: value, key: key) - } - SentrySDK.addBreadcrumb(breadcrumb) - Self.logger.info( - "CLI command started", - metadata: context.logMetadata - ) - } - #endif + try await run(telemetry: Dependencies.telemetry) + } + func run(telemetry: Telemetry) async throws { + let context = TelemetryCommandContext.typesView(name: name, technology: technology, json: json) + telemetry.startCommand(context) let result = try await TypesViewCommandRunner( client: Dependencies.documentationClient, renderer: Dependencies.documentationRenderer(json: json) ).run(name: name, technology: technology) - #if canImport(SentrySwift) - if SentrySDK.isEnabled { - recordPopularityMetrics() - SentrySDK.metrics.distribution( - key: "apple_docs.response.size", - value: Double(result.responseByteCount), - unit: .byte, - attributes: context.metricAttributes - ) - } - #endif + telemetry.record(.typeView(responseBytes: result.responseByteCount), context: context) print(result.output) } - - #if canImport(SentrySwift) - private func recordPopularityMetrics() { - SentrySDK.metrics.count( - key: "apple_docs.technology.requested", - attributes: ["apple_docs.technology": technology] - ) - SentrySDK.metrics.count( - key: "apple_docs.type.requested", - attributes: [ - "apple_docs.technology": technology, - "apple_docs.type": name, - ] - ) - } - #endif } diff --git a/Sources/CLI/main/AppleDocs.swift b/Sources/CLI/main/AppleDocs.swift index c3f28ce..74edd8f 100644 --- a/Sources/CLI/main/AppleDocs.swift +++ b/Sources/CLI/main/AppleDocs.swift @@ -1,25 +1,21 @@ import ArgumentParser -import Foundation import Logging -#if canImport(SentrySwift) - @preconcurrency import SentrySwift -#endif - @main enum AppleDocs { private static let logger = Logger(label: "com.techprimate.apple-docs") @MainActor static func main() async { - let telemetryEnabled = configureTelemetry() + let telemetry = Dependencies.telemetry + telemetry.start() var loggingConfigured = false do { var command = try await CLI.asyncParseAsRoot() LoggingConfiguration.bootstrap( verbose: verboseLoggingEnabled(for: command), - telemetryEnabled: telemetryEnabled + telemetry: telemetry ) loggingConfigured = true Self.logger.debug("CLI command parsed") @@ -29,12 +25,12 @@ enum AppleDocs { try command.run() } Self.logger.debug("CLI command finished") - finishTelemetry(enabled: telemetryEnabled) + telemetry.finishCommand(error: nil) } catch { if !loggingConfigured { - LoggingConfiguration.bootstrap(verbose: false, telemetryEnabled: telemetryEnabled) + LoggingConfiguration.bootstrap(verbose: false, telemetry: telemetry) } - captureTelemetry(error, enabled: telemetryEnabled) + telemetry.finishCommand(error: error) CLI.exit(withError: error) } } @@ -42,53 +38,4 @@ enum AppleDocs { private static func verboseLoggingEnabled(for command: any ParsableCommand) -> Bool { (command as? any GlobalOptionsProviding)?.global.verbose ?? false } - - private static func configureTelemetry() -> Bool { - #if canImport(SentrySwift) - let enabled = SentryConfiguration.isEnabled( - environment: ProcessInfo.processInfo.environment - ) - if enabled { - SentrySDK.start { options in - SentryConfiguration.configure(options) - } - } - return enabled - #else - return false - #endif - } - - private static func finishTelemetry(enabled: Bool) { - #if canImport(SentrySwift) - guard enabled else { - return - } - SentrySDK.span?.status = .ok - Self.logger.info("CLI command completed") - SentrySDK.span?.finish() - SentrySDK.flush(timeout: 2) - #endif - } - - private static func captureTelemetry(_ error: Error, enabled: Bool) { - #if canImport(SentrySwift) - guard enabled else { - return - } - if let span = SentrySDK.span { - // Lookup misses are actionable CLI outcomes, not application reliability failures. - let expected = error is ValidationError || SentryConfiguration.isExpected(error: error) - span.status = expected ? .invalidArgument : .internalError - if expected { - Self.logger.info("CLI command rejected") - } else { - Self.logger.error("CLI command failed") - SentrySDK.capture(error: error) - } - span.finish() - } - SentrySDK.flush(timeout: 2) - #endif - } } diff --git a/Sources/CLI/main/Dependencies.swift b/Sources/CLI/main/Dependencies.swift index 4dff129..e6f7e03 100644 --- a/Sources/CLI/main/Dependencies.swift +++ b/Sources/CLI/main/Dependencies.swift @@ -6,6 +6,12 @@ import Logging #endif enum Dependencies { + static let telemetry = DefaultTelemetry( + // Telemetry starts before SwiftLog is bootstrapped. Resolve its logger only when logging an event. + logger: { Logger(label: "com.techprimate.apple-docs.telemetry") }, + environment: ProcessInfo.processInfo.environment + ) + static let httpCache: URLCache? = { guard let cachesDirectory = FileManager.default.urls( diff --git a/Sources/CLI/main/LoggingConfiguration.swift b/Sources/CLI/main/LoggingConfiguration.swift index f9957d0..218c84b 100644 --- a/Sources/CLI/main/LoggingConfiguration.swift +++ b/Sources/CLI/main/LoggingConfiguration.swift @@ -1,21 +1,11 @@ import Logging -#if canImport(SentrySwift) - import SentrySwiftLog -#endif - enum LoggingConfiguration { - static func bootstrap(verbose: Bool, telemetryEnabled: Bool) { + static func bootstrap(verbose: Bool, telemetry: Telemetry) { LoggingSystem.bootstrap { label in - var telemetry: (any LogHandler)? - #if canImport(SentrySwift) - if telemetryEnabled { - telemetry = SentryLogHandler(logLevel: .info) - } - #endif - return handler( + handler( console: StreamLogHandler.standardError(label: label), - telemetry: telemetry, + telemetry: telemetry.makeLogHandler(), verbose: verbose ) } diff --git a/Sources/CLI/telemetry/NoOpTelemetry.swift b/Sources/CLI/telemetry/NoOpTelemetry.swift new file mode 100644 index 0000000..9d5a467 --- /dev/null +++ b/Sources/CLI/telemetry/NoOpTelemetry.swift @@ -0,0 +1,11 @@ +import Logging + +struct NoOpTelemetry: Sendable { + init(logger: @escaping @Sendable () -> Logger, environment: [String: String]) {} + + func start() {} + func startCommand(_ context: TelemetryCommandContext) {} + func record(_ metric: TelemetryMetric, context: TelemetryCommandContext) {} + func finishCommand(error: (any Error)?) {} + func makeLogHandler() -> (any LogHandler)? { nil } +} diff --git a/Sources/CLI/telemetry/SentryTelemetry.swift b/Sources/CLI/telemetry/SentryTelemetry.swift new file mode 100644 index 0000000..9e80028 --- /dev/null +++ b/Sources/CLI/telemetry/SentryTelemetry.swift @@ -0,0 +1,114 @@ +#if canImport(SentrySwift) + import ArgumentParser + import Logging + @preconcurrency import SentrySwift + import SentrySwiftLog + + struct SentryTelemetry: Sendable { + private let enabled: Bool + private let makeLogger: @Sendable () -> Logger + + init(logger: @escaping @Sendable () -> Logger, environment: [String: String]) { + makeLogger = logger + enabled = SentryConfiguration.isEnabled(environment: environment) + } + + func start() { + guard enabled else { return } + SentrySDK.start { options in + SentryConfiguration.configure(options) + } + } + + func makeLogHandler() -> (any LogHandler)? { + guard enabled, SentrySDK.isEnabled else { return nil } + return SentryLogHandler(logLevel: .info) + } + + func startCommand(_ context: TelemetryCommandContext) { + guard enabled, SentrySDK.isEnabled else { return } + let transaction = SentrySDK.startTransaction( + name: context.transactionName, + operation: "console.command", + bindToScope: true + ) + for (key, value) in context.attributes { + transaction.setData(value: value, key: key) + } + SentrySDK.configureScope { scope in + scope.setContext(value: context.attributes, key: "cli") + } + let breadcrumb = Breadcrumb(level: .info, category: SentryConfiguration.breadcrumbCategory) + breadcrumb.type = "user" + breadcrumb.message = "CLI command invoked" + for (key, value) in context.attributes { + breadcrumb.setData(value: value, key: key) + } + SentrySDK.addBreadcrumb(breadcrumb) + makeLogger().info("CLI command started", metadata: context.logMetadata) + } + + func record(_ metric: TelemetryMetric, context: TelemetryCommandContext) { + guard enabled, SentrySDK.isEnabled else { return } + let attributes = context.metricAttributes.mapValues { $0 as any SentryAttributeValue } + switch metric { + case .technologyCatalog(let count): + SentrySDK.metrics.gauge( + key: "apple_docs.technology.catalog.count", value: Double(count), attributes: attributes + ) + case .typeCatalog(let count): + SentrySDK.metrics.gauge( + key: "apple_docs.type.catalog.count", value: Double(count), attributes: attributes + ) + case .typeSearch(let matches): + SentrySDK.metrics.distribution( + key: "apple_docs.type.search.result.count", value: Double(matches), attributes: attributes + ) + case .typeView(let responseBytes): + recordPopularity(context) + SentrySDK.metrics.distribution( + key: "apple_docs.response.size", value: Double(responseBytes), unit: .byte, attributes: attributes + ) + } + } + + func finishCommand(error: (any Error)?) { + guard enabled, SentrySDK.isEnabled else { return } + let logger = makeLogger() + if let error { + if let span = SentrySDK.span { + // Lookup misses are actionable CLI outcomes, not application reliability failures. + let expected = error is ValidationError || SentryConfiguration.isExpected(error: error) + span.status = expected ? .invalidArgument : .internalError + if expected { + logger.info("CLI command rejected") + } else { + logger.error("CLI command failed") + SentrySDK.capture(error: error) + } + span.finish() + } + } else { + SentrySDK.span?.status = .ok + logger.info("CLI command completed") + SentrySDK.span?.finish() + } + SentrySDK.flush(timeout: 2) + } + + private func recordPopularity(_ context: TelemetryCommandContext) { + if let technology = context.technology { + SentrySDK.metrics.count( + key: "apple_docs.technology.requested", + attributes: ["apple_docs.technology": technology] + ) + if let name = context.typeName { + SentrySDK.metrics.count( + key: "apple_docs.type.requested", + attributes: ["apple_docs.technology": technology, "apple_docs.type": name] + ) + } + } + } + } +#endif diff --git a/Sources/CLI/telemetry/Telemetry.swift b/Sources/CLI/telemetry/Telemetry.swift new file mode 100644 index 0000000..3aca647 --- /dev/null +++ b/Sources/CLI/telemetry/Telemetry.swift @@ -0,0 +1,31 @@ +import Logging + +#if DEBUG + protocol Telemetry: Sendable { + func start() + func startCommand(_ context: TelemetryCommandContext) + func record(_ metric: TelemetryMetric, context: TelemetryCommandContext) + func finishCommand(error: (any Error)?) + func makeLogHandler() -> (any LogHandler)? + } + + extension NoOpTelemetry: Telemetry {} + #if canImport(SentrySwift) + extension SentryTelemetry: Telemetry {} + #endif +#else + typealias Telemetry = DefaultTelemetry +#endif + +#if canImport(SentrySwift) + typealias DefaultTelemetry = SentryTelemetry +#else + typealias DefaultTelemetry = NoOpTelemetry +#endif + +enum TelemetryMetric: Sendable { + case technologyCatalog(count: Int) + case typeCatalog(count: Int) + case typeSearch(matches: Int) + case typeView(responseBytes: Int) +} diff --git a/Sources/CLI/telemetry/SentryCommandContext.swift b/Sources/CLI/telemetry/TelemetryCommandContext.swift similarity index 68% rename from Sources/CLI/telemetry/SentryCommandContext.swift rename to Sources/CLI/telemetry/TelemetryCommandContext.swift index 7dfedce..4beee35 100644 --- a/Sources/CLI/telemetry/SentryCommandContext.swift +++ b/Sources/CLI/telemetry/TelemetryCommandContext.swift @@ -1,36 +1,32 @@ import Logging -#if canImport(SentrySwift) - @preconcurrency import SentrySwift -#endif - -struct SentryCommandContext: Equatable, Sendable { +struct TelemetryCommandContext: Equatable, Sendable { let command: String let outputJSON: Bool? let technology: String? let typeName: String? - static let cacheClean = SentryCommandContext( + static let cacheClean = TelemetryCommandContext( command: "cache.clean", outputJSON: nil, technology: nil, typeName: nil ) - static let agentSkillsGet = SentryCommandContext( + static let agentSkillsGet = TelemetryCommandContext( command: "agent.skills.get", outputJSON: nil, technology: nil, typeName: nil ) - static let agentSkillsList = SentryCommandContext( + static let agentSkillsList = TelemetryCommandContext( command: "agent.skills.list", outputJSON: nil, technology: nil, typeName: nil ) - static func technologiesList(json: Bool) -> SentryCommandContext { - SentryCommandContext( + static func technologiesList(json: Bool) -> TelemetryCommandContext { + TelemetryCommandContext( command: "technologies.list", outputJSON: json, technology: nil, @@ -41,8 +37,8 @@ struct SentryCommandContext: Equatable, Sendable { static func typesList( technology: String, json: Bool - ) -> SentryCommandContext { - SentryCommandContext( + ) -> TelemetryCommandContext { + TelemetryCommandContext( command: "types.list", outputJSON: json, technology: technology, @@ -53,8 +49,8 @@ struct SentryCommandContext: Equatable, Sendable { static func typesSearch( technology: String, json: Bool - ) -> SentryCommandContext { - SentryCommandContext( + ) -> TelemetryCommandContext { + TelemetryCommandContext( command: "types.search", outputJSON: json, technology: technology, @@ -66,8 +62,8 @@ struct SentryCommandContext: Equatable, Sendable { name: String, technology: String, json: Bool - ) -> SentryCommandContext { - SentryCommandContext( + ) -> TelemetryCommandContext { + TelemetryCommandContext( command: "types.view", outputJSON: json, technology: technology, @@ -107,18 +103,14 @@ struct SentryCommandContext: Equatable, Sendable { return metadata } - #if canImport(SentrySwift) - var metricAttributes: [String: any SentryAttributeValue] { - var attributes: [String: any SentryAttributeValue] = [ - "cli.command": command - ] - if let technology { - attributes["apple_docs.technology"] = technology - } - if let typeName { - attributes["apple_docs.type"] = typeName - } - return attributes + var metricAttributes: [String: String] { + var attributes = ["cli.command": command] + if let technology { + attributes["apple_docs.technology"] = technology + } + if let typeName { + attributes["apple_docs.type"] = typeName } - #endif + return attributes + } } diff --git a/Tests/CLITests/telemetry/SentryCommandContextTests.swift b/Tests/CLITests/telemetry/SentryCommandContextTests.swift deleted file mode 100644 index c7963a2..0000000 --- a/Tests/CLITests/telemetry/SentryCommandContextTests.swift +++ /dev/null @@ -1,139 +0,0 @@ -import Testing - -@testable import CLI - -#if canImport(SentrySwift) - @Suite("Sentry command context") - struct SentryCommandContextTests { - @Test("excludes the query from types search telemetry") - func excludesTypesSearchQuery() { - // -- Arrange -- - let expectedKeys = [ - "apple_docs.technology", - "cli.command", - "cli.output_json", - ] - - // -- Act -- - let context = SentryCommandContext.typesSearch( - technology: "SwiftUI", - json: true - ) - - // -- Assert -- - #expect(context.command == "types.search") - #expect(context.typeName == nil) - #expect(context.technology == "SwiftUI") - #expect(context.outputJSON == true) - #expect(context.attributes.keys.sorted() == expectedKeys) - #expect(context.metricAttributes.keys.sorted() == expectedKeys.dropLast()) - #expect(context.logMetadata.keys.sorted() == expectedKeys) - } - - @Test("opts documentation identifiers into types view telemetry") - func includesTypesViewIdentifiers() { - // -- Arrange -- - let expectedKeys = [ - "apple_docs.technology", - "apple_docs.type", - "cli.command", - "cli.output_json", - ] - - // -- Act -- - let context = SentryCommandContext.typesView( - name: "MXHangDiagnostic", - technology: "MetricKit", - json: true - ) - - // -- Assert -- - #expect(context.command == "types.view") - #expect(context.typeName == "MXHangDiagnostic") - #expect(context.technology == "MetricKit") - #expect(context.outputJSON == true) - #expect(context.attributes.keys.sorted() == expectedKeys) - #expect(context.metricAttributes.keys.sorted() == expectedKeys.dropLast()) - #expect(context.logMetadata.keys.sorted() == expectedKeys) - } - - @Test("opts technology and output mode into types list telemetry") - func includesTypesListContext() { - // -- Arrange -- - let expectedKeys = [ - "apple_docs.technology", - "cli.command", - "cli.output_json", - ] - - // -- Act -- - let context = SentryCommandContext.typesList( - technology: "SwiftData", - json: true - ) - - // -- Assert -- - #expect(context.command == "types.list") - #expect(context.typeName == nil) - #expect(context.technology == "SwiftData") - #expect(context.outputJSON == true) - #expect(context.attributes.keys.sorted() == expectedKeys) - #expect(context.metricAttributes.keys.sorted() == expectedKeys.dropLast()) - #expect(context.logMetadata.keys.sorted() == expectedKeys) - } - - @Test("excludes the skill name from agent command telemetry") - func excludesAgentSkillName() { - // -- Arrange -- - let expectedKeys = ["cli.command"] - - // -- Act -- - let context = SentryCommandContext.agentSkillsGet - - // -- Assert -- - #expect(context.command == "agent.skills.get") - #expect(context.typeName == nil) - #expect(context.technology == nil) - #expect(context.outputJSON == nil) - #expect(context.attributes.keys.sorted() == expectedKeys) - #expect(context.metricAttributes.keys.sorted() == expectedKeys) - #expect(context.logMetadata.keys.sorted() == expectedKeys) - } - - @Test("uses only the command name for cache clean telemetry") - func includesCacheCleanCommand() { - // -- Arrange -- - let expectedKeys = ["cli.command"] - - // -- Act -- - let context = SentryCommandContext.cacheClean - - // -- Assert -- - #expect(context.command == "cache.clean") - #expect(context.typeName == nil) - #expect(context.technology == nil) - #expect(context.outputJSON == nil) - #expect(context.attributes.keys.sorted() == expectedKeys) - #expect(context.metricAttributes.keys.sorted() == expectedKeys) - #expect(context.logMetadata.keys.sorted() == expectedKeys) - } - - @Test("opts only output mode into technologies list telemetry") - func includesTechnologiesListOutputMode() { - // -- Arrange -- - let expectedAttributeKeys = ["cli.command", "cli.output_json"] - - // -- Act -- - let context = SentryCommandContext.technologiesList(json: false) - - // -- Assert -- - #expect(context.command == "technologies.list") - #expect(context.typeName == nil) - #expect(context.technology == nil) - #expect(context.outputJSON == false) - #expect(context.attributes.keys.sorted() == expectedAttributeKeys) - #expect(context.metricAttributes.keys.sorted() == ["cli.command"]) - #expect(context.logMetadata.keys.sorted() == expectedAttributeKeys) - } - } -#endif diff --git a/Tests/CLITests/telemetry/TelemetryCommandContextTests.swift b/Tests/CLITests/telemetry/TelemetryCommandContextTests.swift new file mode 100644 index 0000000..5f3cc1a --- /dev/null +++ b/Tests/CLITests/telemetry/TelemetryCommandContextTests.swift @@ -0,0 +1,137 @@ +import Testing + +@testable import CLI + +@Suite("Telemetry command context") +struct TelemetryCommandContextTests { + @Test("excludes the query from types search telemetry") + func excludesTypesSearchQuery() { + // -- Arrange -- + let expectedKeys = [ + "apple_docs.technology", + "cli.command", + "cli.output_json", + ] + + // -- Act -- + let context = TelemetryCommandContext.typesSearch( + technology: "SwiftUI", + json: true + ) + + // -- Assert -- + #expect(context.command == "types.search") + #expect(context.typeName == nil) + #expect(context.technology == "SwiftUI") + #expect(context.outputJSON == true) + #expect(context.attributes.keys.sorted() == expectedKeys) + #expect(context.metricAttributes.keys.sorted() == expectedKeys.dropLast()) + #expect(context.logMetadata.keys.sorted() == expectedKeys) + } + + @Test("opts documentation identifiers into types view telemetry") + func includesTypesViewIdentifiers() { + // -- Arrange -- + let expectedKeys = [ + "apple_docs.technology", + "apple_docs.type", + "cli.command", + "cli.output_json", + ] + + // -- Act -- + let context = TelemetryCommandContext.typesView( + name: "MXHangDiagnostic", + technology: "MetricKit", + json: true + ) + + // -- Assert -- + #expect(context.command == "types.view") + #expect(context.typeName == "MXHangDiagnostic") + #expect(context.technology == "MetricKit") + #expect(context.outputJSON == true) + #expect(context.attributes.keys.sorted() == expectedKeys) + #expect(context.metricAttributes.keys.sorted() == expectedKeys.dropLast()) + #expect(context.logMetadata.keys.sorted() == expectedKeys) + } + + @Test("opts technology and output mode into types list telemetry") + func includesTypesListContext() { + // -- Arrange -- + let expectedKeys = [ + "apple_docs.technology", + "cli.command", + "cli.output_json", + ] + + // -- Act -- + let context = TelemetryCommandContext.typesList( + technology: "SwiftData", + json: true + ) + + // -- Assert -- + #expect(context.command == "types.list") + #expect(context.typeName == nil) + #expect(context.technology == "SwiftData") + #expect(context.outputJSON == true) + #expect(context.attributes.keys.sorted() == expectedKeys) + #expect(context.metricAttributes.keys.sorted() == expectedKeys.dropLast()) + #expect(context.logMetadata.keys.sorted() == expectedKeys) + } + + @Test("excludes the skill name from agent command telemetry") + func excludesAgentSkillName() { + // -- Arrange -- + let expectedKeys = ["cli.command"] + + // -- Act -- + let context = TelemetryCommandContext.agentSkillsGet + + // -- Assert -- + #expect(context.command == "agent.skills.get") + #expect(context.typeName == nil) + #expect(context.technology == nil) + #expect(context.outputJSON == nil) + #expect(context.attributes.keys.sorted() == expectedKeys) + #expect(context.metricAttributes.keys.sorted() == expectedKeys) + #expect(context.logMetadata.keys.sorted() == expectedKeys) + } + + @Test("uses only the command name for cache clean telemetry") + func includesCacheCleanCommand() { + // -- Arrange -- + let expectedKeys = ["cli.command"] + + // -- Act -- + let context = TelemetryCommandContext.cacheClean + + // -- Assert -- + #expect(context.command == "cache.clean") + #expect(context.typeName == nil) + #expect(context.technology == nil) + #expect(context.outputJSON == nil) + #expect(context.attributes.keys.sorted() == expectedKeys) + #expect(context.metricAttributes.keys.sorted() == expectedKeys) + #expect(context.logMetadata.keys.sorted() == expectedKeys) + } + + @Test("opts only output mode into technologies list telemetry") + func includesTechnologiesListOutputMode() { + // -- Arrange -- + let expectedAttributeKeys = ["cli.command", "cli.output_json"] + + // -- Act -- + let context = TelemetryCommandContext.technologiesList(json: false) + + // -- Assert -- + #expect(context.command == "technologies.list") + #expect(context.typeName == nil) + #expect(context.technology == nil) + #expect(context.outputJSON == false) + #expect(context.attributes.keys.sorted() == expectedAttributeKeys) + #expect(context.metricAttributes.keys.sorted() == ["cli.command"]) + #expect(context.logMetadata.keys.sorted() == expectedAttributeKeys) + } +} diff --git a/Tests/CLITests/telemetry/TelemetryTests.swift b/Tests/CLITests/telemetry/TelemetryTests.swift new file mode 100644 index 0000000..570bac8 --- /dev/null +++ b/Tests/CLITests/telemetry/TelemetryTests.swift @@ -0,0 +1,90 @@ +import ArgumentParser +import Logging +import Testing + +@testable import CLI + +@Suite("Telemetry service") +struct TelemetryTests { + @available(macOS 15, *) + @Test("disabled telemetry neither creates its logger nor provides a log handler") + func disabledTelemetryIsNoOp() { + // -- Arrange -- + let recorder = ClientLogRecorder() + let logger = Logger(label: "test") { _ in recorder.handler() } + let telemetry = DefaultTelemetry( + logger: { + logger.info("Logger created") + return logger + }, + environment: ["TELEMETRY_DISABLED": "TRUE"] + ) + let context = TelemetryCommandContext.typesView(name: "String", technology: "Swift", json: true) + + // -- Act -- + telemetry.start() + telemetry.startCommand(context) + telemetry.record(.typeView(responseBytes: 123), context: context) + telemetry.finishCommand(error: nil) + telemetry.finishCommand(error: ValidationError("Invalid request")) + + // -- Assert -- + #expect(telemetry.makeLogHandler() == nil) + #expect(recorder.events.isEmpty) + } + + @available(macOS 15, *) + @Test("no-op telemetry never creates its logger, even without an opt-out flag") + func unsupportedTelemetryIsNoOp() { + // -- Arrange -- + let recorder = ClientLogRecorder() + let logger = Logger(label: "test") { _ in recorder.handler() } + let telemetry = NoOpTelemetry( + logger: { + logger.info("Logger created") + return logger + }, + environment: [:] + ) + + // -- Act -- + telemetry.start() + telemetry.startCommand(.agentSkillsList) + telemetry.record(.technologyCatalog(count: 10), context: .technologiesList(json: false)) + telemetry.finishCommand(error: nil) + + // -- Assert -- + #expect(telemetry.makeLogHandler() == nil) + #expect(recorder.events.isEmpty) + } + + @Test("skill commands start the injected telemetry before a lookup failure") + func injectsCommandTelemetry() throws { + // -- Arrange -- + let command = try AgentSkillsGetCommand.parse(["unknown-skill"]) + let telemetry = CommandTelemetryRecorder() + + // -- Act -- + #expect(throws: ValidationError.self) { + try command.run(telemetry: telemetry) + } + + // -- Assert -- + #expect(telemetry.contexts.map(\.command) == ["agent.skills.get"]) + #expect(telemetry.contexts.first?.typeName == nil) + #expect(telemetry.contexts.first?.technology == nil) + } +} + +private final class CommandTelemetryRecorder: Telemetry, @unchecked Sendable { + // Accessed only by the synchronous command under test. + private(set) var contexts: [TelemetryCommandContext] = [] + + func start() {} + func startCommand(_ context: TelemetryCommandContext) { + contexts.append(context) + } + func record(_ metric: TelemetryMetric, context: TelemetryCommandContext) {} + func finishCommand(error: (any Error)?) {} + func makeLogHandler() -> (any LogHandler)? { nil } +}