diff --git a/Package.swift b/Package.swift index 65e12789..2ebaf7bb 100644 --- a/Package.swift +++ b/Package.swift @@ -6,6 +6,7 @@ let package = Package( products: [ .library(name: "Instrumentation", targets: ["Instrumentation"]), .library(name: "Tracing", targets: ["Tracing"]), + .library(name: "InMemoryTracing", targets: ["InMemoryTracing"]), ], dependencies: [ .package(url: "https://github.com/apple/swift-service-context.git", from: "1.1.0") @@ -44,6 +45,18 @@ let package = Package( .target(name: "Tracing") ] ), + .target( + name: "InMemoryTracing", + dependencies: [ + .target(name: "Tracing") + ] + ), + .testTarget( + name: "InMemoryTracingTests", + dependencies: [ + .target(name: "InMemoryTracing") + ] + ), // ==== -------------------------------------------------------------------------------------------------------- // MARK: Wasm Support diff --git a/Sources/InMemoryTracing/InMemorySpan.swift b/Sources/InMemoryTracing/InMemorySpan.swift new file mode 100644 index 00000000..c61518c3 --- /dev/null +++ b/Sources/InMemoryTracing/InMemorySpan.swift @@ -0,0 +1,220 @@ +//===----------------------------------------------------------------------===// +// +// This source file is part of the Swift Distributed Tracing open source project +// +// Copyright (c) 2025 Apple Inc. and the Swift Distributed Tracing project authors +// Licensed under Apache License v2.0 +// +// See LICENSE.txt for license information +// See CONTRIBUTORS.txt for the list of Swift Distributed Tracing project authors +// +// SPDX-License-Identifier: Apache-2.0 +// +//===----------------------------------------------------------------------===// + +@_spi(Locking) import Instrumentation +import Tracing + +/// A ``Span`` created by the ``InMemoryTracer`` that will be retained in memory when ended. +/// See ``InMemoryTracer/ +public struct InMemorySpan: Span { + + public let context: ServiceContext + public var spanContext: InMemorySpanContext { + context.inMemorySpanContext! + } + + /// The ID of the overall trace this span belongs to. + public var traceID: String { + spanContext.spanID + } + /// The ID of this concrete span. + public var spanID: String { + spanContext.spanID + } + /// The ID of the parent span of this span, if there was any. + /// When this is `nil` it means this is the top-level span of this trace. + public var parentSpanID: String? { + spanContext.parentSpanID + } + + public let kind: SpanKind + public let startInstant: any TracerInstant + + private let _operationName: LockedValueBox + private let _attributes = LockedValueBox([:]) + private let _events = LockedValueBox<[SpanEvent]>([]) + private let _links = LockedValueBox<[SpanLink]>([]) + private let _errors = LockedValueBox<[RecordedError]>([]) + private let _status = LockedValueBox(nil) + private let _isRecording = LockedValueBox(true) + private let onEnd: @Sendable (FinishedInMemorySpan) -> Void + + public init( + operationName: String, + context: ServiceContext, + spanContext: InMemorySpanContext, + kind: SpanKind, + startInstant: any TracerInstant, + onEnd: @escaping @Sendable (FinishedInMemorySpan) -> Void + ) { + self._operationName = LockedValueBox(operationName) + var context = context + context.inMemorySpanContext = spanContext + self.context = context + self.kind = kind + self.startInstant = startInstant + self.onEnd = onEnd + } + + /// The in memory span stops recording (storing mutations performed on the span) when it is ended. + /// In other words, a finished span no longer is mutable and will ignore all subsequent attempts to mutate. + public var isRecording: Bool { + _isRecording.withValue { $0 } + } + + public var operationName: String { + get { + _operationName.withValue { $0 } + } + nonmutating set { + guard isRecording else { return } + _operationName.withValue { $0 = newValue } + } + } + + public var attributes: SpanAttributes { + get { + _attributes.withValue { $0 } + } + nonmutating set { + guard isRecording else { return } + _attributes.withValue { $0 = newValue } + } + } + + public var events: [SpanEvent] { + _events.withValue { $0 } + } + + public func addEvent(_ event: SpanEvent) { + guard isRecording else { return } + _events.withValue { $0.append(event) } + } + + public var links: [SpanLink] { + _links.withValue { $0 } + } + + public func addLink(_ link: SpanLink) { + guard isRecording else { return } + _links.withValue { $0.append(link) } + } + + public var errors: [RecordedError] { + _errors.withValue { $0 } + } + + public func recordError( + _ error: any Error, + attributes: SpanAttributes, + at instant: @autoclosure () -> some TracerInstant + ) { + guard isRecording else { return } + _errors.withValue { + $0.append(RecordedError(error: error, attributes: attributes, instant: instant())) + } + } + + public var status: SpanStatus? { + _status.withValue { $0 } + } + + public func setStatus(_ status: SpanStatus) { + guard isRecording else { return } + _status.withValue { $0 = status } + } + + public func end(at instant: @autoclosure () -> some TracerInstant) { + let shouldRecord = _isRecording.withValue { + let value = $0 + $0 = false // from here on after, stop recording + return value + } + guard shouldRecord else { return } + + let finishedSpan = FinishedInMemorySpan( + operationName: operationName, + context: context, + kind: kind, + startInstant: startInstant, + endInstant: instant(), + attributes: attributes, + events: events, + links: links, + errors: errors, + status: status + ) + onEnd(finishedSpan) + } + + public struct RecordedError: Sendable { + public let error: Error + public let attributes: SpanAttributes + public let instant: any TracerInstant + } +} + +/// Represents a finished span (a ``Span`` that `end()` was called on) +/// that was recorded by the ``InMemoryTracer``. +public struct FinishedInMemorySpan: Sendable { + public var operationName: String + + public var context: ServiceContext + public var spanContext: InMemorySpanContext { + get { + context.inMemorySpanContext! + } + set { + context.inMemorySpanContext = newValue + } + } + + /// The ID of the overall trace this span belongs to. + public var traceID: String { + get { + spanContext.spanID + } + set { + spanContext.spanID = newValue + } + } + /// The ID of this concrete span. + public var spanID: String { + get { + spanContext.spanID + } + set { + spanContext.spanID = newValue + } + } + /// The ID of the parent span of this span, if there was any. + /// When this is `nil` it means this is the top-level span of this trace. + public var parentSpanID: String? { + get { + spanContext.parentSpanID + } + set { + spanContext.parentSpanID = newValue + } + } + + public var kind: SpanKind + public var startInstant: any TracerInstant + public var endInstant: any TracerInstant + public var attributes: SpanAttributes + public var events: [SpanEvent] + public var links: [SpanLink] + public var errors: [InMemorySpan.RecordedError] + public var status: SpanStatus? +} diff --git a/Sources/InMemoryTracing/InMemorySpanContext.swift b/Sources/InMemoryTracing/InMemorySpanContext.swift new file mode 100644 index 00000000..9f8c1919 --- /dev/null +++ b/Sources/InMemoryTracing/InMemorySpanContext.swift @@ -0,0 +1,50 @@ +//===----------------------------------------------------------------------===// +// +// This source file is part of the Swift Distributed Tracing open source project +// +// Copyright (c) 2025 Apple Inc. and the Swift Distributed Tracing project authors +// Licensed under Apache License v2.0 +// +// See LICENSE.txt for license information +// See CONTRIBUTORS.txt for the list of Swift Distributed Tracing project authors +// +// SPDX-License-Identifier: Apache-2.0 +// +//===----------------------------------------------------------------------===// + +import ServiceContextModule + +/// Encapsulates the `traceID`, `spanID` and `parentSpanID` of an `InMemorySpan`. +/// Generally used through the `ServiceContext/inMemorySpanContext` task local value. +public struct InMemorySpanContext: Sendable, Hashable { + /// Idenfifier of top-level trace of which this span is a part of. + public var traceID: String + + /// Identifier of this specific span. + public var spanID: String + + // Identifier of the parent of this span, if any. + public var parentSpanID: String? + + public init(traceID: String, spanID: String, parentSpanID: String?) { + self.traceID = traceID + self.spanID = spanID + self.parentSpanID = parentSpanID + } +} + +extension ServiceContext { + /// Task-local value representing the current tracing ``Span`` as set by the ``InMemoryTracer``. + public var inMemorySpanContext: InMemorySpanContext? { + get { + self[InMemorySpanContextKey.self] + } + set { + self[InMemorySpanContextKey.self] = newValue + } + } +} + +private struct InMemorySpanContextKey: ServiceContextKey { + typealias Value = InMemorySpanContext +} diff --git a/Sources/InMemoryTracing/InMemoryTracer.swift b/Sources/InMemoryTracing/InMemoryTracer.swift new file mode 100644 index 00000000..b66845d5 --- /dev/null +++ b/Sources/InMemoryTracing/InMemoryTracer.swift @@ -0,0 +1,299 @@ +//===----------------------------------------------------------------------===// +// +// This source file is part of the Swift Distributed Tracing open source project +// +// Copyright (c) 2025 Apple Inc. and the Swift Distributed Tracing project authors +// Licensed under Apache License v2.0 +// +// See LICENSE.txt for license information +// See CONTRIBUTORS.txt for the list of Swift Distributed Tracing project authors +// +// SPDX-License-Identifier: Apache-2.0 +// +//===----------------------------------------------------------------------===// + +@_spi(Locking) import Instrumentation +import Tracing + +/// An in-memory implementation of the ``Tracer`` protocol which can be used either in testing, +/// or in manual collecting and interrogating traces within a process, and acting on them programatically. +/// +/// ### Span lifecycle +/// This tracer does _not_ automatically remove spans once they end. +/// Finished spans are retained and available for inspection using the `finishedSpans` property. +/// Spans which have been started but have not yet been called `Span/end()` on are also available +/// for inspection using the ``activeSpans`` property. +/// +/// Spans are retained by the `InMemoryTracer` until they are explicitly removed, e.g. by using +/// ``popFinishedSpans()`` or any of the `clear...` methods (e.g. ``clearFinishedSpans()``) +public struct InMemoryTracer: Tracer { + + public let idGenerator: IDGenerator + + public let recordInjections: Bool + public let recordExtractions: Bool + + struct State { + var activeSpans: [InMemorySpanContext: InMemorySpan] = [:] + var finishedSpans: [FinishedInMemorySpan] = [] + var numberOfForceFlushes: Int = 0 + + var injections: [Injection] = [] + var extractions: [Extraction] = [] + } + var _state = LockedValueBox(.init()) + + /// Create a new ``InMemoryTracer``. + /// + /// - Parameters: + /// - Parameter idGenerator: strategy for generating trace and span identifiers + /// - Parameter idGenerator: strategy for generating trace and span identifiers + public init( + idGenerator: IDGenerator = .incrementing, + recordInjections: Bool = true, + recordExtractions: Bool = true + ) { + self.idGenerator = idGenerator + self.recordInjections = recordInjections + self.recordExtractions = recordExtractions + } +} + +// MARK: - Tracer + +extension InMemoryTracer { + + public func startSpan( + _ operationName: String, + context: @autoclosure () -> ServiceContext, + ofKind kind: SpanKind, + at instant: @autoclosure () -> Instant, + function: String, + file fileID: String, + line: UInt + ) -> InMemorySpan where Instant: TracerInstant { + let parentContext = context() + let spanContext: InMemorySpanContext + + if let parentSpanContext = parentContext.inMemorySpanContext { + // child span + spanContext = InMemorySpanContext( + traceID: parentSpanContext.traceID, + spanID: idGenerator.nextSpanID(), + parentSpanID: parentSpanContext.spanID + ) + } else { + // root span + spanContext = InMemorySpanContext( + traceID: idGenerator.nextTraceID(), + spanID: idGenerator.nextSpanID(), + parentSpanID: nil + ) + } + + var context = parentContext + context.inMemorySpanContext = spanContext + + let span = InMemorySpan( + operationName: operationName, + context: context, + spanContext: spanContext, + kind: kind, + startInstant: instant() + ) { finishedSpan in + _state.withValue { + $0.activeSpans[spanContext] = nil + $0.finishedSpans.append(finishedSpan) + } + } + _state.withValue { $0.activeSpans[spanContext] = span } + return span + } + + public func forceFlush() { + _state.withValue { $0.numberOfForceFlushes += 1 } + } +} + +// MARK: - InMemoryTracer querying + +extension InMemoryTracer { + + /// Array of active spans, i.e. spans which have been started but have not yet finished (by calling `Span/end()`). + public var activeSpans: [InMemorySpan] { + _state.withValue { Array($0.activeSpans.values) } + } + + /// Retrives a specific _active_ span, identified by the specific span, trace, and parent ID's + /// stored in the `inMemorySpanContext` + public func activeSpan(identifiedBy context: ServiceContext) -> InMemorySpan? { + guard let spanContext = context.inMemorySpanContext else { return nil } + return _state.withValue { $0.activeSpans[spanContext] } + } + + /// Count of the number of times ``Tracer/forceFlush()`` was called on this tracer. + public var numberOfForceFlushes: Int { + _state.withValue { $0.numberOfForceFlushes } + } + + /// Gets, without removing, all the finished spans recorded by this tracer. + /// + /// - SeeAlso: `popFinishedSpans()` + public var finishedSpans: [FinishedInMemorySpan] { + _state.withValue { $0.finishedSpans } + } + + /// Returns, and removes, all finished spans recorded by this tracer. + public func popFinishedSpans() -> [FinishedInMemorySpan] { + _state.withValue { state in + defer { state.finishedSpans = [] } + return state.finishedSpans + } + } + + /// Atomically clears any stored finished spans in this tracer. + public func clearFinishedSpans() { + _state.withValue { $0.finishedSpans = [] } + } + + /// Clears all registered finished spans, as well as injections/extractions performed by this tracer. + public func clearAll(includingActive: Bool = false) { + _state.withValue { + $0.finishedSpans = [] + $0.injections = [] + $0.extractions = [] + if includingActive { + $0.activeSpans = [:] + } + } + } +} + +// MARK: - Instrument + +extension InMemoryTracer { + + public static let traceIDKey = "in-memory-trace-id" + public static let spanIDKey = "in-memory-span-id" + + public func inject( + _ context: ServiceContext, + into carrier: inout Carrier, + using injector: Inject + ) where Carrier == Inject.Carrier { + var values = [String: String]() + + if let spanContext = context.inMemorySpanContext { + injector.inject(spanContext.traceID, forKey: Self.traceIDKey, into: &carrier) + values[Self.traceIDKey] = spanContext.traceID + injector.inject(spanContext.spanID, forKey: Self.spanIDKey, into: &carrier) + values[Self.spanIDKey] = spanContext.spanID + } + + if recordInjections { + let injection = Injection(context: context, values: values) + _state.withValue { $0.injections.append(injection) } + } + } + + /// Lists all recorded calls to this tracer's ``Instrument/inject(_:into:using:)`` method. + /// This may be used to inspect what span identifiers are being propagated by this tracer. + public var performedContextInjections: [Injection] { + _state.withValue { $0.injections } + } + + /// Clear the list of recorded context injections (calls to ``Instrument/inject(_:into:using:)``). + public func clearPerformedContextInjections() { + _state.withValue { $0.injections = [] } + } + + /// Represents a recorded call to the InMemoryTracer's ``Instrument/inject(_:into:using:)`` method. + public struct Injection: Sendable { + /// The context from which values were being injected. + public let context: ServiceContext + /// The injected values, these will be specifically the trace and span identifiers of the propagated span. + public let values: [String: String] + } +} + +extension InMemoryTracer { + + public func extract( + _ carrier: Carrier, + into context: inout ServiceContext, + using extractor: Extract + ) where Carrier == Extract.Carrier { + defer { + if self.recordExtractions { + let extraction = Extraction(carrier: carrier, context: context) + _state.withValue { $0.extractions.append(extraction) } + } + } + + guard let traceID = extractor.extract(key: Self.traceIDKey, from: carrier), + let spanID = extractor.extract(key: Self.spanIDKey, from: carrier) + else { + return + } + + context.inMemorySpanContext = InMemorySpanContext(traceID: traceID, spanID: spanID, parentSpanID: nil) + } + + /// Lists all recorded calls to this tracer's ``Instrument/extract(_:into:using:)`` method. + /// This may be used to inspect what span identifiers were extracted from an incoming carrier object into ``ServiceContext``. + public var performedContextExtractions: [Extraction] { + _state.withValue { $0.extractions } + } + + /// Represents a recorded call to the InMemoryTracer's ``Instrument/extract(_:into:using:)`` method. + public struct Extraction: Sendable { + /// The carrier object from which the context values were extracted from, + /// e.g. this frequently is an HTTP request or similar. + public let carrier: any Sendable + /// The constructed service context, containing the extracted ``ServiceContext/inMemorySpanContext``. + public let context: ServiceContext + } +} + +// MARK: - ID Generator + +extension InMemoryTracer { + + /// Can be used to customize how trace and span IDs are generated by the ``InMemoryTracer``. + /// + /// Defaults to a simple sequential numeric scheme (`span-1`, `span-2`, `trace-1`, `trace-2` etc). + public struct IDGenerator: Sendable { + public let nextTraceID: @Sendable () -> String + public let nextSpanID: @Sendable () -> String + + public init( + nextTraceID: @Sendable @escaping () -> String, + nextSpanID: @Sendable @escaping () -> String + ) { + self.nextTraceID = nextTraceID + self.nextSpanID = nextSpanID + } + + public static var incrementing: IDGenerator { + let traceID = LockedValueBox(0) + let spanID = LockedValueBox(0) + + return IDGenerator( + nextTraceID: { + let value = traceID.withValue { + $0 += 1 + return $0 + } + return "trace-\(value)" + }, + nextSpanID: { + let value = spanID.withValue { + $0 += 1 + return $0 + } + return "span-\(value)" + } + ) + } + } +} diff --git a/Sources/Tracing/Tracer.swift b/Sources/Tracing/Tracer.swift index 1fee0788..0a3242f7 100644 --- a/Sources/Tracing/Tracer.swift +++ b/Sources/Tracing/Tracer.swift @@ -349,7 +349,7 @@ public func withSpan( #endif #if compiler(>=6.0) -@_disfavoredOverload@available(*, deprecated, message: "Prefer #isolation version of this API") +@_disfavoredOverload @available(*, deprecated, message: "Prefer #isolation version of this API") #endif @available(macOS 10.15, iOS 13, tvOS 13, watchOS 6, *) // for TaskLocal ServiceContext public func withSpan( @@ -425,7 +425,7 @@ public func withSpan( #endif #if compiler(>=6.0) -@_disfavoredOverload@available(*, deprecated, message: "Prefer #isolation version of this API") +@_disfavoredOverload @available(*, deprecated, message: "Prefer #isolation version of this API") #endif @available(macOS 10.15, iOS 13, tvOS 13, watchOS 6, *) // for TaskLocal ServiceContext public func withSpan( @@ -502,7 +502,7 @@ public func withSpan( #endif #if compiler(>=6.0) -@_disfavoredOverload@available(*, deprecated, message: "Prefer #isolation version of this API") +@_disfavoredOverload @available(*, deprecated, message: "Prefer #isolation version of this API") #endif @available(macOS 10.15, iOS 13, tvOS 13, watchOS 6, *) public func withSpan( diff --git a/Sources/Tracing/TracerProtocol+Legacy.swift b/Sources/Tracing/TracerProtocol+Legacy.swift index 2ddab18e..94b1233d 100644 --- a/Sources/Tracing/TracerProtocol+Legacy.swift +++ b/Sources/Tracing/TracerProtocol+Legacy.swift @@ -341,7 +341,7 @@ extension LegacyTracer { #if compiler(>=6.0) // swift-format-ignore: Spacing // fights with formatter - @_disfavoredOverload@available(*, deprecated, message: "Prefer #isolation version of this API") + @_disfavoredOverload @available(*, deprecated, message: "Prefer #isolation version of this API") #endif public func withAnySpan( _ operationName: String, @@ -432,7 +432,7 @@ extension LegacyTracer { #if compiler(>=6.0) // swift-format-ignore: Spacing // fights with formatter - @_disfavoredOverload@available(*, deprecated, message: "Prefer #isolation version of this API") + @_disfavoredOverload @available(*, deprecated, message: "Prefer #isolation version of this API") #endif public func withAnySpan( _ operationName: String, @@ -631,7 +631,7 @@ extension Tracer { #if compiler(>=6.0) // swift-format-ignore: Spacing // fights with formatter - @_disfavoredOverload@available(*, deprecated, message: "Prefer #isolation version of this API") + @_disfavoredOverload @available(*, deprecated, message: "Prefer #isolation version of this API") #endif public func withAnySpan( _ operationName: String, diff --git a/Sources/Tracing/TracerProtocol.swift b/Sources/Tracing/TracerProtocol.swift index ad717912..35df5fa8 100644 --- a/Sources/Tracing/TracerProtocol.swift +++ b/Sources/Tracing/TracerProtocol.swift @@ -292,7 +292,7 @@ extension Tracer { #if compiler(>=6.0) // swift-format-ignore: Spacing // fights with formatter - @_disfavoredOverload@available(*, deprecated, message: "Prefer #isolation version of this API") + @_disfavoredOverload @available(*, deprecated, message: "Prefer #isolation version of this API") #endif public func withSpan( _ operationName: String, @@ -382,7 +382,7 @@ extension Tracer { #if compiler(>=6.0) // swift-format-ignore: Spacing // fights with formatter - @_disfavoredOverload@available(*, deprecated, message: "Prefer #isolation version of this API") + @_disfavoredOverload @available(*, deprecated, message: "Prefer #isolation version of this API") #endif public func withSpan( _ operationName: String, diff --git a/Tests/InMemoryTracingTests/InMemoryTracerTests.swift b/Tests/InMemoryTracingTests/InMemoryTracerTests.swift new file mode 100644 index 00000000..40b37096 --- /dev/null +++ b/Tests/InMemoryTracingTests/InMemoryTracerTests.swift @@ -0,0 +1,441 @@ +//===----------------------------------------------------------------------===// +// +// This source file is part of the Swift Distributed Tracing open source project +// +// Copyright (c) 2025 Apple Inc. and the Swift Distributed Tracing project authors +// Licensed under Apache License v2.0 +// +// See LICENSE.txt for license information +// See CONTRIBUTORS.txt for the list of Swift Distributed Tracing project authors +// +// SPDX-License-Identifier: Apache-2.0 +// +//===----------------------------------------------------------------------===// + +#if canImport(Testing) +@_spi(Locking) import Instrumentation +import Testing +import Tracing +@_spi(Testing) import InMemoryTracing + +@Suite("InMemoryTracer") +struct InMemoryTracerTests { + @Test("Starts root span", arguments: [SpanKind.client, .consumer, .internal, .producer, .server]) + func rootSpan(kind: SpanKind) throws { + let tracer = InMemoryTracer() + let clock = DefaultTracerClock() + + let startInstant = clock.now + var context = ServiceContext.topLevel + context[UnrelatedContextKey.self] = 42 + + #expect(tracer.activeSpan(identifiedBy: context) == nil) + + let span = tracer.startSpan("root", context: context, ofKind: kind, at: startInstant) + + #expect(span.isRecording == true) + #expect(span.operationName == "root") + #expect(span.spanContext == InMemorySpanContext(traceID: "trace-1", spanID: "span-1", parentSpanID: nil)) + #expect(tracer.finishedSpans.isEmpty) + + let activeSpan = try #require(tracer.activeSpan(identifiedBy: span.context)) + #expect(activeSpan.operationName == "root") + + let endInstant = clock.now + span.end(at: endInstant) + + #expect(span.isRecording == false) + #expect(tracer.activeSpan(identifiedBy: span.context) == nil) + + let finishedSpan = try #require(tracer.finishedSpans.first) + #expect(finishedSpan.operationName == "root") + #expect(finishedSpan.startInstant.nanosecondsSinceEpoch == startInstant.nanosecondsSinceEpoch) + #expect(finishedSpan.endInstant.nanosecondsSinceEpoch == endInstant.nanosecondsSinceEpoch) + } + + @Test("Starts child span") + func childSpan() throws { + let tracer = InMemoryTracer() + var rootContext = ServiceContext.topLevel + rootContext[UnrelatedContextKey.self] = 42 + + #expect(tracer.activeSpan(identifiedBy: rootContext) == nil) + + let rootSpan = tracer.startSpan("root", context: rootContext) + let childSpan = tracer.startSpan("child", context: rootSpan.context) + #expect(childSpan.isRecording == true) + #expect(childSpan.operationName == "child") + #expect( + childSpan.spanContext == InMemorySpanContext(traceID: "trace-1", spanID: "span-2", parentSpanID: "span-1") + ) + #expect(tracer.finishedSpans.isEmpty) + + let activeSpan = try #require(tracer.activeSpan(identifiedBy: childSpan.context)) + #expect(activeSpan.operationName == "child") + + childSpan.end() + #expect(childSpan.isRecording == false) + #expect(tracer.activeSpan(identifiedBy: childSpan.context) == nil) + let finishedChildSpan = try #require(tracer.finishedSpans.first) + #expect(finishedChildSpan.operationName == "child") + + rootSpan.end() + #expect(rootSpan.isRecording == false) + #expect(tracer.activeSpan(identifiedBy: rootSpan.context) == nil) + let finishedRootSpan = try #require(tracer.finishedSpans.last) + #expect(finishedRootSpan.operationName == "root") + } + + @Test("Records force flushes") + func forceFlush() { + let tracer = InMemoryTracer() + #expect(tracer.numberOfForceFlushes == 0) + + for numberOfForceFlushes in 1...10 { + tracer.forceFlush() + #expect(tracer.numberOfForceFlushes == numberOfForceFlushes) + } + } + + @Suite("Context Propagation") + struct ContextPropagationTests { + @Test("Injects span context into carrier and records injection") + func injectWithSpanContext() throws { + let tracer = InMemoryTracer() + var context = ServiceContext.topLevel + let spanContext = InMemorySpanContext( + traceID: "stub", + spanID: "stub", + parentSpanID: "stub" + ) + context.inMemorySpanContext = spanContext + + var values = [String: String]() + tracer.inject(context, into: &values, using: DictionaryInjector()) + + #expect(values == [InMemoryTracer.traceIDKey: "stub", InMemoryTracer.spanIDKey: "stub"]) + + let injection = try #require(tracer.performedContextInjections.first) + #expect(injection.context.inMemorySpanContext == spanContext) + #expect(injection.values == values) + } + + @Test("Does not inject context without span context but records attempt") + func injectWithoutSpanContext() throws { + let tracer = InMemoryTracer() + let context = ServiceContext.topLevel + + var values = [String: String]() + tracer.inject(context, into: &values, using: DictionaryInjector()) + + #expect(values.isEmpty) + + let injection = try #require(tracer.performedContextInjections.first) + #expect(injection.context.inMemorySpanContext == nil) + #expect(injection.values.isEmpty) + } + + @Test("Extracts span context from carrier and records extraction") + func extractWithValues() throws { + let tracer = InMemoryTracer() + var context = ServiceContext.topLevel + + let values = [InMemoryTracer.traceIDKey: "stub", InMemoryTracer.spanIDKey: "stub"] + tracer.extract(values, into: &context, using: DictionaryExtractor()) + + let spanContext = try #require(context.inMemorySpanContext) + + #expect(spanContext == InMemorySpanContext(traceID: "stub", spanID: "stub", parentSpanID: nil)) + + let extraction = try #require(tracer.performedContextExtractions.first) + #expect(extraction.carrier as? [String: String] == values) + #expect(extraction.context.inMemorySpanContext == spanContext) + } + + @Test("Does not extract span context without values but records extraction") + func extractWithoutValues() throws { + let tracer = InMemoryTracer() + var context = ServiceContext.topLevel + + let values = ["foo": "bar"] + tracer.extract(values, into: &context, using: DictionaryExtractor()) + + #expect(context.inMemorySpanContext == nil) + + let extraction = try #require(tracer.performedContextExtractions.first) + #expect(extraction.carrier as? [String: String] == values) + #expect(extraction.context.inMemorySpanContext == nil) + } + } + + @Suite("Span operations") + struct SpanOperationTests { + @Test("Update operation name") + func updateOperationName() { + let span = InMemorySpan.stub + #expect(span.operationName == "stub") + + span.operationName = "updated" + + #expect(span.operationName == "updated") + } + + @Test("Set attributes") + func setAttributes() throws { + let span = InMemorySpan.stub + #expect(span.attributes == [:]) + + span.attributes["x"] = "foo" + #expect(span.attributes == ["x": "foo"]) + + span.attributes["y"] = 42 + #expect(span.attributes == ["x": "foo", "y": 42]) + } + + @Test("Add events") + func addEvents() throws { + let clock = DefaultTracerClock() + let span = InMemorySpan.stub + #expect(span.events == []) + + let event1 = SpanEvent(name: "e1", at: clock.now, attributes: ["foo": "1"]) + span.addEvent(event1) + #expect(span.events == [event1]) + + let event2 = SpanEvent(name: "e2", at: clock.now, attributes: ["foo": "2"]) + span.addEvent(event2) + #expect(span.events == [event1, event2]) + } + + @Test("Add links") + func addLinks() throws { + let span = InMemorySpan.stub + #expect(span.links.isEmpty) + + let spanContext1 = InMemorySpanContext(traceID: "1", spanID: "1", parentSpanID: nil) + var context1 = ServiceContext.topLevel + context1.inMemorySpanContext = spanContext1 + span.addLink(SpanLink(context: context1, attributes: ["foo": "1"])) + let link1 = try #require(span.links.first) + #expect(link1.context.inMemorySpanContext == spanContext1) + #expect(link1.attributes == ["foo": "1"]) + + let spanContext2 = InMemorySpanContext(traceID: "2", spanID: "2", parentSpanID: nil) + var context2 = ServiceContext.topLevel + context2.inMemorySpanContext = spanContext2 + span.addLink(SpanLink(context: context2, attributes: ["foo": "2"])) + let link2 = try #require(span.links.last) + #expect(link2.context.inMemorySpanContext == spanContext2) + #expect(link2.attributes == ["foo": "2"]) + } + + @Test("Record errors") + func recordErrors() throws { + let clock = DefaultTracerClock() + let span = InMemorySpan.stub + #expect(span.errors.isEmpty) + + struct Error1: Error {} + let instant1 = clock.now + span.recordError(Error1(), attributes: ["foo": "1"], at: instant1) + let error1 = try #require(span.errors.first) + #expect(error1.attributes == ["foo": "1"]) + #expect(error1.error is Error1) + #expect(error1.instant.nanosecondsSinceEpoch == instant1.nanosecondsSinceEpoch) + + struct Error2: Error {} + let instant2 = clock.now + span.recordError(Error2(), attributes: ["foo": "2"], at: instant2) + let error2 = try #require(span.errors.last) + #expect(error2.attributes == ["foo": "2"]) + #expect(error2.error is Error2) + #expect(error2.instant.nanosecondsSinceEpoch == instant2.nanosecondsSinceEpoch) + } + + @Test("Set status") + func setStatus() { + let span = InMemorySpan.stub + #expect(span.status == nil) + + let status = SpanStatus(code: .ok, message: "42") + span.setStatus(status) + + #expect(span.status == status) + } + + @Test("End") + func end() throws { + let clock = DefaultTracerClock() + let _finishedSpan = LockedValueBox(nil) + + let startInstant = clock.now + let spanContext = InMemorySpanContext(traceID: "stub", spanID: "stub", parentSpanID: nil) + let span = InMemorySpan( + operationName: "stub", + context: .topLevel, + spanContext: spanContext, + kind: .internal, + startInstant: startInstant, + onEnd: { span in + _finishedSpan.withValue { $0 = span } + } + ) + span.attributes["foo"] = "bar" + span.addEvent("foo") + let otherSpanContext = InMemorySpanContext(traceID: "other", spanID: "other", parentSpanID: nil) + var otherContext = ServiceContext.topLevel + otherContext.inMemorySpanContext = otherSpanContext + span.addLink(SpanLink(context: otherContext, attributes: [:])) + struct TestError: Error {} + span.recordError(TestError()) + + #expect(span.isRecording == true) + + let endInstant = clock.now + span.end(at: endInstant) + #expect(span.isRecording == false) + + let finishedSpan = try #require(_finishedSpan.withValue { $0 }) + #expect(finishedSpan.operationName == "stub") + #expect(finishedSpan.spanContext == spanContext) + #expect(finishedSpan.startInstant.nanosecondsSinceEpoch == startInstant.nanosecondsSinceEpoch) + #expect(finishedSpan.endInstant.nanosecondsSinceEpoch == endInstant.nanosecondsSinceEpoch) + #expect(finishedSpan.attributes == span.attributes) + #expect(finishedSpan.events == span.events) + #expect(finishedSpan.links.count == span.links.count) + #expect(finishedSpan.errors.count == span.errors.count) + #expect(finishedSpan.status == span.status) + } + } + + @Suite("ID Generator") + struct IDGeneratorTests { + @Test("Increments trace ID") + func traceID() { + let idGenerator = InMemoryTracer.IDGenerator.incrementing + + for i in 1...10 { + #expect(idGenerator.nextTraceID() == "trace-\(i)") + } + } + + @Test("Increments span ID") + func spanID() { + let idGenerator = InMemoryTracer.IDGenerator.incrementing + + for i in 1...10 { + #expect(idGenerator.nextSpanID() == "span-\(i)") + } + } + } + + @Suite("End to end") + struct EndToEndTests { + @Test("Parent/child span relationship across boundary") + func parentChild() async throws { + let idGenerator = InMemoryTracer.IDGenerator.incrementing + let clientTracer = InMemoryTracer(idGenerator: idGenerator) + let serverTracer = InMemoryTracer(idGenerator: idGenerator) + + let clientSpan = clientTracer.startSpan("client", ofKind: .client) + #expect(clientSpan.spanContext.traceID == "trace-1") + #expect(clientSpan.spanContext.spanID == "span-1") + #expect(clientSpan.spanContext.parentSpanID == nil) + + // simulate injecting/extracting HTTP headers + var headers = [String: String]() + clientTracer.inject(clientSpan.context, into: &headers, using: DictionaryInjector()) + var serverContext = ServiceContext.topLevel + serverTracer.extract(headers, into: &serverContext, using: DictionaryExtractor()) + + let serverSpan = serverTracer.startSpan("server", context: serverContext, ofKind: .server) + #expect(serverSpan.spanContext.traceID == clientSpan.spanContext.traceID) + #expect(serverSpan.spanContext.spanID == "span-2") + #expect(serverSpan.spanContext.parentSpanID == clientSpan.spanContext.spanID) + } + } + + @Test("Span can't be ended repeatedly") + func inMemoryDoubleEnd() async { + let endCounter = LockedValueBox(0) + let span = InMemorySpan.stub { finished in + endCounter.withValue { counter in + counter += 1 + #expect(counter < 2, "Must not end() a span multiple times.") + } + } + span.setStatus(SpanStatus(code: .ok)) + + let clock = MockClock() + clock.setTime(111) + span.end() + + clock.setTime(222) + span.end(at: clock.now) // should not blow up, but also, not update time again + + #expect(endCounter.withValue { $0 } == 1) + } +} + +extension InMemorySpan { + fileprivate static var stub: InMemorySpan { + InMemorySpan( + operationName: "stub", + context: .topLevel, + spanContext: InMemorySpanContext(traceID: "stub", spanID: "stub", parentSpanID: nil), + kind: .internal, + startInstant: DefaultTracerClock().now, + onEnd: { _ in } + ) + } + + fileprivate static func stub(onEnd: @Sendable @escaping (FinishedInMemorySpan) -> Void) -> InMemorySpan { + InMemorySpan( + operationName: "stub", + context: .topLevel, + spanContext: InMemorySpanContext(traceID: "stub", spanID: "stub", parentSpanID: nil), + kind: .internal, + startInstant: DefaultTracerClock().now, + onEnd: onEnd + ) + } +} + +private struct DictionaryInjector: Injector { + func inject(_ value: String, forKey key: String, into dictionary: inout [String: String]) { + dictionary[key] = value + } +} + +private struct DictionaryExtractor: Extractor { + func extract(key: String, from dictionary: [String: String]) -> String? { + dictionary[key] + } +} + +private struct UnrelatedContextKey: ServiceContextKey { + typealias Value = Int +} + +private final class MockClock { + var _now: UInt64 = 0 + + init() {} + + func setTime(_ time: UInt64) { + self._now = time + } + + struct Instant: TracerInstant { + var nanosecondsSinceEpoch: UInt64 + static func < (lhs: MockClock.Instant, rhs: MockClock.Instant) -> Bool { + lhs.nanosecondsSinceEpoch < rhs.nanosecondsSinceEpoch + } + } + + var now: Instant { + Instant(nanosecondsSinceEpoch: self._now) + } +} + +#endif