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