From 9f76c3c84cf5061fbf5401a20d6c48e083483860 Mon Sep 17 00:00:00 2001 From: Konrad Malawski Date: Thu, 9 Oct 2025 09:30:02 +0900 Subject: [PATCH 1/2] [Tracing] Implement trace header context propagation Somewhere along the way of reviewing previous work this bit got lost; This implements context propagation by injecting the apropriate headers from service context into the headers when forming a `Prepared` http request. --- .../AsyncAwait/HTTPClient+execute.swift | 7 ++++++- .../AsyncAwait/HTTPClientRequest+Prepared.swift | 14 +++++++++++++- .../HTTPClientTracingTests.swift | 14 +++++++++++++- 3 files changed, 32 insertions(+), 3 deletions(-) diff --git a/Sources/AsyncHTTPClient/AsyncAwait/HTTPClient+execute.swift b/Sources/AsyncHTTPClient/AsyncAwait/HTTPClient+execute.swift index 0437211c6..32997cde8 100644 --- a/Sources/AsyncHTTPClient/AsyncAwait/HTTPClient+execute.swift +++ b/Sources/AsyncHTTPClient/AsyncAwait/HTTPClient+execute.swift @@ -92,7 +92,12 @@ extension HTTPClient { // this loop is there to follow potential redirects while true { - let preparedRequest = try HTTPClientRequest.Prepared(currentRequest, dnsOverride: configuration.dnsOverride) + let preparedRequest = + try HTTPClientRequest.Prepared( + currentRequest, + tracing: self.configuration.tracing, + dnsOverride: configuration.dnsOverride + ) let response = try await { var response = try await self.executeCancellable(preparedRequest, deadline: deadline, logger: logger) diff --git a/Sources/AsyncHTTPClient/AsyncAwait/HTTPClientRequest+Prepared.swift b/Sources/AsyncHTTPClient/AsyncAwait/HTTPClientRequest+Prepared.swift index c39452897..217d1e7ff 100644 --- a/Sources/AsyncHTTPClient/AsyncAwait/HTTPClientRequest+Prepared.swift +++ b/Sources/AsyncHTTPClient/AsyncAwait/HTTPClientRequest+Prepared.swift @@ -12,9 +12,11 @@ // //===----------------------------------------------------------------------===// +import Instrumentation import NIOCore import NIOHTTP1 import NIOSSL +import ServiceContextModule import struct Foundation.URL @@ -45,7 +47,11 @@ extension HTTPClientRequest { @available(macOS 10.15, iOS 13.0, watchOS 6.0, tvOS 13.0, *) extension HTTPClientRequest.Prepared { - init(_ request: HTTPClientRequest, dnsOverride: [String: String] = [:]) throws { + init( + _ request: HTTPClientRequest, + tracing: HTTPClient.TracingConfiguration? = nil, + dnsOverride: [String: String] = [:] + ) throws { guard !request.url.isEmpty, let url = URL(string: request.url) else { throw HTTPClientError.invalidURL } @@ -54,6 +60,12 @@ extension HTTPClientRequest.Prepared { var headers = request.headers headers.addHostIfNeeded(for: deconstructedURL) + if let tracer = tracing?.tracer, + let context = ServiceContext.current + { + tracer.inject(context, into: &headers, using: HTTPHeadersInjector.shared) + } + let metadata = try headers.validateAndSetTransportFraming( method: request.method, bodyLength: .init(request.body) diff --git a/Tests/AsyncHTTPClientTests/HTTPClientTracingTests.swift b/Tests/AsyncHTTPClientTests/HTTPClientTracingTests.swift index dd342c2db..27d323af0 100644 --- a/Tests/AsyncHTTPClientTests/HTTPClientTracingTests.swift +++ b/Tests/AsyncHTTPClientTests/HTTPClientTracingTests.swift @@ -12,7 +12,7 @@ // //===----------------------------------------------------------------------===// -@_spi(Tracing) import AsyncHTTPClient // NOT @testable - tests that need @testable go into HTTPClientInternalTests.swift +@_spi(Tracing) @testable import AsyncHTTPClient import Atomics import InMemoryTracing import Logging @@ -147,4 +147,16 @@ final class HTTPClientTracingTests: XCTestCaseHTTPClientTestsBaseClass { XCTAssertTrue(span.errors.isEmpty, "Should have recorded error") XCTAssertEqual(span.attributes.get(client.tracing.attributeKeys.responseStatusCode), 404) } + + func testTrace_preparedHeaders_include_fromSpan() async throws { + let url = self.defaultHTTPBinURLPrefix + "404-does-not-exist" + let request = HTTPClientRequest(url: url) + + try tracer.withSpan("operation") { span in + let prepared = try HTTPClientRequest.Prepared(request, tracing: self.client.tracing) + XCTAssertTrue(prepared.head.headers.count > 2) + XCTAssertTrue(prepared.head.headers.contains(name: "in-memory-trace-id")) + XCTAssertTrue(prepared.head.headers.contains(name: "in-memory-span-id")) + } + } } From c2afc2707f20b25f4cff20ca933ec04244b0156c Mon Sep 17 00:00:00 2001 From: Konrad Malawski Date: Fri, 10 Oct 2025 07:22:05 +0900 Subject: [PATCH 2/2] review feedback --- .../AsyncAwait/HTTPClient+execute.swift | 4 +- .../HTTPClientRequest+Prepared.swift | 4 +- .../HTTPClientTracingInternalTests.swift | 77 +++++++++++++++++++ .../HTTPClientTracingTests.swift | 14 +--- 4 files changed, 82 insertions(+), 17 deletions(-) create mode 100644 Tests/AsyncHTTPClientTests/HTTPClientTracingInternalTests.swift diff --git a/Sources/AsyncHTTPClient/AsyncAwait/HTTPClient+execute.swift b/Sources/AsyncHTTPClient/AsyncAwait/HTTPClient+execute.swift index 32997cde8..bbf8c948c 100644 --- a/Sources/AsyncHTTPClient/AsyncAwait/HTTPClient+execute.swift +++ b/Sources/AsyncHTTPClient/AsyncAwait/HTTPClient+execute.swift @@ -95,8 +95,8 @@ extension HTTPClient { let preparedRequest = try HTTPClientRequest.Prepared( currentRequest, - tracing: self.configuration.tracing, - dnsOverride: configuration.dnsOverride + dnsOverride: configuration.dnsOverride, + tracing: self.configuration.tracing ) let response = try await { var response = try await self.executeCancellable(preparedRequest, deadline: deadline, logger: logger) diff --git a/Sources/AsyncHTTPClient/AsyncAwait/HTTPClientRequest+Prepared.swift b/Sources/AsyncHTTPClient/AsyncAwait/HTTPClientRequest+Prepared.swift index 217d1e7ff..b5649cf90 100644 --- a/Sources/AsyncHTTPClient/AsyncAwait/HTTPClientRequest+Prepared.swift +++ b/Sources/AsyncHTTPClient/AsyncAwait/HTTPClientRequest+Prepared.swift @@ -49,8 +49,8 @@ extension HTTPClientRequest { extension HTTPClientRequest.Prepared { init( _ request: HTTPClientRequest, - tracing: HTTPClient.TracingConfiguration? = nil, - dnsOverride: [String: String] = [:] + dnsOverride: [String: String] = [:], + tracing: HTTPClient.TracingConfiguration? = nil ) throws { guard !request.url.isEmpty, let url = URL(string: request.url) else { throw HTTPClientError.invalidURL diff --git a/Tests/AsyncHTTPClientTests/HTTPClientTracingInternalTests.swift b/Tests/AsyncHTTPClientTests/HTTPClientTracingInternalTests.swift new file mode 100644 index 000000000..53f1138ba --- /dev/null +++ b/Tests/AsyncHTTPClientTests/HTTPClientTracingInternalTests.swift @@ -0,0 +1,77 @@ +//===----------------------------------------------------------------------===// +// +// 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 +// +//===----------------------------------------------------------------------===// + +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 + +@testable @_spi(Tracing) import AsyncHTTPClient + +#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 HTTPClientTracingInternalTests: 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_preparedHeaders_include_fromSpan() async throws { + let url = self.defaultHTTPBinURLPrefix + "404-does-not-exist" + let request = HTTPClientRequest(url: url) + + try tracer.withSpan("operation") { span in + let prepared = try HTTPClientRequest.Prepared(request, tracing: self.client.tracing) + XCTAssertTrue(prepared.head.headers.count > 2) + XCTAssertTrue(prepared.head.headers.contains(name: "in-memory-trace-id")) + XCTAssertTrue(prepared.head.headers.contains(name: "in-memory-span-id")) + } + } +} diff --git a/Tests/AsyncHTTPClientTests/HTTPClientTracingTests.swift b/Tests/AsyncHTTPClientTests/HTTPClientTracingTests.swift index 27d323af0..047c66e6d 100644 --- a/Tests/AsyncHTTPClientTests/HTTPClientTracingTests.swift +++ b/Tests/AsyncHTTPClientTests/HTTPClientTracingTests.swift @@ -12,7 +12,7 @@ // //===----------------------------------------------------------------------===// -@_spi(Tracing) @testable import AsyncHTTPClient +@_spi(Tracing) import AsyncHTTPClient // NOT @testable - tests that need @testable go into HTTPClientTracingInternalTests.swift import Atomics import InMemoryTracing import Logging @@ -147,16 +147,4 @@ final class HTTPClientTracingTests: XCTestCaseHTTPClientTestsBaseClass { XCTAssertTrue(span.errors.isEmpty, "Should have recorded error") XCTAssertEqual(span.attributes.get(client.tracing.attributeKeys.responseStatusCode), 404) } - - func testTrace_preparedHeaders_include_fromSpan() async throws { - let url = self.defaultHTTPBinURLPrefix + "404-does-not-exist" - let request = HTTPClientRequest(url: url) - - try tracer.withSpan("operation") { span in - let prepared = try HTTPClientRequest.Prepared(request, tracing: self.client.tracing) - XCTAssertTrue(prepared.head.headers.count > 2) - XCTAssertTrue(prepared.head.headers.contains(name: "in-memory-trace-id")) - XCTAssertTrue(prepared.head.headers.contains(name: "in-memory-span-id")) - } - } }