From 7df3df8831a24ba6e5437a931ae348389cfef7c4 Mon Sep 17 00:00:00 2001 From: Moritz Lang <16192401+slashmo@users.noreply.github.com> Date: Sat, 30 Aug 2025 14:46:51 +0200 Subject: [PATCH 1/3] Add TestTracer to new TestTracingKit --- Package.swift | 13 + Sources/TracingTestKit/TestSpan.swift | 160 ++++++ Sources/TracingTestKit/TestSpanContext.swift | 42 ++ Sources/TracingTestKit/TestTracer.swift | 193 +++++++ .../TracingTestKitTests/TestTracerTests.swift | 473 ++++++++++++++++++ 5 files changed, 881 insertions(+) create mode 100644 Sources/TracingTestKit/TestSpan.swift create mode 100644 Sources/TracingTestKit/TestSpanContext.swift create mode 100644 Sources/TracingTestKit/TestTracer.swift create mode 100644 Tests/TracingTestKitTests/TestTracerTests.swift diff --git a/Package.swift b/Package.swift index 65e12789..16c96046 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: "TracingTestKit", targets: ["TracingTestKit"]), ], 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: "TracingTestKit", + dependencies: [ + .target(name: "Tracing") + ] + ), + .testTarget( + name: "TracingTestKitTests", + dependencies: [ + .target(name: "TracingTestKit") + ] + ), // ==== -------------------------------------------------------------------------------------------------------- // MARK: Wasm Support diff --git a/Sources/TracingTestKit/TestSpan.swift b/Sources/TracingTestKit/TestSpan.swift new file mode 100644 index 00000000..fa0c8667 --- /dev/null +++ b/Sources/TracingTestKit/TestSpan.swift @@ -0,0 +1,160 @@ +//===----------------------------------------------------------------------===// +// +// 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 + +public struct TestSpan: Span { + public let context: ServiceContext + public let spanContext: TestSpanContext + public let startInstant: any TracerInstant + + init( + operationName: String, + context: ServiceContext, + spanContext: TestSpanContext, + startInstant: any TracerInstant, + onEnd: @escaping @Sendable (FinishedTestSpan) -> Void + ) { + self._operationName = LockedValueBox(operationName) + self.context = context + self.spanContext = spanContext + self.startInstant = startInstant + self.onEnd = onEnd + } + + public var isRecording: Bool { + _isRecording.withValue(\.self) + } + + public var operationName: String { + get { + _operationName.withValue(\.self) + } + nonmutating set { + assertIsRecording() + _operationName.withValue { $0 = newValue } + } + } + + public var attributes: SpanAttributes { + get { + _attributes.withValue(\.self) + } + nonmutating set { + assertIsRecording() + _attributes.withValue { $0 = newValue } + } + } + + public var events: [SpanEvent] { + _events.withValue(\.self) + } + + public func addEvent(_ event: SpanEvent) { + assertIsRecording() + _events.withValue { $0.append(event) } + } + + public var links: [SpanLink] { + _links.withValue(\.self) + } + + public func addLink(_ link: SpanLink) { + assertIsRecording() + _links.withValue { $0.append(link) } + } + + public var errors: [RecordedError] { + _errors.withValue(\.self) + } + + public func recordError( + _ error: any Error, + attributes: SpanAttributes, + at instant: @autoclosure () -> some TracerInstant + ) { + assertIsRecording() + _errors.withValue { + $0.append(RecordedError(error: error, attributes: attributes, instant: instant())) + } + } + + public var status: SpanStatus? { + _status.withValue(\.self) + } + + public func setStatus(_ status: SpanStatus) { + assertIsRecording() + _status.withValue { $0 = status } + } + + public func end(at instant: @autoclosure () -> some TracerInstant) { + assertIsRecording() + let finishedSpan = FinishedTestSpan( + operationName: operationName, + context: context, + spanContext: spanContext, + startInstant: startInstant, + endInstant: instant(), + attributes: attributes, + events: events, + links: links, + errors: errors, + status: status + ) + _isRecording.withValue { $0 = false } + onEnd(finishedSpan) + } + + public struct RecordedError: Sendable { + public let error: Error + public let attributes: SpanAttributes + public let instant: 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 (FinishedTestSpan) -> Void + + private func assertIsRecording( + file: StaticString = #file, + line: UInt = #line + ) { + assert( + _isRecording.withValue(\.self) == true, + "Attempted to mutate already ended span.", + file: file, + line: line + ) + } +} + +public struct FinishedTestSpan: Sendable { + public let operationName: String + public let context: ServiceContext + public let spanContext: TestSpanContext + public let startInstant: any TracerInstant + public let endInstant: any TracerInstant + public let attributes: SpanAttributes + public let events: [SpanEvent] + public let links: [SpanLink] + public let errors: [TestSpan.RecordedError] + public let status: SpanStatus? +} diff --git a/Sources/TracingTestKit/TestSpanContext.swift b/Sources/TracingTestKit/TestSpanContext.swift new file mode 100644 index 00000000..6703039d --- /dev/null +++ b/Sources/TracingTestKit/TestSpanContext.swift @@ -0,0 +1,42 @@ +//===----------------------------------------------------------------------===// +// +// 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 + +public struct TestSpanContext: Sendable, Hashable { + public let traceID: String + public let spanID: String + public let parentSpanID: String? + + public init(traceID: String, spanID: String, parentSpanID: String?) { + self.traceID = traceID + self.spanID = spanID + self.parentSpanID = parentSpanID + } +} + +extension ServiceContext { + var testSpanContext: TestSpanContext? { + get { + self[TestSpanContextKey.self] + } + set { + self[TestSpanContextKey.self] = newValue + } + } +} + +private struct TestSpanContextKey: ServiceContextKey { + typealias Value = TestSpanContext +} diff --git a/Sources/TracingTestKit/TestTracer.swift b/Sources/TracingTestKit/TestTracer.swift new file mode 100644 index 00000000..beffe71b --- /dev/null +++ b/Sources/TracingTestKit/TestTracer.swift @@ -0,0 +1,193 @@ +//===----------------------------------------------------------------------===// +// +// 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 + +public struct TestTracer: Tracer { + public let idGenerator: IDGenerator + + public init(idGenerator: IDGenerator = .incrementing) { + self.idGenerator = idGenerator + } + + // MARK: - Tracer + + public func startSpan( + _ operationName: String, + context: @autoclosure () -> ServiceContext, + ofKind kind: SpanKind, + at instant: @autoclosure () -> Instant, + function: String, + file fileID: String, + line: UInt + ) -> TestSpan where Instant: TracerInstant { + let parentContext = context() + let spanContext: TestSpanContext + + if let parentSpanContext = parentContext.testSpanContext { + // child span + spanContext = TestSpanContext( + traceID: parentSpanContext.traceID, + spanID: idGenerator.nextSpanID(), + parentSpanID: parentSpanContext.spanID + ) + } else { + // root span + spanContext = TestSpanContext( + traceID: idGenerator.nextTraceID(), + spanID: idGenerator.nextSpanID(), + parentSpanID: nil + ) + } + + var context = parentContext + context.testSpanContext = spanContext + + let span = TestSpan( + operationName: operationName, + context: context, + spanContext: spanContext, + startInstant: instant() + ) { finishedSpan in + _activeSpans.withValue { $0[spanContext] = nil } + _finishedSpans.withValue { $0.append(finishedSpan) } + } + _activeSpans.withValue { $0[spanContext] = span } + return span + } + + public func activeSpan(identifiedBy context: ServiceContext) -> TestSpan? { + guard let spanContext = context.testSpanContext else { return nil } + return _activeSpans.withValue { $0[spanContext] } + } + + public func forceFlush() { + _numberOfForceFlushes.withValue { $0 += 1 } + } + + public var numberOfForceFlushes: Int { + _numberOfForceFlushes.withValue(\.self) + } + + public var finishedSpans: [FinishedTestSpan] { + _finishedSpans.withValue(\.self) + } + + private let _activeSpans = LockedValueBox<[TestSpanContext: TestSpan]>([:]) + private let _finishedSpans = LockedValueBox<[FinishedTestSpan]>([]) + private let _numberOfForceFlushes = LockedValueBox(0) + + // MARK: - Instrument + + public static let traceIDKey = "test-trace-id" + public static let spanIDKey = "test-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.testSpanContext { + 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 + } + + let injection = Injection(context: context, values: values) + _injections.withValue { $0.append(injection) } + } + + public var injections: [Injection] { + _injections.withValue(\.self) + } + + public func extract( + _ carrier: Carrier, + into context: inout ServiceContext, + using extractor: Extract + ) where Carrier == Extract.Carrier { + defer { + let extraction = Extraction(carrier: carrier, context: context) + _extractions.withValue { $0.append(extraction) } + } + + guard let traceID = extractor.extract(key: Self.traceIDKey, from: carrier), + let spanID = extractor.extract(key: Self.spanIDKey, from: carrier) + else { + return + } + + context.testSpanContext = TestSpanContext(traceID: traceID, spanID: spanID, parentSpanID: nil) + } + + public var extractions: [Extraction] { + _extractions.withValue(\.self) + } + + public struct Injection: Sendable { + public let context: ServiceContext + public let values: [String: String] + } + + public struct Extraction: Sendable { + public let carrier: any Sendable + public let context: ServiceContext + } + + private let _injections = LockedValueBox<[Injection]>([]) + private let _extractions = LockedValueBox<[Extraction]>([]) +} + +// MARK: - ID Generator + +extension TestTracer { + 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/Tests/TracingTestKitTests/TestTracerTests.swift b/Tests/TracingTestKitTests/TestTracerTests.swift new file mode 100644 index 00000000..9d069715 --- /dev/null +++ b/Tests/TracingTestKitTests/TestTracerTests.swift @@ -0,0 +1,473 @@ +//===----------------------------------------------------------------------===// +// +// 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 +@testable import TracingTestKit + +@Suite("TestTracer") +struct TestTracerTests { + @Test("Starts root span", arguments: [SpanKind.client, .consumer, .internal, .producer, .server]) + func rootSpan(kind: SpanKind) throws { + let tracer = TestTracer() + 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 == TestSpanContext(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 = TestTracer() + 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 == TestSpanContext(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 = TestTracer() + #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 = TestTracer() + var context = ServiceContext.topLevel + let spanContext = TestSpanContext( + traceID: "stub", + spanID: "stub", + parentSpanID: "stub" + ) + context.testSpanContext = spanContext + + var values = [String: String]() + tracer.inject(context, into: &values, using: DictionaryInjector()) + + #expect(values == [TestTracer.traceIDKey: "stub", TestTracer.spanIDKey: "stub"]) + + let injection = try #require(tracer.injections.first) + #expect(injection.context.testSpanContext == spanContext) + #expect(injection.values == values) + } + + @Test("Does not inject context without span context but records attempt") + func injectWithoutSpanContext() throws { + let tracer = TestTracer() + let context = ServiceContext.topLevel + + var values = [String: String]() + tracer.inject(context, into: &values, using: DictionaryInjector()) + + #expect(values.isEmpty) + + let injection = try #require(tracer.injections.first) + #expect(injection.context.testSpanContext == nil) + #expect(injection.values.isEmpty) + } + + @Test("Extracts span context from carrier and records extraction") + func extractWithValues() throws { + let tracer = TestTracer() + var context = ServiceContext.topLevel + + let values = [TestTracer.traceIDKey: "stub", TestTracer.spanIDKey: "stub"] + tracer.extract(values, into: &context, using: DictionaryExtractor()) + + let spanContext = try #require(context.testSpanContext) + + #expect(spanContext == TestSpanContext(traceID: "stub", spanID: "stub", parentSpanID: nil)) + + let extraction = try #require(tracer.extractions.first) + #expect(extraction.carrier as? [String: String] == values) + #expect(extraction.context.testSpanContext == spanContext) + } + + @Test("Does not extract span context without values but records extraction") + func extractWithoutValues() throws { + let tracer = TestTracer() + var context = ServiceContext.topLevel + + let values = ["foo": "bar"] + tracer.extract(values, into: &context, using: DictionaryExtractor()) + + #expect(context.testSpanContext == nil) + + let extraction = try #require(tracer.extractions.first) + #expect(extraction.carrier as? [String: String] == values) + #expect(extraction.context.testSpanContext == nil) + } + } + + @Suite("Span operations") + struct SpanOperationTests { + @Test("Update operation name") + func updateOperationName() { + let span = TestSpan.stub + #expect(span.operationName == "stub") + + span.operationName = "updated" + + #expect(span.operationName == "updated") + } + + @Test("Set attributes") + func setAttributes() throws { + let span = TestSpan.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 = TestSpan.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 = TestSpan.stub + #expect(span.links.isEmpty) + + let spanContext1 = TestSpanContext(traceID: "1", spanID: "1", parentSpanID: nil) + var context1 = ServiceContext.topLevel + context1.testSpanContext = spanContext1 + span.addLink(SpanLink(context: context1, attributes: ["foo": "1"])) + let link1 = try #require(span.links.first) + #expect(link1.context.testSpanContext == spanContext1) + #expect(link1.attributes == ["foo": "1"]) + + let spanContext2 = TestSpanContext(traceID: "2", spanID: "2", parentSpanID: nil) + var context2 = ServiceContext.topLevel + context2.testSpanContext = spanContext2 + span.addLink(SpanLink(context: context2, attributes: ["foo": "2"])) + let link2 = try #require(span.links.last) + #expect(link2.context.testSpanContext == spanContext2) + #expect(link2.attributes == ["foo": "2"]) + } + + @Test("Record errors") + func recordErrors() throws { + let clock = DefaultTracerClock() + let span = TestSpan.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 = TestSpan.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 = TestSpanContext(traceID: "stub", spanID: "stub", parentSpanID: nil) + let span = TestSpan( + operationName: "stub", + context: .topLevel, + spanContext: spanContext, + startInstant: startInstant, + onEnd: { span in + _finishedSpan.withValue { $0 = span } + } + ) + span.attributes["foo"] = "bar" + span.addEvent("foo") + let otherSpanContext = TestSpanContext(traceID: "other", spanID: "other", parentSpanID: nil) + var otherContext = ServiceContext.topLevel + otherContext.testSpanContext = 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 = TestTracer.IDGenerator.incrementing + + for i in 1...10 { + #expect(idGenerator.nextTraceID() == "trace-\(i)") + } + } + + @Test("Increments span ID") + func spanID() { + let idGenerator = TestTracer.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 = TestTracer.IDGenerator.incrementing + let clientTracer = TestTracer(idGenerator: idGenerator) + let serverTracer = TestTracer(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) + } + } + + #if compiler(>=6.2) // Exit tests require Swift 6.2 + @Suite("TestSpan can't be mutated after being ended") + struct FinishedSpanImmutability { + @Test("Operation name is immutable on ended span") + func operationName() async { + await #expect(processExitsWith: .failure) { + let span = TestSpan.stub + span.operationName = "✅" + + span.end() + + span.operationName = "💥" + } + } + + @Test("Attributes are immutable on ended span") + func attributes() async { + await #expect(processExitsWith: .failure) { + let span = TestSpan.stub + span.attributes["before"] = "✅" + + span.end() + + span.attributes["after"] = "💥" + } + } + + @Test("Events are immutable on ended span") + func events() async { + await #expect(processExitsWith: .failure) { + let span = TestSpan.stub + span.addEvent("✅") + + span.end() + + span.addEvent("💥") + } + } + + @Test("Links are immutable on ended span") + func links() async { + await #expect(processExitsWith: .failure) { + let span = TestSpan.stub + span.addLink(.stub) + + span.end() + + span.addLink(.stub) + } + } + + @Test("Errors are immutable on ended span") + func errors() async { + await #expect(processExitsWith: .failure) { + struct TestError: Error {} + let span = TestSpan.stub + span.recordError(TestError()) + + span.end() + + span.recordError(TestError()) + } + } + + @Test("Status is immutable on ended span") + func status() async { + await #expect(processExitsWith: .failure) { + let span = TestSpan.stub + span.setStatus(SpanStatus(code: .ok)) + + span.end() + + span.setStatus(SpanStatus(code: .error)) + } + } + + @Test("Span can't be ended repeatedly") + func end() async { + await #expect(processExitsWith: .failure) { + let span = TestSpan.stub + span.setStatus(SpanStatus(code: .ok)) + + span.end() + + span.end() + } + } + } + #endif +} + +extension TestSpan { + fileprivate static var stub: TestSpan { + TestSpan( + operationName: "stub", + context: .topLevel, + spanContext: TestSpanContext(traceID: "stub", spanID: "stub", parentSpanID: nil), + startInstant: DefaultTracerClock().now, + onEnd: { _ in } + ) + } +} + +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 +} +#endif From f6b7db6d757ab1dffde862272e55569b16924dd2 Mon Sep 17 00:00:00 2001 From: Moritz Lang <16192401+slashmo@users.noreply.github.com> Date: Sat, 30 Aug 2025 15:32:13 +0200 Subject: [PATCH 2/3] Fix compilation in Swift 5.10 --- Sources/TracingTestKit/TestSpan.swift | 16 ++++++++-------- Sources/TracingTestKit/TestTracer.swift | 8 ++++---- 2 files changed, 12 insertions(+), 12 deletions(-) diff --git a/Sources/TracingTestKit/TestSpan.swift b/Sources/TracingTestKit/TestSpan.swift index fa0c8667..ae3711e5 100644 --- a/Sources/TracingTestKit/TestSpan.swift +++ b/Sources/TracingTestKit/TestSpan.swift @@ -35,12 +35,12 @@ public struct TestSpan: Span { } public var isRecording: Bool { - _isRecording.withValue(\.self) + _isRecording.withValue { $0 } } public var operationName: String { get { - _operationName.withValue(\.self) + _operationName.withValue { $0 } } nonmutating set { assertIsRecording() @@ -50,7 +50,7 @@ public struct TestSpan: Span { public var attributes: SpanAttributes { get { - _attributes.withValue(\.self) + _attributes.withValue { $0 } } nonmutating set { assertIsRecording() @@ -59,7 +59,7 @@ public struct TestSpan: Span { } public var events: [SpanEvent] { - _events.withValue(\.self) + _events.withValue { $0 } } public func addEvent(_ event: SpanEvent) { @@ -68,7 +68,7 @@ public struct TestSpan: Span { } public var links: [SpanLink] { - _links.withValue(\.self) + _links.withValue { $0 } } public func addLink(_ link: SpanLink) { @@ -77,7 +77,7 @@ public struct TestSpan: Span { } public var errors: [RecordedError] { - _errors.withValue(\.self) + _errors.withValue { $0 } } public func recordError( @@ -92,7 +92,7 @@ public struct TestSpan: Span { } public var status: SpanStatus? { - _status.withValue(\.self) + _status.withValue { $0 } } public func setStatus(_ status: SpanStatus) { @@ -138,7 +138,7 @@ public struct TestSpan: Span { line: UInt = #line ) { assert( - _isRecording.withValue(\.self) == true, + _isRecording.withValue { $0 } == true, "Attempted to mutate already ended span.", file: file, line: line diff --git a/Sources/TracingTestKit/TestTracer.swift b/Sources/TracingTestKit/TestTracer.swift index beffe71b..8b5c8a09 100644 --- a/Sources/TracingTestKit/TestTracer.swift +++ b/Sources/TracingTestKit/TestTracer.swift @@ -78,11 +78,11 @@ public struct TestTracer: Tracer { } public var numberOfForceFlushes: Int { - _numberOfForceFlushes.withValue(\.self) + _numberOfForceFlushes.withValue { $0 } } public var finishedSpans: [FinishedTestSpan] { - _finishedSpans.withValue(\.self) + _finishedSpans.withValue { $0 } } private let _activeSpans = LockedValueBox<[TestSpanContext: TestSpan]>([:]) @@ -113,7 +113,7 @@ public struct TestTracer: Tracer { } public var injections: [Injection] { - _injections.withValue(\.self) + _injections.withValue { $0 } } public func extract( @@ -136,7 +136,7 @@ public struct TestTracer: Tracer { } public var extractions: [Extraction] { - _extractions.withValue(\.self) + _extractions.withValue { $0 } } public struct Injection: Sendable { From b80efc1c718e51620c394fedbf4fe644cf71b81f Mon Sep 17 00:00:00 2001 From: Moritz Lang <16192401+slashmo@users.noreply.github.com> Date: Sat, 30 Aug 2025 19:36:51 +0200 Subject: [PATCH 3/3] Expose SpanKind in TestSpan and FinishedTestSpan --- Sources/TracingTestKit/TestSpan.swift | 5 +++++ Sources/TracingTestKit/TestTracer.swift | 1 + Tests/TracingTestKitTests/TestTracerTests.swift | 2 ++ 3 files changed, 8 insertions(+) diff --git a/Sources/TracingTestKit/TestSpan.swift b/Sources/TracingTestKit/TestSpan.swift index ae3711e5..da4d6b40 100644 --- a/Sources/TracingTestKit/TestSpan.swift +++ b/Sources/TracingTestKit/TestSpan.swift @@ -18,18 +18,21 @@ import Tracing public struct TestSpan: Span { public let context: ServiceContext public let spanContext: TestSpanContext + public let kind: SpanKind public let startInstant: any TracerInstant init( operationName: String, context: ServiceContext, spanContext: TestSpanContext, + kind: SpanKind, startInstant: any TracerInstant, onEnd: @escaping @Sendable (FinishedTestSpan) -> Void ) { self._operationName = LockedValueBox(operationName) self.context = context self.spanContext = spanContext + self.kind = kind self.startInstant = startInstant self.onEnd = onEnd } @@ -105,6 +108,7 @@ public struct TestSpan: Span { let finishedSpan = FinishedTestSpan( operationName: operationName, context: context, + kind: kind, spanContext: spanContext, startInstant: startInstant, endInstant: instant(), @@ -149,6 +153,7 @@ public struct TestSpan: Span { public struct FinishedTestSpan: Sendable { public let operationName: String public let context: ServiceContext + public let kind: SpanKind public let spanContext: TestSpanContext public let startInstant: any TracerInstant public let endInstant: any TracerInstant diff --git a/Sources/TracingTestKit/TestTracer.swift b/Sources/TracingTestKit/TestTracer.swift index 8b5c8a09..f04453ed 100644 --- a/Sources/TracingTestKit/TestTracer.swift +++ b/Sources/TracingTestKit/TestTracer.swift @@ -59,6 +59,7 @@ public struct TestTracer: Tracer { operationName: operationName, context: context, spanContext: spanContext, + kind: kind, startInstant: instant() ) { finishedSpan in _activeSpans.withValue { $0[spanContext] = nil } diff --git a/Tests/TracingTestKitTests/TestTracerTests.swift b/Tests/TracingTestKitTests/TestTracerTests.swift index 9d069715..301b69d3 100644 --- a/Tests/TracingTestKitTests/TestTracerTests.swift +++ b/Tests/TracingTestKitTests/TestTracerTests.swift @@ -272,6 +272,7 @@ struct TestTracerTests { operationName: "stub", context: .topLevel, spanContext: spanContext, + kind: .internal, startInstant: startInstant, onEnd: { span in _finishedSpan.withValue { $0 = span } @@ -449,6 +450,7 @@ extension TestSpan { operationName: "stub", context: .topLevel, spanContext: TestSpanContext(traceID: "stub", spanID: "stub", parentSpanID: nil), + kind: .internal, startInstant: DefaultTracerClock().now, onEnd: { _ in } )