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:
saravanakumardb1 2026-03-03 09:28:11 -08:00
parent e4c3c7cc13
commit abcf817cb3
9 changed files with 1641 additions and 0 deletions

View 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"
),
]
)

View File

@ -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"

View File

@ -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
}
}

View File

@ -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)")
}
}

View File

@ -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
}

View File

@ -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("")
}
}
}

View File

@ -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

View File

@ -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
}
}

View File

@ -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
}