diff --git a/.licenseignore b/.licenseignore index edceaab62..151ce9245 100644 --- a/.licenseignore +++ b/.licenseignore @@ -16,6 +16,8 @@ *.json Package.swift **/Package.swift +Package@swift-*.swift +**/Package@swift-*.swift Package@-*.swift **/Package@-*.swift Package.resolved diff --git a/Package.swift b/Package.swift index 3294781a9..3cff98089 100644 --- a/Package.swift +++ b/Package.swift @@ -47,6 +47,7 @@ let package = Package( .package(url: "https://github.com/apple/swift-log.git", from: "1.6.0"), .package(url: "https://github.com/apple/swift-atomics.git", from: "1.0.2"), .package(url: "https://github.com/apple/swift-algorithms.git", from: "1.0.0"), + .package(url: "https://github.com/apple/swift-distributed-tracing.git", from: "1.3.0"), ], targets: [ .target( @@ -70,9 +71,11 @@ let package = Package( .product(name: "NIOHTTPCompression", package: "swift-nio-extras"), .product(name: "NIOSOCKS", package: "swift-nio-extras"), .product(name: "NIOTransportServices", package: "swift-nio-transport-services"), - .product(name: "Logging", package: "swift-log"), .product(name: "Atomics", package: "swift-atomics"), .product(name: "Algorithms", package: "swift-algorithms"), + // Observability support + .product(name: "Logging", package: "swift-log"), + .product(name: "Tracing", package: "swift-distributed-tracing"), ], swiftSettings: strictConcurrencySettings ), @@ -89,9 +92,12 @@ let package = Package( .product(name: "NIOSSL", package: "swift-nio-ssl"), .product(name: "NIOHTTP2", package: "swift-nio-http2"), .product(name: "NIOSOCKS", package: "swift-nio-extras"), - .product(name: "Logging", package: "swift-log"), .product(name: "Atomics", package: "swift-atomics"), .product(name: "Algorithms", package: "swift-algorithms"), + // Observability support + .product(name: "Logging", package: "swift-log"), + .product(name: "Tracing", package: "swift-distributed-tracing"), + .product(name: "InMemoryTracing", package: "swift-distributed-tracing"), ], resources: [ .copy("Resources/self_signed_cert.pem"), diff --git a/Sources/AsyncHTTPClient/AsyncAwait/HTTPClient+execute.swift b/Sources/AsyncHTTPClient/AsyncAwait/HTTPClient+execute.swift index 5fc1be9f5..0437211c6 100644 --- a/Sources/AsyncHTTPClient/AsyncAwait/HTTPClient+execute.swift +++ b/Sources/AsyncHTTPClient/AsyncAwait/HTTPClient+execute.swift @@ -15,6 +15,7 @@ import Logging import NIOCore import NIOHTTP1 +import Tracing import struct Foundation.URL @@ -36,12 +37,14 @@ extension HTTPClient { deadline: NIODeadline, logger: Logger? = nil ) async throws -> HTTPClientResponse { - try await self.executeAndFollowRedirectsIfNeeded( - request, - deadline: deadline, - logger: logger ?? Self.loggingDisabled, - redirectState: RedirectState(self.configuration.redirectConfiguration.mode, initialURL: request.url) - ) + try await withRequestSpan(request) { + try await self.executeAndFollowRedirectsIfNeeded( + request, + deadline: deadline, + logger: logger ?? Self.loggingDisabled, + redirectState: RedirectState(self.configuration.redirectConfiguration.mode, initialURL: request.url) + ) + } } } diff --git a/Sources/AsyncHTTPClient/AsyncAwait/HTTPClient+tracing.swift b/Sources/AsyncHTTPClient/AsyncAwait/HTTPClient+tracing.swift new file mode 100644 index 000000000..0be737619 --- /dev/null +++ b/Sources/AsyncHTTPClient/AsyncAwait/HTTPClient+tracing.swift @@ -0,0 +1,41 @@ +//===----------------------------------------------------------------------===// +// +// This source file is part of the AsyncHTTPClient open source project +// +// Copyright (c) 2021 Apple Inc. and the AsyncHTTPClient project authors +// Licensed under Apache License v2.0 +// +// See LICENSE.txt for license information +// See CONTRIBUTORS.txt for the list of AsyncHTTPClient project authors +// +// SPDX-License-Identifier: Apache-2.0 +// +//===----------------------------------------------------------------------===// + +import NIOHTTP1 +import Tracing + +@available(macOS 10.15, iOS 13.0, watchOS 6.0, tvOS 13.0, *) +extension HTTPClient { + @inlinable + func withRequestSpan( + _ request: HTTPClientRequest, + _ body: () async throws -> HTTPClientResponse + ) async rethrows -> HTTPClientResponse { + guard let tracer = self.tracer else { + return try await body() + } + + return try await tracer.withSpan(request.method.rawValue, ofKind: .client) { span in + let keys = self.configuration.tracing.attributeKeys + span.attributes[keys.requestMethod] = request.method.rawValue + // TODO: set more attributes on the span + let response = try await body() + + // set response span attributes + TracingSupport.handleResponseStatusCode(span, response.status, keys: tracing.attributeKeys) + + return response + } + } +} diff --git a/Sources/AsyncHTTPClient/AsyncAwait/Transaction.swift b/Sources/AsyncHTTPClient/AsyncAwait/Transaction.swift index 6bf8b38b7..c0c22bfee 100644 --- a/Sources/AsyncHTTPClient/AsyncAwait/Transaction.swift +++ b/Sources/AsyncHTTPClient/AsyncAwait/Transaction.swift @@ -17,6 +17,7 @@ import NIOConcurrencyHelpers import NIOCore import NIOHTTP1 import NIOSSL +import Tracing @available(macOS 10.15, iOS 13.0, watchOS 6.0, tvOS 13.0, *) @usableFromInline diff --git a/Sources/AsyncHTTPClient/HTTPClient.swift b/Sources/AsyncHTTPClient/HTTPClient.swift index f22810378..fdb453e7e 100644 --- a/Sources/AsyncHTTPClient/HTTPClient.swift +++ b/Sources/AsyncHTTPClient/HTTPClient.swift @@ -23,6 +23,7 @@ import NIOPosix import NIOSSL import NIOTLS import NIOTransportServices +import Tracing extension Logger { private func requestInfo(_ request: HTTPClient.Request) -> Logger.Metadata.Value { @@ -62,15 +63,29 @@ public final class HTTPClient: Sendable { /// /// All HTTP transactions will occur on loops owned by this group. public let eventLoopGroup: EventLoopGroup - let configuration: Configuration let poolManager: HTTPConnectionPool.Manager + @usableFromInline + let configuration: Configuration + /// Shared thread pool used for file IO. It is lazily created on first access of ``Task/fileIOThreadPool``. private let fileIOThreadPool: NIOLockedValueBox private let state: NIOLockedValueBox private let canBeShutDown: Bool + /// Tracer configured for this HTTPClient at configuration time. + @available(macOS 10.15, iOS 13, tvOS 13, watchOS 6, *) + public var tracer: (any Tracer)? { + configuration.tracing.tracer + } + + /// Access to tracing configuration in order to get configured attribute keys etc. + @usableFromInline + package var tracing: TracingConfiguration { + self.configuration.tracing + } + static let loggingDisabled = Logger(label: "AHC-do-not-log", factory: { _ in SwiftLogNoOpLogHandler() }) /// Create an ``HTTPClient`` with specified `EventLoopGroup` provider and configuration. @@ -705,6 +720,7 @@ public final class HTTPClient: Sendable { request, requestID: globalRequestID.wrappingIncrementThenLoad(ordering: .relaxed) ) + let taskEL: EventLoop switch eventLoopPreference.preference { case .indifferent: @@ -734,7 +750,7 @@ public final class HTTPClient: Sendable { ] ) - let failedTask: Task? = self.state.withLockedValue { state in + let failedTask: Task? = self.state.withLockedValue { state -> (Task?) in switch state { case .upAndRunning: return nil @@ -744,6 +760,7 @@ public final class HTTPClient: Sendable { eventLoop: taskEL, error: HTTPClientError.alreadyShutdown, logger: logger, + tracing: tracing, makeOrGetFileIOThreadPool: self.makeOrGetFileIOThreadPool ) } @@ -768,11 +785,14 @@ public final class HTTPClient: Sendable { } }() - let task = Task( - eventLoop: taskEL, - logger: logger, - makeOrGetFileIOThreadPool: self.makeOrGetFileIOThreadPool - ) + let task: HTTPClient.Task = + Task( + eventLoop: taskEL, + logger: logger, + tracing: self.tracing, + makeOrGetFileIOThreadPool: self.makeOrGetFileIOThreadPool + ) + do { let requestBag = try RequestBag( request: request, @@ -884,6 +904,9 @@ public final class HTTPClient: Sendable { /// A method with access to the HTTP/2 stream channel that is called when creating the stream. public var http2StreamChannelDebugInitializer: (@Sendable (Channel) -> EventLoopFuture)? + /// Configuration how distributed traces are created and handled. + public var tracing: TracingConfiguration = .init() + public init( tlsConfiguration: TLSConfiguration? = nil, redirectConfiguration: RedirectConfiguration? = nil, @@ -1012,6 +1035,84 @@ public final class HTTPClient: Sendable { self.http2ConnectionDebugInitializer = http2ConnectionDebugInitializer self.http2StreamChannelDebugInitializer = http2StreamChannelDebugInitializer } + + public init( + tlsConfiguration: TLSConfiguration? = nil, + redirectConfiguration: RedirectConfiguration? = nil, + timeout: Timeout = Timeout(), + connectionPool: ConnectionPool = ConnectionPool(), + proxy: Proxy? = nil, + ignoreUncleanSSLShutdown: Bool = false, + decompression: Decompression = .disabled, + http1_1ConnectionDebugInitializer: (@Sendable (Channel) -> EventLoopFuture)? = nil, + http2ConnectionDebugInitializer: (@Sendable (Channel) -> EventLoopFuture)? = nil, + http2StreamChannelDebugInitializer: (@Sendable (Channel) -> EventLoopFuture)? = nil, + tracing: TracingConfiguration = .init() + ) { + self.init( + tlsConfiguration: tlsConfiguration, + redirectConfiguration: redirectConfiguration, + timeout: timeout, + connectionPool: connectionPool, + proxy: proxy, + ignoreUncleanSSLShutdown: ignoreUncleanSSLShutdown, + decompression: decompression + ) + self.http1_1ConnectionDebugInitializer = http1_1ConnectionDebugInitializer + self.http2ConnectionDebugInitializer = http2ConnectionDebugInitializer + self.http2StreamChannelDebugInitializer = http2StreamChannelDebugInitializer + self.tracing = tracing + } + } + + public struct TracingConfiguration: Sendable { + + @usableFromInline + var _tracer: Optional // erasure trick so we don't have to make Configuration @available + + /// Tracer that should be used by the HTTPClient. + /// + /// This is selected at configuration creation time, and if no tracer is passed explicitly, + /// (including `nil` in order to disable traces), the default global bootstrapped tracer will + /// be stored in this property, and used for all subsequent requests made by this client. + @inlinable + @available(macOS 10.15, iOS 13, tvOS 13, watchOS 6, *) + public var tracer: (any Tracer)? { + get { + guard let _tracer else { + return nil + } + return _tracer as! (any Tracer)? + } + set { + self._tracer = newValue + } + } + + // TODO: Open up customization of keys we use? + /// Configuration for tracing attributes set by the HTTPClient. + @usableFromInline + package var attributeKeys: AttributeKeys + + public init() { + self._tracer = nil + self.attributeKeys = .init() + } + + /// Span attribute keys that the HTTPClient should set automatically. + /// This struct allows the configuration of the attribute names (keys) which will be used for the apropriate values. + @usableFromInline + package struct AttributeKeys: Sendable { + @usableFromInline package var requestMethod: String = "http.request.method" + @usableFromInline package var requestBodySize: String = "http.request.body.size" + + @usableFromInline package var responseBodySize: String = "http.response.body.size" + @usableFromInline package var responseStatusCode: String = "http.status_code" + + @usableFromInline package var httpFlavor: String = "http.flavor" + + @usableFromInline package init() {} + } } /// Specifies how `EventLoopGroup` will be created and establishes lifecycle ownership. diff --git a/Sources/AsyncHTTPClient/HTTPHandler.swift b/Sources/AsyncHTTPClient/HTTPHandler.swift index 8d92d8ef7..f9b337565 100644 --- a/Sources/AsyncHTTPClient/HTTPHandler.swift +++ b/Sources/AsyncHTTPClient/HTTPHandler.swift @@ -19,6 +19,7 @@ import NIOCore import NIOHTTP1 import NIOPosix import NIOSSL +import Tracing #if compiler(>=6.0) import Foundation @@ -924,6 +925,12 @@ extension HTTPClient { /// The `Logger` used by the `Task` for logging. public let logger: Logger // We are okay to store the logger here because a Task is for only one request. + @available(macOS 10.15, iOS 13, tvOS 13, watchOS 6, *) + public var tracer: (any Tracer)? { + tracing.tracer + } + let tracing: TracingConfiguration + let promise: EventLoopPromise struct State: Sendable { @@ -953,10 +960,16 @@ extension HTTPClient { self.makeOrGetFileIOThreadPool() } - init(eventLoop: EventLoop, logger: Logger, makeOrGetFileIOThreadPool: @escaping @Sendable () -> NIOThreadPool) { + init( + eventLoop: EventLoop, + logger: Logger, + tracing: TracingConfiguration, + makeOrGetFileIOThreadPool: @escaping @Sendable () -> NIOThreadPool + ) { self.eventLoop = eventLoop self.promise = eventLoop.makePromise() self.logger = logger + self.tracing = tracing self.makeOrGetFileIOThreadPool = makeOrGetFileIOThreadPool self.state = NIOLockedValueBox(State(isCancelled: false, taskDelegate: nil)) } @@ -965,11 +978,13 @@ extension HTTPClient { eventLoop: EventLoop, error: Error, logger: Logger, + tracing: TracingConfiguration, makeOrGetFileIOThreadPool: @escaping @Sendable () -> NIOThreadPool ) -> Task { let task = self.init( eventLoop: eventLoop, logger: logger, + tracing: tracing, makeOrGetFileIOThreadPool: makeOrGetFileIOThreadPool ) task.promise.fail(error) diff --git a/Sources/AsyncHTTPClient/RequestBag+Tracing.swift b/Sources/AsyncHTTPClient/RequestBag+Tracing.swift new file mode 100644 index 000000000..729b6256a --- /dev/null +++ b/Sources/AsyncHTTPClient/RequestBag+Tracing.swift @@ -0,0 +1,73 @@ +//===----------------------------------------------------------------------===// +// +// This source file is part of the AsyncHTTPClient open source project +// +// Copyright (c) 2021 Apple Inc. and the AsyncHTTPClient project authors +// Licensed under Apache License v2.0 +// +// See LICENSE.txt for license information +// See CONTRIBUTORS.txt for the list of AsyncHTTPClient project authors +// +// SPDX-License-Identifier: Apache-2.0 +// +//===----------------------------------------------------------------------===// + +import Logging +import NIOConcurrencyHelpers +import NIOCore +import NIOHTTP1 +import NIOSSL +import Tracing + +extension RequestBag.LoopBoundState { + + /// Starts the "overall" Span that encompases the beginning of a request until receipt of the head part of the response. + mutating func startRequestSpan(tracer: T?) { + guard #available(macOS 10.15, iOS 13, tvOS 13, watchOS 6, *), + let tracer = tracer as! (any Tracer)? + else { + return + } + + assert( + self.activeSpan == nil, + "Unexpected active span when starting new request span! Was: \(String(describing: self.activeSpan))" + ) + self.activeSpan = tracer.startSpan("\(request.method)", ofKind: .client) + } + + /// Fails the active overall span given some internal error, e.g. timeout, pool shutdown etc. + /// This is not to be used for failing a span given a failure status coded HTTPResponse. + mutating func failRequestSpanAsCancelled() { + if #available(macOS 10.15, iOS 13.0, watchOS 6.0, tvOS 13.0, *) { + failRequestSpan(error: CancellationError()) + } else { + failRequestSpan(error: HTTPRequestCancellationError()) + } + } + + mutating func failRequestSpan(error: any Error) { + guard let span = activeSpan else { + return + } + + span.recordError(error) + span.end() + + self.activeSpan = nil + } + + /// Ends the active overall span upon receipt of the response head. + /// + /// If the status code is in error range, this will automatically fail the span. + mutating func endRequestSpan(response: HTTPResponseHead) { + guard let span = activeSpan else { + return + } + + TracingSupport.handleResponseStatusCode(span, response.status, keys: tracing.attributeKeys) + + span.end() + self.activeSpan = nil + } +} diff --git a/Sources/AsyncHTTPClient/RequestBag.swift b/Sources/AsyncHTTPClient/RequestBag.swift index f206325ee..ff3ed8442 100644 --- a/Sources/AsyncHTTPClient/RequestBag.swift +++ b/Sources/AsyncHTTPClient/RequestBag.swift @@ -17,6 +17,7 @@ import NIOConcurrencyHelpers import NIOCore import NIOHTTP1 import NIOSSL +import Tracing @preconcurrency final class RequestBag: Sendable { @@ -50,6 +51,11 @@ final class RequestBag: Sendabl var consumeBodyPartStackDepth: Int // if a redirect occurs, we store the task for it so we can propagate cancellation var redirectTask: HTTPClient.Task? = nil + + // - Distributed tracing + var tracing: HTTPClient.TracingConfiguration + // The current span, representing the entire request/response made by an execute call. + var activeSpan: (any Span)? = nil } private let loopBoundState: NIOLoopBoundBox @@ -60,6 +66,16 @@ final class RequestBag: Sendabl self.task.logger } + // Available unconditionally, so we can simplify callsites which can just try to pass this value + // regardless if the real tracer exists or not. + var anyTracer: (any Sendable)? { + self.task.tracing._tracer + } + @available(macOS 10.15, iOS 13, tvOS 13, watchOS 6, *) + var tracer: (any Tracer)? { + self.task.tracer + } + let connectionDeadline: NIODeadline let requestOptions: RequestOptions @@ -87,7 +103,8 @@ final class RequestBag: Sendabl let loopBoundState = LoopBoundState( request: request, state: StateMachine(redirectHandler: redirectHandler), - consumeBodyPartStackDepth: 0 + consumeBodyPartStackDepth: 0, + tracing: task.tracing ) self.loopBoundState = NIOLoopBoundBox.makeBoxSendingValue(loopBoundState, eventLoop: task.eventLoop) self.connectionDeadline = connectionDeadline @@ -114,14 +131,19 @@ final class RequestBag: Sendabl // MARK: - Request - private func willExecuteRequest0(_ executor: HTTPRequestExecutor) { + // Immediately start a span for the "whole" request + self.loopBoundState.value.startRequestSpan(tracer: self.anyTracer) + let action = self.loopBoundState.value.state.willExecuteRequest(executor) switch action { case .cancelExecuter(let executor): executor.cancelRequest(self) + self.loopBoundState.value.failRequestSpanAsCancelled() case .failTaskAndCancelExecutor(let error, let executor): self.delegate.didReceiveError(task: self.task, error) self.task.failInternal(with: error) executor.cancelRequest(self) + self.loopBoundState.value.failRequestSpan(error: error) case .none: break } @@ -230,6 +252,7 @@ final class RequestBag: Sendabl private func receiveResponseHead0(_ head: HTTPResponseHead) { self.delegate.didVisitURL(task: self.task, self.loopBoundState.value.request, head) + self.loopBoundState.value.endRequestSpan(response: head) // runs most likely on channel eventLoop switch self.loopBoundState.value.state.receiveResponseHead(head) { diff --git a/Sources/AsyncHTTPClient/TracingSupport.swift b/Sources/AsyncHTTPClient/TracingSupport.swift new file mode 100644 index 000000000..feb564ffb --- /dev/null +++ b/Sources/AsyncHTTPClient/TracingSupport.swift @@ -0,0 +1,53 @@ +//===----------------------------------------------------------------------===// +// +// This source file is part of the AsyncHTTPClient open source project +// +// Copyright (c) 2021 Apple Inc. and the AsyncHTTPClient project authors +// Licensed under Apache License v2.0 +// +// See LICENSE.txt for license information +// See CONTRIBUTORS.txt for the list of AsyncHTTPClient project authors +// +// SPDX-License-Identifier: Apache-2.0 +// +//===----------------------------------------------------------------------===// + +import Logging +import NIOConcurrencyHelpers +import NIOCore +import NIOHTTP1 +import NIOSSL +import Tracing + +// MARK: - Centralized span attribute handling + +@usableFromInline +struct TracingSupport { + @inlinable + static func handleResponseStatusCode( + _ span: Span, + _ status: HTTPResponseStatus, + keys: HTTPClient.TracingConfiguration.AttributeKeys + ) { + if status.code >= 400 { + span.setStatus(.init(code: .error)) + } + span.attributes[keys.responseStatusCode] = SpanAttribute.int64(Int64(status.code)) + } +} + +// MARK: - HTTPHeadersInjector + +struct HTTPHeadersInjector: Injector, @unchecked Sendable { + static let shared: HTTPHeadersInjector = HTTPHeadersInjector() + + private init() {} + + func inject(_ value: String, forKey name: String, into headers: inout HTTPHeaders) { + headers.add(name: name, value: value) + } +} + +// MARK: - Errors + +internal struct HTTPRequestCancellationError: Error {} diff --git a/Tests/AsyncHTTPClientTests/HTTPClientBase.swift b/Tests/AsyncHTTPClientTests/HTTPClientBase.swift index aaf072b2f..15620dd24 100644 --- a/Tests/AsyncHTTPClientTests/HTTPClientBase.swift +++ b/Tests/AsyncHTTPClientTests/HTTPClientBase.swift @@ -61,6 +61,7 @@ class XCTestCaseHTTPClientTestsBaseClass: XCTestCase { } ) backgroundLogger.logLevel = .trace + self.defaultClient = HTTPClient( eventLoopGroupProvider: .shared(self.clientGroup), configuration: HTTPClient.Configuration().enableFastFailureModeForTesting(), diff --git a/Tests/AsyncHTTPClientTests/HTTPClientTracingTests.swift b/Tests/AsyncHTTPClientTests/HTTPClientTracingTests.swift new file mode 100644 index 000000000..dd342c2db --- /dev/null +++ b/Tests/AsyncHTTPClientTests/HTTPClientTracingTests.swift @@ -0,0 +1,150 @@ +//===----------------------------------------------------------------------===// +// +// This source file is part of the AsyncHTTPClient open source project +// +// Copyright (c) 2025 Apple Inc. and the AsyncHTTPClient project authors +// Licensed under Apache License v2.0 +// +// See LICENSE.txt for license information +// See CONTRIBUTORS.txt for the list of AsyncHTTPClient project authors +// +// SPDX-License-Identifier: Apache-2.0 +// +//===----------------------------------------------------------------------===// + +@_spi(Tracing) import AsyncHTTPClient // NOT @testable - tests that need @testable go into HTTPClientInternalTests.swift +import Atomics +import InMemoryTracing +import Logging +import NIOConcurrencyHelpers +import NIOCore +import NIOEmbedded +import NIOFoundationCompat +import NIOHTTP1 +import NIOHTTPCompression +import NIOPosix +import NIOSSL +import NIOTestUtils +import NIOTransportServices +import Tracing +import XCTest + +#if canImport(Network) +import Network +#endif + +private func makeTracedHTTPClient(tracer: InMemoryTracer) -> HTTPClient { + var config = HTTPClient.Configuration() + config.httpVersion = .automatic + config.tracing.tracer = tracer + return HTTPClient( + eventLoopGroupProvider: .singleton, + configuration: config + ) +} + +final class HTTPClientTracingTests: XCTestCaseHTTPClientTestsBaseClass { + + var tracer: InMemoryTracer! + var client: HTTPClient! + + override func setUp() { + super.setUp() + self.tracer = InMemoryTracer() + self.client = makeTracedHTTPClient(tracer: tracer) + } + + override func tearDown() { + if let client = self.client { + XCTAssertNoThrow(try client.syncShutdown()) + self.client = nil + } + tracer = nil + } + + func testTrace_get_sync() throws { + let url = self.defaultHTTPBinURLPrefix + "echo-method" + let _ = try client.get(url: url).wait() + + guard tracer.activeSpans.isEmpty else { + XCTFail("Still active spans which were not finished (\(tracer.activeSpans.count))! \(tracer.activeSpans)") + return + } + guard let span = tracer.finishedSpans.first else { + XCTFail("No span was recorded!") + return + } + + XCTAssertEqual(span.operationName, "GET") + } + + func testTrace_post_sync() throws { + let url = self.defaultHTTPBinURLPrefix + "echo-method" + let _ = try client.post(url: url).wait() + + guard tracer.activeSpans.isEmpty else { + XCTFail("Still active spans which were not finished (\(tracer.activeSpans.count))! \(tracer.activeSpans)") + return + } + guard let span = tracer.finishedSpans.first else { + XCTFail("No span was recorded!") + return + } + + XCTAssertEqual(span.operationName, "POST") + } + + func testTrace_post_sync_404_error() throws { + let url = self.defaultHTTPBinURLPrefix + "404-not-existent" + let _ = try client.post(url: url).wait() + + guard tracer.activeSpans.isEmpty else { + XCTFail("Still active spans which were not finished (\(tracer.activeSpans.count))! \(tracer.activeSpans)") + return + } + guard let span = tracer.finishedSpans.first else { + XCTFail("No span was recorded!") + return + } + + XCTAssertEqual(span.operationName, "POST") + XCTAssertTrue(span.errors.isEmpty, "Should have recorded error") + XCTAssertEqual(span.attributes.get(client.tracing.attributeKeys.responseStatusCode), 404) + } + + func testTrace_execute_async() async throws { + let url = self.defaultHTTPBinURLPrefix + "echo-method" + let request = HTTPClientRequest(url: url) + let _ = try await client.execute(request, deadline: .distantFuture) + + guard tracer.activeSpans.isEmpty else { + XCTFail("Still active spans which were not finished (\(tracer.activeSpans.count))! \(tracer.activeSpans)") + return + } + guard let span = tracer.finishedSpans.first else { + XCTFail("No span was recorded!") + return + } + + XCTAssertEqual(span.operationName, "GET") + } + + func testTrace_execute_async_404_error() async throws { + let url = self.defaultHTTPBinURLPrefix + "404-does-not-exist" + let request = HTTPClientRequest(url: url) + let _ = try await client.execute(request, deadline: .distantFuture) + + guard tracer.activeSpans.isEmpty else { + XCTFail("Still active spans which were not finished (\(tracer.activeSpans.count))! \(tracer.activeSpans)") + return + } + guard let span = tracer.finishedSpans.first else { + XCTFail("No span was recorded!") + return + } + + XCTAssertEqual(span.operationName, "GET") + XCTAssertTrue(span.errors.isEmpty, "Should have recorded error") + XCTAssertEqual(span.attributes.get(client.tracing.attributeKeys.responseStatusCode), 404) + } +} diff --git a/Tests/AsyncHTTPClientTests/RequestBagTests.swift b/Tests/AsyncHTTPClientTests/RequestBagTests.swift index 2b0c2f6e4..f1600fceb 100644 --- a/Tests/AsyncHTTPClientTests/RequestBagTests.swift +++ b/Tests/AsyncHTTPClientTests/RequestBagTests.swift @@ -994,7 +994,7 @@ extension HTTPClient.Task { eventLoop: EventLoop, logger: Logger ) { - self.init(eventLoop: eventLoop, logger: logger) { + self.init(eventLoop: eventLoop, logger: logger, tracing: .init()) { preconditionFailure("thread pool not needed in tests") } }