diff --git a/Sources/Instrumentation/MetricKit/MetricKitConfiguration.swift b/Sources/Instrumentation/MetricKit/MetricKitConfiguration.swift new file mode 100644 index 00000000..99ddb1d2 --- /dev/null +++ b/Sources/Instrumentation/MetricKit/MetricKitConfiguration.swift @@ -0,0 +1,34 @@ +/* + * Copyright The OpenTelemetry Authors + * SPDX-License-Identifier: Apache-2.0 + */ + +#if canImport(MetricKit) && !os(tvOS) && !os(macOS) + import Foundation + import MetricKit + import OpenTelemetryApi + + @available(iOS 13.0, macOS 12.0, macCatalyst 13.1, visionOS 1.0, *) + public struct MetricKitConfiguration { + public init( + useAppleStacktraceFormat: Bool = false, + tracer: Tracer? = nil + ) { + self.useAppleStacktraceFormat = useAppleStacktraceFormat + self.tracer = tracer ?? + OpenTelemetry.instance.tracerProvider.get( + instrumentationName: "MetricKit", + instrumentationVersion: "0.0.1" + ) + } + + /// The tracer to use for creating spans from MetricKit payloads. + public var tracer: Tracer + + /// When true, stacktraces from crash and hang diagnostics will be reported in Apple's + /// native MetricKit JSON format instead of being transformed to the simplified OpenTelemetry format. + /// + /// Default: false (stacktraces are transformed to OTel format) + public var useAppleStacktraceFormat: Bool + } +#endif diff --git a/Sources/Instrumentation/MetricKit/MetricKitInstrumentation.swift b/Sources/Instrumentation/MetricKit/MetricKitInstrumentation.swift index bf7465c2..9c56709f 100644 --- a/Sources/Instrumentation/MetricKit/MetricKitInstrumentation.swift +++ b/Sources/Instrumentation/MetricKit/MetricKitInstrumentation.swift @@ -8,29 +8,34 @@ @available(iOS 13.0, macOS 12.0, macCatalyst 13.1, visionOS 1.0, *) public class MetricKitInstrumentation: NSObject, MXMetricManagerSubscriber { + public let configuration: MetricKitConfiguration + + public override init() { + self.configuration = MetricKitConfiguration() + super.init() + } + + public init(configuration: MetricKitConfiguration) { + self.configuration = configuration + super.init() + } + public func didReceive(_ payloads: [MXMetricPayload]) { for payload in payloads { - reportMetrics(payload: payload) + reportMetrics(payload: payload, configuration: configuration) } } @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) + reportDiagnostics(payload: payload, configuration: configuration) } } } // 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< @@ -54,8 +59,8 @@ } @available(iOS 13.0, macOS 12.0, macCatalyst 13.1, visionOS 1.0, *) - public func reportMetrics(payload: MXMetricPayload) { - let span = getMetricKitTracer().spanBuilder(spanName: "MXMetricPayload") + public func reportMetrics(payload: MXMetricPayload, configuration: MetricKitConfiguration) { + let span = configuration.tracer.spanBuilder(spanName: "MXMetricPayload") .setStartTime(time: payload.timeStampBegin) .startSpan() defer { span.end(time: payload.timeStampEnd) } @@ -305,7 +310,7 @@ // 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") + let span = configuration.tracer.spanBuilder(spanName: "MXSignpostMetric") .startSpan() span.setAttribute(key: "signpost.name", value: signpostMetric.signpostName) span.setAttribute( @@ -347,8 +352,8 @@ } @available(iOS 14.0, macOS 12.0, macCatalyst 14.0, visionOS 1.0, *) - public func reportDiagnostics(payload: MXDiagnosticPayload) { - let span = getMetricKitTracer().spanBuilder(spanName: "MXDiagnosticPayload") + public func reportDiagnostics(payload: MXDiagnosticPayload, configuration: MetricKitConfiguration) { + let span = configuration.tracer.spanBuilder(spanName: "MXDiagnosticPayload") .setStartTime(time: payload.timeStampBegin) .startSpan() defer { span.end() } @@ -407,8 +412,12 @@ 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 stacktraceData: Data + if configuration.useAppleStacktraceFormat { + stacktraceData = appleJson + } else { + stacktraceData = transformStackTrace(appleJson) ?? appleJson + } let stacktraceJson = String(decoding: stacktraceData, as: UTF8.self) let namespacedAttrs: [String: AttributeValueConvertable] = [ @@ -472,8 +481,12 @@ 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 stacktraceData: Data + if configuration.useAppleStacktraceFormat { + stacktraceData = appleJson + } else { + stacktraceData = transformStackTrace(appleJson) ?? appleJson + } let stacktraceJson = String(decoding: stacktraceData, as: UTF8.self) // Standard OTel exception attribute (without namespace prefix) diff --git a/Sources/Instrumentation/MetricKit/README.md b/Sources/Instrumentation/MetricKit/README.md index 69852776..b3f7a070 100644 --- a/Sources/Instrumentation/MetricKit/README.md +++ b/Sources/Instrumentation/MetricKit/README.md @@ -30,6 +30,29 @@ if #available(iOS 13.0, *) { The instrumentation will automatically receive MetricKit payloads and convert them to OpenTelemetry spans and logs. +### Configuration + +You can optionally provide a `MetricKitConfiguration` to customize the instrumentation: + +```swift +if #available(iOS 13.0, *) { + let config = MetricKitConfiguration( + useAppleStacktraceFormat: false, + tracer: customTracer + ) + let metricKit = MetricKitInstrumentation(configuration: config) + MXMetricManager.shared.add(metricKit) + + // Store instrumentation somewhere to keep it alive, e.g.: + // AppDelegate.metricKitInstrumentation = metricKit +} +``` + +| Option | Default | Description | +|-----------------------------|--------------------------------|------------------------------------------------------| +| `tracer` | (a default tracer) | Custom tracer for creating spans | +| `useAppleStacktraceFormat` | `false` | Use Apple's native JSON format for stacktraces instead of OTel | + ## Data Structure Overview MetricKit reports data in two categories: **Metrics** and **Diagnostics**. diff --git a/Tests/InstrumentationTests/MetricKitTests/MetricKitInstrumentationTests.swift b/Tests/InstrumentationTests/MetricKitTests/MetricKitInstrumentationTests.swift index 6ab19599..774ba072 100644 --- a/Tests/InstrumentationTests/MetricKitTests/MetricKitInstrumentationTests.swift +++ b/Tests/InstrumentationTests/MetricKitTests/MetricKitInstrumentationTests.swift @@ -37,7 +37,7 @@ class MetricKitInstrumentationTests: XCTestCase { func testReportMetrics_CreatesMainSpan() { let payload = FakeMetricPayload() - reportMetrics(payload: payload) + reportMetrics(payload: payload, configuration: MetricKitConfiguration()) // Force flush to ensure spans are exported tracerProvider.forceFlush() @@ -58,7 +58,7 @@ class MetricKitInstrumentationTests: XCTestCase { func testReportMetrics_SetsMetadataAttributes() { let payload = FakeMetricPayload() - reportMetrics(payload: payload) + reportMetrics(payload: payload, configuration: MetricKitConfiguration()) // Force flush to ensure spans are exported tracerProvider.forceFlush() @@ -93,7 +93,7 @@ class MetricKitInstrumentationTests: XCTestCase { func testReportMetrics_SetsCPUAttributes() { let payload = FakeMetricPayload() - reportMetrics(payload: payload) + reportMetrics(payload: payload, configuration: MetricKitConfiguration()) // Force flush to ensure spans are exported tracerProvider.forceFlush() @@ -115,7 +115,7 @@ class MetricKitInstrumentationTests: XCTestCase { func testReportMetrics_SetsMemoryAndGPUAttributes() { let payload = FakeMetricPayload() - reportMetrics(payload: payload) + reportMetrics(payload: payload, configuration: MetricKitConfiguration()) // Force flush to ensure spans are exported tracerProvider.forceFlush() @@ -137,7 +137,7 @@ class MetricKitInstrumentationTests: XCTestCase { func testReportMetrics_SetsNetworkAttributes() { let payload = FakeMetricPayload() - reportMetrics(payload: payload) + reportMetrics(payload: payload, configuration: MetricKitConfiguration()) // Force flush to ensure spans are exported tracerProvider.forceFlush() @@ -159,7 +159,7 @@ class MetricKitInstrumentationTests: XCTestCase { func testReportMetrics_SetsAppLaunchAttributes() { let payload = FakeMetricPayload() - reportMetrics(payload: payload) + reportMetrics(payload: payload, configuration: MetricKitConfiguration()) // Force flush to ensure spans are exported tracerProvider.forceFlush() @@ -186,7 +186,7 @@ class MetricKitInstrumentationTests: XCTestCase { func testReportMetrics_SetsAppTimeAttributes() { let payload = FakeMetricPayload() - reportMetrics(payload: payload) + reportMetrics(payload: payload, configuration: MetricKitConfiguration()) // Force flush to ensure spans are exported tracerProvider.forceFlush() @@ -207,7 +207,7 @@ class MetricKitInstrumentationTests: XCTestCase { func testReportMetrics_SetsLocationActivityAttributes() { let payload = FakeMetricPayload() - reportMetrics(payload: payload) + reportMetrics(payload: payload, configuration: MetricKitConfiguration()) // Force flush to ensure spans are exported tracerProvider.forceFlush() @@ -231,7 +231,7 @@ class MetricKitInstrumentationTests: XCTestCase { func testReportMetrics_SetsResponsivenessAttributes() { let payload = FakeMetricPayload() - reportMetrics(payload: payload) + reportMetrics(payload: payload, configuration: MetricKitConfiguration()) // Force flush to ensure spans are exported tracerProvider.forceFlush() @@ -249,7 +249,7 @@ class MetricKitInstrumentationTests: XCTestCase { func testReportMetrics_SetsDiskIOAttributes() { let payload = FakeMetricPayload() - reportMetrics(payload: payload) + reportMetrics(payload: payload, configuration: MetricKitConfiguration()) // Force flush to ensure spans are exported tracerProvider.forceFlush() @@ -267,7 +267,7 @@ class MetricKitInstrumentationTests: XCTestCase { func testReportMetrics_SetsDisplayAttributes() { let payload = FakeMetricPayload() - reportMetrics(payload: payload) + reportMetrics(payload: payload, configuration: MetricKitConfiguration()) // Force flush to ensure spans are exported tracerProvider.forceFlush() @@ -286,7 +286,7 @@ class MetricKitInstrumentationTests: XCTestCase { func testReportMetrics_SetsAnimationAttributes() { let payload = FakeMetricPayload() - reportMetrics(payload: payload) + reportMetrics(payload: payload, configuration: MetricKitConfiguration()) // Force flush to ensure spans are exported tracerProvider.forceFlush() @@ -305,7 +305,7 @@ class MetricKitInstrumentationTests: XCTestCase { func testReportMetrics_SetsAppExitAttributes() { let payload = FakeMetricPayload() - reportMetrics(payload: payload) + reportMetrics(payload: payload, configuration: MetricKitConfiguration()) // Force flush to ensure spans are exported tracerProvider.forceFlush() @@ -340,7 +340,7 @@ class MetricKitInstrumentationTests: XCTestCase { func testReportMetrics_CreatesSignpostSpans() { let payload = FakeMetricPayload() - reportMetrics(payload: payload) + reportMetrics(payload: payload, configuration: MetricKitConfiguration()) // Force flush to ensure spans are exported tracerProvider.forceFlush() @@ -379,7 +379,7 @@ class MetricKitInstrumentationTests: XCTestCase { func testReportMetrics_SetsCellularConditionAttributes() { let payload = FakeMetricPayload() - reportMetrics(payload: payload) + reportMetrics(payload: payload, configuration: MetricKitConfiguration()) // Force flush to ensure spans are exported tracerProvider.forceFlush() @@ -395,6 +395,38 @@ class MetricKitInstrumentationTests: XCTestCase { // 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") } + + func testConfiguration_UsesCustomTracer() { + let customTracerProvider = TracerProviderSdk() + let customExporter = InMemoryExporter() + customTracerProvider.addSpanProcessor(SimpleSpanProcessor(spanExporter: customExporter)) + let customTracer = customTracerProvider.get(instrumentationName: "CustomTracer") + + let config = MetricKitConfiguration(tracer: customTracer) + let payload = FakeMetricPayload() + + reportMetrics(payload: payload, configuration: config) + + customTracerProvider.forceFlush() + + let spans = customExporter.getFinishedSpanItems() + XCTAssertGreaterThanOrEqual(spans.count, 1, "Custom tracer should have received spans") + + let defaultSpans = spanExporter.getFinishedSpanItems() + XCTAssertEqual(defaultSpans.count, 0, "Default tracer should not have received spans") + } + + func testConfiguration_DefaultTracerWhenNoneProvided() { + let config = MetricKitConfiguration() + let payload = FakeMetricPayload() + + reportMetrics(payload: payload, configuration: config) + + tracerProvider.forceFlush() + + let spans = spanExporter.getFinishedSpanItems() + XCTAssertGreaterThanOrEqual(spans.count, 1, "Default tracer should have received spans") + } } // MARK: - Diagnostic Tests @@ -434,7 +466,7 @@ class MetricKitDiagnosticTests: XCTestCase { func testReportDiagnostics_CreatesMainSpan() { let payload = FakeDiagnosticPayload() - reportDiagnostics(payload: payload) + reportDiagnostics(payload: payload, configuration: MetricKitConfiguration()) // Force flush to ensure spans are exported tracerProvider.forceFlush() @@ -449,7 +481,7 @@ class MetricKitDiagnosticTests: XCTestCase { func testReportDiagnostics_CreatesCPUExceptionLogs() { let payload = FakeDiagnosticPayload() - reportDiagnostics(payload: payload) + reportDiagnostics(payload: payload, configuration: MetricKitConfiguration()) let logs = logExporter.getFinishedLogRecords() @@ -466,7 +498,7 @@ class MetricKitDiagnosticTests: XCTestCase { func testReportDiagnostics_CreatesDiskWriteExceptionLogs() { let payload = FakeDiagnosticPayload() - reportDiagnostics(payload: payload) + reportDiagnostics(payload: payload, configuration: MetricKitConfiguration()) let logs = logExporter.getFinishedLogRecords() @@ -482,7 +514,7 @@ class MetricKitDiagnosticTests: XCTestCase { func testReportDiagnostics_CreatesHangDiagnosticLogs() { let payload = FakeDiagnosticPayload() - reportDiagnostics(payload: payload) + reportDiagnostics(payload: payload, configuration: MetricKitConfiguration()) let logs = logExporter.getFinishedLogRecords() @@ -501,7 +533,7 @@ class MetricKitDiagnosticTests: XCTestCase { func testReportDiagnostics_CreatesCrashDiagnosticLogs() { let payload = FakeDiagnosticPayload() - reportDiagnostics(payload: payload) + reportDiagnostics(payload: payload, configuration: MetricKitConfiguration()) let logs = logExporter.getFinishedLogRecords() @@ -545,7 +577,7 @@ class MetricKitDiagnosticTests: XCTestCase { func testReportDiagnostics_CreatesAppLaunchDiagnosticLogs() { let payload = FakeDiagnosticPayload() - reportDiagnostics(payload: payload) + reportDiagnostics(payload: payload, configuration: MetricKitConfiguration()) let logs = logExporter.getFinishedLogRecords() @@ -562,7 +594,7 @@ class MetricKitDiagnosticTests: XCTestCase { func testReportDiagnostics_VerifyLogTimestamps() { let payload = FakeDiagnosticPayload() - reportDiagnostics(payload: payload) + reportDiagnostics(payload: payload, configuration: MetricKitConfiguration()) let logs = logExporter.getFinishedLogRecords() @@ -575,7 +607,7 @@ class MetricKitDiagnosticTests: XCTestCase { func testReportDiagnostics_VerifyLogCount() { let payload = FakeDiagnosticPayload() - reportDiagnostics(payload: payload) + reportDiagnostics(payload: payload, configuration: MetricKitConfiguration()) let logs = logExporter.getFinishedLogRecords() @@ -591,5 +623,69 @@ class MetricKitDiagnosticTests: XCTestCase { XCTAssertEqual(logs.count, 4, "Should have 4 diagnostic logs on macOS") #endif } + + func testConfiguration_UseAppleStacktraceFormat() { + let config = MetricKitConfiguration(useAppleStacktraceFormat: true) + let payload = FakeDiagnosticPayload() + + reportDiagnostics(payload: payload, configuration: config) + + let logs = logExporter.getFinishedLogRecords() + + let hangLog = logs.first { + $0.attributes["name"]?.description == "metrickit.diagnostic.hang" + } + + XCTAssertNotNil(hangLog) + let stacktraceString = hangLog?.attributes["exception.stacktrace"]?.description ?? "" + let stacktraceData = stacktraceString.data(using: .utf8)! + let json = try? JSONSerialization.jsonObject(with: stacktraceData) as? [String: Any] + + XCTAssertNotNil(json) + XCTAssertTrue(json?.keys.contains("callStackTree") ?? false, "Should have Apple's callStackTree wrapper") + XCTAssertFalse(json?.keys.contains("callStacks") ?? true, "Should not have OTel's callStacks") + } + + func testConfiguration_UseOTelStacktraceFormat() { + let config = MetricKitConfiguration(useAppleStacktraceFormat: false) + let payload = FakeDiagnosticPayload() + + reportDiagnostics(payload: payload, configuration: config) + + let logs = logExporter.getFinishedLogRecords() + + let hangLog = logs.first { + $0.attributes["name"]?.description == "metrickit.diagnostic.hang" + } + + XCTAssertNotNil(hangLog) + let stacktraceString = hangLog?.attributes["exception.stacktrace"]?.description ?? "" + let stacktraceData = stacktraceString.data(using: .utf8)! + let json = try? JSONSerialization.jsonObject(with: stacktraceData) as? [String: Any] + + XCTAssertNotNil(json) + XCTAssertTrue(json?.keys.contains("callStacks") ?? false, "Should have OTel's callStacks") + XCTAssertFalse(json?.keys.contains("callStackTree") ?? true, "Should not have Apple's callStackTree wrapper") + } + + func testConfiguration_UsesCustomTracerForDiagnostics() { + let customTracerProvider = TracerProviderSdk() + let customExporter = InMemoryExporter() + customTracerProvider.addSpanProcessor(SimpleSpanProcessor(spanExporter: customExporter)) + let customTracer = customTracerProvider.get(instrumentationName: "CustomTracer") + + let config = MetricKitConfiguration(tracer: customTracer) + let payload = FakeDiagnosticPayload() + + reportDiagnostics(payload: payload, configuration: config) + + customTracerProvider.forceFlush() + + let spans = customExporter.getFinishedSpanItems() + XCTAssertGreaterThanOrEqual(spans.count, 1, "Custom tracer should have received diagnostic span") + + let defaultSpans = spanExporter.getFinishedSpanItems() + XCTAssertEqual(defaultSpans.count, 0, "Default tracer should not have received spans") + } } #endif diff --git a/Tests/InstrumentationTests/MetricKitTests/MetricKitTestHelpers.swift b/Tests/InstrumentationTests/MetricKitTests/MetricKitTestHelpers.swift index 9527016d..2c51e2ec 100644 --- a/Tests/InstrumentationTests/MetricKitTests/MetricKitTestHelpers.swift +++ b/Tests/InstrumentationTests/MetricKitTests/MetricKitTestHelpers.swift @@ -439,7 +439,27 @@ class FakeMetricPayload: MXMetricPayload { @available(iOS 14.0, *) class FakeCallStackTree: MXCallStackTree { override func jsonRepresentation() -> Data { - return Data("fake json stacktrace".utf8) + let appleFormat = """ + { + "callStackTree": { + "callStackPerThread": true, + "callStacks": [ + { + "threadAttributed": true, + "callStackRootFrames": [ + { + "binaryName": "TestApp", + "binaryUUID": "12345678-1234-1234-1234-123456789012", + "offsetIntoBinaryTextSegment": 1000, + "subFrames": [] + } + ] + } + ] + } + } + """ + return Data(appleFormat.utf8) } }