diff --git a/ExampleApp/ExampleApp/AppDelegate.swift b/ExampleApp/ExampleApp/AppDelegate.swift index 25c8656..5614b60 100644 --- a/ExampleApp/ExampleApp/AppDelegate.swift +++ b/ExampleApp/ExampleApp/AppDelegate.swift @@ -11,6 +11,7 @@ let config = { () -> LDConfig in config.plugins = [ Observability( options: .init( + serviceName: "MyApp (ExampleApp)", // otlpEndpoint: "http://localhost:4318", sessionBackgroundTimeout: 3, isDebug: true, diff --git a/Package.swift b/Package.swift index 8025dca..b1e5bdd 100644 --- a/Package.swift +++ b/Package.swift @@ -82,6 +82,16 @@ let package = Package( .product(name: "OpenTelemetryApi", package: "opentelemetry-swift"), ] ), + .target(name: "System"), + .target( + name: "SystemLive", + dependencies: [ + "System", + "Common", + "Instrumentation", + .product(name: "OpenTelemetryApi", package: "opentelemetry-swift"), + ] + ), .target( name: "Observability", dependencies: [ @@ -92,6 +102,8 @@ let package = Package( "Sampling", "SamplingLive", "Instrumentation", + "System", + "SystemLive", .product(name: "OpenTelemetrySdk", package: "opentelemetry-swift"), .product(name: "OpenTelemetryApi", package: "opentelemetry-swift"), .product(name: "OpenTelemetryProtocolExporterHTTP", package: "opentelemetry-swift"), diff --git a/Sources/Common/SemanticConventionLD.swift b/Sources/Common/SemanticConventionLD.swift index c52f966..8291dca 100644 --- a/Sources/Common/SemanticConventionLD.swift +++ b/Sources/Common/SemanticConventionLD.swift @@ -4,4 +4,9 @@ public enum SemanticConvention { public enum LDSemanticAttribute { public static let ATTR_SAMPLING_RATIO = "launchdarkly.sampling.ratio" + public enum System { + public static let systemCpuUtilization = "system.cpu.utilization" + public static let cpuLogicalNumber = "cpu.logical_number" + public static let cpuMode = "cpu.mode" + } } diff --git a/Sources/LaunchDarklyObservability/Plugin/Observability.swift b/Sources/LaunchDarklyObservability/Plugin/Observability.swift index d61bf5f..761800b 100644 --- a/Sources/LaunchDarklyObservability/Plugin/Observability.swift +++ b/Sources/LaunchDarklyObservability/Plugin/Observability.swift @@ -27,13 +27,22 @@ public final class Observability: Plugin { resourceAttributes["launchdarkly.sdk.version"] = .string(String(format: "%@/%@", metadata.sdkMetadata.name, metadata.sdkMetadata.version)) resourceAttributes["highlight.project_id"] = .string(sdkKey) + let containsProjectId = options.customHeaders.contains { (key, value) in + key == "highlight.project_id" + } + var customHeaders = options.customHeaders + if !containsProjectId { + customHeaders.append(("highlight.project_id", sdkKey)) + } + + let options = Options( serviceName: options.serviceName, serviceVersion: options.serviceVersion, otlpEndpoint: options.otlpEndpoint, backendUrl: options.backendUrl, resourceAttributes: options.resourceAttributes.merging(resourceAttributes) { (old, _) in old }, - customHeaders: options.customHeaders, + customHeaders: customHeaders, sessionBackgroundTimeout: options.sessionBackgroundTimeout, isDebug: options.isDebug, disableErrorTracking: options.disableErrorTracking, diff --git a/Sources/Observability/InstrumentationLive.swift b/Sources/Observability/InstrumentationLive.swift index 888f572..6f49fc3 100644 --- a/Sources/Observability/InstrumentationLive.swift +++ b/Sources/Observability/InstrumentationLive.swift @@ -20,22 +20,20 @@ extension Instrumentation { static let logsPath = "/v1/logs" static let metricsPath = "/v1/metrics" - static func noOp() -> Self { - Self( - recordMetric: { _ in }, - recordCount: { _ in }, - recordIncr: { _ in }, - recordHistogram: { _ in }, - recordUpDownCounter: { _ in }, - recordError: { _, _ in }, - recordLog: { _, _, _ in }, - startSpan: { _, _ in - /// No-op implementation of the Tracer - DefaultTracer.instance.spanBuilder(spanName: "").startSpan() - }, - flush: { true } - ) - } + static let noOp: Self = .init( + recordMetric: { _ in }, + recordCount: { _ in }, + recordIncr: { _ in }, + recordHistogram: { _ in }, + recordUpDownCounter: { _ in }, + recordError: { _, _ in }, + recordLog: { _, _, _ in }, + startSpan: { _, _ in + /// No-op implementation of the Tracer + DefaultTracer.instance.spanBuilder(spanName: "").startSpan() + }, + flush: { true } + ) static func build( context: ObservabilityContext, diff --git a/Sources/Observability/InstrumentationManager.swift b/Sources/Observability/InstrumentationManager.swift index 0cd445e..f864094 100644 --- a/Sources/Observability/InstrumentationManager.swift +++ b/Sources/Observability/InstrumentationManager.swift @@ -33,7 +33,7 @@ final class InstrumentationManager { private let sampler = ExportSampler.customSampler() private var otelLogger: (any OpenTelemetryApi.Logger) - private var otelTracer: Tracer? + private var otelTracer: Tracer private var otelMeter: (any Meter) private var urlSessionInstrumentation: URLSessionInstrumentation? @@ -105,7 +105,7 @@ final class InstrumentationManager { let exporter = SamplingTraceExporterDecorator( exporter: OtlpHttpTraceExporter( endpoint: url, - envVarHeaders: context.options.customHeaders + config: .init(headers: context.options.customHeaders) ), sampler: sampler ) @@ -161,7 +161,7 @@ final class InstrumentationManager { SamplingLogExporterDecorator( exporter: OtlpHttpLogExporter( endpoint: url, - envVarHeaders: context.options.customHeaders + config: .init(headers: context.options.customHeaders) ), sampler: sampler ) @@ -217,7 +217,7 @@ final class InstrumentationManager { if let url = URL(string: context.options.otlpEndpoint)?.appendingPathComponent(Instrumentation.metricsPath) { let exporter = OtlpHttpMetricExporter( endpoint: url, - envVarHeaders: context.options.customHeaders + config: .init(headers: context.options.customHeaders) ) let reader = PeriodicMetricReaderBuilder(exporter: exporter) @@ -228,14 +228,22 @@ final class InstrumentationManager { reader.forceFlush() } + let provider = MeterProviderSdk.builder() - .registerView( - selector: InstrumentSelector.builder().setInstrument(name: context.options.serviceName).build(), - view: View.builder().build() - ) .registerMetricReader( reader: reader ) + .registerView( + selector: InstrumentSelector + .builder() + .setInstrument(name: ".*") + .setMeter(name: options.serviceName) + .build(), + view: View + .builder() + .withName(name: options.serviceName) + .build() + ) .build() /// Register custom meter @@ -320,7 +328,9 @@ final class InstrumentationManager { .build() cachedGauges[metric.name] = gauge } - gauge?.record(value: metric.value, attributes: metric.attributes) + let attributes = metric.attributes.merging(context.options.resourceAttributes, uniquingKeysWith: { current, _ in current }) + gauge?.record(value: metric.value, attributes: attributes) +// gauge?.record(value: metric.value, attributes: attributes) } func recordCount(metric: Metric) { @@ -375,41 +385,31 @@ final class InstrumentationManager { func recordError(error: Error, attributes: [String: AttributeValue]) { var attributes = attributes - let builder = otelTracer?.spanBuilder(spanName: "highlight.error") + let builder = otelTracer.spanBuilder(spanName: "highlight.error") if let parent = OpenTelemetry.instance.contextProvider.activeSpan { - builder?.setParent(parent) + builder.setParent(parent) } attributes.forEach { - builder?.setAttribute(key: $0.key, value: $0.value) + builder.setAttribute(key: $0.key, value: $0.value) } let sessionId = sessionManager.sessionInfo.id if !sessionId.isEmpty { - builder?.setAttribute(key: SemanticConvention.highlightSessionId, value: sessionId) + builder.setAttribute(key: SemanticConvention.highlightSessionId, value: sessionId) attributes[SemanticConvention.highlightSessionId] = .string(sessionId) } - let span = builder?.startSpan() - span?.setAttributes(attributes) - span?.recordException(ErrorSpanException(error: error), attributes: attributes) - span?.end() + let span = builder.startSpan() + span.setAttributes(attributes) + span.recordException(ErrorSpanException(error: error), attributes: attributes) + span.end() } func startSpan(name: String, attributes: [String: AttributeValue]) -> any Span { - let tracer: Tracer - if let otelTracer { - tracer = otelTracer - } else { - tracer = OpenTelemetry.instance.tracerProvider.get( - instrumentationName: context.options.serviceName, - instrumentationVersion: context.options.serviceVersion - ) - } - - let builder = tracer.spanBuilder(spanName: name) + let builder = otelTracer.spanBuilder(spanName: name) if let parent = OpenTelemetry.instance.contextProvider.activeSpan { builder.setParent(parent) diff --git a/Sources/Observability/ObservabilityClient.swift b/Sources/Observability/ObservabilityClient.swift index 6cade67..e59a960 100644 --- a/Sources/Observability/ObservabilityClient.swift +++ b/Sources/Observability/ObservabilityClient.swift @@ -8,10 +8,13 @@ import Instrumentation import Common import CrashReporter import CrashReporterLive +import System +import SystemLive public final class ObservabilityClient: Observe { private let instrumentationManager: Instrumentation private let sessionManager: SessionManager + private let systemInfoManager: SystemInfo private let context: ObservabilityContext private var cachedSpans = AtomicDictionary() @@ -21,12 +24,23 @@ public final class ObservabilityClient: Observe { let sessionManager = SessionManager(options: .init(timeout: context.options.sessionBackgroundTimeout)) do { - self.instrumentationManager = try Instrumentation.build( + let instrumentation = try Instrumentation.build( context: context, sessionManager: sessionManager ) + + let systemInfoManager = SystemInfo.build( + monitoringInterval: 5, + instrumentation: instrumentation, + logger: context.logger + ) + + self.instrumentationManager = instrumentation + self.systemInfoManager = systemInfoManager + systemInfoManager.startMonitoring() } catch { - self.instrumentationManager = Instrumentation.noOp() + self.instrumentationManager = Instrumentation.noOp + self.systemInfoManager = SystemInfo.noOp os_log("%{public}@", log: context.logger.log, type: .error, "Failed to initialize Instrumentation manager with error: \(error)") } diff --git a/Sources/System/CPUStatistics.swift b/Sources/System/CPUStatistics.swift new file mode 100644 index 0000000..cb35805 --- /dev/null +++ b/Sources/System/CPUStatistics.swift @@ -0,0 +1,15 @@ +public struct CPUStatistics { + public let user: Double + public let system: Double + public let idle: Double + public let nice: Double + public let total: Double + + public init(user: Double, system: Double, idle: Double, nice: Double, total: Double) { + self.user = user + self.system = system + self.idle = idle + self.nice = nice + self.total = total + } +} diff --git a/Sources/System/SystemInfo.swift b/Sources/System/SystemInfo.swift new file mode 100644 index 0000000..66f4fe2 --- /dev/null +++ b/Sources/System/SystemInfo.swift @@ -0,0 +1,9 @@ +public struct SystemInfo { + public var startMonitoring: () -> Void + public var stopMonitoring: () -> Void + + public init(startMonitoring: @escaping () -> Void, stopMonitoring: @escaping () -> Void) { + self.startMonitoring = startMonitoring + self.stopMonitoring = stopMonitoring + } +} diff --git a/Sources/System/SystemMetricError.swift b/Sources/System/SystemMetricError.swift new file mode 100644 index 0000000..3dfeb3a --- /dev/null +++ b/Sources/System/SystemMetricError.swift @@ -0,0 +1,4 @@ +public enum SystemMetricError: Error { + case cpuLoadInfoFetchFailed + case cpuLoadInfoFailed +} diff --git a/Sources/SystemLive/CPULoad.swift b/Sources/SystemLive/CPULoad.swift new file mode 100644 index 0000000..8840b8a --- /dev/null +++ b/Sources/SystemLive/CPULoad.swift @@ -0,0 +1,61 @@ +import MachO + +import System + +public struct CPULoad { + private static let machHost = mach_host_self() + private var loadPrevious = host_cpu_load_info() + + public init() {} + + private func hostCPULoadInfo() throws -> host_cpu_load_info { + let hostCpuLoadInfoCount = MemoryLayout.stride/MemoryLayout.stride + var size = mach_msg_type_number_t(hostCpuLoadInfoCount) + var cpuLoadInfo = host_cpu_load_info() + + let result = withUnsafeMutablePointer(to: &cpuLoadInfo) { + $0.withMemoryRebound(to: integer_t.self, capacity: hostCpuLoadInfoCount) { + host_statistics(mach_host_self(), HOST_CPU_LOAD_INFO, $0, &size) + } + } + guard result == KERN_SUCCESS else { + print("Error - \(#file): \(#function) - kern_result_t = \(result)") + throw SystemMetricError.cpuLoadInfoFetchFailed + } + return cpuLoadInfo + } + + /// Get CPU usage (system, user, idle, nice). Determined by the delta between the current and previous invocations. + public func cpuUsage() throws -> CPUStatistics { + do { + let load = try hostCPULoadInfo(); + let userDiff: Double = Double(load.cpu_ticks.0 - loadPrevious.cpu_ticks.0); + let systemDiff = Double(load.cpu_ticks.1 - loadPrevious.cpu_ticks.1); + let idleDiff = Double(load.cpu_ticks.2 - loadPrevious.cpu_ticks.2); + let niceDiff = Double(load.cpu_ticks.3 - loadPrevious.cpu_ticks.3); + + let totalTicks = userDiff + systemDiff + idleDiff + niceDiff + let system = systemDiff / totalTicks * 100.0 + let user = userDiff / totalTicks * 100.0 + let idle = idleDiff / totalTicks * 100.0 + let nice = niceDiff / totalTicks * 100.0 + + return .init( + user: user, + system: system, + idle: idle, + nice: nice, + total: totalTicks + ) + } catch { + throw error + } + } + + public func physicalCoresCount() -> UInt { + var size: size_t = MemoryLayout.size + var coresCount: UInt = 0 + sysctlbyname("hw.physicalcpu", &coresCount, &size, nil, 0) + return coresCount + } +} diff --git a/Sources/SystemLive/LiveValue.swift b/Sources/SystemLive/LiveValue.swift new file mode 100644 index 0000000..86822b4 --- /dev/null +++ b/Sources/SystemLive/LiveValue.swift @@ -0,0 +1,107 @@ +import Foundation +import OSLog + +import OpenTelemetryApi + +import Common +import Instrumentation +import System + +extension SystemInfo { + public static let noOp: Self = .init(startMonitoring: {}, stopMonitoring: {}) + + public static func build( + monitoringInterval: TimeInterval = 2, + instrumentation: Instrumentation, + logger: ObservabilityLogger = .init() + ) -> Self { + + let facade = SystemInfoFacade( + monitoringInterval: monitoringInterval, + instrumentationManager: instrumentation, + logger: logger + ) + + return .init( + startMonitoring: { + facade.startMonitoring() + }, + stopMonitoring: { + facade.stopMonitoring() + } + ) + } +} + +final class SystemInfoFacade { + private let monitoringInterval: TimeInterval + private let instrumentationManager: Instrumentation + private let logger: ObservabilityLogger + private var tasks = [UUID]() + + private let scheduler = Scheduler() + + init( + monitoringInterval: TimeInterval = 2, + instrumentationManager: Instrumentation, + logger: ObservabilityLogger + ) { + self.monitoringInterval = monitoringInterval + self.instrumentationManager = instrumentationManager + self.logger = logger + } + + deinit { + tasks.forEach(scheduler.stopRepeating(id:)) + } + + func startMonitoring() { + let cpu = CPULoad() + let log = logger.log + let instrumentation = instrumentationManager + tasks.append( + scheduler.scheduleRepeating( + every: monitoringInterval) { + do { + let statistics = try cpu.cpuUsage() + let physicalCores = Int(cpu.physicalCoresCount()) + instrumentation.recordMetric( + metric: .init( + name: LDSemanticAttribute.System.systemCpuUtilization, + value: statistics.user, + attributes: [ + LDSemanticAttribute.System.cpuLogicalNumber: .int(physicalCores), + LDSemanticAttribute.System.cpuMode: .string("user") + ] + ) + ) + + instrumentation.recordMetric( + metric: .init( + name: LDSemanticAttribute.System.systemCpuUtilization, + value: statistics.user, + attributes: [ + LDSemanticAttribute.System.cpuLogicalNumber: .int(physicalCores), + LDSemanticAttribute.System.cpuMode: .string("idle") + ] + ) + ) + + let info = """ + user: \(statistics.user) \ + system: \(statistics.system) \ + idle: \(statistics.idle) \ + nice: \(statistics.nice) + """ + os_log("%{public}@", log: log, type: .debug, info) + } catch { + os_log("%{public}@", log: log, type: .error, "failed to get CPU usage") + } + } + ) + } + + func stopMonitoring() { + tasks.forEach(scheduler.stopRepeating(id:)) + } +} diff --git a/Sources/SystemLive/Scheduler.swift b/Sources/SystemLive/Scheduler.swift new file mode 100644 index 0000000..ffe75cf --- /dev/null +++ b/Sources/SystemLive/Scheduler.swift @@ -0,0 +1,65 @@ +import Foundation +import OSLog + +import Common + +final class Scheduler { + private let queue = DispatchQueue(label: "com.launchdarkly.system.scheduler") + private var workItems: [UUID: DispatchWorkItem] = [:] + private var timers: [UUID: DispatchSourceTimer] = [:] + + private var timer: DispatchSourceTimer? + private let logger: ObservabilityLogger = .init() + + @discardableResult func schedule( + after delay: TimeInterval, + task: @escaping () -> Void, + completion: (() -> Void)? = nil + ) -> UUID { + let id = UUID() + let log = logger.log + let workItem = DispatchWorkItem { + os_log("%{public}@", log: log, type: .info, "scheduled task \(id.uuidString)started after \(delay)s") + task() + os_log("%{public}@", log: log, type: .info, "scheduled task \(id.uuidString) completed") + completion?() + } + workItems[id] = workItem + queue.asyncAfter(deadline: .now() + delay, execute: workItem) + + return id + } + + func cancelScheduledTask(id: UUID) { + workItems[id]?.cancel() + timers.removeValue(forKey: id) + os_log("%{public}@", log: logger.log, type: .info, "Task \(id.uuidString) cancelled") + } + + @discardableResult func scheduleRepeating( + every interval: TimeInterval, + task: @escaping () -> Void, + completion: (() -> Void)? = nil + ) -> UUID { + let id = UUID() + let timer = DispatchSource.makeTimerSource(queue: queue) + timer.schedule(deadline: .now() + interval, repeating: interval) + let log = logger.log + timer.setEventHandler { + os_log("%{public}@", log: log, type: .info, "Repeating task \(id.uuidString) started (interval: \(interval)s)") + task() + os_log("%{public}@", log: log, type: .info, "Repeating task \(id.uuidString) completed") + completion?() + } + timer.resume() + timers[id] = timer + + return id + } + + func stopRepeating(id: UUID) { + timers[id]?.cancel() + timers.removeValue(forKey: id) + os_log("%{public}@", log: logger.log, type: .info, "Repeating task \(id.uuidString) stopped") + } +}