From 6d7d5626fd5c22cbd32a9e268152f390ba2539e8 Mon Sep 17 00:00:00 2001 From: Brian Gustafson Date: Tue, 29 Nov 2022 09:47:03 -0700 Subject: [PATCH 1/7] Adds device stats and polls for them every 5 seconds --- .gitignore | 1 + .../project.pbxproj | 8 ++ .../CrashReporting.swift | 48 ++++++++++- .../SplunkRumCrashReporting/DeviceStats.swift | 79 +++++++++++++++++++ .../DeviceStatsTests.swift | 39 +++++++++ 5 files changed, 171 insertions(+), 4 deletions(-) create mode 100644 SplunkRumCrashReporting/SplunkRumCrashReporting/DeviceStats.swift create mode 100644 SplunkRumCrashReporting/SplunkRumCrashReportingTests/DeviceStatsTests.swift diff --git a/.gitignore b/.gitignore index e4379c8..c8f95c9 100644 --- a/.gitignore +++ b/.gitignore @@ -3,3 +3,4 @@ xcuserdata/ .build Package.resolved +.idea \ No newline at end of file diff --git a/SplunkRumCrashReporting/SplunkRumCrashReporting.xcodeproj/project.pbxproj b/SplunkRumCrashReporting/SplunkRumCrashReporting.xcodeproj/project.pbxproj index c38c8e5..b4dd493 100644 --- a/SplunkRumCrashReporting/SplunkRumCrashReporting.xcodeproj/project.pbxproj +++ b/SplunkRumCrashReporting/SplunkRumCrashReporting.xcodeproj/project.pbxproj @@ -14,6 +14,8 @@ 86461EFC269729C0007C6DC0 /* CrashReporter in Frameworks */ = {isa = PBXBuildFile; productRef = 86461EFB269729C0007C6DC0 /* CrashReporter */; }; 86461F0526972A11007C6DC0 /* sample.plcrash in Resources */ = {isa = PBXBuildFile; fileRef = 86461F0426972A11007C6DC0 /* sample.plcrash */; }; 86D3180A271655B300B43379 /* SplunkOtel in Frameworks */ = {isa = PBXBuildFile; productRef = 86D31809271655B300B43379 /* SplunkOtel */; }; + D774545D28E38CF40056159F /* DeviceStats.swift in Sources */ = {isa = PBXBuildFile; fileRef = D774545C28E38CF40056159F /* DeviceStats.swift */; }; + D7C64D1228E494C50086368D /* DeviceStatsTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = D7C64D1128E494C50086368D /* DeviceStatsTests.swift */; }; /* End PBXBuildFile section */ /* Begin PBXContainerItemProxy section */ @@ -35,6 +37,8 @@ 86461EEA26972906007C6DC0 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; 86461EF626972964007C6DC0 /* CrashReporting.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = CrashReporting.swift; sourceTree = ""; }; 86461F0426972A11007C6DC0 /* sample.plcrash */ = {isa = PBXFileReference; lastKnownFileType = file; path = sample.plcrash; sourceTree = ""; }; + D774545C28E38CF40056159F /* DeviceStats.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DeviceStats.swift; sourceTree = ""; }; + D7C64D1128E494C50086368D /* DeviceStatsTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DeviceStatsTests.swift; sourceTree = ""; }; /* End PBXFileReference section */ /* Begin PBXFrameworksBuildPhase section */ @@ -80,6 +84,7 @@ isa = PBXGroup; children = ( 86461EF626972964007C6DC0 /* CrashReporting.swift */, + D774545C28E38CF40056159F /* DeviceStats.swift */, 86461EDD26972906007C6DC0 /* SplunkRumCrashReporting.h */, 86461EDE26972906007C6DC0 /* Info.plist */, ); @@ -91,6 +96,7 @@ children = ( 86461F0426972A11007C6DC0 /* sample.plcrash */, 86461EE826972906007C6DC0 /* CrashTests.swift */, + D7C64D1128E494C50086368D /* DeviceStatsTests.swift */, 86461EEA26972906007C6DC0 /* Info.plist */, ); path = SplunkRumCrashReportingTests; @@ -215,6 +221,7 @@ buildActionMask = 2147483647; files = ( 86461EF726972964007C6DC0 /* CrashReporting.swift in Sources */, + D774545D28E38CF40056159F /* DeviceStats.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -223,6 +230,7 @@ buildActionMask = 2147483647; files = ( 86461EE926972906007C6DC0 /* CrashTests.swift in Sources */, + D7C64D1228E494C50086368D /* DeviceStatsTests.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; diff --git a/SplunkRumCrashReporting/SplunkRumCrashReporting/CrashReporting.swift b/SplunkRumCrashReporting/SplunkRumCrashReporting/CrashReporting.swift index 92e13c4..619319d 100644 --- a/SplunkRumCrashReporting/SplunkRumCrashReporting/CrashReporting.swift +++ b/SplunkRumCrashReporting/SplunkRumCrashReporting/CrashReporting.swift @@ -20,9 +20,10 @@ import CrashReporter import SplunkOtel import OpenTelemetryApi -let CrashReportingVersionString = "0.2.0" +let CrashReportingVersionString = "0.3.0" var TheCrashReporter: PLCrashReporter? +private var customDataDictionary: [String: String] = [String: String]() func initializeCrashReporting() { let startupSpan = buildTracer().spanBuilder(spanName: "SplunkRumCrashReporting").startSpan() @@ -46,6 +47,8 @@ func initializeCrashReporting() { } TheCrashReporter = crashReporter updateCrashReportSessionId() + updateDeviceStats() + startPollingForDeviceStats() SplunkRum.addSessionIdChangeCallback { updateCrashReportSessionId() } @@ -71,7 +74,40 @@ private func buildTracer() -> Tracer { } func updateCrashReportSessionId() { - TheCrashReporter?.customData = SplunkRum.getSessionId().data(using: .utf8) + do { + customDataDictionary["sessionId"] = SplunkRum.getSessionId() + let customData = try NSKeyedArchiver.archivedData(withRootObject: customDataDictionary, requiringSecureCoding: false) + TheCrashReporter?.customData = customData + } catch { + // We have failed to archive the custom data dictionary. + SplunkRum.debugLog("Failed to add the sessionId to the crash reports custom data.") + } +} + +private func updateDeviceStats() { + do { + customDataDictionary["batteryLevel"] = DeviceStats.batteryLevel + customDataDictionary["freeDiskSpace"] = DeviceStats.freeDiskSpace + customDataDictionary["freeMemory"] = DeviceStats.freeMemory + let customData = try NSKeyedArchiver.archivedData(withRootObject: customDataDictionary, requiringSecureCoding: false) + TheCrashReporter?.customData = customData + } catch { + // We have failed to archive the custom data dictionary. + SplunkRum.debugLog("Failed to add the device stats to the crash reports custom data.") + } +} + +/* + Will poll every 5 seconds to update the device stats. + */ +private func startPollingForDeviceStats() { + let repeatSeconds: Double = 5 * 1000 + DispatchQueue.global(qos: .background).async { + let timer = Timer.scheduledTimer(withTimeInterval: repeatSeconds, repeats: true) { timer in + updateDeviceStats() + } + timer.fire() + } } func loadPendingCrashReport(_ data: Data!) throws { @@ -86,7 +122,11 @@ func loadPendingCrashReport(_ data: Data!) throws { let span = buildTracer().spanBuilder(spanName: exceptionType ?? "unknown").setStartTime(time: now).setNoParent().startSpan() span.setAttribute(key: "component", value: "crash") if report.customData != nil { - span.setAttribute(key: "crash.rumSessionId", value: String(decoding: report.customData, as: UTF8.self)) + let customData = NSKeyedUnarchiver.unarchiveObject(with: report.customData) as! [String: String] + span.setAttribute(key: "crash.rumSessionId", value: customData["sessionId"] as! String) + span.setAttribute(key: "crash.batteryLevel", value: customData["batteryLevel"] as! String) + span.setAttribute(key: "crash.freeDiskSpace", value: customData["freeDiskSpace"] as! String) + span.setAttribute(key: "crash.freeRAM", value: customData["freeMemory"] as! String) } // "marketing version" here matches up to our use of CFBundleShortVersionString span.setAttribute(key: "crash.app.version", value: report.applicationInfo.applicationMarketingVersion) @@ -105,7 +145,7 @@ func loadPendingCrashReport(_ data: Data!) throws { span.end(time: now) } -// FIXME this is a messy copy+paste of select bits of PLCrashReportTextForamtter +// FIXME this is a messy copy+paste of select bits of PLCrashReportTextFormatter func crashedThreadToStack(report: PLCrashReport, thread: PLCrashReportThreadInfo) -> String { let text = NSMutableString() text.appendFormat("Thread %ld", thread.threadNumber) diff --git a/SplunkRumCrashReporting/SplunkRumCrashReporting/DeviceStats.swift b/SplunkRumCrashReporting/SplunkRumCrashReporting/DeviceStats.swift new file mode 100644 index 0000000..3ffe428 --- /dev/null +++ b/SplunkRumCrashReporting/SplunkRumCrashReporting/DeviceStats.swift @@ -0,0 +1,79 @@ +// +/* +Copyright 2021 Splunk Inc. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + +http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +import Foundation +import System +import UIKit + +internal class DeviceStats { + + class var batteryLevel: String { + get { + UIDevice.current.isBatteryMonitoringEnabled = true + let level = abs(UIDevice.current.batteryLevel * 100) + return "\(level)%" + } + } + + class var freeDiskSpace: String { + get { + do { + let systemAttributes = try FileManager.default.attributesOfFileSystem(forPath: NSHomeDirectory() as String) + let maybeFreeSpace = (systemAttributes[FileAttributeKey.systemFreeSize] as? NSNumber)?.int64Value + + guard let freeSpace = maybeFreeSpace else { + return "Unknown" + } + + return ByteCountFormatter.string(fromByteCount: freeSpace, countStyle: .file) + } catch { + return "Unknown" + } + } + } + + class var freeMemory: String { + get { + var usedBytes: Float = 0 + let totalBytes = Float(ProcessInfo.processInfo.physicalMemory) + + var info = mach_task_basic_info() + var count = mach_msg_type_number_t(MemoryLayout.size) / 4 + + let kerr: kern_return_t = withUnsafeMutablePointer(to: &info) { + $0.withMemoryRebound(to: integer_t.self, capacity: 1) { + task_info( + mach_task_self_, + task_flavor_t(MACH_TASK_BASIC_INFO), + $0, + &count + ) + } + } + + if kerr == KERN_SUCCESS { + usedBytes = Float(info.resident_size) + } else { + return "Unknown" + } + + let freeBytes = totalBytes - usedBytes + return ByteCountFormatter.string(fromByteCount: Int64(freeBytes), countStyle: .memory) + } + } + +} diff --git a/SplunkRumCrashReporting/SplunkRumCrashReportingTests/DeviceStatsTests.swift b/SplunkRumCrashReporting/SplunkRumCrashReportingTests/DeviceStatsTests.swift new file mode 100644 index 0000000..70c65ba --- /dev/null +++ b/SplunkRumCrashReporting/SplunkRumCrashReportingTests/DeviceStatsTests.swift @@ -0,0 +1,39 @@ +// +/* +Copyright 2021 Splunk Inc. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + +http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +@testable import SplunkRumCrashReporting +import Foundation +import XCTest + +class DeviceStatsTests: XCTestCase { + + func testBattery() throws { + let batteryLevel = DeviceStats.batteryLevel + XCTAssertTrue(!batteryLevel.isEmpty) + } + + func testFreeDiskSpace() throws { + let diskSpace = DeviceStats.freeDiskSpace + XCTAssertTrue(!diskSpace.isEmpty) + } + + func testFreeMemory() throws { + let freeMemory = DeviceStats.freeMemory + XCTAssertTrue(!freeMemory.isEmpty) + } + +} From 13546dee27aaf53017e4dae7a5dd7addd91d84a9 Mon Sep 17 00:00:00 2001 From: Brian Gustafson Date: Tue, 29 Nov 2022 11:08:57 -0700 Subject: [PATCH 2/7] swiftlint fixes --- .../SplunkRumCrashReporting/DeviceStats.swift | 7 ------- .../SplunkRumCrashReportingTests/DeviceStatsTests.swift | 6 +----- 2 files changed, 1 insertion(+), 12 deletions(-) diff --git a/SplunkRumCrashReporting/SplunkRumCrashReporting/DeviceStats.swift b/SplunkRumCrashReporting/SplunkRumCrashReporting/DeviceStats.swift index 3ffe428..3b43605 100644 --- a/SplunkRumCrashReporting/SplunkRumCrashReporting/DeviceStats.swift +++ b/SplunkRumCrashReporting/SplunkRumCrashReporting/DeviceStats.swift @@ -20,7 +20,6 @@ import System import UIKit internal class DeviceStats { - class var batteryLevel: String { get { UIDevice.current.isBatteryMonitoringEnabled = true @@ -28,7 +27,6 @@ internal class DeviceStats { return "\(level)%" } } - class var freeDiskSpace: String { get { do { @@ -45,15 +43,12 @@ internal class DeviceStats { } } } - class var freeMemory: String { get { var usedBytes: Float = 0 let totalBytes = Float(ProcessInfo.processInfo.physicalMemory) - var info = mach_task_basic_info() var count = mach_msg_type_number_t(MemoryLayout.size) / 4 - let kerr: kern_return_t = withUnsafeMutablePointer(to: &info) { $0.withMemoryRebound(to: integer_t.self, capacity: 1) { task_info( @@ -64,13 +59,11 @@ internal class DeviceStats { ) } } - if kerr == KERN_SUCCESS { usedBytes = Float(info.resident_size) } else { return "Unknown" } - let freeBytes = totalBytes - usedBytes return ByteCountFormatter.string(fromByteCount: Int64(freeBytes), countStyle: .memory) } diff --git a/SplunkRumCrashReporting/SplunkRumCrashReportingTests/DeviceStatsTests.swift b/SplunkRumCrashReporting/SplunkRumCrashReportingTests/DeviceStatsTests.swift index 70c65ba..e858bdf 100644 --- a/SplunkRumCrashReporting/SplunkRumCrashReportingTests/DeviceStatsTests.swift +++ b/SplunkRumCrashReporting/SplunkRumCrashReportingTests/DeviceStatsTests.swift @@ -20,20 +20,16 @@ import Foundation import XCTest class DeviceStatsTests: XCTestCase { - func testBattery() throws { let batteryLevel = DeviceStats.batteryLevel XCTAssertTrue(!batteryLevel.isEmpty) } - func testFreeDiskSpace() throws { let diskSpace = DeviceStats.freeDiskSpace XCTAssertTrue(!diskSpace.isEmpty) } - func testFreeMemory() throws { let freeMemory = DeviceStats.freeMemory XCTAssertTrue(!freeMemory.isEmpty) } - -} +} \ No newline at end of file From 9521353c6b3c4991da27a96542a45fe4aca4ff27 Mon Sep 17 00:00:00 2001 From: Brian Gustafson Date: Tue, 29 Nov 2022 11:12:02 -0700 Subject: [PATCH 3/7] more swiftlint fixes --- .../CrashReporting.swift | 2 +- .../SplunkRumCrashReporting/DeviceStats.swift | 58 ++++++++----------- 2 files changed, 26 insertions(+), 34 deletions(-) diff --git a/SplunkRumCrashReporting/SplunkRumCrashReporting/CrashReporting.swift b/SplunkRumCrashReporting/SplunkRumCrashReporting/CrashReporting.swift index 619319d..5303a99 100644 --- a/SplunkRumCrashReporting/SplunkRumCrashReporting/CrashReporting.swift +++ b/SplunkRumCrashReporting/SplunkRumCrashReporting/CrashReporting.swift @@ -103,7 +103,7 @@ private func updateDeviceStats() { private func startPollingForDeviceStats() { let repeatSeconds: Double = 5 * 1000 DispatchQueue.global(qos: .background).async { - let timer = Timer.scheduledTimer(withTimeInterval: repeatSeconds, repeats: true) { timer in + let timer = Timer.scheduledTimer(withTimeInterval: repeatSeconds, repeats: true) { _ in updateDeviceStats() } timer.fire() diff --git a/SplunkRumCrashReporting/SplunkRumCrashReporting/DeviceStats.swift b/SplunkRumCrashReporting/SplunkRumCrashReporting/DeviceStats.swift index 3b43605..218c864 100644 --- a/SplunkRumCrashReporting/SplunkRumCrashReporting/DeviceStats.swift +++ b/SplunkRumCrashReporting/SplunkRumCrashReporting/DeviceStats.swift @@ -21,52 +21,44 @@ import UIKit internal class DeviceStats { class var batteryLevel: String { - get { - UIDevice.current.isBatteryMonitoringEnabled = true - let level = abs(UIDevice.current.batteryLevel * 100) - return "\(level)%" - } + UIDevice.current.isBatteryMonitoringEnabled = true + let level = abs(UIDevice.current.batteryLevel * 100) + return "\(level)%" } class var freeDiskSpace: String { - get { - do { - let systemAttributes = try FileManager.default.attributesOfFileSystem(forPath: NSHomeDirectory() as String) - let maybeFreeSpace = (systemAttributes[FileAttributeKey.systemFreeSize] as? NSNumber)?.int64Value - - guard let freeSpace = maybeFreeSpace else { - return "Unknown" - } - - return ByteCountFormatter.string(fromByteCount: freeSpace, countStyle: .file) - } catch { + do { + let systemAttributes = try FileManager.default.attributesOfFileSystem(forPath: NSHomeDirectory() as String) + let maybeFreeSpace = (systemAttributes[FileAttributeKey.systemFreeSize] as? NSNumber)?.int64Value + guard let freeSpace = maybeFreeSpace else { return "Unknown" } + return ByteCountFormatter.string(fromByteCount: freeSpace, countStyle: .file) + } catch { + return "Unknown" } } class var freeMemory: String { - get { - var usedBytes: Float = 0 - let totalBytes = Float(ProcessInfo.processInfo.physicalMemory) - var info = mach_task_basic_info() - var count = mach_msg_type_number_t(MemoryLayout.size) / 4 - let kerr: kern_return_t = withUnsafeMutablePointer(to: &info) { - $0.withMemoryRebound(to: integer_t.self, capacity: 1) { - task_info( + var usedBytes: Float = 0 + let totalBytes = Float(ProcessInfo.processInfo.physicalMemory) + var info = mach_task_basic_info() + var count = mach_msg_type_number_t(MemoryLayout.size) / 4 + let kerr: kern_return_t = withUnsafeMutablePointer(to: &info) { + $0.withMemoryRebound(to: integer_t.self, capacity: 1) { + task_info( mach_task_self_, task_flavor_t(MACH_TASK_BASIC_INFO), $0, &count - ) - } - } - if kerr == KERN_SUCCESS { - usedBytes = Float(info.resident_size) - } else { - return "Unknown" + ) } - let freeBytes = totalBytes - usedBytes - return ByteCountFormatter.string(fromByteCount: Int64(freeBytes), countStyle: .memory) } + if kerr == KERN_SUCCESS { + usedBytes = Float(info.resident_size) + } else { + return "Unknown" + } + let freeBytes = totalBytes - usedBytes + return ByteCountFormatter.string(fromByteCount: Int64(freeBytes), countStyle: .memory) } } From 0f852b700c51510ff2120409db5172b18b29a1a5 Mon Sep 17 00:00:00 2001 From: Brian Gustafson Date: Tue, 29 Nov 2022 11:13:44 -0700 Subject: [PATCH 4/7] adds new line --- .../SplunkRumCrashReportingTests/DeviceStatsTests.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/SplunkRumCrashReporting/SplunkRumCrashReportingTests/DeviceStatsTests.swift b/SplunkRumCrashReporting/SplunkRumCrashReportingTests/DeviceStatsTests.swift index e858bdf..0146049 100644 --- a/SplunkRumCrashReporting/SplunkRumCrashReportingTests/DeviceStatsTests.swift +++ b/SplunkRumCrashReporting/SplunkRumCrashReportingTests/DeviceStatsTests.swift @@ -32,4 +32,4 @@ class DeviceStatsTests: XCTestCase { let freeMemory = DeviceStats.freeMemory XCTAssertTrue(!freeMemory.isEmpty) } -} \ No newline at end of file +} From 6a4cd89a0f417c5f443b659313c41bc3be6af1cf Mon Sep 17 00:00:00 2001 From: Brian Gustafson Date: Tue, 29 Nov 2022 13:23:05 -0700 Subject: [PATCH 5/7] Adds fallback for existing crash reports --- .../SplunkRumCrashReporting/CrashReporting.swift | 14 +++++++++----- 1 file changed, 9 insertions(+), 5 deletions(-) diff --git a/SplunkRumCrashReporting/SplunkRumCrashReporting/CrashReporting.swift b/SplunkRumCrashReporting/SplunkRumCrashReporting/CrashReporting.swift index 5303a99..22ec559 100644 --- a/SplunkRumCrashReporting/SplunkRumCrashReporting/CrashReporting.swift +++ b/SplunkRumCrashReporting/SplunkRumCrashReporting/CrashReporting.swift @@ -122,11 +122,15 @@ func loadPendingCrashReport(_ data: Data!) throws { let span = buildTracer().spanBuilder(spanName: exceptionType ?? "unknown").setStartTime(time: now).setNoParent().startSpan() span.setAttribute(key: "component", value: "crash") if report.customData != nil { - let customData = NSKeyedUnarchiver.unarchiveObject(with: report.customData) as! [String: String] - span.setAttribute(key: "crash.rumSessionId", value: customData["sessionId"] as! String) - span.setAttribute(key: "crash.batteryLevel", value: customData["batteryLevel"] as! String) - span.setAttribute(key: "crash.freeDiskSpace", value: customData["freeDiskSpace"] as! String) - span.setAttribute(key: "crash.freeRAM", value: customData["freeMemory"] as! String) + let customData = NSKeyedUnarchiver.unarchiveObject(with: report.customData) as? [String: String] + if customData != nil { + span.setAttribute(key: "crash.rumSessionId", value: customData!["sessionId"] as! String) + span.setAttribute(key: "crash.batteryLevel", value: customData!["batteryLevel"] as! String) + span.setAttribute(key: "crash.freeDiskSpace", value: customData!["freeDiskSpace"] as! String) + span.setAttribute(key: "crash.freeRAM", value: customData!["freeMemory"] as! String) + } else { + span.setAttribute(key: "crash.rumSessionId", value: String(decoding: report.customData, as: UTF8.self)) + } } // "marketing version" here matches up to our use of CFBundleShortVersionString span.setAttribute(key: "crash.app.version", value: report.applicationInfo.applicationMarketingVersion) From c3dba48a67bc332657ff0999af1cef5b7c17db58 Mon Sep 17 00:00:00 2001 From: Brian Gustafson Date: Tue, 29 Nov 2022 13:25:25 -0700 Subject: [PATCH 6/7] changes freeRAM key to freeMemory --- .../SplunkRumCrashReporting/CrashReporting.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/SplunkRumCrashReporting/SplunkRumCrashReporting/CrashReporting.swift b/SplunkRumCrashReporting/SplunkRumCrashReporting/CrashReporting.swift index 22ec559..d335233 100644 --- a/SplunkRumCrashReporting/SplunkRumCrashReporting/CrashReporting.swift +++ b/SplunkRumCrashReporting/SplunkRumCrashReporting/CrashReporting.swift @@ -127,7 +127,7 @@ func loadPendingCrashReport(_ data: Data!) throws { span.setAttribute(key: "crash.rumSessionId", value: customData!["sessionId"] as! String) span.setAttribute(key: "crash.batteryLevel", value: customData!["batteryLevel"] as! String) span.setAttribute(key: "crash.freeDiskSpace", value: customData!["freeDiskSpace"] as! String) - span.setAttribute(key: "crash.freeRAM", value: customData!["freeMemory"] as! String) + span.setAttribute(key: "crash.freeMemory", value: customData!["freeMemory"] as! String) } else { span.setAttribute(key: "crash.rumSessionId", value: String(decoding: report.customData, as: UTF8.self)) } From 8b89c8be92b7913fae411e8008127c60d496b5f5 Mon Sep 17 00:00:00 2001 From: Brian Gustafson Date: Wed, 30 Nov 2022 15:33:43 -0700 Subject: [PATCH 7/7] updates tests --- .../project.pbxproj | 12 ++++-- .../CrashReporting.swift | 10 ++--- .../SplunkRumCrashReporting/DeviceStats.swift | 1 + .../CrashTests.swift | 39 +++++++++++++++++- .../DeviceStatsTests.swift | 8 ++-- .../{sample.plcrash => sample_v1.plcrash} | Bin .../sample_v2.plcrash | Bin 0 -> 53014 bytes 7 files changed, 56 insertions(+), 14 deletions(-) rename SplunkRumCrashReporting/SplunkRumCrashReportingTests/{sample.plcrash => sample_v1.plcrash} (100%) create mode 100644 SplunkRumCrashReporting/SplunkRumCrashReportingTests/sample_v2.plcrash diff --git a/SplunkRumCrashReporting/SplunkRumCrashReporting.xcodeproj/project.pbxproj b/SplunkRumCrashReporting/SplunkRumCrashReporting.xcodeproj/project.pbxproj index b4dd493..3aec226 100644 --- a/SplunkRumCrashReporting/SplunkRumCrashReporting.xcodeproj/project.pbxproj +++ b/SplunkRumCrashReporting/SplunkRumCrashReporting.xcodeproj/project.pbxproj @@ -12,10 +12,11 @@ 86461EEB26972906007C6DC0 /* SplunkRumCrashReporting.h in Headers */ = {isa = PBXBuildFile; fileRef = 86461EDD26972906007C6DC0 /* SplunkRumCrashReporting.h */; settings = {ATTRIBUTES = (Public, ); }; }; 86461EF726972964007C6DC0 /* CrashReporting.swift in Sources */ = {isa = PBXBuildFile; fileRef = 86461EF626972964007C6DC0 /* CrashReporting.swift */; }; 86461EFC269729C0007C6DC0 /* CrashReporter in Frameworks */ = {isa = PBXBuildFile; productRef = 86461EFB269729C0007C6DC0 /* CrashReporter */; }; - 86461F0526972A11007C6DC0 /* sample.plcrash in Resources */ = {isa = PBXBuildFile; fileRef = 86461F0426972A11007C6DC0 /* sample.plcrash */; }; + 86461F0526972A11007C6DC0 /* sample_v1.plcrash in Resources */ = {isa = PBXBuildFile; fileRef = 86461F0426972A11007C6DC0 /* sample_v1.plcrash */; }; 86D3180A271655B300B43379 /* SplunkOtel in Frameworks */ = {isa = PBXBuildFile; productRef = 86D31809271655B300B43379 /* SplunkOtel */; }; D774545D28E38CF40056159F /* DeviceStats.swift in Sources */ = {isa = PBXBuildFile; fileRef = D774545C28E38CF40056159F /* DeviceStats.swift */; }; D7C64D1228E494C50086368D /* DeviceStatsTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = D7C64D1128E494C50086368D /* DeviceStatsTests.swift */; }; + D7D14290293804A200CAD87E /* sample_v2.plcrash in Resources */ = {isa = PBXBuildFile; fileRef = D7D1428F293804A200CAD87E /* sample_v2.plcrash */; }; /* End PBXBuildFile section */ /* Begin PBXContainerItemProxy section */ @@ -36,9 +37,10 @@ 86461EE826972906007C6DC0 /* CrashTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CrashTests.swift; sourceTree = ""; }; 86461EEA26972906007C6DC0 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; 86461EF626972964007C6DC0 /* CrashReporting.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = CrashReporting.swift; sourceTree = ""; }; - 86461F0426972A11007C6DC0 /* sample.plcrash */ = {isa = PBXFileReference; lastKnownFileType = file; path = sample.plcrash; sourceTree = ""; }; + 86461F0426972A11007C6DC0 /* sample_v1.plcrash */ = {isa = PBXFileReference; lastKnownFileType = file.plcrash; path = sample_v1.plcrash; sourceTree = ""; }; D774545C28E38CF40056159F /* DeviceStats.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DeviceStats.swift; sourceTree = ""; }; D7C64D1128E494C50086368D /* DeviceStatsTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DeviceStatsTests.swift; sourceTree = ""; }; + D7D1428F293804A200CAD87E /* sample_v2.plcrash */ = {isa = PBXFileReference; lastKnownFileType = file; path = sample_v2.plcrash; sourceTree = ""; }; /* End PBXFileReference section */ /* Begin PBXFrameworksBuildPhase section */ @@ -94,7 +96,8 @@ 86461EE726972906007C6DC0 /* SplunkRumCrashReportingTests */ = { isa = PBXGroup; children = ( - 86461F0426972A11007C6DC0 /* sample.plcrash */, + D7D1428F293804A200CAD87E /* sample_v2.plcrash */, + 86461F0426972A11007C6DC0 /* sample_v1.plcrash */, 86461EE826972906007C6DC0 /* CrashTests.swift */, D7C64D1128E494C50086368D /* DeviceStatsTests.swift */, 86461EEA26972906007C6DC0 /* Info.plist */, @@ -209,7 +212,8 @@ isa = PBXResourcesBuildPhase; buildActionMask = 2147483647; files = ( - 86461F0526972A11007C6DC0 /* sample.plcrash in Resources */, + 86461F0526972A11007C6DC0 /* sample_v1.plcrash in Resources */, + D7D14290293804A200CAD87E /* sample_v2.plcrash in Resources */, ); runOnlyForDeploymentPostprocessing = 0; }; diff --git a/SplunkRumCrashReporting/SplunkRumCrashReporting/CrashReporting.swift b/SplunkRumCrashReporting/SplunkRumCrashReporting/CrashReporting.swift index d335233..56ee2fe 100644 --- a/SplunkRumCrashReporting/SplunkRumCrashReporting/CrashReporting.swift +++ b/SplunkRumCrashReporting/SplunkRumCrashReporting/CrashReporting.swift @@ -101,7 +101,7 @@ private func updateDeviceStats() { Will poll every 5 seconds to update the device stats. */ private func startPollingForDeviceStats() { - let repeatSeconds: Double = 5 * 1000 + let repeatSeconds: Double = 5 DispatchQueue.global(qos: .background).async { let timer = Timer.scheduledTimer(withTimeInterval: repeatSeconds, repeats: true) { _ in updateDeviceStats() @@ -124,10 +124,10 @@ func loadPendingCrashReport(_ data: Data!) throws { if report.customData != nil { let customData = NSKeyedUnarchiver.unarchiveObject(with: report.customData) as? [String: String] if customData != nil { - span.setAttribute(key: "crash.rumSessionId", value: customData!["sessionId"] as! String) - span.setAttribute(key: "crash.batteryLevel", value: customData!["batteryLevel"] as! String) - span.setAttribute(key: "crash.freeDiskSpace", value: customData!["freeDiskSpace"] as! String) - span.setAttribute(key: "crash.freeMemory", value: customData!["freeMemory"] as! String) + span.setAttribute(key: "crash.rumSessionId", value: customData!["sessionId"]!) + span.setAttribute(key: "crash.batteryLevel", value: customData!["batteryLevel"]!) + span.setAttribute(key: "crash.freeDiskSpace", value: customData!["freeDiskSpace"]!) + span.setAttribute(key: "crash.freeMemory", value: customData!["freeMemory"]!) } else { span.setAttribute(key: "crash.rumSessionId", value: String(decoding: report.customData, as: UTF8.self)) } diff --git a/SplunkRumCrashReporting/SplunkRumCrashReporting/DeviceStats.swift b/SplunkRumCrashReporting/SplunkRumCrashReporting/DeviceStats.swift index 218c864..06fe0b7 100644 --- a/SplunkRumCrashReporting/SplunkRumCrashReporting/DeviceStats.swift +++ b/SplunkRumCrashReporting/SplunkRumCrashReporting/DeviceStats.swift @@ -37,6 +37,7 @@ internal class DeviceStats { return "Unknown" } } + // https://stackoverflow.com/questions/5012886/determining-the-available-amount-of-ram-on-an-ios-device/8540665#8540665 class var freeMemory: String { var usedBytes: Float = 0 let totalBytes = Float(ProcessInfo.processInfo.physicalMemory) diff --git a/SplunkRumCrashReporting/SplunkRumCrashReportingTests/CrashTests.swift b/SplunkRumCrashReporting/SplunkRumCrashReportingTests/CrashTests.swift index f736cd1..144f861 100644 --- a/SplunkRumCrashReporting/SplunkRumCrashReportingTests/CrashTests.swift +++ b/SplunkRumCrashReporting/SplunkRumCrashReportingTests/CrashTests.swift @@ -43,8 +43,8 @@ class TestSpanExporter: SpanExporter { } class CrashTests: XCTestCase { - func testBasics() throws { - let crashPath = Bundle(for: CrashTests.self).url(forResource: "sample", withExtension: "plcrash")! + func testBasics_v1() throws { + let crashPath = Bundle(for: CrashTests.self).url(forResource: "sample_v1", withExtension: "plcrash")! let crashData = try Data(contentsOf: crashPath) SplunkRum.initialize(beaconUrl: "http://127.0.0.1:8989/v1/traces", rumAuth: "FAKE", options: SplunkRumOptions(allowInsecureBeacon: true, debug: true)) @@ -75,4 +75,39 @@ class CrashTests: XCTestCase { XCTAssertEqual(startup!.attributes["component"]?.description, "appstart") } + func testBasics_v2() throws { + let crashPath = Bundle(for: CrashTests.self).url(forResource: "sample_v2", withExtension: "plcrash")! + let crashData = try Data(contentsOf: crashPath) + + SplunkRum.initialize(beaconUrl: "http://127.0.0.1:8989/v1/traces", rumAuth: "FAKE", options: SplunkRumOptions(allowInsecureBeacon: true, debug: true)) + OpenTelemetrySDK.instance.tracerProvider.addSpanProcessor(SimpleSpanProcessor(spanExporter: TestSpanExporter())) + localSpans.removeAll() + + SplunkRumCrashReporting.start() + try loadPendingCrashReport(crashData) + + XCTAssertEqual(localSpans.count, 4) + let crashReport = localSpans.first(where: { (span) -> Bool in + return span.name == "SIGTRAP" + }) + let startup = localSpans.first(where: { (span) -> Bool in + return span.name == "SplunkRumCrashReporting" + }) + + XCTAssertNotNil(crashReport) + XCTAssertNotEqual(crashReport!.attributes["splunk.rumSessionId"], crashReport!.attributes["crash.rumSessionId"]) + XCTAssertEqual(crashReport!.attributes["crash.rumSessionId"]?.description, "388e59237de675ef8e9751fcf2b0f936") + XCTAssertEqual(crashReport!.attributes["crash.address"]?.description, "7595465412") + XCTAssertEqual(crashReport!.attributes["component"]?.description, "crash") + XCTAssertEqual(crashReport!.attributes["error"]?.description, "true") + XCTAssertEqual(crashReport!.attributes["exception.type"]?.description, "SIGTRAP") + XCTAssertTrue(crashReport!.attributes["exception.stacktrace"]?.description.contains("UIKitCore") ?? false) + XCTAssertEqual(crashReport!.attributes["crash.batteryLevel"]?.description, "91.0%") + XCTAssertEqual(crashReport!.attributes["crash.freeDiskSpace"]?.description, "197.23 GB") + XCTAssertEqual(crashReport!.attributes["crash.freeMemory"]?.description, "5.54 GB") + + XCTAssertNotNil(startup) + XCTAssertEqual(startup!.attributes["component"]?.description, "appstart") + + } } diff --git a/SplunkRumCrashReporting/SplunkRumCrashReportingTests/DeviceStatsTests.swift b/SplunkRumCrashReporting/SplunkRumCrashReportingTests/DeviceStatsTests.swift index 0146049..b7c1a1c 100644 --- a/SplunkRumCrashReporting/SplunkRumCrashReportingTests/DeviceStatsTests.swift +++ b/SplunkRumCrashReporting/SplunkRumCrashReportingTests/DeviceStatsTests.swift @@ -22,14 +22,16 @@ import XCTest class DeviceStatsTests: XCTestCase { func testBattery() throws { let batteryLevel = DeviceStats.batteryLevel - XCTAssertTrue(!batteryLevel.isEmpty) + XCTAssertEqual(batteryLevel, "100.0%") } func testFreeDiskSpace() throws { let diskSpace = DeviceStats.freeDiskSpace - XCTAssertTrue(!diskSpace.isEmpty) + let space = Int(diskSpace.components(separatedBy: CharacterSet.decimalDigits.inverted).joined()) ?? 0 + XCTAssertTrue(space > 0) } func testFreeMemory() throws { let freeMemory = DeviceStats.freeMemory - XCTAssertTrue(!freeMemory.isEmpty) + let space = Int(freeMemory.components(separatedBy: CharacterSet.decimalDigits.inverted).joined()) ?? 0 + XCTAssertTrue(space > 0) } } diff --git a/SplunkRumCrashReporting/SplunkRumCrashReportingTests/sample.plcrash b/SplunkRumCrashReporting/SplunkRumCrashReportingTests/sample_v1.plcrash similarity index 100% rename from SplunkRumCrashReporting/SplunkRumCrashReportingTests/sample.plcrash rename to SplunkRumCrashReporting/SplunkRumCrashReportingTests/sample_v1.plcrash diff --git a/SplunkRumCrashReporting/SplunkRumCrashReportingTests/sample_v2.plcrash b/SplunkRumCrashReporting/SplunkRumCrashReportingTests/sample_v2.plcrash new file mode 100644 index 0000000000000000000000000000000000000000..7860010b99cf3c35622fd63631fa569dcc0f8a91 GIT binary patch literal 53014 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 a8tL}Ywb8ZLk^k3BSMdq`YW%0`@&5qfTFzqt literal 0 HcmV?d00001