diff --git a/.github/workflows/build-binaries.yml b/.github/workflows/build-binaries.yml index a2be7f9..5dba20f 100644 --- a/.github/workflows/build-binaries.yml +++ b/.github/workflows/build-binaries.yml @@ -25,6 +25,9 @@ on: APPLE_NOTARIZATION_APP_STORE_CONNECT_API_KEY_P8_BASE64: description: "Base64-encoded App Store Connect API key (.p8 PEM file) used for notarization" required: false + SENTRY_AUTH_TOKEN: + description: "Organization token used to upload Sentry debug symbols" + required: false jobs: build: @@ -70,15 +73,51 @@ jobs: static let formatted = "\\(version) (commit: \\(commit), built: \\(buildDate), environment: \\(environment))" + static let sentryRelease = "apple-docs@\\(version)+\\(commit)" } EOF - name: Build CLI + id: build run: | swift build -c release --arch "${{ matrix.architecture }}" BIN_DIR=$(swift build -c release --arch "${{ matrix.architecture }}" --show-bin-path) + test -d "$BIN_DIR/apple-docs.dSYM" mkdir -p dist cp "$BIN_DIR/apple-docs" "dist/apple-docs-${{ matrix.platform }}" + echo "bin_dir=$BIN_DIR" >> "$GITHUB_OUTPUT" + + - name: Install sentry-cli + if: github.event_name != 'pull_request' + env: + SENTRY_CLI_VERSION: "3.7.0" + SENTRY_CLI_SHA256: "10ccaaa39e6eee2b52034546f5f617533fdc76c64aa75c3038887045da1a367d" + run: | + curl --fail --silent --show-error --location \ + "https://github.com/getsentry/sentry-cli/releases/download/${SENTRY_CLI_VERSION}/sentry-cli-Darwin-universal" \ + --output "$RUNNER_TEMP/sentry-cli" + echo "$SENTRY_CLI_SHA256 $RUNNER_TEMP/sentry-cli" | shasum -a 256 --check + chmod +x "$RUNNER_TEMP/sentry-cli" + + - name: Upload dSYM to Sentry + if: github.event_name != 'pull_request' + env: + SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }} + SENTRY_ORG: techprimate + SENTRY_PROJECT: apple-docs-cli + run: | + if [ -z "$SENTRY_AUTH_TOKEN" ]; then + echo "::error::SENTRY_AUTH_TOKEN is not configured." + exit 1 + fi + + DSYM_PATH="${{ steps.build.outputs.bin_dir }}/apple-docs.dSYM" + "$RUNNER_TEMP/sentry-cli" debug-files check \ + "$DSYM_PATH/Contents/Resources/DWARF/apple-docs" + "$RUNNER_TEMP/sentry-cli" debug-files upload \ + --include-sources \ + --wait \ + "$DSYM_PATH" - name: Upload artifact uses: actions/upload-artifact@v7 diff --git a/Package.resolved b/Package.resolved index 6decbc2..6ecd9f2 100644 --- a/Package.resolved +++ b/Package.resolved @@ -1,6 +1,24 @@ { - "originHash" : "b051eec99d6d30d683a127dacd0af960629dd5f3d29a9eafde6bcefad0c915b0", + "originHash" : "e5a2e07cfe718bc696966f4be58c7ba17881a105799cad205462689ec29829a8", "pins" : [ + { + "identity" : "sentry-apple-swift-log", + "kind" : "remoteSourceControl", + "location" : "https://github.com/getsentry/sentry-apple-swift-log.git", + "state" : { + "revision" : "9b33c1c8d988c7b9a00e4c63cc247d3d234c180a", + "version" : "9.27.0" + } + }, + { + "identity" : "sentry-cocoa", + "kind" : "remoteSourceControl", + "location" : "https://github.com/getsentry/sentry-cocoa.git", + "state" : { + "revision" : "aaf96c4a417d30208b3c25d90910d6156ddc973d", + "version" : "9.27.0" + } + }, { "identity" : "swift-argument-parser", "kind" : "remoteSourceControl", @@ -9,6 +27,15 @@ "revision" : "6a52f3251125d74daf04fcbd5e6f08a75d074382", "version" : "1.8.2" } + }, + { + "identity" : "swift-log", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-log.git", + "state" : { + "revision" : "3ffafb9722d5d918c614feb496c8789a3b59d222", + "version" : "1.15.0" + } } ], "version" : 3 diff --git a/Package.swift b/Package.swift index 5337955..d392bef 100644 --- a/Package.swift +++ b/Package.swift @@ -9,13 +9,27 @@ let package = Package( .executable(name: "apple-docs", targets: ["CLI"]) ], dependencies: [ - .package(url: "https://github.com/apple/swift-argument-parser.git", from: "1.8.2") + .package(url: "https://github.com/apple/swift-argument-parser.git", from: "1.8.2"), + .package(url: "https://github.com/apple/swift-log.git", exact: "1.15.0"), + .package( + url: "https://github.com/getsentry/sentry-apple-swift-log.git", + exact: "9.27.0", + traits: ["SentryFromSource"] + ), + .package( + url: "https://github.com/getsentry/sentry-cocoa.git", + exact: "9.27.0", + traits: ["NoUIFramework"] + ), ], targets: [ .executableTarget( name: "CLI", dependencies: [ - .product(name: "ArgumentParser", package: "swift-argument-parser") + .product(name: "ArgumentParser", package: "swift-argument-parser"), + .product(name: "Logging", package: "swift-log"), + .product(name: "SentrySwiftLog", package: "sentry-apple-swift-log"), + .product(name: "SentrySPM", package: "sentry-cocoa"), ]), .testTarget(name: "CLITests", dependencies: ["CLI"]), .testTarget(name: "CLIIntegrationTests"), diff --git a/Sources/CLI/cmd/agent/skills/AgentSkillsGetCommand.swift b/Sources/CLI/cmd/agent/skills/AgentSkillsGetCommand.swift index 3f2ef89..0aaad58 100644 --- a/Sources/CLI/cmd/agent/skills/AgentSkillsGetCommand.swift +++ b/Sources/CLI/cmd/agent/skills/AgentSkillsGetCommand.swift @@ -1,6 +1,12 @@ import ArgumentParser +import Logging +@preconcurrency import SentrySwift struct AgentSkillsGetCommand: ParsableCommand { + 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." @@ -10,6 +16,35 @@ struct AgentSkillsGetCommand: ParsableCommand { var name: String mutating func run() throws { + 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 + ) + } + 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 6bdb412..56a10d8 100644 --- a/Sources/CLI/cmd/agent/skills/AgentSkillsListCommand.swift +++ b/Sources/CLI/cmd/agent/skills/AgentSkillsListCommand.swift @@ -1,12 +1,47 @@ import ArgumentParser +import Logging +@preconcurrency import SentrySwift struct AgentSkillsListCommand: ParsableCommand { + 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 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 + ) + } + for skill in BundledAgentSkills.all { print("\(skill.name)\t\(skill.shortDescription)") } diff --git a/Sources/CLI/cmd/technologies/TechnologiesListCommand.swift b/Sources/CLI/cmd/technologies/TechnologiesListCommand.swift index 231dca6..230a279 100644 --- a/Sources/CLI/cmd/technologies/TechnologiesListCommand.swift +++ b/Sources/CLI/cmd/technologies/TechnologiesListCommand.swift @@ -1,6 +1,12 @@ import ArgumentParser +import Logging +@preconcurrency import SentrySwift struct TechnologiesListCommand: AsyncParsableCommand { + private static let logger = Logger( + label: "com.techprimate.apple-docs.technologies-list" + ) + static let configuration = CommandConfiguration( commandName: "list", abstract: "List Apple documentation technologies." @@ -10,10 +16,46 @@ struct TechnologiesListCommand: AsyncParsableCommand { var json = false mutating func run() async throws { - let output = try await TechnologiesListCommandRunner( + let context = SentryCommandContext.technologiesList(json: json) + 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 + ) + } + + let result = try await TechnologiesListCommandRunner( client: Dependencies.documentationClient, renderer: Dependencies.technologyListRenderer(json: json) ).run() - print(output) + if SentrySDK.isEnabled { + SentrySDK.metrics.gauge( + key: "apple_docs.technology.catalog.count", + value: Double(result.technologyCount), + attributes: context.metricAttributes + ) + } + print(result.output) } } diff --git a/Sources/CLI/cmd/technologies/TechnologiesListCommandRunner.swift b/Sources/CLI/cmd/technologies/TechnologiesListCommandRunner.swift index c803b88..44b10bb 100644 --- a/Sources/CLI/cmd/technologies/TechnologiesListCommandRunner.swift +++ b/Sources/CLI/cmd/technologies/TechnologiesListCommandRunner.swift @@ -1,6 +1,11 @@ import Foundation struct TechnologiesListCommandRunner: Sendable { + struct Result: Sendable { + let output: String + let technologyCount: Int + } + private let client: TechnologyCatalogClient private let renderer: TechnologyListRenderer @@ -12,10 +17,13 @@ struct TechnologiesListCommandRunner: Sendable { self.renderer = renderer } - func run() async throws -> String { + func run() async throws -> Result { let technologies = try await client.fetchTechnologies().sorted { $0.name.compare($1.name, options: .caseInsensitive) == .orderedAscending } - return try renderer.render(technologies) + return Result( + output: try renderer.render(technologies), + technologyCount: technologies.count + ) } } diff --git a/Sources/CLI/cmd/types/TypesViewCommand.swift b/Sources/CLI/cmd/types/TypesViewCommand.swift index b278df3..c69abb4 100644 --- a/Sources/CLI/cmd/types/TypesViewCommand.swift +++ b/Sources/CLI/cmd/types/TypesViewCommand.swift @@ -1,6 +1,12 @@ import ArgumentParser +import Logging +@preconcurrency import SentrySwift struct TypesViewCommand: AsyncParsableCommand { + private static let logger = Logger( + label: "com.techprimate.apple-docs.types-view" + ) + static let configuration = CommandConfiguration( commandName: "view", abstract: "Show documentation for a type." @@ -16,10 +22,66 @@ struct TypesViewCommand: AsyncParsableCommand { var json = false mutating func run() async throws { - let output = try await TypesViewCommandRunner( + let context = SentryCommandContext.typesView( + name: name, + technology: technology, + json: json + ) + 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 + ) + } + + let result = try await TypesViewCommandRunner( client: Dependencies.documentationClient, renderer: Dependencies.documentationRenderer(json: json) ).run(name: name, technology: technology) - print(output) + if SentrySDK.isEnabled { + recordPopularityMetrics() + SentrySDK.metrics.distribution( + key: "apple_docs.response.size", + value: Double(result.responseByteCount), + unit: .byte, + attributes: context.metricAttributes + ) + } + print(result.output) + } + + 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, + ] + ) } } diff --git a/Sources/CLI/cmd/types/TypesViewCommandRunner.swift b/Sources/CLI/cmd/types/TypesViewCommandRunner.swift index 469727e..8f3b40b 100644 --- a/Sources/CLI/cmd/types/TypesViewCommandRunner.swift +++ b/Sources/CLI/cmd/types/TypesViewCommandRunner.swift @@ -1,4 +1,9 @@ struct TypesViewCommandRunner: Sendable { + struct Result: Sendable { + let output: String + let responseByteCount: Int + } + private let client: AppleDocumentationClient private let renderer: TypeDocumentationRenderer @@ -10,11 +15,14 @@ struct TypesViewCommandRunner: Sendable { self.renderer = renderer } - func run(name: String, technology: String) async throws -> String { + func run(name: String, technology: String) async throws -> Result { let document = try await client.fetchType( named: name, technology: technology ) - return renderer.render(document) + return Result( + output: renderer.render(document), + responseByteCount: document.data.count + ) } } diff --git a/Sources/CLI/main/AppleDocs.swift b/Sources/CLI/main/AppleDocs.swift new file mode 100644 index 0000000..b3319bd --- /dev/null +++ b/Sources/CLI/main/AppleDocs.swift @@ -0,0 +1,60 @@ +import ArgumentParser +import Foundation +import Logging +@preconcurrency import SentrySwift +import SentrySwiftLog + +@main +enum AppleDocs { + private static let logger = Logger(label: "com.techprimate.apple-docs") + + @MainActor + static func main() async { + let telemetryEnabled = SentryConfiguration.isEnabled( + environment: ProcessInfo.processInfo.environment + ) + if telemetryEnabled { + SentrySDK.start { options in + SentryConfiguration.configure(options) + } + LoggingSystem.bootstrap { _ in + SentryLogHandler(logLevel: .info) + } + } else { + LoggingSystem.bootstrap { _ in + SwiftLogNoOpLogHandler() + } + } + + do { + var command = try await CLI.asyncParseAsRoot() + if var asyncCommand = command as? any AsyncParsableCommand { + try await asyncCommand.run() + } else { + try command.run() + } + if telemetryEnabled { + SentrySDK.span?.status = .ok + Self.logger.info("CLI command completed") + SentrySDK.span?.finish() + SentrySDK.flush(timeout: 2) + } + } catch { + if telemetryEnabled, let span = SentrySDK.span { + let expected = error is ValidationError + 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() + } + if telemetryEnabled { + SentrySDK.flush(timeout: 2) + } + CLI.exit(withError: error) + } + } +} diff --git a/Sources/CLI/main/BuildMetadata.swift b/Sources/CLI/main/BuildMetadata.swift index 2381cab..d11d2c7 100644 --- a/Sources/CLI/main/BuildMetadata.swift +++ b/Sources/CLI/main/BuildMetadata.swift @@ -6,4 +6,5 @@ enum BuildMetadata { static let formatted = "\(version) (commit: \(commit), built: \(buildDate), environment: \(environment))" + static let sentryRelease = "apple-docs@\(version)+\(commit)" } diff --git a/Sources/CLI/main/CLI.swift b/Sources/CLI/main/CLI.swift index 56cc04e..942be66 100644 --- a/Sources/CLI/main/CLI.swift +++ b/Sources/CLI/main/CLI.swift @@ -1,6 +1,5 @@ import ArgumentParser -@main struct CLI: AsyncParsableCommand { static let configuration = CommandConfiguration( commandName: "apple-docs", diff --git a/Sources/CLI/telemetry/SentryCommandContext.swift b/Sources/CLI/telemetry/SentryCommandContext.swift new file mode 100644 index 0000000..067dedd --- /dev/null +++ b/Sources/CLI/telemetry/SentryCommandContext.swift @@ -0,0 +1,89 @@ +import Logging +@preconcurrency import SentrySwift + +struct SentryCommandContext: Equatable, Sendable { + let command: String + let outputJSON: Bool? + let technology: String? + let typeName: String? + + static let agentSkillsGet = SentryCommandContext( + command: "agent.skills.get", + outputJSON: nil, + technology: nil, + typeName: nil + ) + static let agentSkillsList = SentryCommandContext( + command: "agent.skills.list", + outputJSON: nil, + technology: nil, + typeName: nil + ) + + static func technologiesList(json: Bool) -> SentryCommandContext { + SentryCommandContext( + command: "technologies.list", + outputJSON: json, + technology: nil, + typeName: nil + ) + } + + static func typesView( + name: String, + technology: String, + json: Bool + ) -> SentryCommandContext { + SentryCommandContext( + command: "types.view", + outputJSON: json, + technology: technology, + typeName: name + ) + } + + var transactionName: String { + "apple-docs \(command)" + } + + var attributes: [String: Any] { + var attributes: [String: Any] = ["cli.command": command] + if let outputJSON { + attributes["cli.output_json"] = outputJSON + } + if let technology { + attributes["apple_docs.technology"] = technology + } + if let typeName { + attributes["apple_docs.type"] = typeName + } + return attributes + } + + var logMetadata: Logger.Metadata { + var metadata: Logger.Metadata = ["cli.command": .string(command)] + if let outputJSON { + metadata["cli.output_json"] = .stringConvertible(outputJSON) + } + if let technology { + metadata["apple_docs.technology"] = .string(technology) + } + if let typeName { + metadata["apple_docs.type"] = .string(typeName) + } + return metadata + } + + 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 + } +} diff --git a/Sources/CLI/telemetry/SentryConfiguration.swift b/Sources/CLI/telemetry/SentryConfiguration.swift new file mode 100644 index 0000000..3b1365a --- /dev/null +++ b/Sources/CLI/telemetry/SentryConfiguration.swift @@ -0,0 +1,178 @@ +import Foundation +@preconcurrency import SentrySwift + +struct SentryConfiguration { + private static let allowedBreadcrumbDataKeys: Set = [ + "apple_docs.technology", + "apple_docs.type", + "cli.command", + "cli.output_json", + ] + private static let allowedContextKeys: Set = [ + "cli", + "os", + "runtime", + "trace", + ] + private static let allowedLogAttributes: Set = [ + "environment", + "release", + "sentry.origin", + "sentry.sdk.name", + "sentry.sdk.version", + "swift-log.apple_docs.technology", + "swift-log.apple_docs.type", + "swift-log.cli.command", + "swift-log.cli.output_json", + "swift-log.level", + "swift-log.source", + ] + private static let allowedLogBodies: Set = [ + "CLI command completed", + "CLI command failed", + "CLI command rejected", + "CLI command started", + ] + private static let allowedMetricAttributes: Set = [ + "apple_docs.technology", + "apple_docs.type", + "cli.command", + "environment", + "release", + ] + private static let allowedMetricNames: Set = [ + "apple_docs.response.size", + "apple_docs.technology.catalog.count", + "apple_docs.technology.requested", + "apple_docs.type.requested", + ] + static let breadcrumbCategory = "cli.command" + static let dsn = + "https://927b98fc26175a0d5dda2124b9b471dd@o188824.ingest.us.sentry.io/4512051116376064" + + static func isEnabled(environment: [String: String]) -> Bool { + environment["TELEMETRY_DISABLED"]?.caseInsensitiveCompare("true") != .orderedSame + } + + static func configure(_ options: Options) { + options.dsn = dsn + options.environment = BuildMetadata.environment + options.releaseName = BuildMetadata.sentryRelease + options.debug = false + configureErrorMonitoring(options) + configureTracing(options) + configureBreadcrumbs(options) + configureSignals(options) + configureFilters(options) + } + + private static func configureErrorMonitoring(_ options: Options) { + options.sendDefaultPii = false + options.attachStacktrace = true + options.enableCrashHandler = true + options.enableAppHangTracking = false + options.enableMemoryIntrospection = false + } + + private static func configureTracing(_ options: Options) { + options.tracesSampleRate = 1.0 + options.enableAutoPerformanceTracing = false + options.enableNetworkTracking = false + options.enableFileIOTracing = false + options.enableCoreDataTracing = false + options.enableSwizzling = false + options.tracePropagationTargets = [] + } + + private static func configureBreadcrumbs(_ options: Options) { + options.enableAutoBreadcrumbTracking = false + options.enableNetworkBreadcrumbs = false + options.enableCaptureFailedRequests = false + options.maxBreadcrumbs = 10 + options.beforeBreadcrumb = { breadcrumb in + guard breadcrumb.category == breadcrumbCategory else { + return nil + } + breadcrumb.message = "CLI command invoked" + breadcrumb.type = "user" + if let data = breadcrumb.data { + for key in data.keys where !allowedBreadcrumbDataKeys.contains(key) { + breadcrumb.setData(value: nil, key: key) + } + } + return breadcrumb + } + } + + private static func configureSignals(_ options: Options) { + options.enableLogs = true + options.enableMetrics = true + } + + private static func configureFilters(_ options: Options) { + options.beforeSend = { event in + event.user = nil + event.request = nil + event.serverName = nil + event.extra = nil + event.tags = nil + event.message = nil + event.error = nil + event.context = event.context?.filter { + allowedContextKeys.contains($0.key) + } + event.breadcrumbs = event.breadcrumbs?.filter { + $0.category == breadcrumbCategory + } + for exception in event.exceptions ?? [] { + exception.value = "CLI command failed" + } + sanitizePaths(in: event) + return event + } + options.beforeSendSpan = { span in + span.operation == "console.command" ? span : nil + } + options.beforeSendLog = { log in + guard allowedLogBodies.contains(log.body) else { + return nil + } + log.attributes = log.attributes.filter { + allowedLogAttributes.contains($0.key) + } + return log + } + options.beforeSendMetric = { metric in + guard allowedMetricNames.contains(metric.name) else { + return nil + } + var metric = metric + metric.attributes = metric.attributes.filter { + allowedMetricAttributes.contains($0.key) + } + return metric + } + } + + private static func sanitizePaths(in event: Event) { + for debugImage in event.debugMeta ?? [] { + debugImage.codeFile = fileName(from: debugImage.codeFile) + } + for exception in event.exceptions ?? [] { + sanitizePaths(in: exception.stacktrace) + } + for thread in event.threads ?? [] { + sanitizePaths(in: thread.stacktrace) + } + } + + private static func sanitizePaths(in stacktrace: SentryStacktrace?) { + for frame in stacktrace?.frames ?? [] { + frame.package = fileName(from: frame.package) + } + } + + private static func fileName(from path: String?) -> String? { + path.map { URL(fileURLWithPath: $0).lastPathComponent } + } +} diff --git a/Tests/CLITests/cmd/technologies/TechnologiesListCommandRunnerTests.swift b/Tests/CLITests/cmd/technologies/TechnologiesListCommandRunnerTests.swift index 78bea07..10c4cc9 100644 --- a/Tests/CLITests/cmd/technologies/TechnologiesListCommandRunnerTests.swift +++ b/Tests/CLITests/cmd/technologies/TechnologiesListCommandRunnerTests.swift @@ -21,11 +21,15 @@ struct TechnologiesListCommandRunnerTests { ) // -- Act -- - let output = try await runner.run() + let result = try await runner.run() // -- Assert -- - let technologies = try JSONDecoder().decode([Technology].self, from: Data(output.utf8)) + let technologies = try JSONDecoder().decode( + [Technology].self, + from: Data(result.output.utf8) + ) #expect(technologies.map(\.name) == ["ARKit", "MetricKit", "swiftUI"]) + #expect(result.technologyCount == 3) } } diff --git a/Tests/CLITests/cmd/types/TypesViewCommandRunnerTests.swift b/Tests/CLITests/cmd/types/TypesViewCommandRunnerTests.swift index e921923..c37adfa 100644 --- a/Tests/CLITests/cmd/types/TypesViewCommandRunnerTests.swift +++ b/Tests/CLITests/cmd/types/TypesViewCommandRunnerTests.swift @@ -38,13 +38,14 @@ struct TypesViewCommandRunnerTests { let runner = TypesViewCommandRunner(client: client, renderer: renderer) // -- Act -- - let output = try await runner.run( + let result = try await runner.run( name: "MXHangDiagnostic", technology: "MetricKit" ) // -- Assert -- - #expect(output == "rendered documentation") + #expect(result.output == "rendered documentation") + #expect(result.responseByteCount == data.count) } } diff --git a/Tests/CLITests/telemetry/SentryCommandContextTests.swift b/Tests/CLITests/telemetry/SentryCommandContextTests.swift new file mode 100644 index 0000000..239c629 --- /dev/null +++ b/Tests/CLITests/telemetry/SentryCommandContextTests.swift @@ -0,0 +1,69 @@ +import Testing + +@testable import CLI + +@Suite("Sentry command context") +struct SentryCommandContextTests { + @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("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("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) + } +} diff --git a/Tests/CLITests/telemetry/SentryConfigurationTests.swift b/Tests/CLITests/telemetry/SentryConfigurationTests.swift new file mode 100644 index 0000000..5663f63 --- /dev/null +++ b/Tests/CLITests/telemetry/SentryConfigurationTests.swift @@ -0,0 +1,45 @@ +import Testing + +@testable import CLI + +@Suite("Sentry configuration") +struct SentryConfigurationTests { + @Test("enables telemetry by default") + func enablesTelemetryByDefault() { + // -- Arrange -- + let environment: [String: String] = [:] + + // -- Act -- + let enabled = SentryConfiguration.isEnabled(environment: environment) + + // -- Assert -- + #expect(enabled) + } + + @Test( + "disables telemetry for a true environmental flag", + arguments: ["true", "TRUE", "True"] + ) + func disablesTelemetry(value: String) { + // -- Arrange -- + let environment = ["TELEMETRY_DISABLED": value] + + // -- Act -- + let enabled = SentryConfiguration.isEnabled(environment: environment) + + // -- Assert -- + #expect(!enabled) + } + + @Test("keeps telemetry enabled for other environmental flag values") + func ignoresOtherFlagValues() { + // -- Arrange -- + let environment = ["TELEMETRY_DISABLED": "false"] + + // -- Act -- + let enabled = SentryConfiguration.isEnabled(environment: environment) + + // -- Assert -- + #expect(enabled) + } +} diff --git a/docs/TELEMETRY.md b/docs/TELEMETRY.md new file mode 100644 index 0000000..ad4e9a1 --- /dev/null +++ b/docs/TELEMETRY.md @@ -0,0 +1,120 @@ +# Telemetry + +`apple-docs` uses telemetry to understand failures, improve reliability, and learn which parts of Apple documentation are useful. Telemetry is enabled by default and can be disabled by setting `TELEMETRY_DISABLED=true`. + +## Principles + +Telemetry follows an explicit opt-in model: + +- Capture only fields that have been reviewed and approved. +- Treat new commands, arguments, and metadata as private by default. +- Collect the minimum context needed to reproduce failures. +- Prefer stable, structured fields over raw text and payloads. +- Keep expected user mistakes out of error monitoring. +- Apply privacy controls before data leaves the process and again at ingestion. + +Adding a CLI argument does not automatically add it to telemetry. + +## What We Capture + +### Errors + +Unexpected transport, decoding, and rendering failures are reported. Error values are replaced with a generic message while retaining the error type, stack trace, release, environment, and approved command context. + +Invalid commands, missing required arguments, invalid flags, and command-level validation errors are user errors. They are not reported as application errors. + +### Command Context + +Each leaf command starts a `console.command` transaction and opts in to a fixed set of fields. + +| Command | Captured fields | +| ------------------- | ------------------------------------------------------------------ | +| `types view` | Command name, documentation type, technology, and JSON output mode | +| `technologies list` | Command name and JSON output mode | +| `agent skills list` | Command name | +| `agent skills get` | Command name | + +Documentation type and technology values are captured because they identify public Apple documentation and are required to reproduce page-specific failures. Other positional values are excluded unless explicitly approved. + +### Logs + +Application logs use `apple/swift-log` with the Sentry Swift Log handler. Messages are selected from a fixed allowlist and contain only approved command metadata. + +Source file paths, function names, arbitrary metadata, interpolated values, and unapproved log messages are removed before transmission. + +### Breadcrumbs + +A command invocation adds one breadcrumb to subsequent errors. Its message and category are fixed, and its data is restricted to the approved command context. + +Automatic system and network breadcrumbs are disabled. + +### Metrics + +The CLI records: + +- `apple_docs.technology.requested` to measure technology popularity. +- `apple_docs.type.requested` to measure type popularity within a technology. +- `apple_docs.response.size` to monitor Apple documentation response sizes. +- `apple_docs.technology.catalog.count` to monitor the size of the technology catalog. + +Metric names and attributes are allowlisted. Type and technology are the only variable popularity dimensions. + +## What We Do Not Capture + +The CLI does not intentionally send: + +- Raw process arguments. +- Environment variables. +- User identity or account information. +- IP addresses or geographic location. +- Hostnames or persistent device identifiers. +- Request or response headers. +- Cookies or authentication values. +- Query strings. +- Request or response bodies. +- Apple documentation page contents. +- Local file paths. +- Agent Skill names requested by the user. + +Automatic network, file I/O, failed-request, and performance instrumentation is disabled. Command transactions are created manually so their data remains within the allowlist. + +## Privacy Controls + +Before transmission, the SDK: + +- Disables default PII collection. +- Removes user, request, server, extra, and arbitrary tag data from error events. +- Restricts event contexts, breadcrumbs, logs, metrics, and spans. +- Reduces native frame and debug-image paths to filenames. +- Replaces error values with a generic message. + +Sentry ingestion also prevents IP storage, applies default data scrubbing, and removes geographic information. These server-side controls are a backstop rather than a substitute for SDK-side filtering. + +## Debug Symbols + +Release builds upload matching dSYMs to Sentry so native stack traces can be symbolicated. Uploads run only for release builds, use an organization token stored in GitHub Actions secrets, and fail the release workflow if authentication or processing fails. + +Source context is included because this repository is public. Auth tokens and other upload credentials must never be committed or printed. + +## Disabling Telemetry + +Set the environment variable before invoking the CLI: + +```sh +TELEMETRY_DISABLED=true apple-docs types view String --technology Swift +``` + +The value `true` is matched case-insensitively. When disabled, Sentry is not initialized and SDK calls are skipped so command output remains unchanged. + +## Adding Telemetry + +Before adding a field or signal: + +1. State the production question it answers. +2. Confirm that an existing error, trace, log, or metric does not already answer it. +3. Decide whether the value can contain personal, local, credential, or user-authored data. +4. Add only the minimum approved fields to the command context and relevant allowlists. +5. Verify enabled and disabled command output. +6. Trigger the signal and inspect the stored event for unexpected fields. + +When uncertain whether a value is sensitive, do not capture it.