diff --git a/OpenTelemetry-Swift-Instrumentation-MetricKit.podspec b/OpenTelemetry-Swift-Instrumentation-MetricKit.podspec new file mode 100644 index 00000000..2dde5f6c --- /dev/null +++ b/OpenTelemetry-Swift-Instrumentation-MetricKit.podspec @@ -0,0 +1,25 @@ +Pod::Spec.new do |spec| + spec.name = "OpenTelemetry-Swift-Instrumentation-MetricKit" + spec.version = "2.2.0" + spec.summary = "Swift OpenTelemetry MetricKit Instrumentation" + + spec.homepage = "https://github.com/open-telemetry/opentelemetry-swift" + spec.documentation_url = "https://opentelemetry.io/docs/languages/swift" + spec.license = { :type => "Apache 2.0", :file => "LICENSE" } + spec.authors = "OpenTelemetry Authors" + + spec.source = { :git => "https://github.com/open-telemetry/opentelemetry-swift.git", :tag => spec.version.to_s } + spec.source_files = "Sources/Instrumentation/MetricKit/*.swift" + spec.exclude_files = "Sources/Instrumentation/MetricKit/README.md" + + spec.swift_version = "5.10" + spec.ios.deployment_target = "13.0" + spec.osx.deployment_target = "12.0" + spec.watchos.deployment_target = "6.0" + spec.visionos.deployment_target = "1.0" + spec.module_name = "MetricKitInstrumentation" + + spec.dependency 'OpenTelemetry-Swift-Sdk', '~> 2.1.1' + spec.pod_target_xcconfig = { "OTHER_SWIFT_FLAGS" => "-module-name MetricKitInstrumentation -package-name opentelemetry_swift_metrickit_instrumentation" } + +end diff --git a/Package.swift b/Package.swift index a165b7b1..8f12103b 100644 --- a/Package.swift +++ b/Package.swift @@ -253,7 +253,8 @@ extension Package { .executable(name: "OTLPExporter", targets: ["OTLPExporter"]), .executable(name: "OTLPHTTPExporter", targets: ["OTLPHTTPExporter"]), .library(name: "SignPostIntegration", targets: ["SignPostIntegration"]), - .library(name: "ResourceExtension", targets: ["ResourceExtension"]) + .library(name: "ResourceExtension", targets: ["ResourceExtension"]), + .library(name: "MetricKitInstrumentation", targets: ["MetricKitInstrumentation"]) ]) targets.append(contentsOf: [ .target( @@ -381,6 +382,23 @@ extension Package { ], path: "Tests/InstrumentationTests/SDKResourceExtensionTests" ), + .target( + name: "MetricKitInstrumentation", + dependencies: [ + .product(name: "OpenTelemetrySdk", package: "opentelemetry-swift-core") + ], + path: "Sources/Instrumentation/MetricKit", + exclude: ["README.md"] + ), + .testTarget( + name: "MetricKitInstrumentationTests", + dependencies: [ + "MetricKitInstrumentation", + "InMemoryExporter", + .product(name: "OpenTelemetrySdk", package: "opentelemetry-swift-core") + ], + path: "Tests/InstrumentationTests/MetricKitTests" + ), .executableTarget( name: "PrometheusSample", dependencies: [ diff --git a/Sources/Instrumentation/MetricKit/AttributeValueExtensions.swift b/Sources/Instrumentation/MetricKit/AttributeValueExtensions.swift new file mode 100644 index 00000000..6bfc4398 --- /dev/null +++ b/Sources/Instrumentation/MetricKit/AttributeValueExtensions.swift @@ -0,0 +1,52 @@ +#if canImport(MetricKit) && !os(tvOS) && !os(macOS) +import Foundation +import OpenTelemetryApi + +/// A protocol to make it easier to write generic functions for AttributeValues. +protocol AttributeValueConvertable { + func attributeValue() -> AttributeValue +} + +extension Int: AttributeValueConvertable { + func attributeValue() -> AttributeValue { + AttributeValue.int(self) + } +} +extension Bool: AttributeValueConvertable { + func attributeValue() -> AttributeValue { + AttributeValue.bool(self) + } +} +extension String: AttributeValueConvertable { + func attributeValue() -> AttributeValue { + AttributeValue.string(self) + } +} + +extension [String]: AttributeValueConvertable { + func attributeValue() -> AttributeValue { + AttributeValue.array(AttributeArray(values: self.map { AttributeValue.string($0) })) + } +} + +extension TimeInterval: AttributeValueConvertable { + func attributeValue() -> AttributeValue { + // The OTel standard for time durations is seconds, which is also what TimeInterval is. + // https://opentelemetry.io/docs/specs/semconv/general/metrics/ + AttributeValue.double(self) + } +} + +extension Measurement: AttributeValueConvertable { + func attributeValue() -> AttributeValue { + // Convert to the "base unit", such as seconds or bytes. + let value = + if let unit = self.unit as? Dimension { + unit.converter.baseUnitValue(fromValue: self.value) + } else { + self.value + } + return AttributeValue.double(value) + } +} +#endif diff --git a/Sources/Instrumentation/MetricKit/MetricKitInstrumentation.swift b/Sources/Instrumentation/MetricKit/MetricKitInstrumentation.swift new file mode 100644 index 00000000..bf7465c2 --- /dev/null +++ b/Sources/Instrumentation/MetricKit/MetricKitInstrumentation.swift @@ -0,0 +1,609 @@ +#if canImport(MetricKit) && !os(tvOS) && !os(macOS) + import Foundation + import MetricKit + import OpenTelemetryApi + + private let metricKitInstrumentationName = "MetricKit" + private let metricKitInstrumentationVersion = "0.0.1" + + @available(iOS 13.0, macOS 12.0, macCatalyst 13.1, visionOS 1.0, *) + public class MetricKitInstrumentation: NSObject, MXMetricManagerSubscriber { + public func didReceive(_ payloads: [MXMetricPayload]) { + for payload in payloads { + reportMetrics(payload: payload) + } + } + + @available(iOS 14.0, macOS 12.0, macCatalyst 14.0, watchOS 7.0, *) + public func didReceive(_ payloads: [MXDiagnosticPayload]) { + for payload in payloads { + reportDiagnostics(payload: payload) + } + } + } + + // MARK: - MetricKit helpers + + func getMetricKitTracer() -> Tracer { + return OpenTelemetry.instance.tracerProvider.get( + instrumentationName: metricKitInstrumentationName, + instrumentationVersion: metricKitInstrumentationVersion, + ) + } + + /// Estimates the average value of the whole histogram. + @available(iOS 13.0, macOS 12.0, macCatalyst 13.1, visionOS 1.0, *) + func estimateHistogramAverage(_ histogram: MXHistogram) -> Measurement< + UnitType + >? { + var estimatedSum: Measurement? + var sampleCount = 0.0 + for bucket in histogram.bucketEnumerator { + let bucket = bucket as! MXHistogramBucket + let estimatedValue = (bucket.bucketStart + bucket.bucketEnd) / 2.0 + let count = Double(bucket.bucketCount) + estimatedSum = + if let previousSum = estimatedSum { + previousSum + estimatedValue * count + } else { + estimatedValue * count + } + sampleCount += count + } + return estimatedSum.map { $0 / sampleCount } + } + + @available(iOS 13.0, macOS 12.0, macCatalyst 13.1, visionOS 1.0, *) + public func reportMetrics(payload: MXMetricPayload) { + let span = getMetricKitTracer().spanBuilder(spanName: "MXMetricPayload") + .setStartTime(time: payload.timeStampBegin) + .startSpan() + defer { span.end(time: payload.timeStampEnd) } + + // There are so many nested metrics we want to capture, it's worth setting up some helper + // methods to reduce the amount of repeated code. + + var namespaceStack = ["metrickit"] + + func captureMetric(key: String, value: AttributeValueConvertable) { + let namespace = namespaceStack.joined(separator: ".") + span.setAttribute(key: "\(namespace).\(key)", value: value.attributeValue()) + } + + // Helper functions for sending histograms, specifically. + func captureMetric(key: String, value histogram: MXHistogram) { + if let average = estimateHistogramAverage(histogram) { + captureMetric(key: key, value: average) + } + } + + // This helper makes it easier to process each category without typing its name repeatedly. + func withCategory(_ parent: T?, _ namespace: String, using closure: (T) -> Void) { + namespaceStack.append(namespace) + if let p = parent { + closure(p) + } + namespaceStack.removeLast() + } + + // These attribute names follow the guidelines at + // https://opentelemetry.io/docs/specs/semconv/general/attribute-naming/ + + captureMetric( + key: "includes_multiple_application_versions", + value: payload.includesMultipleApplicationVersions + ) + captureMetric( + key: "latest_application_version", + value: payload.latestApplicationVersion + ) + captureMetric( + key: "timestamp_begin", + value: payload.timeStampBegin.timeIntervalSince1970 + ) + captureMetric(key: "timestamp_end", value: payload.timeStampEnd.timeIntervalSince1970) + + withCategory(payload.metaData, "metadata") { + captureMetric(key: "app_build_version", value: $0.applicationBuildVersion) + captureMetric(key: "device_type", value: $0.deviceType) + captureMetric(key: "os_version", value: $0.osVersion) + captureMetric(key: "region_format", value: $0.regionFormat) + if #available(iOS 14.0, *) { + captureMetric(key: "platform_arch", value: $0.platformArchitecture) + } + if #available(iOS 17.0, macOS 14.0, *) { + captureMetric(key: "is_test_flight_app", value: $0.isTestFlightApp) + captureMetric(key: "low_power_mode_enabled", value: $0.lowPowerModeEnabled) + captureMetric(key: "pid", value: Int($0.pid)) + } + } + withCategory(payload.applicationLaunchMetrics, "app_launch") { + captureMetric( + key: "time_to_first_draw_average", + value: $0.histogrammedTimeToFirstDraw + ) + captureMetric( + key: "app_resume_time_average", + value: $0.histogrammedApplicationResumeTime + ) + if #available(iOS 15.2, macOS 12.2, *) { + captureMetric( + key: "optimized_time_to_first_draw_average", + value: $0.histogrammedOptimizedTimeToFirstDraw + ) + } + if #available(iOS 16.0, macOS 13.0, *) { + captureMetric( + key: "extended_launch_average", + value: $0.histogrammedExtendedLaunch + ) + } + } + withCategory(payload.applicationResponsivenessMetrics, "app_responsiveness") { + captureMetric(key: "hang_time_average", value: $0.histogrammedApplicationHangTime) + } + withCategory(payload.cellularConditionMetrics, "cellular_condition") { + captureMetric(key: "bars_average", value: $0.histogrammedCellularConditionTime) + } + withCategory(payload.locationActivityMetrics, "location_activity") { + captureMetric(key: "best_accuracy_time", value: $0.cumulativeBestAccuracyTime) + captureMetric( + key: "best_accuracy_for_nav_time", + value: $0.cumulativeBestAccuracyForNavigationTime + ) + captureMetric( + key: "accuracy_10m_time", + value: $0.cumulativeNearestTenMetersAccuracyTime + ) + captureMetric( + key: "accuracy_100m_time", + value: $0.cumulativeHundredMetersAccuracyTime + ) + captureMetric(key: "accuracy_1km_time", value: $0.cumulativeKilometerAccuracyTime) + captureMetric( + key: "accuracy_3km_time", + value: $0.cumulativeThreeKilometersAccuracyTime + ) + } + withCategory(payload.networkTransferMetrics, "network_transfer") { + captureMetric(key: "cellular_download", value: $0.cumulativeCellularDownload) + captureMetric(key: "cellular_upload", value: $0.cumulativeCellularUpload) + captureMetric(key: "wifi_download", value: $0.cumulativeWifiDownload) + captureMetric(key: "wifi_upload", value: $0.cumulativeWifiUpload) + } + if #available(iOS 14.0, *) { + withCategory(payload.applicationExitMetrics, "app_exit") { + withCategory($0.foregroundExitData, "foreground") { + captureMetric( + key: "abnormal_exit_count", + value: $0.cumulativeAbnormalExitCount + ) + captureMetric( + key: "app_watchdog_exit_count", + value: $0.cumulativeAppWatchdogExitCount + ) + captureMetric( + key: "bad_access_exit_count", + value: $0.cumulativeBadAccessExitCount + ) + captureMetric( + key: "illegal_instruction_exit_count", + value: $0.cumulativeIllegalInstructionExitCount + ) + captureMetric( + key: "memory_resource_limit_exit-count", + value: $0.cumulativeMemoryResourceLimitExitCount + ) + captureMetric( + key: "normal_app_exit_count", + value: $0.cumulativeNormalAppExitCount + ) + } + + withCategory($0.backgroundExitData, "background") { + captureMetric( + key: "abnormal_exit_count", + value: $0.cumulativeAbnormalExitCount + ) + captureMetric( + key: "app_watchdog_exit_count", + value: $0.cumulativeAppWatchdogExitCount + ) + captureMetric( + key: "bad_access-exit_count", + value: $0.cumulativeBadAccessExitCount + ) + captureMetric( + key: "normal_app_exit_count", + value: $0.cumulativeNormalAppExitCount + ) + captureMetric( + key: "memory_pressure_exit_count", + value: $0.cumulativeMemoryPressureExitCount + ) + captureMetric( + key: "illegal_instruction_exit_count", + value: $0.cumulativeIllegalInstructionExitCount + ) + captureMetric( + key: "cpu_resource_limit_exit_count", + value: $0.cumulativeCPUResourceLimitExitCount + ) + captureMetric( + key: "memory_resource_limit_exit_count", + value: $0.cumulativeMemoryResourceLimitExitCount + ) + captureMetric( + key: "suspended_with_locked_file_exit_count", + value: $0.cumulativeSuspendedWithLockedFileExitCount + ) + captureMetric( + key: "background_task_assertion_timeout_exit_count", + value: $0.cumulativeBackgroundTaskAssertionTimeoutExitCount + ) + } + } + } + if #available(iOS 14.0, *) { + withCategory(payload.animationMetrics, "animation") { + captureMetric(key: "scroll_hitch_time_ratio", value: $0.scrollHitchTimeRatio) + } + } + withCategory(payload.applicationTimeMetrics, "app_time") { + captureMetric( + key: "foreground_time", + value: $0.cumulativeForegroundTime + ) + captureMetric( + key: "background_time", + value: $0.cumulativeBackgroundTime + ) + captureMetric( + key: "background_audio_time", + value: $0.cumulativeBackgroundAudioTime + ) + captureMetric( + key: "background_location_time", + value: $0.cumulativeBackgroundLocationTime + ) + } + withCategory(payload.cellularConditionMetrics, "cellular_condition") { + captureMetric( + key: "cellular_condition_time_average", + value: $0.histogrammedCellularConditionTime + ) + } + withCategory(payload.cpuMetrics, "cpu") { + if #available(iOS 14.0, *) { + captureMetric(key: "instruction_count", value: $0.cumulativeCPUInstructions) + } + captureMetric(key: "cpu_time", value: $0.cumulativeCPUTime) + } + withCategory(payload.gpuMetrics, "gpu") { + captureMetric(key: "time", value: $0.cumulativeGPUTime) + } + withCategory(payload.diskIOMetrics, "diskio") { + captureMetric(key: "logical_write_count", value: $0.cumulativeLogicalWrites) + } + withCategory(payload.memoryMetrics, "memory") { + captureMetric(key: "peak_memory_usage", value: $0.peakMemoryUsage) + captureMetric( + key: "suspended_memory_average", + value: $0.averageSuspendedMemory.averageMeasurement + ) + } + // Display metrics *only* has pixel luminance, and it's an MXAverage value. + withCategory(payload.displayMetrics, "display") { + if let averagePixelLuminance = $0.averagePixelLuminance { + captureMetric( + key: "pixel_luminance_average", + value: averagePixelLuminance.averageMeasurement + ) + } + } + + // Signpost metrics are a little different from the other metrics, since they can have arbitrary names. + if let signpostMetrics = payload.signpostMetrics { + for signpostMetric in signpostMetrics { + let span = getMetricKitTracer().spanBuilder(spanName: "MXSignpostMetric") + .startSpan() + span.setAttribute(key: "signpost.name", value: signpostMetric.signpostName) + span.setAttribute( + key: "signpost.category", + value: signpostMetric.signpostCategory + ) + span.setAttribute(key: "signpost.count", value: signpostMetric.totalCount) + if let intervalData = signpostMetric.signpostIntervalData { + if let cpuTime = intervalData.cumulativeCPUTime { + span.setAttribute( + key: "signpost.cpu_time", + value: cpuTime.attributeValue() + ) + } + if let memoryAverage = intervalData.averageMemory { + span.setAttribute( + key: "signpost.memory_average", + value: memoryAverage.averageMeasurement.attributeValue() + ) + } + if let logicalWriteCount = intervalData.cumulativeLogicalWrites { + span.setAttribute( + key: "signpost.logical_write_count", + value: logicalWriteCount.attributeValue() + ) + } + if #available(iOS 15.0, *) { + if let hitchTimeRatio = intervalData.cumulativeHitchTimeRatio { + span.setAttribute( + key: "signpost.hitch_time_ratio", + value: hitchTimeRatio.attributeValue() + ) + } + } + } + span.end() + } + } + } + + @available(iOS 14.0, macOS 12.0, macCatalyst 14.0, visionOS 1.0, *) + public func reportDiagnostics(payload: MXDiagnosticPayload) { + let span = getMetricKitTracer().spanBuilder(spanName: "MXDiagnosticPayload") + .setStartTime(time: payload.timeStampBegin) + .startSpan() + defer { span.end() } + + let logger = OpenTelemetry.instance.loggerProvider.get( + instrumentationScopeName: metricKitInstrumentationName + ) + + let now = Date() + + // A helper for looping over the items in an optional list and logging each one. + func logForEach( + _ parent: [T]?, + _ namespace: String, + using closure: (T) -> ([String: AttributeValueConvertable], [String: AttributeValueConvertable]) + ) { + if let arr = parent { + for item in arr { + var attributes: [String: AttributeValue] = [ + "name": "metrickit.diagnostic.\(namespace)".attributeValue() + ] + let (namespacedAttrs, globalAttrs) = closure(item) + + // Add namespaced attributes with prefix + for (key, value) in namespacedAttrs { + let namespacedKey = "metrickit.diagnostic.\(namespace).\(key)" + attributes[namespacedKey] = value.attributeValue() + } + + // Add global attributes without prefix (for standard OTel attributes) + for (key, value) in globalAttrs { + attributes[key] = value.attributeValue() + } + + logger.logRecordBuilder() + .setTimestamp(payload.timeStampEnd) + .setObservedTimestamp(now) + .setAttributes(attributes) + .emit() + } + } + } + + #if !os(macOS) + if #available(iOS 16.0, *) { + logForEach(payload.appLaunchDiagnostics, "app_launch") { + (["launch_duration": $0.launchDuration], [:]) + } + } + #endif + + logForEach(payload.diskWriteExceptionDiagnostics, "disk_write_exception") { + (["total_writes_caused": $0.totalWritesCaused], [:]) + } + logForEach(payload.hangDiagnostics, "hang") { + let callStackTree = $0.callStackTree + let appleJson = callStackTree.jsonRepresentation() + + // Transform to simplified format, fall back to original if transformation fails + let stacktraceData = transformStackTrace(appleJson) ?? appleJson + let stacktraceJson = String(decoding: stacktraceData, as: UTF8.self) + + let namespacedAttrs: [String: AttributeValueConvertable] = [ + "hang_duration": $0.hangDuration + ] + + let globalAttrs: [String: AttributeValueConvertable] = [ + "exception.stacktrace": stacktraceJson + ] + + return (namespacedAttrs, globalAttrs) + } + logForEach(payload.cpuExceptionDiagnostics, "cpu_exception") { + ([ + "total_cpu_time": $0.totalCPUTime, + "total_sampled_time": $0.totalSampledTime, + ], [:]) + } + logForEach(payload.crashDiagnostics, "crash") { + var namespacedAttrs: [String: AttributeValueConvertable] = [:] + var globalAttrs: [String: AttributeValueConvertable] = [:] + + // Standard OTel exception attributes - will be populated below + var otelType: String? + var otelMessage: String? + + if let exceptionCode = $0.exceptionCode { + namespacedAttrs["exception.code"] = exceptionCode.intValue + } + if let signal = $0.signal { + namespacedAttrs["exception.signal"] = signal.intValue + let signalName = signalNameMap[signal.int32Value] + ?? "Unknown signal: \(String(describing: signal))" + namespacedAttrs["exception.signal.name"] = signalName + let signalDescription = signalDescriptionMap[signal.int32Value] + ?? "Unknown signal: \(String(describing: signal))" + namespacedAttrs["exception.signal.description"] = signalDescription + + // Use signal for OTel attributes if we don't have anything better + if otelType == nil { + otelType = signalName + otelMessage = signalDescription + } + } + if let exceptionType = $0.exceptionType { + namespacedAttrs["exception.mach_exception.type"] = exceptionType.intValue + let machExceptionName = exceptionNameMap[exceptionType.int32Value] + ?? "Unknown exception type: \(String(describing: exceptionType))" + namespacedAttrs["exception.mach_exception.name"] = machExceptionName + let machExceptionDescription = exceptionDescriptionMap[exceptionType.int32Value] + ?? "Unknown exception type: \(String(describing: exceptionType))" + namespacedAttrs["exception.mach_exception.description"] = machExceptionDescription + + // Prefer Mach exception over signal for OTel attributes + otelType = machExceptionName + otelMessage = machExceptionDescription + } + if let terminationReason = $0.terminationReason { + namespacedAttrs["exception.termination_reason"] = terminationReason + } + let callStackTree = $0.callStackTree + let appleJson = callStackTree.jsonRepresentation() + + // Transform to simplified format, fall back to original if transformation fails + let stacktraceData = transformStackTrace(appleJson) ?? appleJson + let stacktraceJson = String(decoding: stacktraceData, as: UTF8.self) + + // Standard OTel exception attribute (without namespace prefix) + globalAttrs["exception.stacktrace"] = stacktraceJson + + if #available(iOS 17.0, macOS 14.0, *) { + if let exceptionReason = $0.exceptionReason { + namespacedAttrs["exception.objc.type"] = exceptionReason.exceptionType + let objcMessage = exceptionReason.composedMessage + namespacedAttrs["exception.objc.message"] = objcMessage + namespacedAttrs["exception.objc.classname"] = exceptionReason.className + let objcName = exceptionReason.exceptionName + namespacedAttrs["exception.objc.name"] = objcName + + // Prefer Objective-C exception info for OTel attributes (most specific) + otelType = objcName + otelMessage = objcMessage + } + } + + // Set standard OTel exception attributes (without namespace prefix) + if let type = otelType { + globalAttrs["exception.type"] = type + } + if let message = otelMessage { + globalAttrs["exception.message"] = message + } + + return (namespacedAttrs, globalAttrs) + } + } + + // names/descriptions taken from exception_types.h + let exceptionNameMap: [Int32: String] = [ + EXC_BAD_ACCESS: "EXC_BAD_ACCESS", + EXC_BAD_INSTRUCTION: "EXC_BAD_INSTRUCTION", + EXC_ARITHMETIC: "EXC_ARITHMETIC", + EXC_EMULATION: "EXC_EMULATION", + EXC_SOFTWARE: "EXC_SOFTWARE", + EXC_BREAKPOINT: "EXC_BREAKPOINT", + EXC_SYSCALL: "EXC_SYSCALL", + EXC_MACH_SYSCALL: "EXC_MACH_SYSCALL", + EXC_RPC_ALERT: "EXC_RPC_ALERT", + EXC_CRASH: "EXC_CRASH", + EXC_RESOURCE: "EXC_RESOURCE", + EXC_GUARD: "EXC_GUARD", + EXC_CORPSE_NOTIFY: "EXC_CORPSE_NOTIFY", + ] + let exceptionDescriptionMap: [Int32: String] = [ + EXC_BAD_ACCESS: "Could not access memory", + EXC_BAD_INSTRUCTION: "Instruction failed", + EXC_ARITHMETIC: "Arithmetic exception", + EXC_EMULATION: "Emulation instruction", + EXC_SOFTWARE: "Software generated exception", + EXC_BREAKPOINT: "Trace, breakpoint, etc.", + EXC_SYSCALL: "System calls.", + EXC_MACH_SYSCALL: "Mach system calls.", + EXC_RPC_ALERT: "RPC alert", + EXC_CRASH: "Abnormal process exit", + EXC_RESOURCE: "Hit resource consumption limit", + EXC_GUARD: "Violated guarded resource protections", + EXC_CORPSE_NOTIFY: "Abnormal process exited to corpse state", + ] + + // names/descriptions taken from signal.h + let signalNameMap: [Int32: String] = [ + SIGHUP: "SIGHUP", + SIGINT: "SIGINT", + SIGQUIT: "SIGQUIT", + SIGILL: "SIGILL", + SIGTRAP: "SIGTRAP", + SIGABRT: "SIGABRT", + SIGEMT: "SIGEMT", + SIGFPE: "SIGFPE", + SIGKILL: "SIGKILL", + SIGBUS: "SIGBUS", + SIGSEGV: "SIGSEGV", + SIGSYS: "SIGSYS", + SIGPIPE: "SIGPIPE", + SIGALRM: "SIGALRM", + SIGTERM: "SIGTERM", + SIGURG: "SIGURG", + SIGSTOP: "SIGSTOP", + SIGTSTP: "SIGTSTP", + SIGCONT: "SIGCONT", + SIGCHLD: "SIGCHLD", + SIGTTIN: "SIGTTIN", + SIGTTOU: "SIGTTOU", + SIGIO: "SIGIO", + SIGXCPU: "SIGXCPU", + SIGXFSZ: "SIGXFSZ", + SIGVTALRM: "SIGVTALRM", + SIGPROF: "SIGPROF", + SIGWINCH: "SIGWINCH", + SIGINFO: "SIGINFO", + SIGUSR1: "SIGUSR1", + SIGUSR2: "SIGUSR2", + ] + + let signalDescriptionMap: [Int32: String] = [ + SIGHUP: "hangup", + SIGINT: "interrupt", + SIGQUIT: "quit", + SIGILL: "illegal instruction (not reset when caught)", + SIGTRAP: "trace trap (not reset when caught)", + SIGABRT: "abort()", + SIGEMT: "EMT instruction", + SIGFPE: "floating point exception", + SIGKILL: "kill (cannot be caught or ignored)", + SIGBUS: "bus error", + SIGSEGV: "segmentation violation", + SIGSYS: "bad argument to system call", + SIGPIPE: "write on a pipe with no one to read it", + SIGALRM: "alarm clock", + SIGTERM: "software termination signal from kill", + SIGURG: "urgent condition on IO channel", + SIGSTOP: "sendable stop signal not from tty", + SIGTSTP: "stop signal from tty", + SIGCONT: "continue a stopped process", + SIGCHLD: "to parent on child stop or exit", + SIGTTIN: "to readers pgrp upon background tty read", + SIGTTOU: "like TTIN for output if (tp->t_local<OSTOP)", + SIGIO: "input/output possible signal", + SIGXCPU: "exceeded CPU time limit", + SIGXFSZ: "exceeded file size limit", + SIGVTALRM: "virtual time alarm", + SIGPROF: "profiling time alarm", + SIGWINCH: "window size changes", + SIGINFO: "information request", + SIGUSR1: "user defined signal 1", + SIGUSR2: "user defined signal 2", + ] +#endif diff --git a/Sources/Instrumentation/MetricKit/README.md b/Sources/Instrumentation/MetricKit/README.md new file mode 100644 index 00000000..69852776 --- /dev/null +++ b/Sources/Instrumentation/MetricKit/README.md @@ -0,0 +1,244 @@ +# MetricKit Instrumentation + +This instrumentation adds MetricKit signals to OpenTelemetry, capturing performance metrics and diagnostic data from Apple's MetricKit framework. MetricKit provides aggregated data about your app's performance and diagnostics, reported approximately once per day with cumulative data from the previous 24-hour period. + +All data is captured using the instrumentation scope `"MetricKit"` with version `"0.0.1"`. + +## Usage + +To use the MetricKit instrumentation, register it with MetricKit's metric manager: + +```swift +import MetricKit +import OpenTelemetryApi + +// Initialize OpenTelemetry providers (tracer and logger) +// ... your OpenTelemetry setup code ... + +// Register the MetricKit instrumentation +// IMPORTANT: Store the instrumentation instance in a static or app-level variable. +// MXMetricManager.shared holds only a weak reference, so if the instance +// is released, it won't receive MetricKit callbacks. +if #available(iOS 13.0, *) { + let metricKit = MetricKitInstrumentation() + MXMetricManager.shared.add(metricKit) + + // Store instrumentation somewhere to keep it alive, e.g.: + // AppDelegate.metricKitInstrumentation = metricKit +} +``` + +The instrumentation will automatically receive MetricKit payloads and convert them to OpenTelemetry spans and logs. + +## Data Structure Overview + +MetricKit reports data in two categories: **Metrics** and **Diagnostics**. + +### Why Traces Instead of OTel Metrics? + +This instrumentation represents MetricKit data as OpenTelemetry traces (spans) rather than OTel metrics for several reasons: + +1. **Pre-aggregated data**: MetricKit data is already aggregated over 24 hours, not individual measurements, so OTel metric semantics (counters, gauges, histograms with live aggregation) don't map naturally +2. **Timing semantics**: The data represents activity over a 24-hour period, not point-in-time measurements. Using spans with start/end times better represents this temporal nature +3. **API simplicity**: The OpenTelemetry metrics API is complex, and spans provide a simpler way to represent this pre-aggregated, time-windowed data + +## Units + +All MetricKit measurements have units (e.g., "1 kb" or "3 hours"). When converted to attributes, they are normalized to base units (bytes, seconds, etc.) and represented as doubles. + +## Timestamps + +All data in MetricKit payloads includes two timestamps: + +- **`timeStampBegin`**: The start of the 24-hour reporting period +- **`timeStampEnd`**: The end of the reporting period + +For metrics spans, `timeStampBegin` is used as the span start time and `timeStampEnd` as the span end time, so spans are typically 24 hours long. + +For diagnostics, both `timestamp` (set to `timeStampEnd`) and `observedTimestamp` (set to the current time when the log is emitted) are included. `timeStampEnd` is used so that diagnostic events appear as "new" data in observability systems when they arrive, even though the actual event occurred sometime during the 24-hour period. + +## MXMetricPayload + +Metrics are pre-aggregated measurements over the reporting period, such as "total CPU time" and "number of abnormal exits". + +### Data Representation + +For metrics, a single span named `"MXMetricPayload"` is reported. The span's start time is the beginning of the reporting period and the end time is the end of the period (typically 24 hours). Each metric is included as an attribute on this span, with attributes namespaced by their category in MXMetricPayload (e.g., `metrickit.app_exit.foreground.abnormal_exit_count`, `metrickit.cpu.cpu_time`). + +For histogram data, only the average value is estimated and reported. + +### Attribute Reference + +| Attribute Name | Type | Units | Apple Documentation | +|----------------|------|-------|---------------------| +| `metrickit.includes_multiple_application_versions` | bool | - | [includesMultipleApplicationVersions](https://developer.apple.com/documentation/metrickit/mxmetricpayload/includesmultipleapplicationversions) | +| `metrickit.latest_application_version` | string | - | [latestApplicationVersion](https://developer.apple.com/documentation/metrickit/mxmetricpayload/latestapplicationversion) | +| `metrickit.timestamp_begin` | double | seconds (Unix epoch) | [timeStampBegin](https://developer.apple.com/documentation/metrickit/mxmetricpayload/timestampbegin) | +| `metrickit.timestamp_end` | double | seconds (Unix epoch) | [timeStampEnd](https://developer.apple.com/documentation/metrickit/mxmetricpayload/timestampend) | +| **CPU Metrics** | | | [MXCPUMetric](https://developer.apple.com/documentation/metrickit/mxcpumetric) | +| `metrickit.cpu.cpu_time` | double | seconds | [cumulativeCPUTime](https://developer.apple.com/documentation/metrickit/mxcpumetric/cumulativecputime) | +| `metrickit.cpu.instruction_count` | double | instructions | [cumulativeCPUInstructions](https://developer.apple.com/documentation/metrickit/mxcpumetric/cumulativecpuinstructions) (iOS 14+) | +| **GPU Metrics** | | | [MXGPUMetric](https://developer.apple.com/documentation/metrickit/mxgpumetric) | +| `metrickit.gpu.time` | double | seconds | [cumulativeGPUTime](https://developer.apple.com/documentation/metrickit/mxgpumetric/cumulativegputime) | +| **Cellular Metrics** | | | [MXCellularConditionMetric](https://developer.apple.com/documentation/metrickit/mxcellularconditionmetric) | +| `metrickit.cellular_condition.bars_average` | double | bars | [histogrammedCellularConditionTime](https://developer.apple.com/documentation/metrickit/mxcellularconditionmetric/histogrammedcellularconditiontime) | +| **App Time Metrics** | | | [MXAppRunTimeMetric](https://developer.apple.com/documentation/metrickit/mxappruntimemetric) | +| `metrickit.app_time.foreground_time` | double | seconds | [cumulativeForegroundTime](https://developer.apple.com/documentation/metrickit/mxappruntimemetric/cumulativeforegroundtime) | +| `metrickit.app_time.background_time` | double | seconds | [cumulativeBackgroundTime](https://developer.apple.com/documentation/metrickit/mxappruntimemetric/cumulativebackgroundtime) | +| `metrickit.app_time.background_audio_time` | double | seconds | [cumulativeBackgroundAudioTime](https://developer.apple.com/documentation/metrickit/mxappruntimemetric/cumulativebackgroundaudiotime) | +| `metrickit.app_time.background_location_time` | double | seconds | [cumulativeBackgroundLocationTime](https://developer.apple.com/documentation/metrickit/mxappruntimemetric/cumulativebackgroundlocationtime) | +| **Location Activity Metrics** | | | [MXLocationActivityMetric](https://developer.apple.com/documentation/metrickit/mxlocationactivitymetric) | +| `metrickit.location_activity.best_accuracy_time` | double | seconds | [cumulativeBestAccuracyTime](https://developer.apple.com/documentation/metrickit/mxlocationactivitymetric/cumulativebestaccuracytime) | +| `metrickit.location_activity.best_accuracy_for_nav_time` | double | seconds | [cumulativeBestAccuracyForNavigationTime](https://developer.apple.com/documentation/metrickit/mxlocationactivitymetric/cumulativebestaccuracyfornavigationtime) | +| `metrickit.location_activity.accuracy_10m_time` | double | seconds | [cumulativeNearestTenMetersAccuracyTime](https://developer.apple.com/documentation/metrickit/mxlocationactivitymetric/cumulativenearesttenmetersaccuracytime) | +| `metrickit.location_activity.accuracy_100m_time` | double | seconds | [cumulativeHundredMetersAccuracyTime](https://developer.apple.com/documentation/metrickit/mxlocationactivitymetric/cumulativehundredmetersaccuracytime) | +| `metrickit.location_activity.accuracy_1km_time` | double | seconds | [cumulativeKilometerAccuracyTime](https://developer.apple.com/documentation/metrickit/mxlocationactivitymetric/cumulativekilometeraccuracytime) | +| `metrickit.location_activity.accuracy_3km_time` | double | seconds | [cumulativeThreeKilometersAccuracyTime](https://developer.apple.com/documentation/metrickit/mxlocationactivitymetric/cumulativethreekilometersaccuracytime) | +| **Network Transfer Metrics** | | | [MXNetworkTransferMetric](https://developer.apple.com/documentation/metrickit/mxnetworktransfermetric) | +| `metrickit.network_transfer.wifi_upload` | double | bytes | [cumulativeWifiUpload](https://developer.apple.com/documentation/metrickit/mxnetworktransfermetric/cumulativewifiupload) | +| `metrickit.network_transfer.wifi_download` | double | bytes | [cumulativeWifiDownload](https://developer.apple.com/documentation/metrickit/mxnetworktransfermetric/cumulativewifidownload) | +| `metrickit.network_transfer.cellular_upload` | double | bytes | [cumulativeCellularUpload](https://developer.apple.com/documentation/metrickit/mxnetworktransfermetric/cumulativecellularupload) | +| `metrickit.network_transfer.cellular_download` | double | bytes | [cumulativeCellularDownload](https://developer.apple.com/documentation/metrickit/mxnetworktransfermetric/cumulativecellulardownload) | +| **App Launch Metrics** | | | [MXAppLaunchMetric](https://developer.apple.com/documentation/metrickit/mxapplaunchmetric) | +| `metrickit.app_launch.time_to_first_draw_average` | double | seconds | [histogrammedTimeToFirstDraw](https://developer.apple.com/documentation/metrickit/mxapplaunchmetric/histogrammedtimetofirstdraw) (average) | +| `metrickit.app_launch.app_resume_time_average` | double | seconds | [histogrammedApplicationResumeTime](https://developer.apple.com/documentation/metrickit/mxapplaunchmetric/histogrammedapplicationresumetime) (average) | +| `metrickit.app_launch.optimized_time_to_first_draw_average` | double | seconds | [histogrammedOptimizedTimeToFirstDraw](https://developer.apple.com/documentation/metrickit/mxapplaunchmetric/histogrammedoptimizedtimetofirstdraw) (average, iOS 15.2+) | +| `metrickit.app_launch.extended_launch_average` | double | seconds | [histogrammedExtendedLaunch](https://developer.apple.com/documentation/metrickit/mxapplaunchmetric/histogrammedextendedlaunch) (average, iOS 16+) | +| **App Responsiveness Metrics** | | | [MXAppResponsivenessMetric](https://developer.apple.com/documentation/metrickit/mxappresponsivenessmetric) | +| `metrickit.app_responsiveness.hang_time_average` | double | seconds | [histogrammedApplicationHangTime](https://developer.apple.com/documentation/metrickit/mxappresponsivenessmetric/histogrammedapplicationhangtime) (average) | +| **Disk I/O Metrics** | | | [MXDiskIOMetric](https://developer.apple.com/documentation/metrickit/mxdiskiometric) | +| `metrickit.diskio.logical_write_count` | double | bytes | [cumulativeLogicalWrites](https://developer.apple.com/documentation/metrickit/mxdiskiometric/cumulativelogicalwrites) | +| **Memory Metrics** | | | [MXMemoryMetric](https://developer.apple.com/documentation/metrickit/mxmemorymetric) | +| `metrickit.memory.peak_memory_usage` | double | bytes | [peakMemoryUsage](https://developer.apple.com/documentation/metrickit/mxmemorymetric/peakmemoryusage) | +| `metrickit.memory.suspended_memory_average` | double | bytes | [averageSuspendedMemory](https://developer.apple.com/documentation/metrickit/mxmemorymetric/averagesuspendedmemory) (average) | +| **Display Metrics** | | | [MXDisplayMetric](https://developer.apple.com/documentation/metrickit/mxdisplaymetric) | +| `metrickit.display.pixel_luminance_average` | double | APL (average pixel luminance) | [averagePixelLuminance](https://developer.apple.com/documentation/metrickit/mxdisplaymetric/averagepixelluminance) (average) | +| **Animation Metrics** | | | [MXAnimationMetric](https://developer.apple.com/documentation/metrickit/mxanimationmetric) | +| `metrickit.animation.scroll_hitch_time_ratio` | double | ratio (dimensionless) | [scrollHitchTimeRatio](https://developer.apple.com/documentation/metrickit/mxanimationmetric/scrollhitchtimeratio) (iOS 14+) | +| **Metadata** | | | [MXMetaData](https://developer.apple.com/documentation/metrickit/mxmetadata) | +| `metrickit.metadata.pid` | int | - | [pid](https://developer.apple.com/documentation/metrickit/mxmetadata/pid) (iOS 17+) | +| `metrickit.metadata.app_build_version` | string | - | [applicationBuildVersion](https://developer.apple.com/documentation/metrickit/mxmetadata/applicationbuildversion) | +| `metrickit.metadata.device_type` | string | - | [deviceType](https://developer.apple.com/documentation/metrickit/mxmetadata/devicetype) | +| `metrickit.metadata.is_test_flight_app` | bool | - | [isTestFlightApp](https://developer.apple.com/documentation/metrickit/mxmetadata/istestflightapp) (iOS 17+) | +| `metrickit.metadata.low_power_mode_enabled` | bool | - | [lowPowerModeEnabled](https://developer.apple.com/documentation/metrickit/mxmetadata/lowpowermodeenabled) (iOS 17+) | +| `metrickit.metadata.os_version` | string | - | [osVersion](https://developer.apple.com/documentation/metrickit/mxmetadata/osversion) | +| `metrickit.metadata.platform_arch` | string | - | [platformArchitecture](https://developer.apple.com/documentation/metrickit/mxmetadata/platformarchitecture) (iOS 14+) | +| `metrickit.metadata.region_format` | string | - | [regionFormat](https://developer.apple.com/documentation/metrickit/mxmetadata/regionformat) | +| **App Exit Metrics - Foreground** | | | [MXForegroundExitData](https://developer.apple.com/documentation/metrickit/mxforegroundexitdata) | +| `metrickit.app_exit.foreground.normal_app_exit_count` | int | count | [cumulativeNormalAppExitCount](https://developer.apple.com/documentation/metrickit/mxforegroundexitdata/cumulativenormalappexitcount) | +| `metrickit.app_exit.foreground.memory_resource_limit_exit-count` | int | count | [cumulativeMemoryResourceLimitExitCount](https://developer.apple.com/documentation/metrickit/mxforegroundexitdata/cumulativememoryresourcelimitexitcount) | +| `metrickit.app_exit.foreground.bad_access_exit_count` | int | count | [cumulativeBadAccessExitCount](https://developer.apple.com/documentation/metrickit/mxforegroundexitdata/cumulativebadaccessexitcount) | +| `metrickit.app_exit.foreground.abnormal_exit_count` | int | count | [cumulativeAbnormalExitCount](https://developer.apple.com/documentation/metrickit/mxforegroundexitdata/cumulativeabnormalexitcount) | +| `metrickit.app_exit.foreground.illegal_instruction_exit_count` | int | count | [cumulativeIllegalInstructionExitCount](https://developer.apple.com/documentation/metrickit/mxforegroundexitdata/cumulativeillegalinstructionexitcount) | +| `metrickit.app_exit.foreground.app_watchdog_exit_count` | int | count | [cumulativeAppWatchdogExitCount](https://developer.apple.com/documentation/metrickit/mxforegroundexitdata/cumulativeappwatchdogexitcount) | +| **App Exit Metrics - Background** | | | [MXBackgroundExitData](https://developer.apple.com/documentation/metrickit/mxbackgroundexitdata) | +| `metrickit.app_exit.background.normal_app_exit_count` | int | count | [cumulativeNormalAppExitCount](https://developer.apple.com/documentation/metrickit/mxbackgroundexitdata/cumulativenormalappexitcount) | +| `metrickit.app_exit.background.memory_resource_limit_exit_count` | int | count | [cumulativeMemoryResourceLimitExitCount](https://developer.apple.com/documentation/metrickit/mxbackgroundexitdata/cumulativememoryresourcelimitexitcount) | +| `metrickit.app_exit.background.cpu_resource_limit_exit_count` | int | count | [cumulativeCPUResourceLimitExitCount](https://developer.apple.com/documentation/metrickit/mxbackgroundexitdata/cumulativecpuresourcelimitexitcount) | +| `metrickit.app_exit.background.memory_pressure_exit_count` | int | count | [cumulativeMemoryPressureExitCount](https://developer.apple.com/documentation/metrickit/mxbackgroundexitdata/cumulativememorypressureexitcount) | +| `metrickit.app_exit.background.bad_access-exit_count` | int | count | [cumulativeBadAccessExitCount](https://developer.apple.com/documentation/metrickit/mxbackgroundexitdata/cumulativebadaccessexitcount) | +| `metrickit.app_exit.background.abnormal_exit_count` | int | count | [cumulativeAbnormalExitCount](https://developer.apple.com/documentation/metrickit/mxbackgroundexitdata/cumulativeabnormalexitcount) | +| `metrickit.app_exit.background.illegal_instruction_exit_count` | int | count | [cumulativeIllegalInstructionExitCount](https://developer.apple.com/documentation/metrickit/mxbackgroundexitdata/cumulativeillegalinstructionexitcount) | +| `metrickit.app_exit.background.app_watchdog_exit_count` | int | count | [cumulativeAppWatchdogExitCount](https://developer.apple.com/documentation/metrickit/mxbackgroundexitdata/cumulativeappwatchdogexitcount) | +| `metrickit.app_exit.background.suspended_with_locked_file_exit_count` | int | count | [cumulativeSuspendedWithLockedFileExitCount](https://developer.apple.com/documentation/metrickit/mxbackgroundexitdata/cumulativesuspendedwithlockedfileexitcount) | +| `metrickit.app_exit.background.background_task_assertion_timeout_exit_count` | int | count | [cumulativeBackgroundTaskAssertionTimeoutExitCount](https://developer.apple.com/documentation/metrickit/mxbackgroundexitdata/cumulativebackgroundtaskassertiontimeoutexitcount) | + +## MXSignpostMetric + +Signpost metrics are custom performance measurements you define in your app using [os_signpost](https://developer.apple.com/documentation/os/logging/recording_performance_data). Unlike the other MetricKit metrics which are aggregated into a single span, each signpost metric generates its own individual span. + +### Data Representation + +Each signpost metric creates a separate span named `"MXSignpostMetric"` with attributes describing the signpost's category, name, count, and performance measurements. The instrumentation scope is `"MetricKit"`. + +### Attribute Reference + +| Attribute Name | Type | Units | Apple Documentation | +|----------------|------|-------|---------------------| +| `signpost.name` | string | - | [signpostName](https://developer.apple.com/documentation/metrickit/mxsignpostmetric/signpostname) | +| `signpost.category` | string | - | [signpostCategory](https://developer.apple.com/documentation/metrickit/mxsignpostmetric/signpostcategory) | +| `signpost.count` | int | count | [totalCount](https://developer.apple.com/documentation/metrickit/mxsignpostmetric/totalcount) | +| `signpost.cpu_time` | double | seconds | [cumulativeCPUTime](https://developer.apple.com/documentation/metrickit/mxsignpostintervaldata/cumulativecputime) | +| `signpost.memory_average` | double | bytes | [averageMemory](https://developer.apple.com/documentation/metrickit/mxsignpostintervaldata/averagememory) (average) | +| `signpost.logical_write_count` | double | bytes | [cumulativeLogicalWrites](https://developer.apple.com/documentation/metrickit/mxsignpostintervaldata/cumulativelogicalwrites) | +| `signpost.hitch_time_ratio` | double | ratio (dimensionless) | [cumulativeHitchTimeRatio](https://developer.apple.com/documentation/metrickit/mxsignpostintervaldata/cumulativehitchtimeratio) (iOS 15+) | + +## MXDiagnosticPayload + +Diagnostics are individual events that occurred during the reporting period, such as crashes, hangs, and exceptions. Unlike metrics which are aggregated, each diagnostic represents a discrete event, though the exact time it occurred within the 24-hour window is not known. + +### Data Representation + +For diagnostics, a parent span named `"MXDiagnosticPayload"` is created spanning the reporting period (start time = `timeStampBegin`, end time = `timeStampEnd`). For each diagnostic event, an OpenTelemetry log record is emitted (not a span, since each event is instantaneous). Each log has: + +- A `name` attribute identifying the diagnostic type (e.g., `"metrickit.diagnostic.crash"`) +- Additional attributes with diagnostic details, all namespaced by type (e.g., `metrickit.diagnostic.crash.exception.code`) +- A `timestamp` set to `timeStampEnd` (so the event appears as "new" when it arrives) +- An `observedTimestamp` set to the current time when the log is emitted + +The instrumentation scope is `"MetricKit"`. + +### Log Names + +Each diagnostic log includes a `name` attribute that identifies its type: + +- `metrickit.diagnostic.cpu_exception` +- `metrickit.diagnostic.disk_write_exception` +- `metrickit.diagnostic.hang` +- `metrickit.diagnostic.crash` +- `metrickit.diagnostic.app_launch` (iOS 16+, not available on macOS) + +### OpenTelemetry Semantic Conventions + +This instrumentation extends the standard OpenTelemetry [exception semantic conventions](https://opentelemetry.io/docs/specs/semconv/exceptions/exceptions-logs/) with additional MetricKit-specific attributes. The standard OTel attributes for exceptions are: + +- `exception.type` - The exception type/class name +- `exception.message` - The exception message +- `exception.stacktrace` - The stacktrace as a string + +For MetricKit crash diagnostics, additional attributes are added in the `metrickit.diagnostic.crash.*` namespace to capture MetricKit's rich exception data (Mach exception types, signal numbers, Objective-C exception details). + +#### How Standard Exception Attributes Are Derived + +For **crash diagnostics**, `exception.type` and `exception.message` are derived from the most specific available information using the following priority order (highest to lowest): + +1. **Objective-C exception info** (iOS 17+, highest priority) - Uses `objc.name` for type and `objc.message` for message +2. **Mach exception info** - Uses `mach_exception.name` for type and `mach_exception.description` for message +3. **POSIX signal info** (lowest priority) - Uses `signal.name` for type and `signal.description` for message + +For **hang diagnostics**, only `exception.stacktrace` is set (from `callStackTree`). No `exception.type` or `exception.message` is provided since hangs don't have exception objects. + +The `exception.stacktrace` attribute is always set to the JSON representation of the `callStackTree` for both crashes and hangs. + +### Attribute Reference + +| Attribute Name | Type | Units | Apple Documentation | +|----------------|------|-------|---------------------| +| **CPU Exception Diagnostics** | | | [MXCPUExceptionDiagnostic](https://developer.apple.com/documentation/metrickit/mxcpuexceptiondiagnostic) | +| `metrickit.diagnostic.cpu_exception.total_cpu_time` | double | seconds | [totalCPUTime](https://developer.apple.com/documentation/metrickit/mxcpuexceptiondiagnostic/totalcputime) | +| `metrickit.diagnostic.cpu_exception.total_sampled_time` | double | seconds | [totalSampledTime](https://developer.apple.com/documentation/metrickit/mxcpuexceptiondiagnostic/totalsampledtime) | +| **Disk Write Exception Diagnostics** | | | [MXDiskWriteExceptionDiagnostic](https://developer.apple.com/documentation/metrickit/mxdiskwriteexceptiondiagnostic) | +| `metrickit.diagnostic.disk_write_exception.total_writes_caused` | double | bytes | [totalWritesCaused](https://developer.apple.com/documentation/metrickit/mxdiskwriteexceptiondiagnostic/totalwritescaused) | +| **Hang Diagnostics** | | | [MXHangDiagnostic](https://developer.apple.com/documentation/metrickit/mxhangdiagnostic) | +| `metrickit.diagnostic.hang.hang_duration` | double | seconds | [hangDuration](https://developer.apple.com/documentation/metrickit/mxhangdiagnostic/hangduration) | +| **Crash Diagnostics** | | | [MXCrashDiagnostic](https://developer.apple.com/documentation/metrickit/mxcrashdiagnostic) | +| `metrickit.diagnostic.crash.exception.code` | int | - | [exceptionCode](https://developer.apple.com/documentation/metrickit/mxcrashdiagnostic/exceptioncode) | +| `metrickit.diagnostic.crash.exception.mach_exception.type` | int | - | [exceptionType](https://developer.apple.com/documentation/metrickit/mxcrashdiagnostic/exceptiontype) | +| `metrickit.diagnostic.crash.exception.mach_exception.name` | string | - | Human-readable name for the Mach exception type (e.g., "EXC_BAD_ACCESS") | +| `metrickit.diagnostic.crash.exception.mach_exception.description` | string | - | Description of the Mach exception type | +| `metrickit.diagnostic.crash.exception.signal` | int | - | [signal](https://developer.apple.com/documentation/metrickit/mxcrashdiagnostic/signal) | +| `metrickit.diagnostic.crash.exception.signal.name` | string | - | POSIX signal name (e.g., "SIGSEGV") | +| `metrickit.diagnostic.crash.exception.signal.description` | string | - | Description of the POSIX signal | +| `metrickit.diagnostic.crash.exception.termination_reason` | string | - | [terminationReason](https://developer.apple.com/documentation/metrickit/mxcrashdiagnostic/terminationreason) | +| `metrickit.diagnostic.crash.exception.objc.type` | string | - | [exceptionType](https://developer.apple.com/documentation/metrickit/mxcrashdiagnosticobjectivecexceptionreason/exceptiontype) (iOS 17+) | +| `metrickit.diagnostic.crash.exception.objc.message` | string | - | [composedMessage](https://developer.apple.com/documentation/metrickit/mxcrashdiagnosticobjectivecexceptionreason/composedmessage) (iOS 17+) | +| `metrickit.diagnostic.crash.exception.objc.name` | string | - | [exceptionName](https://developer.apple.com/documentation/metrickit/mxcrashdiagnosticobjectivecexceptionreason/exceptionname) (iOS 17+) | +| `metrickit.diagnostic.crash.exception.objc.classname` | string | - | [className](https://developer.apple.com/documentation/metrickit/mxcrashdiagnosticobjectivecexceptionreason/classname) (iOS 17+) | +| **App Launch Diagnostics** | | | [MXAppLaunchDiagnostic](https://developer.apple.com/documentation/metrickit/mxapplaunchdiagnostic) | +| `metrickit.diagnostic.app_launch.launch_duration` | double | seconds | [launchDuration](https://developer.apple.com/documentation/metrickit/mxapplaunchdiagnostic/launchduration) (iOS 16+, not on macOS) | + +### Stacktrace Format + +For details on the stack trace format used in crash and hang diagnostics, see [StackTraceFormat.md](StackTraceFormat.md). + +**Note:** The instrumentation attempts to transform Apple's native MetricKit format into the simplified OpenTelemetry format described in StackTraceFormat.md. If the transformation fails for any reason (e.g., if the OS returns a stacktrace in a different format than documented), the stacktrace will be included as-is in its original format to ensure diagnostic data is never lost. diff --git a/Sources/Instrumentation/MetricKit/StackTraceFormat.md b/Sources/Instrumentation/MetricKit/StackTraceFormat.md new file mode 100644 index 00000000..f4bc7a95 --- /dev/null +++ b/Sources/Instrumentation/MetricKit/StackTraceFormat.md @@ -0,0 +1,105 @@ +# OpenTelemetry Semantic Convention Proposal: MetricKit Stack Traces + +## Overview + +This document proposes a JSON format for representing Apple MetricKit stack traces in the OpenTelemetry [`exception.stacktrace`](https://opentelemetry.io/docs/specs/semconv/exceptions/exceptions-spans/) attribute. The format is based on Apple's MetricKit `MXCallStackTree` but simplified to reduce unnecessary complexity and make the data easier to process for post-mortem crash analysis. + +## Format Specification + +### Root Object + +The root object contains two keys: + +**`callStackPerThread`** (boolean, required) +Whether the stack trace is for a single process thread (true) or for all process threads (false). + +**`callStacks`** (array, required) +An array of call stacks for a process or thread. + +--- + +### Call Stack Object + +A call stack is a dictionary containing two keys: + +**`threadAttributed`** (boolean, optional) +Indicates that the crash or exception occurred in this call stack. + +**`callStackFrames`** (array, required) +A flat array of stack frames, ordered from innermost (most recent call) to outermost (root call). + +--- + +### Stack Frame Object + +A stack frame is a dictionary containing three keys: + +**`binaryName`** (string, required) +The name of the binary associated with the stack frame. + +**`binaryUUID`** (string, required) +A unique ID used to symbolicate a stack frame (format: 8-4-4-4-12 hex digits). + +**`offsetAddress`** (integer, required) +The offset of the stack frame into the text segment of the binary. This is used for symbolication. + +--- + +### Example +```json +{ + "callStackPerThread": true, + "callStacks": [ + { + "threadAttributed": false, + "callStackFrames": [ + { + "binaryUUID": "70B89F27-1634-3580-A695-57CDB41D7743", + "offsetAddress": 165304, + "binaryName": "MetricKitTestApp" + }, + { + "binaryUUID": "77A62F2E-8212-30F3-84C1-E8497440ACF8", + "offsetAddress": 6948, + "binaryName": "libdyld.dylib" + } + ] + }, + { + "threadAttributed": true, + "callStackFrames": [ + { + "binaryUUID": "A1B2C3D4-5678-90AB-CDEF-1234567890AB", + "offsetAddress": 42000, + "binaryName": "Foundation" + } + ] + } + ] +} +``` + +## Differences from Apple's MetricKit Format + +This format is based on Apple's `MXCallStackTree.jsonRepresentation()` format but includes several simplifications to make the JSON easier to process. + +### 1. Removed `callStackTree` Wrapper + +The `callStackTree` wrapper adds an unnecessary level of nesting. Since the entire JSON document represents call stack data, the wrapper provides no additional information and only complicates parsing. + +### 2. Flattened Stack Frames + +Renamed `callStackRootFrames` to `callStackFrames` and removed the nested `subFrames` structure. Stack frames naturally form a sequence, not a tree, so representing them as a flat array is more intuitive. The order within the array (innermost to outermost) preserves all the information from the nested structure while being significantly easier to iterate over and process. + +### 3. Removed `sampleCount` Field + +The `sampleCount` field is specific to CPU profiling scenarios and is not relevant for general crash analysis. Since this format is designed primarily for post-mortem crash analysis, removing this field simplifies the format without losing essential information. + +### 4. Renamed `offsetIntoBinaryTextSegment` to `offsetAddress` + +Renamed for brevity while preserving the same semantic meaning - this field contains the offset into the binary's text segment used for symbolication. + +### 5. Removed `address` Field + +The runtime memory `address` changes with each execution due to Address Space Layout Randomization (ASLR) and cannot be used for symbolication. The `offsetAddress` (formerly `offsetIntoBinaryTextSegment`) contains all the information needed to symbolicate crashes post-mortem. Including `address` adds redundant data that serves no purpose for the primary use case. + diff --git a/Sources/Instrumentation/MetricKit/StackTraceTransform.swift b/Sources/Instrumentation/MetricKit/StackTraceTransform.swift new file mode 100644 index 00000000..c45174ae --- /dev/null +++ b/Sources/Instrumentation/MetricKit/StackTraceTransform.swift @@ -0,0 +1,99 @@ +#if canImport(MetricKit) && !os(tvOS) && !os(macOS) + import Foundation + + /// Transforms Apple's MetricKit stack trace format into the simplified OpenTelemetry format. + /// See StackTraceFormat.md for details on the format specification. + @available(iOS 14.0, macOS 12.0, macCatalyst 14.0, visionOS 1.0, *) + func transformStackTrace(_ appleJsonData: Data) -> Data? { + // Define structures for Apple's format + struct AppleCallStackTree: Codable { + let callStackTree: AppleCallStackTreeContent + } + + struct AppleCallStackTreeContent: Codable { + let callStackPerThread: Bool + let callStacks: [AppleCallStack] + } + + struct AppleCallStack: Codable { + let threadAttributed: Bool? + let callStackRootFrames: [AppleStackFrame] + } + + struct AppleStackFrame: Codable { + let binaryName: String + let binaryUUID: String + let offsetIntoBinaryTextSegment: Int + let address: Int? + let sampleCount: Int? + let subFrames: [AppleStackFrame]? + } + + // Define structures for our simplified format + struct SimplifiedCallStackTree: Codable { + let callStackPerThread: Bool + let callStacks: [SimplifiedCallStack] + } + + struct SimplifiedCallStack: Codable { + let threadAttributed: Bool? + let callStackFrames: [SimplifiedStackFrame] + } + + struct SimplifiedStackFrame: Codable { + let binaryName: String + let binaryUUID: String + let offsetAddress: Int + } + + // Helper to recursively flatten a frame and its subframes + func flattenFrames(_ frame: AppleStackFrame) -> [SimplifiedStackFrame] { + var result: [SimplifiedStackFrame] = [] + + // Add the current frame + result.append(SimplifiedStackFrame( + binaryName: frame.binaryName, + binaryUUID: frame.binaryUUID, + offsetAddress: frame.offsetIntoBinaryTextSegment + )) + + // Recursively add subframes + if let subFrames = frame.subFrames { + for subFrame in subFrames { + result.append(contentsOf: flattenFrames(subFrame)) + } + } + + return result + } + + do { + // Decode Apple's format + let decoder = JSONDecoder() + let appleTree = try decoder.decode(AppleCallStackTree.self, from: appleJsonData) + + // Transform to simplified format + let simplifiedCallStacks = appleTree.callStackTree.callStacks.map { appleStack in + // Flatten all root frames and their subframes + let allFrames = appleStack.callStackRootFrames.flatMap { flattenFrames($0) } + + return SimplifiedCallStack( + threadAttributed: appleStack.threadAttributed, + callStackFrames: allFrames + ) + } + + let simplifiedTree = SimplifiedCallStackTree( + callStackPerThread: appleTree.callStackTree.callStackPerThread, + callStacks: simplifiedCallStacks + ) + + // Encode to JSON + let encoder = JSONEncoder() + return try encoder.encode(simplifiedTree) + } catch { + // If transformation fails, return nil and the caller will use the original format + return nil + } + } +#endif diff --git a/Tests/InstrumentationTests/MetricKitTests/MetricKitInstrumentationTests.swift b/Tests/InstrumentationTests/MetricKitTests/MetricKitInstrumentationTests.swift new file mode 100644 index 00000000..6ab19599 --- /dev/null +++ b/Tests/InstrumentationTests/MetricKitTests/MetricKitInstrumentationTests.swift @@ -0,0 +1,595 @@ +/* + * Copyright The OpenTelemetry Authors + * SPDX-License-Identifier: Apache-2.0 + */ + +#if canImport(MetricKit) && !os(tvOS) && !os(macOS) +import Foundation +import MetricKit +@testable import MetricKitInstrumentation +@testable import OpenTelemetryApi +@testable import OpenTelemetrySdk +import InMemoryExporter +import XCTest + +@available(iOS 13.0, macOS 10.15, macCatalyst 13.1, visionOS 1.0, *) +class MetricKitInstrumentationTests: XCTestCase { + var spanExporter: InMemoryExporter! + var tracerProvider: TracerProviderSdk! + + override func setUp() { + super.setUp() + + // Set up tracer provider with in-memory exporter + spanExporter = InMemoryExporter() + tracerProvider = TracerProviderSdk() + tracerProvider.addSpanProcessor(SimpleSpanProcessor(spanExporter: spanExporter)) + + // Register the tracer provider + OpenTelemetry.registerTracerProvider(tracerProvider: tracerProvider) + } + + override func tearDown() { + spanExporter.reset() + super.tearDown() + } + + func testReportMetrics_CreatesMainSpan() { + let payload = FakeMetricPayload() + + reportMetrics(payload: payload) + + // Force flush to ensure spans are exported + tracerProvider.forceFlush() + + let spans = spanExporter.getFinishedSpanItems() + + // Should have the main MXMetricPayload span plus 2 signpost spans + XCTAssertGreaterThanOrEqual(spans.count, 1, "Should have at least one span") + + let mainSpan = spans.first { $0.name == "MXMetricPayload" } + XCTAssertNotNil(mainSpan, "Should have a MXMetricPayload span") + + // Verify timestamps + XCTAssertEqual(mainSpan?.startTime, payload.timeStampBegin) + XCTAssertEqual(mainSpan?.endTime, payload.timeStampEnd) + } + + func testReportMetrics_SetsMetadataAttributes() { + let payload = FakeMetricPayload() + + reportMetrics(payload: payload) + + // Force flush to ensure spans are exported + tracerProvider.forceFlush() + + let spans = spanExporter.getFinishedSpanItems() + let mainSpan = spans.first { $0.name == "MXMetricPayload" } + + XCTAssertNotNil(mainSpan) + let attributes = mainSpan?.attributes + + // Check top-level attributes + XCTAssertEqual(attributes?["metrickit.latest_application_version"]?.description, "3.14.159") + XCTAssertEqual(attributes?["metrickit.includes_multiple_application_versions"]?.description, "false") + + // Check metadata attributes + XCTAssertEqual(attributes?["metrickit.metadata.app_build_version"]?.description, "build") + XCTAssertEqual(attributes?["metrickit.metadata.device_type"]?.description, "device") + XCTAssertEqual(attributes?["metrickit.metadata.os_version"]?.description, "os") + XCTAssertEqual(attributes?["metrickit.metadata.region_format"]?.description, "format") + + if #available(iOS 14.0, *) { + XCTAssertEqual(attributes?["metrickit.metadata.platform_arch"]?.description, "arch") + } + + if #available(iOS 17.0, *) { + XCTAssertEqual(attributes?["metrickit.metadata.is_test_flight_app"]?.description, "true") + XCTAssertEqual(attributes?["metrickit.metadata.low_power_mode_enabled"]?.description, "true") + XCTAssertEqual(attributes?["metrickit.metadata.pid"]?.description, "29") + } + } + + func testReportMetrics_SetsCPUAttributes() { + let payload = FakeMetricPayload() + + reportMetrics(payload: payload) + + // Force flush to ensure spans are exported + tracerProvider.forceFlush() + + let spans = spanExporter.getFinishedSpanItems() + let mainSpan = spans.first { $0.name == "MXMetricPayload" } + + XCTAssertNotNil(mainSpan) + let attributes = mainSpan?.attributes + + // CPU metrics + XCTAssertEqual(attributes?["metrickit.cpu.cpu_time"]?.description, "1.0") + + if #available(iOS 14.0, *) { + XCTAssertEqual(attributes?["metrickit.cpu.instruction_count"]?.description, "2.0") + } + } + + func testReportMetrics_SetsMemoryAndGPUAttributes() { + let payload = FakeMetricPayload() + + reportMetrics(payload: payload) + + // Force flush to ensure spans are exported + tracerProvider.forceFlush() + + let spans = spanExporter.getFinishedSpanItems() + let mainSpan = spans.first { $0.name == "MXMetricPayload" } + + XCTAssertNotNil(mainSpan) + let attributes = mainSpan?.attributes + + // GPU metrics + XCTAssertEqual(attributes?["metrickit.gpu.time"]?.description, "10800.0") + + // Memory metrics + XCTAssertEqual(attributes?["metrickit.memory.peak_memory_usage"]?.description, "25.0") + XCTAssertEqual(attributes?["metrickit.memory.suspended_memory_average"]?.description, "26.0") + } + + func testReportMetrics_SetsNetworkAttributes() { + let payload = FakeMetricPayload() + + reportMetrics(payload: payload) + + // Force flush to ensure spans are exported + tracerProvider.forceFlush() + + let spans = spanExporter.getFinishedSpanItems() + let mainSpan = spans.first { $0.name == "MXMetricPayload" } + + XCTAssertNotNil(mainSpan) + let attributes = mainSpan?.attributes + + // Network transfer metrics + XCTAssertEqual(attributes?["metrickit.network_transfer.wifi_upload"]?.description, "15.0") + XCTAssertEqual(attributes?["metrickit.network_transfer.wifi_download"]?.description, "16000.0") + XCTAssertEqual(attributes?["metrickit.network_transfer.cellular_upload"]?.description, "17000000.0") + XCTAssertEqual(attributes?["metrickit.network_transfer.cellular_download"]?.description, "18000000000.0") + } + + @available(iOS 13.0, macOS 12.0, macCatalyst 13.1, visionOS 1.0, *) + func testReportMetrics_SetsAppLaunchAttributes() { + let payload = FakeMetricPayload() + + reportMetrics(payload: payload) + + // Force flush to ensure spans are exported + tracerProvider.forceFlush() + + let spans = spanExporter.getFinishedSpanItems() + let mainSpan = spans.first { $0.name == "MXMetricPayload" } + + XCTAssertNotNil(mainSpan) + let attributes = mainSpan?.attributes + + // App launch metrics (histograms) + XCTAssertEqual(attributes?["metrickit.app_launch.time_to_first_draw_average"]?.description, "1140.0") + XCTAssertEqual(attributes?["metrickit.app_launch.app_resume_time_average"]?.description, "1200.0") + + if #available(iOS 15.2, *) { + XCTAssertEqual(attributes?["metrickit.app_launch.optimized_time_to_first_draw_average"]?.description, "1260.0") + } + + if #available(iOS 16.0, *) { + XCTAssertEqual(attributes?["metrickit.app_launch.extended_launch_average"]?.description, "1320.0") + } + } + + func testReportMetrics_SetsAppTimeAttributes() { + let payload = FakeMetricPayload() + + reportMetrics(payload: payload) + + // Force flush to ensure spans are exported + tracerProvider.forceFlush() + + let spans = spanExporter.getFinishedSpanItems() + let mainSpan = spans.first { $0.name == "MXMetricPayload" } + + XCTAssertNotNil(mainSpan) + let attributes = mainSpan?.attributes + + // App time metrics + XCTAssertEqual(attributes?["metrickit.app_time.foreground_time"]?.description, "300.0") + XCTAssertEqual(attributes?["metrickit.app_time.background_time"]?.description, "6e-06") + XCTAssertEqual(attributes?["metrickit.app_time.background_audio_time"]?.description, "0.007") + XCTAssertEqual(attributes?["metrickit.app_time.background_location_time"]?.description, "480.0") + } + + func testReportMetrics_SetsLocationActivityAttributes() { + let payload = FakeMetricPayload() + + reportMetrics(payload: payload) + + // Force flush to ensure spans are exported + tracerProvider.forceFlush() + + let spans = spanExporter.getFinishedSpanItems() + let mainSpan = spans.first { $0.name == "MXMetricPayload" } + + XCTAssertNotNil(mainSpan) + let attributes = mainSpan?.attributes + + // Location activity metrics + XCTAssertEqual(attributes?["metrickit.location_activity.best_accuracy_time"]?.description, "9.0") + XCTAssertEqual(attributes?["metrickit.location_activity.best_accuracy_for_nav_time"]?.description, "10.0") + XCTAssertEqual(attributes?["metrickit.location_activity.accuracy_10m_time"]?.description, "11.0") + XCTAssertEqual(attributes?["metrickit.location_activity.accuracy_100m_time"]?.description, "12.0") + XCTAssertEqual(attributes?["metrickit.location_activity.accuracy_1km_time"]?.description, "13.0") + XCTAssertEqual(attributes?["metrickit.location_activity.accuracy_3km_time"]?.description, "14.0") + } + + @available(iOS 13.0, macOS 12.0, macCatalyst 13.1, visionOS 1.0, *) + func testReportMetrics_SetsResponsivenessAttributes() { + let payload = FakeMetricPayload() + + reportMetrics(payload: payload) + + // Force flush to ensure spans are exported + tracerProvider.forceFlush() + + let spans = spanExporter.getFinishedSpanItems() + let mainSpan = spans.first { $0.name == "MXMetricPayload" } + + XCTAssertNotNil(mainSpan) + let attributes = mainSpan?.attributes + + // App responsiveness metrics + XCTAssertEqual(attributes?["metrickit.app_responsiveness.hang_time_average"]?.description, "82800.0") + } + + func testReportMetrics_SetsDiskIOAttributes() { + let payload = FakeMetricPayload() + + reportMetrics(payload: payload) + + // Force flush to ensure spans are exported + tracerProvider.forceFlush() + + let spans = spanExporter.getFinishedSpanItems() + let mainSpan = spans.first { $0.name == "MXMetricPayload" } + + XCTAssertNotNil(mainSpan) + let attributes = mainSpan?.attributes + + // Disk I/O metrics + XCTAssertEqual(attributes?["metrickit.diskio.logical_write_count"]?.description, "24000000000000.0") + } + + func testReportMetrics_SetsDisplayAttributes() { + let payload = FakeMetricPayload() + + reportMetrics(payload: payload) + + // Force flush to ensure spans are exported + tracerProvider.forceFlush() + + let spans = spanExporter.getFinishedSpanItems() + let mainSpan = spans.first { $0.name == "MXMetricPayload" } + + XCTAssertNotNil(mainSpan) + let attributes = mainSpan?.attributes + + // Display metrics + XCTAssertEqual(attributes?["metrickit.display.pixel_luminance_average"]?.description, "27.0") + } + + @available(iOS 14.0, *) + func testReportMetrics_SetsAnimationAttributes() { + let payload = FakeMetricPayload() + + reportMetrics(payload: payload) + + // Force flush to ensure spans are exported + tracerProvider.forceFlush() + + let spans = spanExporter.getFinishedSpanItems() + let mainSpan = spans.first { $0.name == "MXMetricPayload" } + + XCTAssertNotNil(mainSpan) + let attributes = mainSpan?.attributes + + // Animation metrics + XCTAssertEqual(attributes?["metrickit.animation.scroll_hitch_time_ratio"]?.description, "28.0") + } + + @available(iOS 14.0, *) + func testReportMetrics_SetsAppExitAttributes() { + let payload = FakeMetricPayload() + + reportMetrics(payload: payload) + + // Force flush to ensure spans are exported + tracerProvider.forceFlush() + + let spans = spanExporter.getFinishedSpanItems() + let mainSpan = spans.first { $0.name == "MXMetricPayload" } + + XCTAssertNotNil(mainSpan) + let attributes = mainSpan?.attributes + + // Foreground exit metrics + XCTAssertEqual(attributes?["metrickit.app_exit.foreground.normal_app_exit_count"]?.description, "30") + XCTAssertEqual(attributes?["metrickit.app_exit.foreground.memory_resource_limit_exit-count"]?.description, "31") + XCTAssertEqual(attributes?["metrickit.app_exit.foreground.bad_access_exit_count"]?.description, "32") + XCTAssertEqual(attributes?["metrickit.app_exit.foreground.abnormal_exit_count"]?.description, "33") + XCTAssertEqual(attributes?["metrickit.app_exit.foreground.illegal_instruction_exit_count"]?.description, "34") + XCTAssertEqual(attributes?["metrickit.app_exit.foreground.app_watchdog_exit_count"]?.description, "35") + + // Background exit metrics + XCTAssertEqual(attributes?["metrickit.app_exit.background.normal_app_exit_count"]?.description, "36") + XCTAssertEqual(attributes?["metrickit.app_exit.background.memory_resource_limit_exit_count"]?.description, "37") + XCTAssertEqual(attributes?["metrickit.app_exit.background.cpu_resource_limit_exit_count"]?.description, "38") + XCTAssertEqual(attributes?["metrickit.app_exit.background.memory_pressure_exit_count"]?.description, "39") + XCTAssertEqual(attributes?["metrickit.app_exit.background.bad_access-exit_count"]?.description, "40") + XCTAssertEqual(attributes?["metrickit.app_exit.background.abnormal_exit_count"]?.description, "41") + XCTAssertEqual(attributes?["metrickit.app_exit.background.illegal_instruction_exit_count"]?.description, "42") + XCTAssertEqual(attributes?["metrickit.app_exit.background.app_watchdog_exit_count"]?.description, "43") + XCTAssertEqual(attributes?["metrickit.app_exit.background.suspended_with_locked_file_exit_count"]?.description, "44") + XCTAssertEqual(attributes?["metrickit.app_exit.background.background_task_assertion_timeout_exit_count"]?.description, "45") + } + + func testReportMetrics_CreatesSignpostSpans() { + let payload = FakeMetricPayload() + + reportMetrics(payload: payload) + + // Force flush to ensure spans are exported + tracerProvider.forceFlush() + + let spans = spanExporter.getFinishedSpanItems() + + // Find signpost spans + let signpostSpans = spans.filter { $0.name == "MXSignpostMetric" } + XCTAssertEqual(signpostSpans.count, 2, "Should have 2 signpost spans") + + // Verify first signpost + let signpost1 = signpostSpans.first { + $0.attributes["signpost.name"]?.description == "signpost1" + } + XCTAssertNotNil(signpost1) + XCTAssertEqual(signpost1?.attributes["signpost.category"]?.description, "cat1") + XCTAssertEqual(signpost1?.attributes["signpost.count"]?.description, "51") + XCTAssertEqual(signpost1?.attributes["signpost.cpu_time"]?.description, "47.0") + XCTAssertEqual(signpost1?.attributes["signpost.memory_average"]?.description, "48.0") + XCTAssertEqual(signpost1?.attributes["signpost.logical_write_count"]?.description, "49.0") + + if #available(iOS 15.0, *) { + XCTAssertEqual(signpost1?.attributes["signpost.hitch_time_ratio"]?.description, "50.0") + } + + // Verify second signpost + let signpost2 = signpostSpans.first { + $0.attributes["signpost.name"]?.description == "signpost2" + } + XCTAssertNotNil(signpost2) + XCTAssertEqual(signpost2?.attributes["signpost.category"]?.description, "cat2") + XCTAssertEqual(signpost2?.attributes["signpost.count"]?.description, "52") + } + + @available(iOS 13.0, macOS 12.0, macCatalyst 13.1, visionOS 1.0, *) + func testReportMetrics_SetsCellularConditionAttributes() { + let payload = FakeMetricPayload() + + reportMetrics(payload: payload) + + // Force flush to ensure spans are exported + tracerProvider.forceFlush() + + let spans = spanExporter.getFinishedSpanItems() + let mainSpan = spans.first { $0.name == "MXMetricPayload" } + + XCTAssertNotNil(mainSpan) + let attributes = mainSpan?.attributes + + // Cellular condition metrics (histogram average) + XCTAssertEqual(attributes?["metrickit.cellular_condition.bars_average"]?.description, "4.0") + // Note: The attribute is set twice in the code (lines 146 and 270), the second one wins + XCTAssertEqual(attributes?["metrickit.cellular_condition.cellular_condition_time_average"]?.description, "4.0") + } +} + +// MARK: - Diagnostic Tests + +@available(iOS 14.0, macOS 12.0, macCatalyst 14.0, visionOS 1.0, *) +class MetricKitDiagnosticTests: XCTestCase { + var logExporter: InMemoryLogRecordExporter! + var loggerProvider: LoggerProviderSdk! + var spanExporter: InMemoryExporter! + var tracerProvider: TracerProviderSdk! + + override func setUp() { + super.setUp() + + // Set up logger provider with in-memory exporter + logExporter = InMemoryLogRecordExporter() + loggerProvider = LoggerProviderBuilder() + .with(processors: [SimpleLogRecordProcessor(logRecordExporter: logExporter)]) + .build() + + // Set up tracer provider with in-memory exporter + spanExporter = InMemoryExporter() + tracerProvider = TracerProviderSdk() + tracerProvider.addSpanProcessor(SimpleSpanProcessor(spanExporter: spanExporter)) + + // Register providers + OpenTelemetry.registerLoggerProvider(loggerProvider: loggerProvider) + OpenTelemetry.registerTracerProvider(tracerProvider: tracerProvider) + } + + override func tearDown() { + logExporter.shutdown() + spanExporter.reset() + super.tearDown() + } + + func testReportDiagnostics_CreatesMainSpan() { + let payload = FakeDiagnosticPayload() + + reportDiagnostics(payload: payload) + + // Force flush to ensure spans are exported + tracerProvider.forceFlush() + + let spans = spanExporter.getFinishedSpanItems() + let mainSpan = spans.first { $0.name == "MXDiagnosticPayload" } + + XCTAssertNotNil(mainSpan, "Should have a MXDiagnosticPayload span") + XCTAssertEqual(mainSpan?.startTime, payload.timeStampBegin) + } + + func testReportDiagnostics_CreatesCPUExceptionLogs() { + let payload = FakeDiagnosticPayload() + + reportDiagnostics(payload: payload) + + let logs = logExporter.getFinishedLogRecords() + + // Find CPU exception log + let cpuLog = logs.first { + $0.attributes["name"]?.description == "metrickit.diagnostic.cpu_exception" + } + + XCTAssertNotNil(cpuLog, "Should have a CPU exception log") + XCTAssertEqual(cpuLog?.attributes["metrickit.diagnostic.cpu_exception.total_cpu_time"]?.description, "3180.0") // 53 minutes + XCTAssertEqual(cpuLog?.attributes["metrickit.diagnostic.cpu_exception.total_sampled_time"]?.description, "194400.0") // 54 hours + } + + func testReportDiagnostics_CreatesDiskWriteExceptionLogs() { + let payload = FakeDiagnosticPayload() + + reportDiagnostics(payload: payload) + + let logs = logExporter.getFinishedLogRecords() + + // Find disk write exception log + let diskLog = logs.first { + $0.attributes["name"]?.description == "metrickit.diagnostic.disk_write_exception" + } + + XCTAssertNotNil(diskLog, "Should have a disk write exception log") + XCTAssertEqual(diskLog?.attributes["metrickit.diagnostic.disk_write_exception.total_writes_caused"]?.description, "55000000.0") // 55 megabytes + } + + func testReportDiagnostics_CreatesHangDiagnosticLogs() { + let payload = FakeDiagnosticPayload() + + reportDiagnostics(payload: payload) + + let logs = logExporter.getFinishedLogRecords() + + // Find hang diagnostic log + let hangLog = logs.first { + $0.attributes["name"]?.description == "metrickit.diagnostic.hang" + } + + XCTAssertNotNil(hangLog, "Should have a hang diagnostic log") + XCTAssertEqual(hangLog?.attributes["metrickit.diagnostic.hang.hang_duration"]?.description, "56.0") // 56 seconds + + // Verify standard OTel exception attribute (without namespace prefix) + XCTAssertNotNil(hangLog?.attributes["exception.stacktrace"]) + } + + func testReportDiagnostics_CreatesCrashDiagnosticLogs() { + let payload = FakeDiagnosticPayload() + + reportDiagnostics(payload: payload) + + let logs = logExporter.getFinishedLogRecords() + + // Find crash diagnostic log + let crashLog = logs.first { + $0.attributes["name"]?.description == "metrickit.diagnostic.crash" + } + + XCTAssertNotNil(crashLog, "Should have a crash diagnostic log") + + // Verify exception attributes + XCTAssertEqual(crashLog?.attributes["metrickit.diagnostic.crash.exception.code"]?.description, "58") + XCTAssertEqual(crashLog?.attributes["metrickit.diagnostic.crash.exception.mach_exception.type"]?.description, "57") + XCTAssertEqual(crashLog?.attributes["metrickit.diagnostic.crash.exception.mach_exception.name"]?.description, "Unknown exception type: 57") + XCTAssertEqual(crashLog?.attributes["metrickit.diagnostic.crash.exception.signal"]?.description, "59") + XCTAssertEqual(crashLog?.attributes["metrickit.diagnostic.crash.exception.signal.name"]?.description, "Unknown signal: 59") + XCTAssertEqual(crashLog?.attributes["metrickit.diagnostic.crash.exception.termination_reason"]?.description, "reason") + + // Verify standard OTel exception attributes (without namespace prefix) + XCTAssertNotNil(crashLog?.attributes["exception.stacktrace"]) + + // Verify Objective-C exception attributes (iOS 17+) + if #available(iOS 17.0, *) { + XCTAssertEqual(crashLog?.attributes["metrickit.diagnostic.crash.exception.objc.type"]?.description, "ExceptionType") + XCTAssertEqual(crashLog?.attributes["metrickit.diagnostic.crash.exception.objc.message"]?.description, "message: 1 2") + XCTAssertEqual(crashLog?.attributes["metrickit.diagnostic.crash.exception.objc.classname"]?.description, "MyClass") + XCTAssertEqual(crashLog?.attributes["metrickit.diagnostic.crash.exception.objc.name"]?.description, "MyCrash") + + // On iOS 17+, standard OTel attributes should use Objective-C exception info (highest priority) + XCTAssertEqual(crashLog?.attributes["exception.type"]?.description, "MyCrash") + XCTAssertEqual(crashLog?.attributes["exception.message"]?.description, "message: 1 2") + } else { + // On iOS 14-16, standard OTel attributes should use Mach exception info (preferred over signal) + XCTAssertEqual(crashLog?.attributes["exception.type"]?.description, "Unknown exception type: 57") + XCTAssertEqual(crashLog?.attributes["exception.message"]?.description, "Unknown exception type: 57") + } + } + + #if !os(macOS) + @available(iOS 16.0, *) + func testReportDiagnostics_CreatesAppLaunchDiagnosticLogs() { + let payload = FakeDiagnosticPayload() + + reportDiagnostics(payload: payload) + + let logs = logExporter.getFinishedLogRecords() + + // Find app launch diagnostic log + let launchLog = logs.first { + $0.attributes["name"]?.description == "metrickit.diagnostic.app_launch" + } + + XCTAssertNotNil(launchLog, "Should have an app launch diagnostic log") + XCTAssertEqual(launchLog?.attributes["metrickit.diagnostic.app_launch.launch_duration"]?.description, "60.0") + } + #endif + + func testReportDiagnostics_VerifyLogTimestamps() { + let payload = FakeDiagnosticPayload() + + reportDiagnostics(payload: payload) + + let logs = logExporter.getFinishedLogRecords() + + // All logs should have the payload's end timestamp + for log in logs { + XCTAssertEqual(log.timestamp, payload.timeStampEnd) + } + } + + func testReportDiagnostics_VerifyLogCount() { + let payload = FakeDiagnosticPayload() + + reportDiagnostics(payload: payload) + + let logs = logExporter.getFinishedLogRecords() + + // Should have 4 logs on macOS (cpu_exception, disk_write_exception, hang, crash) + // Should have 5 logs on iOS 16+ (!macOS) (cpu_exception, disk_write_exception, hang, crash, app_launch) + #if !os(macOS) + if #available(iOS 16.0, *) { + XCTAssertEqual(logs.count, 5, "Should have 5 diagnostic logs on iOS 16+") + } else { + XCTAssertEqual(logs.count, 4, "Should have 4 diagnostic logs on iOS 14-15") + } + #else + XCTAssertEqual(logs.count, 4, "Should have 4 diagnostic logs on macOS") + #endif + } +} +#endif diff --git a/Tests/InstrumentationTests/MetricKitTests/MetricKitTestHelpers.swift b/Tests/InstrumentationTests/MetricKitTests/MetricKitTestHelpers.swift new file mode 100644 index 00000000..9527016d --- /dev/null +++ b/Tests/InstrumentationTests/MetricKitTests/MetricKitTestHelpers.swift @@ -0,0 +1,530 @@ +#if canImport(MetricKit) && !os(tvOS) && !os(macOS) +import Foundation +import MetricKit + +// Most MetricKit classes are readonly, and don't have public constructors, so to make fake data, +// we have to subclass them. Unfortunately, Swift doesn't really have any metaprogramming +// capability, so there is a ton of boilerplate code. +// +// We have to create a separate fake histogram class for each type of histogram and average, +// because "Inheritance from a generic Objective-C class 'MXHistogramBucket' must bind type +// parameters of 'MXHistogramBucket' to specific concrete types." + +class FakeDurationHistogramBucket: MXHistogramBucket { + private let start: Measurement + private let end: Measurement + private let count: Int + + init( + start: Measurement, + end: Measurement, + count: Int + ) { + self.start = start + self.end = end + self.count = count + super.init() + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + override var bucketStart: Measurement { start } + override var bucketEnd: Measurement { end } + override var bucketCount: Int { count } +} + +class FakeDurationHistogram: MXHistogram { + private let average: Measurement + + /// Creates a Histogram with a single bucket that will have the correct average value. + init(average: Measurement) { + self.average = average + super.init() + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + override var totalBucketCount: Int { 1 } + + override var bucketEnumerator: NSEnumerator { + // Just make a bucket whose average will be correct. + let delta = average * 0.5 + let bucket = FakeDurationHistogramBucket( + start: average - delta, + end: average + delta, + count: 1 + ) + return NSArray(object: bucket).objectEnumerator() + } +} + +class FakeSignalBarsHistogramBucket: MXHistogramBucket { + private let start: Measurement + private let end: Measurement + private let count: Int + + init( + start: Measurement, + end: Measurement, + count: Int + ) { + self.start = start + self.end = end + self.count = count + super.init() + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + override var bucketStart: Measurement { start } + override var bucketEnd: Measurement { end } + override var bucketCount: Int { count } +} + +class FakeSignalBarsHistogram: MXHistogram { + private let average: Measurement + + /// Creates a Histogram with a single bucket that will have the correct average value. + init(average: Measurement) { + self.average = average + super.init() + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + override var totalBucketCount: Int { 1 } + + override var bucketEnumerator: NSEnumerator { + // Just make a bucket whose average will be correct. + let delta = average * 0.5 + let bucket = FakeSignalBarsHistogramBucket( + start: average - delta, + end: average + delta, + count: 1 + ) + return NSArray(object: bucket).objectEnumerator() + } +} + +class FakeInformationStorageAverage: MXAverage { + private let average: Measurement + + init(average: Measurement) { + self.average = average + super.init() + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + override var averageMeasurement: Measurement { average } + override var sampleCount: Int { 1 } + override var standardDeviation: Double { 1.0 } +} + +class FakeMetricPayload: MXMetricPayload { + private let now = Date() + + override init() { + super.init() + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + override var latestApplicationVersion: String { "3.14.159" } + + override var includesMultipleApplicationVersions: Bool { false } + + override var timeStampBegin: Date { + // MetricKit generally reports data from the previous day. + now.advanced(by: TimeInterval(-1 * 60 * 60 * 24)) + } + + override var timeStampEnd: Date { now } + + override var cpuMetrics: MXCPUMetric? { + class FakeCPUMetric: MXCPUMetric { + override var cumulativeCPUTime: Measurement { + Measurement(value: 1.0, unit: UnitDuration.seconds) + } + + @available(iOS 14.0, *) + override var cumulativeCPUInstructions: Measurement { + Measurement(value: 2.0, unit: Unit(symbol: "instructions")) + } + } + return FakeCPUMetric() + } + + override var gpuMetrics: MXGPUMetric? { + class FakeGPUMetric: MXGPUMetric { + override var cumulativeGPUTime: Measurement { + return Measurement(value: 3.0, unit: UnitDuration.hours) + } + } + return FakeGPUMetric() + } + + override var cellularConditionMetrics: MXCellularConditionMetric? { + class FakeCellularConditionMetric: MXCellularConditionMetric { + override var histogrammedCellularConditionTime: MXHistogram { + FakeSignalBarsHistogram( + average: Measurement(value: 4.0, unit: MXUnitSignalBars.bars) + ) + } + } + return FakeCellularConditionMetric() + } + + override var applicationTimeMetrics: MXAppRunTimeMetric? { + class FakeAppRunTimeMetric: MXAppRunTimeMetric { + override var cumulativeForegroundTime: Measurement { + Measurement(value: 5.0, unit: UnitDuration.minutes) + } + override var cumulativeBackgroundTime: Measurement { + Measurement(value: 6.0, unit: UnitDuration.microseconds) + } + override var cumulativeBackgroundAudioTime: Measurement { + Measurement(value: 7.0, unit: UnitDuration.milliseconds) + } + override var cumulativeBackgroundLocationTime: Measurement { + Measurement(value: 8.0, unit: UnitDuration.minutes) + } + } + return FakeAppRunTimeMetric() + } + + override var locationActivityMetrics: MXLocationActivityMetric? { + class FakeLocationActivityMetric: MXLocationActivityMetric { + override var cumulativeBestAccuracyTime: Measurement { + Measurement(value: 9.0, unit: UnitDuration.seconds) + } + override var cumulativeBestAccuracyForNavigationTime: Measurement { + Measurement(value: 10.0, unit: UnitDuration.seconds) + } + override var cumulativeNearestTenMetersAccuracyTime: Measurement { + Measurement(value: 11.0, unit: UnitDuration.seconds) + } + override var cumulativeHundredMetersAccuracyTime: Measurement { + Measurement(value: 12.0, unit: UnitDuration.seconds) + } + override var cumulativeKilometerAccuracyTime: Measurement { + Measurement(value: 13.0, unit: UnitDuration.seconds) + } + override var cumulativeThreeKilometersAccuracyTime: Measurement { + Measurement(value: 14.0, unit: UnitDuration.seconds) + } + } + return FakeLocationActivityMetric() + } + + override var networkTransferMetrics: MXNetworkTransferMetric? { + class FakeNetworkTransferMetric: MXNetworkTransferMetric { + override var cumulativeWifiUpload: Measurement { + Measurement(value: 15.0, unit: UnitInformationStorage.bytes) + } + override var cumulativeWifiDownload: Measurement { + Measurement(value: 16.0, unit: UnitInformationStorage.kilobytes) + } + override var cumulativeCellularUpload: Measurement { + Measurement(value: 17.0, unit: UnitInformationStorage.megabytes) + } + override var cumulativeCellularDownload: Measurement { + Measurement(value: 18.0, unit: UnitInformationStorage.gigabytes) + } + } + return FakeNetworkTransferMetric() + } + + override var applicationLaunchMetrics: MXAppLaunchMetric? { + class FakeAppLaunchMetric: MXAppLaunchMetric { + override var histogrammedTimeToFirstDraw: MXHistogram { + FakeDurationHistogram(average: Measurement(value: 19.0, unit: UnitDuration.minutes)) + } + override var histogrammedApplicationResumeTime: MXHistogram { + FakeDurationHistogram(average: Measurement(value: 20.0, unit: UnitDuration.minutes)) + } + + @available(iOS 15.2, *) + override var histogrammedOptimizedTimeToFirstDraw: MXHistogram { + FakeDurationHistogram(average: Measurement(value: 21.0, unit: UnitDuration.minutes)) + } + + @available(iOS 16.0, *) + override var histogrammedExtendedLaunch: MXHistogram { + FakeDurationHistogram(average: Measurement(value: 22.0, unit: UnitDuration.minutes)) + } + } + return FakeAppLaunchMetric() + } + + override var applicationResponsivenessMetrics: MXAppResponsivenessMetric? { + class FakeAppResponsivenessMetric: MXAppResponsivenessMetric { + override var histogrammedApplicationHangTime: MXHistogram { + FakeDurationHistogram(average: Measurement(value: 23.0, unit: UnitDuration.hours)) + } + } + return FakeAppResponsivenessMetric() + } + + override var diskIOMetrics: MXDiskIOMetric? { + class FakeDiskIOMetric: MXDiskIOMetric { + override var cumulativeLogicalWrites: Measurement { + Measurement(value: 24.0, unit: UnitInformationStorage.terabytes) + } + } + return FakeDiskIOMetric() + } + + override var memoryMetrics: MXMemoryMetric? { + class FakeMemoryMetric: MXMemoryMetric { + override var peakMemoryUsage: Measurement { + Measurement(value: 25.0, unit: UnitInformationStorage.bytes) + } + override var averageSuspendedMemory: MXAverage { + FakeInformationStorageAverage( + average: Measurement(value: 26.0, unit: UnitInformationStorage.bytes) + ) + } + } + return FakeMemoryMetric() + } + + override var displayMetrics: MXDisplayMetric? { + class FakePixelLuminanceAverage: MXAverage { + override var averageMeasurement: Measurement { + Measurement(value: 27.0, unit: MXUnitAveragePixelLuminance.apl) + } + override var sampleCount: Int { 1 } + override var standardDeviation: Double { 1.0 } + } + class FakeDisplayMetric: MXDisplayMetric { + override var averagePixelLuminance: MXAverage? { + FakePixelLuminanceAverage() + } + } + return FakeDisplayMetric() + } + + @available(iOS 14.0, *) + override var animationMetrics: MXAnimationMetric? { + class FakeAnimationMetric: MXAnimationMetric { + override var scrollHitchTimeRatio: Measurement { + Measurement(value: 28.0, unit: Unit(symbol: "ratio")) + } + } + return FakeAnimationMetric() + } + + override var metaData: MXMetaData? { + class FakeMetaData: MXMetaData { + override var regionFormat: String { "format" } + override var osVersion: String { "os" } + override var deviceType: String { "device" } + override var applicationBuildVersion: String { "build" } + + @available(iOS 14.0, *) + override var platformArchitecture: String { "arch" } + + @available(iOS 17.0, *) + override var lowPowerModeEnabled: Bool { true } + + @available(iOS 17.0, *) + override var isTestFlightApp: Bool { true } + + @available(iOS 17.0, *) + override var pid: pid_t { 29 } + } + return FakeMetaData() + } + + @available(iOS 14.0, *) + override var applicationExitMetrics: MXAppExitMetric? { + class FakeAppExitMetric: MXAppExitMetric { + override var foregroundExitData: MXForegroundExitData { + class FakeForegroundExitData: MXForegroundExitData { + override var cumulativeNormalAppExitCount: Int { 30 } + override var cumulativeMemoryResourceLimitExitCount: Int { 31 } + override var cumulativeBadAccessExitCount: Int { 32 } + override var cumulativeAbnormalExitCount: Int { 33 } + override var cumulativeIllegalInstructionExitCount: Int { 34 } + override var cumulativeAppWatchdogExitCount: Int { 35 } + } + return FakeForegroundExitData() + } + + override var backgroundExitData: MXBackgroundExitData { + class FakeBackgroundExitData: MXBackgroundExitData { + override var cumulativeNormalAppExitCount: Int { 36 } + override var cumulativeMemoryResourceLimitExitCount: Int { 37 } + override var cumulativeCPUResourceLimitExitCount: Int { 38 } + override var cumulativeMemoryPressureExitCount: Int { 39 } + override var cumulativeBadAccessExitCount: Int { 40 } + override var cumulativeAbnormalExitCount: Int { 41 } + override var cumulativeIllegalInstructionExitCount: Int { 42 } + override var cumulativeAppWatchdogExitCount: Int { 43 } + override var cumulativeSuspendedWithLockedFileExitCount: Int { 44 } + override var cumulativeBackgroundTaskAssertionTimeoutExitCount: Int { 45 } + } + return FakeBackgroundExitData() + } + } + return FakeAppExitMetric() + } + + override var signpostMetrics: [MXSignpostMetric]? { + class FakeSignpostIntervalData: MXSignpostIntervalData { + override var histogrammedSignpostDuration: MXHistogram { + FakeDurationHistogram(average: Measurement(value: 46.0, unit: UnitDuration.seconds)) + } + override var cumulativeCPUTime: Measurement? { + Measurement(value: 47.0, unit: UnitDuration.seconds) + } + override var averageMemory: MXAverage? { + FakeInformationStorageAverage( + average: Measurement(value: 48.0, unit: UnitInformationStorage.bytes) + ) + } + override var cumulativeLogicalWrites: Measurement? { + Measurement(value: 49.0, unit: UnitInformationStorage.bytes) + } + + @available(iOS 15.0, *) + override var cumulativeHitchTimeRatio: Measurement? { + Measurement(value: 50.0, unit: Unit(symbol: "ratio")) + } + } + + class FakeSignpostMetric: MXSignpostMetric { + private let name: String + private let category: String + private let count: Int + + init(name: String, category: String, count: Int) { + self.name = name + self.category = category + self.count = count + super.init() + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + override var signpostName: String { name } + override var signpostCategory: String { category } + override var signpostIntervalData: MXSignpostIntervalData? { + FakeSignpostIntervalData() + } + override var totalCount: Int { count } + } + return [ + FakeSignpostMetric(name: "signpost1", category: "cat1", count: 51), + FakeSignpostMetric(name: "signpost2", category: "cat2", count: 52), + ] + } +} + +@available(iOS 14.0, *) +class FakeCallStackTree: MXCallStackTree { + override func jsonRepresentation() -> Data { + return Data("fake json stacktrace".utf8) + } +} + +@available(iOS 14.0, *) +class FakeDiagnosticPayload: MXDiagnosticPayload { + private let now = Date() + + override var timeStampBegin: Date { + // MetricKit generally reports data from the previous day. + now.advanced(by: TimeInterval(-1 * 60 * 60 * 24)) + } + + override var timeStampEnd: Date { now } + + override var cpuExceptionDiagnostics: [MXCPUExceptionDiagnostic]? { + class FakeCPUExceptionDiagnostic: MXCPUExceptionDiagnostic { + override var callStackTree: MXCallStackTree { FakeCallStackTree() } + override var totalCPUTime: Measurement { + Measurement(value: 53.0, unit: UnitDuration.minutes) + } + override var totalSampledTime: Measurement { + Measurement(value: 54.0, unit: UnitDuration.hours) + } + } + return [FakeCPUExceptionDiagnostic()] + } + + override var diskWriteExceptionDiagnostics: [MXDiskWriteExceptionDiagnostic]? { + class FakeDiskWriteExceptionDiagnostic: MXDiskWriteExceptionDiagnostic { + override var callStackTree: MXCallStackTree { FakeCallStackTree() } + override var totalWritesCaused: Measurement { + Measurement(value: 55.0, unit: UnitInformationStorage.megabytes) + } + } + return [FakeDiskWriteExceptionDiagnostic()] + } + + override var hangDiagnostics: [MXHangDiagnostic]? { + class FakeHangDiagnostic: MXHangDiagnostic { + override var callStackTree: MXCallStackTree { FakeCallStackTree() } + override var hangDuration: Measurement { + Measurement(value: 56.0, unit: UnitDuration.seconds) + } + } + return [FakeHangDiagnostic()] + } + + override var crashDiagnostics: [MXCrashDiagnostic]? { + class FakeCrashDiagnostic: MXCrashDiagnostic { + override var callStackTree: MXCallStackTree { FakeCallStackTree() } + override var terminationReason: String? { "reason" } + override var virtualMemoryRegionInfo: String? { nil } + override var exceptionType: NSNumber? { NSNumber(integerLiteral: 57) } + override var exceptionCode: NSNumber? { NSNumber(integerLiteral: 58) } + override var signal: NSNumber? { NSNumber(integerLiteral: 59) } + + @available(iOS 17.0, *) + override var exceptionReason: MXCrashDiagnosticObjectiveCExceptionReason? { + class FakeCrashDiagnosticObjectiveCExceptionReason: + MXCrashDiagnosticObjectiveCExceptionReason + { + override var composedMessage: String { "message: 1 2" } + override var formatString: String { "message: %d %d" } + override var arguments: [String] { ["1", "2"] } + override var exceptionType: String { "ExceptionType" } + override var className: String { "MyClass" } + override var exceptionName: String { "MyCrash" } + } + return FakeCrashDiagnosticObjectiveCExceptionReason() + } + } + return [FakeCrashDiagnostic()] + } + + #if !os(macOS) + @available(iOS 16.0, *) + override var appLaunchDiagnostics: [MXAppLaunchDiagnostic]? { + class FakeAppLaunchDiagnostic: MXAppLaunchDiagnostic { + override var callStackTree: MXCallStackTree { FakeCallStackTree() } + override var launchDuration: Measurement { + Measurement(value: 60.0, unit: UnitDuration.seconds) + } + } + return [FakeAppLaunchDiagnostic()] + } + #endif +} +#endif diff --git a/Tests/InstrumentationTests/MetricKitTests/StackTraceTransformTests.swift b/Tests/InstrumentationTests/MetricKitTests/StackTraceTransformTests.swift new file mode 100644 index 00000000..1a20d188 --- /dev/null +++ b/Tests/InstrumentationTests/MetricKitTests/StackTraceTransformTests.swift @@ -0,0 +1,359 @@ +/* + * Copyright The OpenTelemetry Authors + * SPDX-License-Identifier: Apache-2.0 + */ + +#if canImport(MetricKit) && !os(tvOS) && !os(macOS) +import Foundation +@testable import MetricKitInstrumentation +import XCTest + +@available(iOS 14.0, macOS 12.0, macCatalyst 14.0, visionOS 1.0, *) +class StackTraceTransformTests: XCTestCase { + + func testTransformStackTrace_FlattensSingleThreadWithSubFrames() throws { + // Create Apple's format with nested subframes + let appleFormat = """ + { + "callStackTree": { + "callStackPerThread": true, + "callStacks": [ + { + "threadAttributed": true, + "callStackRootFrames": [ + { + "binaryName": "MyApp", + "binaryUUID": "A1B2C3D4-5678-90AB-CDEF-1234567890AB", + "offsetIntoBinaryTextSegment": 1000, + "address": 12345678, + "sampleCount": 5, + "subFrames": [ + { + "binaryName": "Foundation", + "binaryUUID": "B2C3D4E5-6789-01BC-DEF0-234567890ABC", + "offsetIntoBinaryTextSegment": 2000, + "address": 23456789, + "sampleCount": 3, + "subFrames": [ + { + "binaryName": "libdyld.dylib", + "binaryUUID": "C3D4E5F6-7890-12CD-EF01-34567890ABCD", + "offsetIntoBinaryTextSegment": 3000, + "address": 34567890, + "sampleCount": 1 + } + ] + } + ] + } + ] + } + ] + } + } + """ + + let appleData = appleFormat.data(using: .utf8)! + + // Transform to simplified format + let transformedData = transformStackTrace(appleData) + XCTAssertNotNil(transformedData, "Transformation should succeed") + + // Parse transformed JSON + let json = try JSONSerialization.jsonObject(with: transformedData!, options: []) as! [String: Any] + + // Verify top-level structure + XCTAssertEqual(json["callStackPerThread"] as? Bool, true) + + let callStacks = json["callStacks"] as! [[String: Any]] + XCTAssertEqual(callStacks.count, 1) + + let callStack = callStacks[0] + XCTAssertEqual(callStack["threadAttributed"] as? Bool, true) + + // Verify frames are flattened in correct order (innermost to outermost) + let frames = callStack["callStackFrames"] as! [[String: Any]] + XCTAssertEqual(frames.count, 3, "Should have 3 flattened frames") + + // First frame (innermost) + XCTAssertEqual(frames[0]["binaryName"] as? String, "MyApp") + XCTAssertEqual(frames[0]["binaryUUID"] as? String, "A1B2C3D4-5678-90AB-CDEF-1234567890AB") + XCTAssertEqual(frames[0]["offsetAddress"] as? Int, 1000) + XCTAssertNil(frames[0]["address"], "address should be removed") + XCTAssertNil(frames[0]["sampleCount"], "sampleCount should be removed") + XCTAssertNil(frames[0]["subFrames"], "subFrames should be removed") + + // Second frame + XCTAssertEqual(frames[1]["binaryName"] as? String, "Foundation") + XCTAssertEqual(frames[1]["binaryUUID"] as? String, "B2C3D4E5-6789-01BC-DEF0-234567890ABC") + XCTAssertEqual(frames[1]["offsetAddress"] as? Int, 2000) + + // Third frame (outermost) + XCTAssertEqual(frames[2]["binaryName"] as? String, "libdyld.dylib") + XCTAssertEqual(frames[2]["binaryUUID"] as? String, "C3D4E5F6-7890-12CD-EF01-34567890ABCD") + XCTAssertEqual(frames[2]["offsetAddress"] as? Int, 3000) + } + + func testTransformStackTrace_HandlesMultipleThreads() throws { + // Create Apple's format with multiple threads + let appleFormat = """ + { + "callStackTree": { + "callStackPerThread": false, + "callStacks": [ + { + "threadAttributed": false, + "callStackRootFrames": [ + { + "binaryName": "Thread1", + "binaryUUID": "11111111-1111-1111-1111-111111111111", + "offsetIntoBinaryTextSegment": 100 + } + ] + }, + { + "threadAttributed": true, + "callStackRootFrames": [ + { + "binaryName": "Thread2", + "binaryUUID": "22222222-2222-2222-2222-222222222222", + "offsetIntoBinaryTextSegment": 200, + "subFrames": [ + { + "binaryName": "Thread2Sub", + "binaryUUID": "33333333-3333-3333-3333-333333333333", + "offsetIntoBinaryTextSegment": 300 + } + ] + } + ] + } + ] + } + } + """ + + let appleData = appleFormat.data(using: .utf8)! + + // Transform to simplified format + let transformedData = transformStackTrace(appleData) + XCTAssertNotNil(transformedData, "Transformation should succeed") + + // Parse transformed JSON + let json = try JSONSerialization.jsonObject(with: transformedData!, options: []) as! [String: Any] + + // Verify top-level structure + XCTAssertEqual(json["callStackPerThread"] as? Bool, false) + + let callStacks = json["callStacks"] as! [[String: Any]] + XCTAssertEqual(callStacks.count, 2) + + // First thread + let thread1 = callStacks[0] + XCTAssertEqual(thread1["threadAttributed"] as? Bool, false) + let frames1 = thread1["callStackFrames"] as! [[String: Any]] + XCTAssertEqual(frames1.count, 1) + XCTAssertEqual(frames1[0]["binaryName"] as? String, "Thread1") + + // Second thread (the one that crashed) + let thread2 = callStacks[1] + XCTAssertEqual(thread2["threadAttributed"] as? Bool, true) + let frames2 = thread2["callStackFrames"] as! [[String: Any]] + XCTAssertEqual(frames2.count, 2, "Should have 2 frames from root + subframe") + XCTAssertEqual(frames2[0]["binaryName"] as? String, "Thread2") + XCTAssertEqual(frames2[1]["binaryName"] as? String, "Thread2Sub") + } + + func testTransformStackTrace_HandlesMultipleRootFrames() throws { + // Create Apple's format with multiple root frames in a single thread + let appleFormat = """ + { + "callStackTree": { + "callStackPerThread": true, + "callStacks": [ + { + "callStackRootFrames": [ + { + "binaryName": "Root1", + "binaryUUID": "11111111-1111-1111-1111-111111111111", + "offsetIntoBinaryTextSegment": 100, + "subFrames": [ + { + "binaryName": "Root1Sub", + "binaryUUID": "22222222-2222-2222-2222-222222222222", + "offsetIntoBinaryTextSegment": 200 + } + ] + }, + { + "binaryName": "Root2", + "binaryUUID": "33333333-3333-3333-3333-333333333333", + "offsetIntoBinaryTextSegment": 300 + } + ] + } + ] + } + } + """ + + let appleData = appleFormat.data(using: .utf8)! + + // Transform to simplified format + let transformedData = transformStackTrace(appleData) + XCTAssertNotNil(transformedData, "Transformation should succeed") + + // Parse transformed JSON + let json = try JSONSerialization.jsonObject(with: transformedData!, options: []) as! [String: Any] + + let callStacks = json["callStacks"] as! [[String: Any]] + XCTAssertEqual(callStacks.count, 1) + + // All frames from all root frames should be flattened + let frames = callStacks[0]["callStackFrames"] as! [[String: Any]] + XCTAssertEqual(frames.count, 3, "Should have 3 frames: Root1, Root1Sub, Root2") + XCTAssertEqual(frames[0]["binaryName"] as? String, "Root1") + XCTAssertEqual(frames[1]["binaryName"] as? String, "Root1Sub") + XCTAssertEqual(frames[2]["binaryName"] as? String, "Root2") + } + + func testTransformStackTrace_RemovesCallStackTreeWrapper() throws { + // Verify that the callStackTree wrapper is removed in the output + let appleFormat = """ + { + "callStackTree": { + "callStackPerThread": true, + "callStacks": [ + { + "callStackRootFrames": [ + { + "binaryName": "Test", + "binaryUUID": "12345678-1234-1234-1234-123456789012", + "offsetIntoBinaryTextSegment": 100 + } + ] + } + ] + } + } + """ + + let appleData = appleFormat.data(using: .utf8)! + let transformedData = transformStackTrace(appleData) + XCTAssertNotNil(transformedData) + + let json = try JSONSerialization.jsonObject(with: transformedData!, options: []) as! [String: Any] + + // Should NOT have callStackTree wrapper + XCTAssertNil(json["callStackTree"], "callStackTree wrapper should be removed") + + // Should have callStackPerThread and callStacks at root level + XCTAssertNotNil(json["callStackPerThread"]) + XCTAssertNotNil(json["callStacks"]) + } + + func testTransformStackTrace_HandlesInvalidJSON() { + // Test with invalid JSON + let invalidData = "not valid json".data(using: .utf8)! + + let result = transformStackTrace(invalidData) + + // Should return nil on failure + XCTAssertNil(result, "Should return nil for invalid JSON") + } + + func testTransformStackTrace_HandlesMissingOptionalFields() throws { + // Test with minimal valid data (no optional fields) + let minimalFormat = """ + { + "callStackTree": { + "callStackPerThread": true, + "callStacks": [ + { + "callStackRootFrames": [ + { + "binaryName": "Test", + "binaryUUID": "12345678-1234-1234-1234-123456789012", + "offsetIntoBinaryTextSegment": 100 + } + ] + } + ] + } + } + """ + + let appleData = minimalFormat.data(using: .utf8)! + let transformedData = transformStackTrace(appleData) + + XCTAssertNotNil(transformedData, "Should handle minimal valid data") + + let json = try JSONSerialization.jsonObject(with: transformedData!, options: []) as! [String: Any] + let callStacks = json["callStacks"] as! [[String: Any]] + + // threadAttributed is optional, should be present but might be nil + XCTAssertEqual(callStacks[0]["threadAttributed"] as? Bool, nil) + + let frames = callStacks[0]["callStackFrames"] as! [[String: Any]] + XCTAssertEqual(frames.count, 1) + XCTAssertEqual(frames[0]["binaryName"] as? String, "Test") + } + + func testTransformStackTrace_PreservesFrameOrder() throws { + // Test that frames are ordered correctly: innermost to outermost + let appleFormat = """ + { + "callStackTree": { + "callStackPerThread": true, + "callStacks": [ + { + "callStackRootFrames": [ + { + "binaryName": "Frame1_Innermost", + "binaryUUID": "11111111-1111-1111-1111-111111111111", + "offsetIntoBinaryTextSegment": 100, + "subFrames": [ + { + "binaryName": "Frame2_Middle", + "binaryUUID": "22222222-2222-2222-2222-222222222222", + "offsetIntoBinaryTextSegment": 200, + "subFrames": [ + { + "binaryName": "Frame3_Outer", + "binaryUUID": "33333333-3333-3333-3333-333333333333", + "offsetIntoBinaryTextSegment": 300, + "subFrames": [ + { + "binaryName": "Frame4_Outermost", + "binaryUUID": "44444444-4444-4444-4444-444444444444", + "offsetIntoBinaryTextSegment": 400 + } + ] + } + ] + } + ] + } + ] + } + ] + } + } + """ + + let appleData = appleFormat.data(using: .utf8)! + let transformedData = transformStackTrace(appleData) + XCTAssertNotNil(transformedData) + + let json = try JSONSerialization.jsonObject(with: transformedData!, options: []) as! [String: Any] + let callStacks = json["callStacks"] as! [[String: Any]] + let frames = callStacks[0]["callStackFrames"] as! [[String: Any]] + + XCTAssertEqual(frames.count, 4) + XCTAssertEqual(frames[0]["binaryName"] as? String, "Frame1_Innermost") + XCTAssertEqual(frames[1]["binaryName"] as? String, "Frame2_Middle") + XCTAssertEqual(frames[2]["binaryName"] as? String, "Frame3_Outer") + XCTAssertEqual(frames[3]["binaryName"] as? String, "Frame4_Outermost") + } +} +#endif