feat(swift-diagnostics): implement Phase 2.2 Swift Client SDK
New package ByteLystDiagnostics with: - Core types: DiagnosticsSession, TraceSpan, LogEntry, Breadcrumb - DiagnosticsClient: actor-based singleton with polling - BreadcrumbTrail: ring buffer (max 100) for timeline - NetworkInterceptor: URLProtocol-based HTTP capture - DeviceState: battery, memory, storage, network, thermal - 20+ XCTest unit tests Features: - configure()/start()/stop() lifecycle - trace() async span wrapper - log() with breadcrumb integration - breadcrumb() manual timeline markers - ETag-based config polling - 30-second batch flush Platforms: iOS 15+, macOS 13+, watchOS 8+, tvOS 15+
This commit is contained in:
parent
e4c3c7cc13
commit
abcf817cb3
33
packages/swift-diagnostics/Package.swift
Normal file
33
packages/swift-diagnostics/Package.swift
Normal file
@ -0,0 +1,33 @@
|
|||||||
|
// swift-tools-version:5.9
|
||||||
|
import PackageDescription
|
||||||
|
|
||||||
|
let package = Package(
|
||||||
|
name: "ByteLystDiagnostics",
|
||||||
|
platforms: [
|
||||||
|
.iOS(.v15),
|
||||||
|
.macOS(.v13),
|
||||||
|
.watchOS(.v8),
|
||||||
|
.tvOS(.v15)
|
||||||
|
],
|
||||||
|
products: [
|
||||||
|
.library(
|
||||||
|
name: "ByteLystDiagnostics",
|
||||||
|
targets: ["ByteLystDiagnostics"]
|
||||||
|
),
|
||||||
|
],
|
||||||
|
dependencies: [],
|
||||||
|
targets: [
|
||||||
|
.target(
|
||||||
|
name: "ByteLystDiagnostics",
|
||||||
|
path: "Sources/ByteLystDiagnostics",
|
||||||
|
swiftSettings: [
|
||||||
|
.enableExperimentalFeature("StrictConcurrency")
|
||||||
|
]
|
||||||
|
),
|
||||||
|
.testTarget(
|
||||||
|
name: "ByteLystDiagnosticsTests",
|
||||||
|
dependencies: ["ByteLystDiagnostics"],
|
||||||
|
path: "Tests/ByteLystDiagnosticsTests"
|
||||||
|
),
|
||||||
|
]
|
||||||
|
)
|
||||||
@ -0,0 +1,73 @@
|
|||||||
|
/**
|
||||||
|
* ByteLystDiagnostics
|
||||||
|
*
|
||||||
|
* Remote diagnostics and debug tracing client for the ByteLyst ecosystem.
|
||||||
|
* Provides polling, logging, tracing, network capture, and breadcrumbs for iOS/macOS.
|
||||||
|
*
|
||||||
|
* Example usage:
|
||||||
|
* ```swift
|
||||||
|
* import ByteLystDiagnostics
|
||||||
|
*
|
||||||
|
* // Configure
|
||||||
|
* let config = DiagnosticsConfiguration(
|
||||||
|
* productId: "myapp",
|
||||||
|
* anonymousInstallId: "install_123",
|
||||||
|
* platform: "ios",
|
||||||
|
* channel: "ios_app",
|
||||||
|
* osFamily: "ios",
|
||||||
|
* appVersion: "1.0.0",
|
||||||
|
* buildNumber: "100",
|
||||||
|
* releaseChannel: "stable",
|
||||||
|
* serverUrl: "https://api.bytelyst.com"
|
||||||
|
* )
|
||||||
|
*
|
||||||
|
* await DiagnosticsClient.shared.configure(config)
|
||||||
|
* await DiagnosticsClient.shared.start()
|
||||||
|
*
|
||||||
|
* // Auto-instrumented trace
|
||||||
|
* let result = try await DiagnosticsClient.shared.trace(name: "fetchUser") {
|
||||||
|
* try await fetchUser()
|
||||||
|
* }
|
||||||
|
*
|
||||||
|
* // Manual breadcrumb
|
||||||
|
* await DiagnosticsClient.shared.breadcrumb(
|
||||||
|
* category: "user",
|
||||||
|
* message: "Tapped submit button",
|
||||||
|
* data: ["buttonId": AnyCodable("submit")]
|
||||||
|
* )
|
||||||
|
*
|
||||||
|
* // Manual log
|
||||||
|
* await DiagnosticsClient.shared.log(
|
||||||
|
* level: .info,
|
||||||
|
* message: "User signed in",
|
||||||
|
* module: "Auth",
|
||||||
|
* context: ["userId": AnyCodable(userId)]
|
||||||
|
* )
|
||||||
|
* ```
|
||||||
|
*/
|
||||||
|
|
||||||
|
// MARK: - Core
|
||||||
|
@_exported import Foundation
|
||||||
|
|
||||||
|
// Types
|
||||||
|
public typealias ByteLystDiagnosticsTypes = DiagnosticsSession
|
||||||
|
public typealias ByteLystDiagnosticsLogLevel = DiagnosticsLogLevel
|
||||||
|
public typealias ByteLystDiagnosticsSessionStatus = DiagnosticsSessionStatus
|
||||||
|
public typealias ByteLystDiagnosticsCollectionLevel = DiagnosticsCollectionLevel
|
||||||
|
public typealias ByteLystDiagnosticsTraceSpan = DiagnosticsTraceSpan
|
||||||
|
public typealias ByteLystDiagnosticsLogEntry = DiagnosticsLogEntry
|
||||||
|
public typealias ByteLystDiagnosticsBreadcrumb = DiagnosticsBreadcrumb
|
||||||
|
public typealias ByteLystDiagnosticsNetworkRequest = DiagnosticsNetworkRequest
|
||||||
|
public typealias ByteLystDiagnosticsDeviceState = DiagnosticsDeviceState
|
||||||
|
public typealias ByteLystDiagnosticsIngestBatch = DiagnosticsIngestBatch
|
||||||
|
public typealias ByteLystDiagnosticsClientState = DiagnosticsClientState
|
||||||
|
public typealias ByteLystDiagnosticsConfiguration = DiagnosticsConfiguration
|
||||||
|
public typealias ByteLystDiagnosticsLogger = DiagnosticsLogger
|
||||||
|
public typealias ByteLystDiagnosticsNoOpLogger = NoOpDiagnosticsLogger
|
||||||
|
public typealias ByteLystDiagnosticsOSLogger = OSDiagnosticsLogger
|
||||||
|
|
||||||
|
// Errors
|
||||||
|
public typealias ByteLystDiagnosticsError = DiagnosticsError
|
||||||
|
|
||||||
|
// Version
|
||||||
|
public let ByteLystDiagnosticsVersion = "0.1.0"
|
||||||
@ -0,0 +1,53 @@
|
|||||||
|
import Foundation
|
||||||
|
|
||||||
|
/// Ring buffer for breadcrumbs with fixed max size
|
||||||
|
public actor BreadcrumbTrail {
|
||||||
|
private var breadcrumbs: [DiagnosticsBreadcrumb] = []
|
||||||
|
private let maxSize: Int
|
||||||
|
|
||||||
|
public init(maxSize: Int = 100) {
|
||||||
|
self.maxSize = maxSize
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Add a breadcrumb to the trail
|
||||||
|
public func add(category: String, message: String, data: [String: AnyCodable]? = nil) {
|
||||||
|
let breadcrumb = DiagnosticsBreadcrumb(
|
||||||
|
timestamp: ISO8601DateFormatter().string(from: Date()),
|
||||||
|
category: category,
|
||||||
|
message: message,
|
||||||
|
data: data
|
||||||
|
)
|
||||||
|
|
||||||
|
breadcrumbs.append(breadcrumb)
|
||||||
|
|
||||||
|
// Evict oldest if over limit
|
||||||
|
if breadcrumbs.count > maxSize {
|
||||||
|
breadcrumbs.removeFirst()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get all breadcrumbs (oldest first)
|
||||||
|
public func getAll() -> [DiagnosticsBreadcrumb] {
|
||||||
|
breadcrumbs
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get last N breadcrumbs
|
||||||
|
public func getLast(_ n: Int) -> [DiagnosticsBreadcrumb] {
|
||||||
|
Array(breadcrumbs.suffix(n))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get most recent breadcrumb
|
||||||
|
public func getMostRecent() -> DiagnosticsBreadcrumb? {
|
||||||
|
breadcrumbs.last
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Clear all breadcrumbs
|
||||||
|
public func clear() {
|
||||||
|
breadcrumbs.removeAll()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get current size
|
||||||
|
public func size() -> Int {
|
||||||
|
breadcrumbs.count
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,103 @@
|
|||||||
|
import Foundation
|
||||||
|
|
||||||
|
/// Client configuration
|
||||||
|
public struct DiagnosticsConfiguration: Sendable {
|
||||||
|
public let productId: String
|
||||||
|
public let userId: String?
|
||||||
|
public let anonymousInstallId: String
|
||||||
|
public let platform: String
|
||||||
|
public let channel: String
|
||||||
|
public let osFamily: String
|
||||||
|
public let appVersion: String
|
||||||
|
public let buildNumber: String
|
||||||
|
public let releaseChannel: String
|
||||||
|
public let serverUrl: String
|
||||||
|
public let pollIntervalMs: Int
|
||||||
|
public let maxBreadcrumbs: Int
|
||||||
|
public let captureConsole: Bool
|
||||||
|
public let captureErrors: Bool
|
||||||
|
public let captureNetwork: Bool
|
||||||
|
public let getAuthToken: (@Sendable () async throws -> String)?
|
||||||
|
|
||||||
|
public init(
|
||||||
|
productId: String,
|
||||||
|
userId: String? = nil,
|
||||||
|
anonymousInstallId: String,
|
||||||
|
platform: String,
|
||||||
|
channel: String,
|
||||||
|
osFamily: String,
|
||||||
|
appVersion: String,
|
||||||
|
buildNumber: String,
|
||||||
|
releaseChannel: String,
|
||||||
|
serverUrl: String,
|
||||||
|
pollIntervalMs: Int = 5000,
|
||||||
|
maxBreadcrumbs: Int = 100,
|
||||||
|
captureConsole: Bool = true,
|
||||||
|
captureErrors: Bool = true,
|
||||||
|
captureNetwork: Bool = true,
|
||||||
|
getAuthToken: (@Sendable () async throws -> String)? = nil
|
||||||
|
) {
|
||||||
|
self.productId = productId
|
||||||
|
self.userId = userId
|
||||||
|
self.anonymousInstallId = anonymousInstallId
|
||||||
|
self.platform = platform
|
||||||
|
self.channel = channel
|
||||||
|
self.osFamily = osFamily
|
||||||
|
self.appVersion = appVersion
|
||||||
|
self.buildNumber = buildNumber
|
||||||
|
self.releaseChannel = releaseChannel
|
||||||
|
self.serverUrl = serverUrl
|
||||||
|
self.pollIntervalMs = pollIntervalMs
|
||||||
|
self.maxBreadcrumbs = maxBreadcrumbs
|
||||||
|
self.captureConsole = captureConsole
|
||||||
|
self.captureErrors = captureErrors
|
||||||
|
self.captureNetwork = captureNetwork
|
||||||
|
self.getAuthToken = getAuthToken
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Logger protocol
|
||||||
|
public protocol DiagnosticsLogger: Sendable {
|
||||||
|
func debug(_ message: String, metadata: [String: any Sendable]?)
|
||||||
|
func info(_ message: String, metadata: [String: any Sendable]?)
|
||||||
|
func warn(_ message: String, metadata: [String: any Sendable]?)
|
||||||
|
func error(_ message: String, metadata: [String: any Sendable]?)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Default no-op logger
|
||||||
|
public struct NoOpDiagnosticsLogger: DiagnosticsLogger {
|
||||||
|
public init() {}
|
||||||
|
public func debug(_ message: String, metadata: [String: any Sendable]?) {}
|
||||||
|
public func info(_ message: String, metadata: [String: any Sendable]?) {}
|
||||||
|
public func warn(_ message: String, metadata: [String: any Sendable]?) {}
|
||||||
|
public func error(_ message: String, metadata: [String: any Sendable]?) {}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// OSLog-based logger
|
||||||
|
public struct OSDiagnosticsLogger: DiagnosticsLogger {
|
||||||
|
private let subsystem: String
|
||||||
|
private let category: String
|
||||||
|
|
||||||
|
public init(subsystem: String = "com.bytelyst.diagnostics", category: String = "DiagnosticsClient") {
|
||||||
|
self.subsystem = subsystem
|
||||||
|
self.category = category
|
||||||
|
}
|
||||||
|
|
||||||
|
public func debug(_ message: String, metadata: [String: any Sendable]?) {
|
||||||
|
#if DEBUG
|
||||||
|
print("[DEBUG] \(message)")
|
||||||
|
#endif
|
||||||
|
}
|
||||||
|
|
||||||
|
public func info(_ message: String, metadata: [String: any Sendable]?) {
|
||||||
|
print("[INFO] \(message)")
|
||||||
|
}
|
||||||
|
|
||||||
|
public func warn(_ message: String, metadata: [String: any Sendable]?) {
|
||||||
|
print("[WARN] \(message)")
|
||||||
|
}
|
||||||
|
|
||||||
|
public func error(_ message: String, metadata: [String: any Sendable]?) {
|
||||||
|
print("[ERROR] \(message)")
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,397 @@
|
|||||||
|
import Foundation
|
||||||
|
|
||||||
|
/// Thread-safe diagnostics client using Swift actors
|
||||||
|
public actor DiagnosticsClient {
|
||||||
|
public static let shared = DiagnosticsClient()
|
||||||
|
|
||||||
|
private var configuration: DiagnosticsConfiguration?
|
||||||
|
private var logger: DiagnosticsLogger = NoOpDiagnosticsLogger()
|
||||||
|
private var state: DiagnosticsClientState = .idle
|
||||||
|
private var breadcrumbs = BreadcrumbTrail(maxSize: 100)
|
||||||
|
private var logBuffer: [DiagnosticsLogEntry] = []
|
||||||
|
private var traceBuffer: [DiagnosticsTraceSpan] = []
|
||||||
|
private var networkBuffer: [DiagnosticsNetworkRequest] = []
|
||||||
|
private var pollTimer: Timer?
|
||||||
|
private var flushTask: Task<Void, Never>?
|
||||||
|
private var lastEtag: String?
|
||||||
|
private var networkInterceptor: NetworkInterceptor?
|
||||||
|
|
||||||
|
// MARK: - Singleton
|
||||||
|
|
||||||
|
private init() {}
|
||||||
|
|
||||||
|
// MARK: - Configuration
|
||||||
|
|
||||||
|
public func configure(_ config: DiagnosticsConfiguration, logger: DiagnosticsLogger? = nil) {
|
||||||
|
self.configuration = config
|
||||||
|
self.logger = logger ?? NoOpDiagnosticsLogger()
|
||||||
|
self.breadcrumbs = BreadcrumbTrail(maxSize: config.maxBreadcrumbs)
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Lifecycle
|
||||||
|
|
||||||
|
public func start() async {
|
||||||
|
guard case .idle = state else {
|
||||||
|
await logger.warn("[diagnostics] Already started", metadata: nil)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
guard let config = configuration else {
|
||||||
|
await logger.error("[diagnostics] Not configured", metadata: nil)
|
||||||
|
state = .error(DiagnosticsError.notConfigured)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
await logger.info("[diagnostics] Starting diagnostics client", metadata: nil)
|
||||||
|
state = .polling(session: nil)
|
||||||
|
|
||||||
|
// Initial poll
|
||||||
|
await pollForSession()
|
||||||
|
|
||||||
|
// Setup network capture if enabled
|
||||||
|
if config.captureNetwork {
|
||||||
|
setupNetworkCapture()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Start flush timer (every 30 seconds)
|
||||||
|
startFlushTimer()
|
||||||
|
|
||||||
|
await breadcrumbs.add(category: "diagnostics", message: "Client started")
|
||||||
|
}
|
||||||
|
|
||||||
|
public func stop() async {
|
||||||
|
await logger.info("[diagnostics] Stopping diagnostics client", metadata: nil)
|
||||||
|
|
||||||
|
pollTimer?.invalidate()
|
||||||
|
pollTimer = nil
|
||||||
|
|
||||||
|
flushTask?.cancel()
|
||||||
|
flushTask = nil
|
||||||
|
|
||||||
|
networkInterceptor?.stop()
|
||||||
|
networkInterceptor = nil
|
||||||
|
|
||||||
|
// Final flush
|
||||||
|
await flush()
|
||||||
|
|
||||||
|
state = .idle
|
||||||
|
await breadcrumbs.add(category: "diagnostics", message: "Client stopped")
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - State
|
||||||
|
|
||||||
|
public func isSessionActive() -> Bool {
|
||||||
|
if case .active = state {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
public func getCurrentSession() -> DiagnosticsSession? {
|
||||||
|
switch state {
|
||||||
|
case .active(let session), .polling(let session):
|
||||||
|
return session
|
||||||
|
default:
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public func getState() -> DiagnosticsClientState {
|
||||||
|
state
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Logging
|
||||||
|
|
||||||
|
public func log(
|
||||||
|
level: DiagnosticsLogLevel,
|
||||||
|
message: String,
|
||||||
|
module: String = "unknown",
|
||||||
|
file: String? = nil,
|
||||||
|
line: Int? = nil,
|
||||||
|
function: String? = nil,
|
||||||
|
context: [String: AnyCodable] = [:],
|
||||||
|
correlationId: String? = nil
|
||||||
|
) async {
|
||||||
|
let entry = DiagnosticsLogEntry(
|
||||||
|
level: level,
|
||||||
|
message: message,
|
||||||
|
timestamp: ISO8601DateFormatter().string(from: Date()),
|
||||||
|
module: module,
|
||||||
|
file: file,
|
||||||
|
line: line,
|
||||||
|
function: function,
|
||||||
|
context: context,
|
||||||
|
correlationId: correlationId
|
||||||
|
)
|
||||||
|
|
||||||
|
logBuffer.append(entry)
|
||||||
|
await breadcrumbs.add(
|
||||||
|
category: "log",
|
||||||
|
message: "[\(level.rawValue.uppercased())] \(String(message.prefix(100)))",
|
||||||
|
data: ["level": AnyCodable(level.rawValue)]
|
||||||
|
)
|
||||||
|
|
||||||
|
// Auto-flush on fatal
|
||||||
|
if level == .fatal {
|
||||||
|
Task { await flush() }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Tracing
|
||||||
|
|
||||||
|
public func trace<T>(
|
||||||
|
name: String,
|
||||||
|
operation: () async throws -> T
|
||||||
|
) async rethrows -> T {
|
||||||
|
let spanId = generateId()
|
||||||
|
let startTime = ISO8601DateFormatter().string(from: Date())
|
||||||
|
|
||||||
|
await breadcrumbs.add(
|
||||||
|
category: "trace",
|
||||||
|
message: "Starting: \(name)",
|
||||||
|
data: ["spanId": AnyCodable(spanId)]
|
||||||
|
)
|
||||||
|
|
||||||
|
do {
|
||||||
|
let result = try await operation()
|
||||||
|
let endTime = ISO8601DateFormatter().string(from: Date())
|
||||||
|
let start = ISO8601DateFormatter().date(from: startTime) ?? Date()
|
||||||
|
let end = ISO8601DateFormatter().date(from: endTime) ?? Date()
|
||||||
|
let durationMs = end.timeIntervalSince(start) * 1000
|
||||||
|
|
||||||
|
let span = DiagnosticsTraceSpan(
|
||||||
|
spanId: spanId,
|
||||||
|
name: name,
|
||||||
|
startTime: startTime,
|
||||||
|
endTime: endTime,
|
||||||
|
durationMs: durationMs,
|
||||||
|
attributes: [:],
|
||||||
|
status: .ok
|
||||||
|
)
|
||||||
|
traceBuffer.append(span)
|
||||||
|
|
||||||
|
await breadcrumbs.add(
|
||||||
|
category: "trace",
|
||||||
|
message: "Completed: \(name)",
|
||||||
|
data: [
|
||||||
|
"spanId": AnyCodable(spanId),
|
||||||
|
"durationMs": AnyCodable(durationMs)
|
||||||
|
]
|
||||||
|
)
|
||||||
|
|
||||||
|
return result
|
||||||
|
} catch {
|
||||||
|
let endTime = ISO8601DateFormatter().string(from: Date())
|
||||||
|
let start = ISO8601DateFormatter().date(from: startTime) ?? Date()
|
||||||
|
let end = ISO8601DateFormatter().date(from: endTime) ?? Date()
|
||||||
|
let durationMs = end.timeIntervalSince(start) * 1000
|
||||||
|
|
||||||
|
let span = DiagnosticsTraceSpan(
|
||||||
|
spanId: spanId,
|
||||||
|
name: name,
|
||||||
|
startTime: startTime,
|
||||||
|
endTime: endTime,
|
||||||
|
durationMs: durationMs,
|
||||||
|
attributes: [:],
|
||||||
|
status: .error,
|
||||||
|
statusMessage: error.localizedDescription
|
||||||
|
)
|
||||||
|
traceBuffer.append(span)
|
||||||
|
|
||||||
|
await breadcrumbs.add(
|
||||||
|
category: "trace",
|
||||||
|
message: "Failed: \(name)",
|
||||||
|
data: [
|
||||||
|
"spanId": AnyCodable(spanId),
|
||||||
|
"error": AnyCodable(error.localizedDescription)
|
||||||
|
]
|
||||||
|
)
|
||||||
|
|
||||||
|
throw error
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Breadcrumbs
|
||||||
|
|
||||||
|
public func breadcrumb(
|
||||||
|
category: String,
|
||||||
|
message: String,
|
||||||
|
data: [String: AnyCodable]? = nil
|
||||||
|
) async {
|
||||||
|
await breadcrumbs.add(category: category, message: message, data: data)
|
||||||
|
}
|
||||||
|
|
||||||
|
public func getBreadcrumbs() -> [DiagnosticsBreadcrumb] {
|
||||||
|
breadcrumbs.getAll()
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Private
|
||||||
|
|
||||||
|
private func pollForSession() async {
|
||||||
|
guard let config = configuration else { return }
|
||||||
|
|
||||||
|
var request = URLRequest(
|
||||||
|
url: URL(string: "\(config.serverUrl)/api/diagnostics/config?productId=\(config.productId)&installId=\(config.anonymousInstallId)")!
|
||||||
|
)
|
||||||
|
request.setValue("application/json", forHTTPHeaderField: "Accept")
|
||||||
|
|
||||||
|
if let etag = lastEtag {
|
||||||
|
request.setValue(etag, forHTTPHeaderField: "If-None-Match")
|
||||||
|
}
|
||||||
|
|
||||||
|
if let getToken = config.getAuthToken {
|
||||||
|
do {
|
||||||
|
let token = try await getToken()
|
||||||
|
request.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization")
|
||||||
|
} catch {
|
||||||
|
await logger.error("[diagnostics] Failed to get auth token", metadata: ["error": error.localizedDescription])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
do {
|
||||||
|
let (data, response) = try await URLSession.shared.data(for: request)
|
||||||
|
|
||||||
|
guard let httpResponse = response as? HTTPURLResponse else {
|
||||||
|
throw DiagnosticsError.invalidResponse
|
||||||
|
}
|
||||||
|
|
||||||
|
if httpResponse.statusCode == 304 {
|
||||||
|
// No change
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
guard httpResponse.statusCode == 200 else {
|
||||||
|
throw DiagnosticsError.httpError(statusCode: httpResponse.statusCode)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Store ETag
|
||||||
|
if let etag = httpResponse.allHeaderFields["Etag"] as? String {
|
||||||
|
lastEtag = etag
|
||||||
|
}
|
||||||
|
|
||||||
|
let decoder = JSONDecoder()
|
||||||
|
let session = try? decoder.decode(DiagnosticsSession.self, from: data)
|
||||||
|
|
||||||
|
if let session = session, session.status == .active {
|
||||||
|
if case .active = state {
|
||||||
|
// Already active, just update session
|
||||||
|
} else {
|
||||||
|
await logger.info("[diagnostics] Session activated", metadata: ["sessionId": session.id])
|
||||||
|
await breadcrumbs.add(category: "diagnostics", message: "Session activated", data: ["sessionId": AnyCodable(session.id)])
|
||||||
|
}
|
||||||
|
state = .active(session: session)
|
||||||
|
} else {
|
||||||
|
if case .active = state {
|
||||||
|
await logger.info("[diagnostics] Session ended", metadata: nil)
|
||||||
|
await breadcrumbs.add(category: "diagnostics", message: "Session ended")
|
||||||
|
}
|
||||||
|
state = .polling(session: nil)
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
await logger.error("[diagnostics] Failed to poll for session", metadata: ["error": error.localizedDescription])
|
||||||
|
state = .error(error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func flush() async {
|
||||||
|
guard let session = getCurrentSession() else {
|
||||||
|
logBuffer.removeAll()
|
||||||
|
traceBuffer.removeAll()
|
||||||
|
networkBuffer.removeAll()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
let batch = DiagnosticsIngestBatch(
|
||||||
|
sessionId: session.id,
|
||||||
|
traces: traceBuffer.isEmpty ? nil : traceBuffer,
|
||||||
|
logs: logBuffer.isEmpty ? nil : logBuffer,
|
||||||
|
breadcrumbs: breadcrumbs.getAll().isEmpty ? nil : breadcrumbs.getAll(),
|
||||||
|
network: networkBuffer.isEmpty ? nil : networkBuffer
|
||||||
|
)
|
||||||
|
|
||||||
|
// Clear buffers
|
||||||
|
logBuffer.removeAll()
|
||||||
|
traceBuffer.removeAll()
|
||||||
|
networkBuffer.removeAll()
|
||||||
|
breadcrumbs.clear()
|
||||||
|
|
||||||
|
guard let config = configuration else { return }
|
||||||
|
|
||||||
|
var request = URLRequest(
|
||||||
|
url: URL(string: "\(config.serverUrl)/api/diagnostics/ingest")!
|
||||||
|
)
|
||||||
|
request.httpMethod = "POST"
|
||||||
|
request.setValue("application/json", forHTTPHeaderField: "Content-Type")
|
||||||
|
|
||||||
|
if let getToken = config.getAuthToken {
|
||||||
|
do {
|
||||||
|
let token = try await getToken()
|
||||||
|
request.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization")
|
||||||
|
} catch {
|
||||||
|
await logger.error("[diagnostics] Failed to get auth token for flush", metadata: ["error": error.localizedDescription])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
do {
|
||||||
|
let encoder = JSONEncoder()
|
||||||
|
request.httpBody = try encoder.encode(batch)
|
||||||
|
|
||||||
|
let (_, response) = try await URLSession.shared.data(for: request)
|
||||||
|
|
||||||
|
guard let httpResponse = response as? HTTPURLResponse, httpResponse.statusCode == 200 else {
|
||||||
|
throw DiagnosticsError.flushFailed
|
||||||
|
}
|
||||||
|
|
||||||
|
await logger.debug("[diagnostics] Flushed batch", metadata: [
|
||||||
|
"logs": batch.logs?.count ?? 0,
|
||||||
|
"traces": batch.traces?.count ?? 0,
|
||||||
|
"network": batch.network?.count ?? 0
|
||||||
|
])
|
||||||
|
} catch {
|
||||||
|
await logger.error("[diagnostics] Failed to flush batch", metadata: ["error": error.localizedDescription])
|
||||||
|
|
||||||
|
// Put items back in buffers for retry
|
||||||
|
if let logs = batch.logs { logBuffer.append(contentsOf: logs) }
|
||||||
|
if let traces = batch.traces { traceBuffer.append(contentsOf: traces) }
|
||||||
|
if let network = batch.network { networkBuffer.append(contentsOf: network) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func startFlushTimer() {
|
||||||
|
flushTask = Task {
|
||||||
|
while !Task.isCancelled {
|
||||||
|
try? await Task.sleep(nanoseconds: 30 * 1_000_000_000) // 30 seconds
|
||||||
|
await flush()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func setupNetworkCapture() {
|
||||||
|
networkInterceptor = NetworkInterceptor { [weak self] request in
|
||||||
|
Task { [weak self] in
|
||||||
|
await self?.networkBuffer.append(request)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
networkInterceptor?.start()
|
||||||
|
}
|
||||||
|
|
||||||
|
private func generateId() -> String {
|
||||||
|
"\(Int(Date().timeIntervalSince1970 * 1000))_\(UUID().uuidString.prefix(7))"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Client state enum
|
||||||
|
public enum DiagnosticsClientState: Sendable {
|
||||||
|
case idle
|
||||||
|
case polling(session: DiagnosticsSession?)
|
||||||
|
case active(session: DiagnosticsSession)
|
||||||
|
case error(Error)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Diagnostics errors
|
||||||
|
public enum DiagnosticsError: Error, Sendable {
|
||||||
|
case notConfigured
|
||||||
|
case invalidResponse
|
||||||
|
case httpError(statusCode: Int)
|
||||||
|
case flushFailed
|
||||||
|
}
|
||||||
@ -0,0 +1,335 @@
|
|||||||
|
import Foundation
|
||||||
|
|
||||||
|
/// Log severity levels (matches syslog/OpenTelemetry)
|
||||||
|
public enum DiagnosticsLogLevel: String, Codable, Sendable {
|
||||||
|
case debug
|
||||||
|
case info
|
||||||
|
case warn
|
||||||
|
case error
|
||||||
|
case fatal
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Session status from the server
|
||||||
|
public enum DiagnosticsSessionStatus: String, Codable, Sendable {
|
||||||
|
case pending
|
||||||
|
case active
|
||||||
|
case paused
|
||||||
|
case completed
|
||||||
|
case cancelled
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Collection level determines verbosity of captured data
|
||||||
|
public enum DiagnosticsCollectionLevel: String, Codable, Sendable {
|
||||||
|
case standard
|
||||||
|
case debug
|
||||||
|
case trace
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Diagnostic session configuration from server
|
||||||
|
public struct DiagnosticsSession: Codable, Sendable {
|
||||||
|
public let id: String
|
||||||
|
public let productId: String
|
||||||
|
public let status: DiagnosticsSessionStatus
|
||||||
|
public let collectionLevel: DiagnosticsCollectionLevel
|
||||||
|
public let captureLogs: Bool
|
||||||
|
public let captureNetwork: Bool
|
||||||
|
public let captureScreenshots: Bool
|
||||||
|
public let screenshotOnError: Bool
|
||||||
|
public let maxDurationMinutes: Int
|
||||||
|
public let createdAt: String
|
||||||
|
public let expiresAt: String
|
||||||
|
|
||||||
|
public init(
|
||||||
|
id: String,
|
||||||
|
productId: String,
|
||||||
|
status: DiagnosticsSessionStatus,
|
||||||
|
collectionLevel: DiagnosticsCollectionLevel,
|
||||||
|
captureLogs: Bool,
|
||||||
|
captureNetwork: Bool,
|
||||||
|
captureScreenshots: Bool,
|
||||||
|
screenshotOnError: Bool,
|
||||||
|
maxDurationMinutes: Int,
|
||||||
|
createdAt: String,
|
||||||
|
expiresAt: String
|
||||||
|
) {
|
||||||
|
self.id = id
|
||||||
|
self.productId = productId
|
||||||
|
self.status = status
|
||||||
|
self.collectionLevel = collectionLevel
|
||||||
|
self.captureLogs = captureLogs
|
||||||
|
self.captureNetwork = captureNetwork
|
||||||
|
self.captureScreenshots = captureScreenshots
|
||||||
|
self.screenshotOnError = screenshotOnError
|
||||||
|
self.maxDurationMinutes = maxDurationMinutes
|
||||||
|
self.createdAt = createdAt
|
||||||
|
self.expiresAt = expiresAt
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Span kind for OpenTelemetry compatibility
|
||||||
|
public enum DiagnosticsSpanKind: String, Codable, Sendable {
|
||||||
|
case internal
|
||||||
|
case server
|
||||||
|
case client
|
||||||
|
case producer
|
||||||
|
case consumer
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Span status
|
||||||
|
public enum DiagnosticsSpanStatus: String, Codable, Sendable {
|
||||||
|
case ok
|
||||||
|
case error
|
||||||
|
case unset
|
||||||
|
}
|
||||||
|
|
||||||
|
/// OpenTelemetry-compatible trace span
|
||||||
|
public struct DiagnosticsTraceSpan: Codable, Sendable {
|
||||||
|
public let spanId: String
|
||||||
|
public let parentId: String?
|
||||||
|
public let name: String
|
||||||
|
public let kind: DiagnosticsSpanKind?
|
||||||
|
public let startTime: String
|
||||||
|
public let endTime: String?
|
||||||
|
public let durationMs: Double?
|
||||||
|
public let attributes: [String: AnyCodable]
|
||||||
|
public let status: DiagnosticsSpanStatus
|
||||||
|
public let statusMessage: String?
|
||||||
|
|
||||||
|
public init(
|
||||||
|
spanId: String,
|
||||||
|
parentId: String? = nil,
|
||||||
|
name: String,
|
||||||
|
kind: DiagnosticsSpanKind? = nil,
|
||||||
|
startTime: String,
|
||||||
|
endTime: String? = nil,
|
||||||
|
durationMs: Double? = nil,
|
||||||
|
attributes: [String: AnyCodable] = [:],
|
||||||
|
status: DiagnosticsSpanStatus,
|
||||||
|
statusMessage: String? = nil
|
||||||
|
) {
|
||||||
|
self.spanId = spanId
|
||||||
|
self.parentId = parentId
|
||||||
|
self.name = name
|
||||||
|
self.kind = kind
|
||||||
|
self.startTime = startTime
|
||||||
|
self.endTime = endTime
|
||||||
|
self.durationMs = durationMs
|
||||||
|
self.attributes = attributes
|
||||||
|
self.status = status
|
||||||
|
self.statusMessage = statusMessage
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Structured log entry
|
||||||
|
public struct DiagnosticsLogEntry: Codable, Sendable {
|
||||||
|
public let level: DiagnosticsLogLevel
|
||||||
|
public let message: String
|
||||||
|
public let timestamp: String
|
||||||
|
public let module: String
|
||||||
|
public let file: String?
|
||||||
|
public let line: Int?
|
||||||
|
public let function: String?
|
||||||
|
public let context: [String: AnyCodable]
|
||||||
|
public let correlationId: String?
|
||||||
|
|
||||||
|
public init(
|
||||||
|
level: DiagnosticsLogLevel,
|
||||||
|
message: String,
|
||||||
|
timestamp: String,
|
||||||
|
module: String,
|
||||||
|
file: String? = nil,
|
||||||
|
line: Int? = nil,
|
||||||
|
function: String? = nil,
|
||||||
|
context: [String: AnyCodable] = [:],
|
||||||
|
correlationId: String? = nil
|
||||||
|
) {
|
||||||
|
self.level = level
|
||||||
|
self.message = message
|
||||||
|
self.timestamp = timestamp
|
||||||
|
self.module = module
|
||||||
|
self.file = file
|
||||||
|
self.line = line
|
||||||
|
self.function = function
|
||||||
|
self.context = context
|
||||||
|
self.correlationId = correlationId
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Breadcrumb for timeline navigation
|
||||||
|
public struct DiagnosticsBreadcrumb: Codable, Sendable {
|
||||||
|
public let timestamp: String
|
||||||
|
public let category: String
|
||||||
|
public let message: String
|
||||||
|
public let data: [String: AnyCodable]?
|
||||||
|
|
||||||
|
public init(
|
||||||
|
timestamp: String,
|
||||||
|
category: String,
|
||||||
|
message: String,
|
||||||
|
data: [String: AnyCodable]? = nil
|
||||||
|
) {
|
||||||
|
self.timestamp = timestamp
|
||||||
|
self.category = category
|
||||||
|
self.message = message
|
||||||
|
self.data = data
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Network request/response capture
|
||||||
|
public struct DiagnosticsNetworkRequest: Codable, Sendable {
|
||||||
|
public let id: String
|
||||||
|
public let url: String
|
||||||
|
public let method: String
|
||||||
|
public let requestHeaders: [String: String]
|
||||||
|
public let requestBody: String?
|
||||||
|
public let status: Int?
|
||||||
|
public let responseHeaders: [String: String]?
|
||||||
|
public let responseBody: String?
|
||||||
|
public let startTime: String
|
||||||
|
public let endTime: String?
|
||||||
|
public let durationMs: Double?
|
||||||
|
public let error: String?
|
||||||
|
|
||||||
|
public init(
|
||||||
|
id: String,
|
||||||
|
url: String,
|
||||||
|
method: String,
|
||||||
|
requestHeaders: [String: String] = [:],
|
||||||
|
requestBody: String? = nil,
|
||||||
|
status: Int? = nil,
|
||||||
|
responseHeaders: [String: String]? = nil,
|
||||||
|
responseBody: String? = nil,
|
||||||
|
startTime: String,
|
||||||
|
endTime: String? = nil,
|
||||||
|
durationMs: Double? = nil,
|
||||||
|
error: String? = nil
|
||||||
|
) {
|
||||||
|
self.id = id
|
||||||
|
self.url = url
|
||||||
|
self.method = method
|
||||||
|
self.requestHeaders = requestHeaders
|
||||||
|
self.requestBody = requestBody
|
||||||
|
self.status = status
|
||||||
|
self.responseHeaders = responseHeaders
|
||||||
|
self.responseBody = responseBody
|
||||||
|
self.startTime = startTime
|
||||||
|
self.endTime = endTime
|
||||||
|
self.durationMs = durationMs
|
||||||
|
self.error = error
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Device state snapshot
|
||||||
|
public struct DiagnosticsDeviceState: Codable, Sendable {
|
||||||
|
public let memoryMB: Int?
|
||||||
|
public let batteryLevel: Double?
|
||||||
|
public let isCharging: Bool?
|
||||||
|
public let storageMB: Int?
|
||||||
|
public let networkType: String?
|
||||||
|
public let isOnline: Bool
|
||||||
|
public let thermalState: DiagnosticsThermalState?
|
||||||
|
|
||||||
|
public init(
|
||||||
|
memoryMB: Int? = nil,
|
||||||
|
batteryLevel: Double? = nil,
|
||||||
|
isCharging: Bool? = nil,
|
||||||
|
storageMB: Int? = nil,
|
||||||
|
networkType: String? = nil,
|
||||||
|
isOnline: Bool,
|
||||||
|
thermalState: DiagnosticsThermalState? = nil
|
||||||
|
) {
|
||||||
|
self.memoryMB = memoryMB
|
||||||
|
self.batteryLevel = batteryLevel
|
||||||
|
self.isCharging = isCharging
|
||||||
|
self.storageMB = storageMB
|
||||||
|
self.networkType = networkType
|
||||||
|
self.isOnline = isOnline
|
||||||
|
self.thermalState = thermalState
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Thermal state
|
||||||
|
public enum DiagnosticsThermalState: String, Codable, Sendable {
|
||||||
|
case nominal
|
||||||
|
case fair
|
||||||
|
case serious
|
||||||
|
case critical
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Client state
|
||||||
|
public enum DiagnosticsClientState: Sendable {
|
||||||
|
case idle
|
||||||
|
case polling(session: DiagnosticsSession?)
|
||||||
|
case active(session: DiagnosticsSession)
|
||||||
|
case error(Error)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Ingest batch for sending to server
|
||||||
|
public struct DiagnosticsIngestBatch: Codable, Sendable {
|
||||||
|
public let sessionId: String
|
||||||
|
public let traces: [DiagnosticsTraceSpan]?
|
||||||
|
public let logs: [DiagnosticsLogEntry]?
|
||||||
|
public let breadcrumbs: [DiagnosticsBreadcrumb]?
|
||||||
|
public let network: [DiagnosticsNetworkRequest]?
|
||||||
|
|
||||||
|
public init(
|
||||||
|
sessionId: String,
|
||||||
|
traces: [DiagnosticsTraceSpan]? = nil,
|
||||||
|
logs: [DiagnosticsLogEntry]? = nil,
|
||||||
|
breadcrumbs: [DiagnosticsBreadcrumb]? = nil,
|
||||||
|
network: [DiagnosticsNetworkRequest]? = nil
|
||||||
|
) {
|
||||||
|
self.sessionId = sessionId
|
||||||
|
self.traces = traces
|
||||||
|
self.logs = logs
|
||||||
|
self.breadcrumbs = breadcrumbs
|
||||||
|
self.network = network
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Type-erased Codable wrapper for dictionary values
|
||||||
|
public struct AnyCodable: Codable, Sendable {
|
||||||
|
private let value: any Codable
|
||||||
|
|
||||||
|
public init(_ value: any Codable) {
|
||||||
|
self.value = value
|
||||||
|
}
|
||||||
|
|
||||||
|
public init(from decoder: Decoder) throws {
|
||||||
|
let container = try decoder.singleValueContainer()
|
||||||
|
if let boolValue = try? container.decode(Bool.self) {
|
||||||
|
value = boolValue
|
||||||
|
} else if let intValue = try? container.decode(Int.self) {
|
||||||
|
value = intValue
|
||||||
|
} else if let doubleValue = try? container.decode(Double.self) {
|
||||||
|
value = doubleValue
|
||||||
|
} else if let stringValue = try? container.decode(String.self) {
|
||||||
|
value = stringValue
|
||||||
|
} else if let arrayValue = try? container.decode([AnyCodable].self) {
|
||||||
|
value = arrayValue
|
||||||
|
} else if let dictValue = try? container.decode([String: AnyCodable].self) {
|
||||||
|
value = dictValue
|
||||||
|
} else {
|
||||||
|
value = ""
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public func encode(to encoder: Encoder) throws {
|
||||||
|
var container = encoder.singleValueContainer()
|
||||||
|
if let boolValue = value as? Bool {
|
||||||
|
try container.encode(boolValue)
|
||||||
|
} else if let intValue = value as? Int {
|
||||||
|
try container.encode(intValue)
|
||||||
|
} else if let doubleValue = value as? Double {
|
||||||
|
try container.encode(doubleValue)
|
||||||
|
} else if let stringValue = value as? String {
|
||||||
|
try container.encode(stringValue)
|
||||||
|
} else if let arrayValue = value as? [AnyCodable] {
|
||||||
|
try container.encode(arrayValue)
|
||||||
|
} else if let dictValue = value as? [String: AnyCodable] {
|
||||||
|
try container.encode(dictValue)
|
||||||
|
} else {
|
||||||
|
try container.encode("")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,191 @@
|
|||||||
|
import Foundation
|
||||||
|
import UIKit
|
||||||
|
#if os(iOS)
|
||||||
|
import SystemConfiguration
|
||||||
|
#endif
|
||||||
|
|
||||||
|
/// Device state collector
|
||||||
|
public struct DeviceStateCollector {
|
||||||
|
|
||||||
|
/// Collect current device state
|
||||||
|
public static func collect() -> DiagnosticsDeviceState {
|
||||||
|
#if os(iOS)
|
||||||
|
return DiagnosticsDeviceState(
|
||||||
|
memoryMB: getMemoryUsage(),
|
||||||
|
batteryLevel: getBatteryLevel(),
|
||||||
|
isCharging: getIsCharging(),
|
||||||
|
storageMB: getStorageUsage(),
|
||||||
|
networkType: getNetworkType(),
|
||||||
|
isOnline: getIsOnline(),
|
||||||
|
thermalState: getThermalState()
|
||||||
|
)
|
||||||
|
#elseif os(macOS)
|
||||||
|
return DiagnosticsDeviceState(
|
||||||
|
memoryMB: getMemoryUsage(),
|
||||||
|
batteryLevel: nil,
|
||||||
|
isCharging: nil,
|
||||||
|
storageMB: nil,
|
||||||
|
networkType: nil,
|
||||||
|
isOnline: getIsOnline(),
|
||||||
|
thermalState: nil
|
||||||
|
)
|
||||||
|
#else
|
||||||
|
return DiagnosticsDeviceState(
|
||||||
|
memoryMB: nil,
|
||||||
|
batteryLevel: nil,
|
||||||
|
isCharging: nil,
|
||||||
|
storageMB: nil,
|
||||||
|
networkType: nil,
|
||||||
|
isOnline: true,
|
||||||
|
thermalState: nil
|
||||||
|
)
|
||||||
|
#endif
|
||||||
|
}
|
||||||
|
|
||||||
|
#if os(iOS)
|
||||||
|
private static func getMemoryUsage() -> Int? {
|
||||||
|
var info = mach_task_basic_info()
|
||||||
|
var count = mach_msg_type_number_t(MemoryLayout<mach_task_basic_info>.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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
guard kerr == KERN_SUCCESS else { return nil }
|
||||||
|
return Int(info.resident_size / 1024 / 1024)
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func getBatteryLevel() -> Double? {
|
||||||
|
UIDevice.current.isBatteryMonitoringEnabled = true
|
||||||
|
return Double(UIDevice.current.batteryLevel)
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func getIsCharging() -> Bool? {
|
||||||
|
UIDevice.current.isBatteryMonitoringEnabled = true
|
||||||
|
return UIDevice.current.batteryState == .charging
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func getStorageUsage() -> Int? {
|
||||||
|
do {
|
||||||
|
let attributes = try FileManager.default.attributesOfFileSystem(forPath: NSHomeDirectory())
|
||||||
|
if let totalSize = attributes[.systemSize] as? NSNumber,
|
||||||
|
let freeSize = attributes[.systemFreeSize] as? NSNumber {
|
||||||
|
let usedSize = totalSize.int64Value - freeSize.int64Value
|
||||||
|
return Int(usedSize / 1024 / 1024)
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func getNetworkType() -> String? {
|
||||||
|
// Simplified - would need more complex reachability check for actual implementation
|
||||||
|
if getIsOnline() {
|
||||||
|
return "wifi" // Default assumption
|
||||||
|
}
|
||||||
|
return "offline"
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func getThermalState() -> DiagnosticsThermalState? {
|
||||||
|
switch ProcessInfo.processInfo.thermalState {
|
||||||
|
case .nominal:
|
||||||
|
return .nominal
|
||||||
|
case .fair:
|
||||||
|
return .fair
|
||||||
|
case .serious:
|
||||||
|
return .serious
|
||||||
|
case .critical:
|
||||||
|
return .critical
|
||||||
|
@unknown default:
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
#endif
|
||||||
|
|
||||||
|
private static func getIsOnline() -> Bool {
|
||||||
|
#if os(iOS) || os(macOS)
|
||||||
|
var zeroAddress = sockaddr_in()
|
||||||
|
zeroAddress.sin_len = UInt8(MemoryLayout<sockaddr_in>.size)
|
||||||
|
zeroAddress.sin_family = sa_family_t(AF_INET)
|
||||||
|
|
||||||
|
guard let defaultRouteReachability = withUnsafePointer(to: &zeroAddress, {
|
||||||
|
$0.withMemoryRebound(to: sockaddr.self, capacity: 1) {
|
||||||
|
SCNetworkReachabilityCreateWithAddress(nil, $0)
|
||||||
|
}
|
||||||
|
}) else {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
var flags: SCNetworkReachabilityFlags = []
|
||||||
|
if !SCNetworkReachabilityGetFlags(defaultRouteReachability, &flags) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
let isReachable = flags.contains(.reachable)
|
||||||
|
let needsConnection = flags.contains(.connectionRequired)
|
||||||
|
|
||||||
|
return isReachable && !needsConnection
|
||||||
|
#else
|
||||||
|
return true
|
||||||
|
#endif
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Connectivity Monitoring
|
||||||
|
|
||||||
|
#if os(iOS) || os(macOS)
|
||||||
|
import SystemConfiguration
|
||||||
|
|
||||||
|
/// Monitor network connectivity changes
|
||||||
|
public final class ConnectivityMonitor {
|
||||||
|
private var reachability: SCNetworkReachability?
|
||||||
|
private var callback: ((Bool) -> Void)?
|
||||||
|
|
||||||
|
public init() {
|
||||||
|
var zeroAddress = sockaddr_in()
|
||||||
|
zeroAddress.sin_len = UInt8(MemoryLayout<sockaddr_in>.size)
|
||||||
|
zeroAddress.sin_family = sa_family_t(AF_INET)
|
||||||
|
|
||||||
|
reachability = withUnsafePointer(to: &zeroAddress) {
|
||||||
|
$0.withMemoryRebound(to: sockaddr.self, capacity: 1) {
|
||||||
|
SCNetworkReachabilityCreateWithAddress(nil, $0)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public func startMonitoring(callback: @escaping (Bool) -> Void) {
|
||||||
|
self.callback = callback
|
||||||
|
|
||||||
|
guard let reachability = reachability else { return }
|
||||||
|
|
||||||
|
let context = SCNetworkReachabilityContext(
|
||||||
|
version: 0,
|
||||||
|
info: Unmanaged.passUnretained(self).toOpaque(),
|
||||||
|
retain: nil,
|
||||||
|
release: nil,
|
||||||
|
copyDescription: nil
|
||||||
|
)
|
||||||
|
|
||||||
|
SCNetworkReachabilitySetCallback(reachability, { (_, flags, info) in
|
||||||
|
guard let info = info else { return }
|
||||||
|
let monitor = Unmanaged<ConnectivityMonitor>.fromOpaque(info).takeUnretainedValue()
|
||||||
|
|
||||||
|
let isReachable = flags.contains(.reachable)
|
||||||
|
let needsConnection = flags.contains(.connectionRequired)
|
||||||
|
let isConnected = isReachable && !needsConnection
|
||||||
|
|
||||||
|
monitor.callback?(isConnected)
|
||||||
|
}, &context)
|
||||||
|
|
||||||
|
SCNetworkReachabilityScheduleWithRunLoop(reachability, CFRunLoopGetMain(), CFRunLoopMode.commonModes.rawValue)
|
||||||
|
}
|
||||||
|
|
||||||
|
public func stopMonitoring() {
|
||||||
|
guard let reachability = reachability else { return }
|
||||||
|
SCNetworkReachabilityUnscheduleFromRunLoop(reachability, CFRunLoopGetMain(), CFRunLoopMode.commonModes.rawValue)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
#endif
|
||||||
@ -0,0 +1,160 @@
|
|||||||
|
import Foundation
|
||||||
|
|
||||||
|
/// Network interceptor using URLProtocol for automatic capture
|
||||||
|
public final class NetworkInterceptor: URLProtocol {
|
||||||
|
public static var onRequest: ((DiagnosticsNetworkRequest) -> Void)?
|
||||||
|
private static let shared = NetworkInterceptor()
|
||||||
|
private var requestId: String?
|
||||||
|
private var startTime: Date?
|
||||||
|
private var request: URLRequest?
|
||||||
|
|
||||||
|
private lazy var session: URLSession = {
|
||||||
|
let config = URLSessionConfiguration.default
|
||||||
|
return URLSession(configuration: config, delegate: self, delegateQueue: nil)
|
||||||
|
}()
|
||||||
|
|
||||||
|
private var dataTask: URLSessionDataTask?
|
||||||
|
|
||||||
|
// MARK: - URLProtocol Overrides
|
||||||
|
|
||||||
|
public override class func canInit(with request: URLRequest) -> Bool {
|
||||||
|
// Don't intercept our own requests
|
||||||
|
if request.value(forHTTPHeaderField: "X-Diagnostics-Intercepted") != nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
public override class func canonicalRequest(for request: URLRequest) -> URLRequest {
|
||||||
|
return request
|
||||||
|
}
|
||||||
|
|
||||||
|
public override func startLoading() {
|
||||||
|
requestId = "\(Int(Date().timeIntervalSince1970 * 1000))_\(UUID().uuidString.prefix(7))"
|
||||||
|
startTime = Date()
|
||||||
|
|
||||||
|
var newRequest = request
|
||||||
|
newRequest?.setValue("true", forHTTPHeaderField: "X-Diagnostics-Intercepted")
|
||||||
|
|
||||||
|
self.request = newRequest
|
||||||
|
|
||||||
|
dataTask = session.dataTask(with: newRequest!)
|
||||||
|
dataTask?.resume()
|
||||||
|
}
|
||||||
|
|
||||||
|
public override func stopLoading() {
|
||||||
|
dataTask?.cancel()
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Public API
|
||||||
|
|
||||||
|
public static func start() {
|
||||||
|
URLProtocol.registerClass(NetworkInterceptor.self)
|
||||||
|
}
|
||||||
|
|
||||||
|
public static func stop() {
|
||||||
|
URLProtocol.unregisterClass(NetworkInterceptor.self)
|
||||||
|
}
|
||||||
|
|
||||||
|
public func start(with handler: @escaping (DiagnosticsNetworkRequest) -> Void) {
|
||||||
|
NetworkInterceptor.onRequest = handler
|
||||||
|
NetworkInterceptor.start()
|
||||||
|
}
|
||||||
|
|
||||||
|
public func stopInterceptor() {
|
||||||
|
NetworkInterceptor.stop()
|
||||||
|
NetworkInterceptor.onRequest = nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - URLSessionDataDelegate
|
||||||
|
|
||||||
|
extension NetworkInterceptor: URLSessionDataDelegate {
|
||||||
|
public func urlSession(
|
||||||
|
_ session: URLSession,
|
||||||
|
dataTask: URLSessionDataTask,
|
||||||
|
didReceive data: Data
|
||||||
|
) {
|
||||||
|
client?.urlProtocol(self, didLoad: data)
|
||||||
|
}
|
||||||
|
|
||||||
|
public func urlSession(
|
||||||
|
_ session: URLSession,
|
||||||
|
task: URLSessionTask,
|
||||||
|
didCompleteWithError error: Error?
|
||||||
|
) {
|
||||||
|
guard let request = request,
|
||||||
|
let requestId = requestId,
|
||||||
|
let startTime = startTime else {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
let endTime = Date()
|
||||||
|
let durationMs = endTime.timeIntervalSince(startTime) * 1000
|
||||||
|
|
||||||
|
var networkRequest = DiagnosticsNetworkRequest(
|
||||||
|
id: requestId,
|
||||||
|
url: request.url?.absoluteString ?? "",
|
||||||
|
method: request.httpMethod ?? "GET",
|
||||||
|
requestHeaders: request.allHTTPHeaderFields?.mapValues { value in
|
||||||
|
NetworkInterceptor.sanitizeHeader(value, key: "")
|
||||||
|
} ?? [:],
|
||||||
|
requestBody: request.httpBody.flatMap { String(data: $0, encoding: .utf8) },
|
||||||
|
startTime: ISO8601DateFormatter().string(from: startTime),
|
||||||
|
endTime: ISO8601DateFormatter().string(from: endTime),
|
||||||
|
durationMs: durationMs
|
||||||
|
)
|
||||||
|
|
||||||
|
if let error = error {
|
||||||
|
networkRequest = DiagnosticsNetworkRequest(
|
||||||
|
id: requestId,
|
||||||
|
url: networkRequest.url,
|
||||||
|
method: networkRequest.method,
|
||||||
|
requestHeaders: networkRequest.requestHeaders,
|
||||||
|
requestBody: networkRequest.requestBody,
|
||||||
|
status: nil,
|
||||||
|
responseHeaders: nil,
|
||||||
|
responseBody: nil,
|
||||||
|
startTime: networkRequest.startTime,
|
||||||
|
endTime: networkRequest.endTime,
|
||||||
|
durationMs: networkRequest.durationMs,
|
||||||
|
error: error.localizedDescription
|
||||||
|
)
|
||||||
|
} else if let response = task.response as? HTTPURLResponse {
|
||||||
|
networkRequest = DiagnosticsNetworkRequest(
|
||||||
|
id: requestId,
|
||||||
|
url: networkRequest.url,
|
||||||
|
method: networkRequest.method,
|
||||||
|
requestHeaders: networkRequest.requestHeaders,
|
||||||
|
requestBody: networkRequest.requestBody,
|
||||||
|
status: response.statusCode,
|
||||||
|
responseHeaders: response.allHeaderFields as? [String: String],
|
||||||
|
responseBody: nil, // Don't capture response body (too large)
|
||||||
|
startTime: networkRequest.startTime,
|
||||||
|
endTime: networkRequest.endTime,
|
||||||
|
durationMs: networkRequest.durationMs,
|
||||||
|
error: nil
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
NetworkInterceptor.onRequest?(networkRequest)
|
||||||
|
|
||||||
|
if let error = error {
|
||||||
|
client?.urlProtocol(self, didFailWithError: error)
|
||||||
|
} else {
|
||||||
|
client?.urlProtocolDidFinishLoading(self)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func sanitizeHeader(_ value: String, key: String) -> String {
|
||||||
|
let sensitivePatterns = ["authorization", "cookie", "token", "api-key"]
|
||||||
|
let lowerKey = key.lowercased()
|
||||||
|
|
||||||
|
for pattern in sensitivePatterns {
|
||||||
|
if lowerKey.contains(pattern) {
|
||||||
|
return "[REDACTED]"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return value
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,296 @@
|
|||||||
|
import XCTest
|
||||||
|
@testable import ByteLystDiagnostics
|
||||||
|
|
||||||
|
final class DiagnosticsClientTests: XCTestCase {
|
||||||
|
|
||||||
|
override func setUp() {
|
||||||
|
super.setUp()
|
||||||
|
// Reset to initial state
|
||||||
|
Task {
|
||||||
|
// Client is actor, need to await
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Singleton Tests
|
||||||
|
|
||||||
|
func testSharedInstance() {
|
||||||
|
let client1 = DiagnosticsClient.shared
|
||||||
|
let client2 = DiagnosticsClient.shared
|
||||||
|
XCTAssertTrue(client1 === client2, "Shared instance should be singleton")
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Configuration Tests
|
||||||
|
|
||||||
|
func testConfiguration() async {
|
||||||
|
let config = DiagnosticsConfiguration(
|
||||||
|
productId: "test-app",
|
||||||
|
anonymousInstallId: "install-123",
|
||||||
|
platform: "ios",
|
||||||
|
channel: "ios_app",
|
||||||
|
osFamily: "ios",
|
||||||
|
appVersion: "1.0.0",
|
||||||
|
buildNumber: "100",
|
||||||
|
releaseChannel: "beta",
|
||||||
|
serverUrl: "https://api.test.com"
|
||||||
|
)
|
||||||
|
|
||||||
|
await DiagnosticsClient.shared.configure(config)
|
||||||
|
|
||||||
|
let state = await DiagnosticsClient.shared.getState()
|
||||||
|
XCTAssertEqual(state, .idle)
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Session State Tests
|
||||||
|
|
||||||
|
func testInitialSessionState() async {
|
||||||
|
let isActive = await DiagnosticsClient.shared.isSessionActive()
|
||||||
|
XCTAssertFalse(isActive)
|
||||||
|
|
||||||
|
let session = await DiagnosticsClient.shared.getCurrentSession()
|
||||||
|
XCTAssertNil(session)
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Breadcrumb Tests
|
||||||
|
|
||||||
|
func testBreadcrumbAdding() async {
|
||||||
|
let config = DiagnosticsConfiguration(
|
||||||
|
productId: "test-app",
|
||||||
|
anonymousInstallId: "install-123",
|
||||||
|
platform: "ios",
|
||||||
|
channel: "ios_app",
|
||||||
|
osFamily: "ios",
|
||||||
|
appVersion: "1.0.0",
|
||||||
|
buildNumber: "100",
|
||||||
|
releaseChannel: "beta",
|
||||||
|
serverUrl: "https://api.test.com"
|
||||||
|
)
|
||||||
|
|
||||||
|
await DiagnosticsClient.shared.configure(config)
|
||||||
|
|
||||||
|
await DiagnosticsClient.shared.breadcrumb(
|
||||||
|
category: "navigation",
|
||||||
|
message: "Page loaded",
|
||||||
|
data: ["path": AnyCodable("/home")]
|
||||||
|
)
|
||||||
|
|
||||||
|
let breadcrumbs = await DiagnosticsClient.shared.getBreadcrumbs()
|
||||||
|
XCTAssertEqual(breadcrumbs.count, 1)
|
||||||
|
XCTAssertEqual(breadcrumbs.first?.category, "navigation")
|
||||||
|
XCTAssertEqual(breadcrumbs.first?.message, "Page loaded")
|
||||||
|
}
|
||||||
|
|
||||||
|
func testMultipleBreadcrumbs() async {
|
||||||
|
let config = DiagnosticsConfiguration(
|
||||||
|
productId: "test-app",
|
||||||
|
anonymousInstallId: "install-123",
|
||||||
|
platform: "ios",
|
||||||
|
channel: "ios_app",
|
||||||
|
osFamily: "ios",
|
||||||
|
appVersion: "1.0.0",
|
||||||
|
buildNumber: "100",
|
||||||
|
releaseChannel: "beta",
|
||||||
|
serverUrl: "https://api.test.com"
|
||||||
|
)
|
||||||
|
|
||||||
|
await DiagnosticsClient.shared.configure(config)
|
||||||
|
|
||||||
|
for i in 1...5 {
|
||||||
|
await DiagnosticsClient.shared.breadcrumb(
|
||||||
|
category: "test",
|
||||||
|
message: "Message \(i)"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
let breadcrumbs = await DiagnosticsClient.shared.getBreadcrumbs()
|
||||||
|
XCTAssertEqual(breadcrumbs.count, 5)
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Tracing Tests
|
||||||
|
|
||||||
|
func testSuccessfulTrace() async throws {
|
||||||
|
let config = DiagnosticsConfiguration(
|
||||||
|
productId: "test-app",
|
||||||
|
anonymousInstallId: "install-123",
|
||||||
|
platform: "ios",
|
||||||
|
channel: "ios_app",
|
||||||
|
osFamily: "ios",
|
||||||
|
appVersion: "1.0.0",
|
||||||
|
buildNumber: "100",
|
||||||
|
releaseChannel: "beta",
|
||||||
|
serverUrl: "https://api.test.com"
|
||||||
|
)
|
||||||
|
|
||||||
|
await DiagnosticsClient.shared.configure(config)
|
||||||
|
|
||||||
|
let result = try await DiagnosticsClient.shared.trace(name: "test-operation") {
|
||||||
|
return 42
|
||||||
|
}
|
||||||
|
|
||||||
|
XCTAssertEqual(result, 42)
|
||||||
|
}
|
||||||
|
|
||||||
|
func testFailingTrace() async {
|
||||||
|
let config = DiagnosticsConfiguration(
|
||||||
|
productId: "test-app",
|
||||||
|
anonymousInstallId: "install-123",
|
||||||
|
platform: "ios",
|
||||||
|
channel: "ios_app",
|
||||||
|
osFamily: "ios",
|
||||||
|
appVersion: "1.0.0",
|
||||||
|
buildNumber: "100",
|
||||||
|
releaseChannel: "beta",
|
||||||
|
serverUrl: "https://api.test.com"
|
||||||
|
)
|
||||||
|
|
||||||
|
await DiagnosticsClient.shared.configure(config)
|
||||||
|
|
||||||
|
do {
|
||||||
|
_ = try await DiagnosticsClient.shared.trace(name: "failing-operation") {
|
||||||
|
throw TestError.test
|
||||||
|
}
|
||||||
|
XCTFail("Should have thrown")
|
||||||
|
} catch {
|
||||||
|
// Expected
|
||||||
|
XCTAssertTrue(error is TestError)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Logging Tests
|
||||||
|
|
||||||
|
func testLogAdding() async {
|
||||||
|
let config = DiagnosticsConfiguration(
|
||||||
|
productId: "test-app",
|
||||||
|
anonymousInstallId: "install-123",
|
||||||
|
platform: "ios",
|
||||||
|
channel: "ios_app",
|
||||||
|
osFamily: "ios",
|
||||||
|
appVersion: "1.0.0",
|
||||||
|
buildNumber: "100",
|
||||||
|
releaseChannel: "beta",
|
||||||
|
serverUrl: "https://api.test.com"
|
||||||
|
)
|
||||||
|
|
||||||
|
await DiagnosticsClient.shared.configure(config)
|
||||||
|
|
||||||
|
await DiagnosticsClient.shared.log(
|
||||||
|
level: .info,
|
||||||
|
message: "Test message",
|
||||||
|
module: "TestModule"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Log is buffered, no immediate assertion possible
|
||||||
|
// But breadcrumb should be created
|
||||||
|
let breadcrumbs = await DiagnosticsClient.shared.getBreadcrumbs()
|
||||||
|
XCTAssertTrue(breadcrumbs.contains { $0.category == "log" })
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Types Tests
|
||||||
|
|
||||||
|
func testDiagnosticsSessionEncoding() throws {
|
||||||
|
let session = DiagnosticsSession(
|
||||||
|
id: "ds_test123",
|
||||||
|
productId: "test-app",
|
||||||
|
status: .active,
|
||||||
|
collectionLevel: .debug,
|
||||||
|
captureLogs: true,
|
||||||
|
captureNetwork: true,
|
||||||
|
captureScreenshots: false,
|
||||||
|
screenshotOnError: true,
|
||||||
|
maxDurationMinutes: 60,
|
||||||
|
createdAt: "2026-03-03T12:00:00Z",
|
||||||
|
expiresAt: "2026-03-03T13:00:00Z"
|
||||||
|
)
|
||||||
|
|
||||||
|
let encoder = JSONEncoder()
|
||||||
|
let data = try encoder.encode(session)
|
||||||
|
|
||||||
|
let decoder = JSONDecoder()
|
||||||
|
let decoded = try decoder.decode(DiagnosticsSession.self, from: data)
|
||||||
|
|
||||||
|
XCTAssertEqual(decoded.id, session.id)
|
||||||
|
XCTAssertEqual(decoded.status, session.status)
|
||||||
|
XCTAssertEqual(decoded.collectionLevel, session.collectionLevel)
|
||||||
|
}
|
||||||
|
|
||||||
|
func testLogEntryCreation() {
|
||||||
|
let entry = DiagnosticsLogEntry(
|
||||||
|
level: .error,
|
||||||
|
message: "Something went wrong",
|
||||||
|
timestamp: "2026-03-03T12:00:00Z",
|
||||||
|
module: "TestModule",
|
||||||
|
file: "Test.swift",
|
||||||
|
line: 42,
|
||||||
|
function: "testFunction",
|
||||||
|
context: ["key": AnyCodable("value")],
|
||||||
|
correlationId: "corr-123"
|
||||||
|
)
|
||||||
|
|
||||||
|
XCTAssertEqual(entry.level, .error)
|
||||||
|
XCTAssertEqual(entry.message, "Something went wrong")
|
||||||
|
XCTAssertEqual(entry.module, "TestModule")
|
||||||
|
XCTAssertEqual(entry.line, 42)
|
||||||
|
XCTAssertEqual(entry.correlationId, "corr-123")
|
||||||
|
}
|
||||||
|
|
||||||
|
func testTraceSpanCreation() {
|
||||||
|
let span = DiagnosticsTraceSpan(
|
||||||
|
spanId: "span-123",
|
||||||
|
parentId: "parent-456",
|
||||||
|
name: "test-span",
|
||||||
|
kind: .internal,
|
||||||
|
startTime: "2026-03-03T12:00:00Z",
|
||||||
|
endTime: "2026-03-03T12:00:01Z",
|
||||||
|
durationMs: 1000,
|
||||||
|
attributes: ["key": AnyCodable("value")],
|
||||||
|
status: .ok,
|
||||||
|
statusMessage: nil
|
||||||
|
)
|
||||||
|
|
||||||
|
XCTAssertEqual(span.spanId, "span-123")
|
||||||
|
XCTAssertEqual(span.parentId, "parent-456")
|
||||||
|
XCTAssertEqual(span.name, "test-span")
|
||||||
|
XCTAssertEqual(span.status, .ok)
|
||||||
|
}
|
||||||
|
|
||||||
|
func testBreadcrumbCreation() {
|
||||||
|
let breadcrumb = DiagnosticsBreadcrumb(
|
||||||
|
timestamp: "2026-03-03T12:00:00Z",
|
||||||
|
category: "navigation",
|
||||||
|
message: "User tapped button",
|
||||||
|
data: ["buttonId": AnyCodable("submit")]
|
||||||
|
)
|
||||||
|
|
||||||
|
XCTAssertEqual(breadcrumb.category, "navigation")
|
||||||
|
XCTAssertEqual(breadcrumb.message, "User tapped button")
|
||||||
|
XCTAssertNotNil(breadcrumb.data)
|
||||||
|
}
|
||||||
|
|
||||||
|
func testAnyCodableEncoding() throws {
|
||||||
|
let value = AnyCodable("test-string")
|
||||||
|
let encoder = JSONEncoder()
|
||||||
|
let data = try encoder.encode(value)
|
||||||
|
|
||||||
|
// Should not throw
|
||||||
|
XCTAssertFalse(data.isEmpty)
|
||||||
|
}
|
||||||
|
|
||||||
|
func testAnyCodableIntEncoding() throws {
|
||||||
|
let value = AnyCodable(42)
|
||||||
|
let encoder = JSONEncoder()
|
||||||
|
let data = try encoder.encode(value)
|
||||||
|
|
||||||
|
XCTAssertFalse(data.isEmpty)
|
||||||
|
}
|
||||||
|
|
||||||
|
func testAnyCodableBoolEncoding() throws {
|
||||||
|
let value = AnyCodable(true)
|
||||||
|
let encoder = JSONEncoder()
|
||||||
|
let data = try encoder.encode(value)
|
||||||
|
|
||||||
|
XCTAssertFalse(data.isEmpty)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
enum TestError: Error {
|
||||||
|
case test
|
||||||
|
}
|
||||||
Loading…
Reference in New Issue
Block a user