From 6dc5dfaaf8852dfebb47fce5405633fdb0f33dff Mon Sep 17 00:00:00 2001 From: Aleksander Lorenc Date: Wed, 21 Jan 2026 12:50:00 +0100 Subject: [PATCH 1/7] Adds some span attributes mentioned in https://github.com/swift-server/async-http-client/issues/860 Motivation: Adding more trace span attributes in order to be able to intrument any system using AsyncHTTPClient, better. Modifications: Exposed DeconstructedURL to be usable from inline to reach into specific url components. Result: The span attributes `http.response.body.size`, `http.response.status_code`, `network.protocol.version`, `url.path`, `url.scheme`, `server.hostname` and `server.port` should be available on the span. --- .../AsyncAwait/HTTPClient+tracing.swift | 13 ++++++++++++- Sources/AsyncHTTPClient/ConnectionTarget.swift | 5 ++++- Sources/AsyncHTTPClient/DeconstructedURL.swift | 8 +++++++- Sources/AsyncHTTPClient/HTTPClient.swift | 10 +++++++++- Sources/AsyncHTTPClient/Scheme.swift | 3 ++- 5 files changed, 34 insertions(+), 5 deletions(-) diff --git a/Sources/AsyncHTTPClient/AsyncAwait/HTTPClient+tracing.swift b/Sources/AsyncHTTPClient/AsyncAwait/HTTPClient+tracing.swift index 0be737619..de1fd0aa2 100644 --- a/Sources/AsyncHTTPClient/AsyncAwait/HTTPClient+tracing.swift +++ b/Sources/AsyncHTTPClient/AsyncAwait/HTTPClient+tracing.swift @@ -29,11 +29,22 @@ extension HTTPClient { 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 + + // set url attributes + if let deconstructedURL = try? DeconstructedURL(url: request.url) { + span.attributes[keys.urlScheme] = deconstructedURL.scheme.rawValue + span.attributes[keys.urlPath] = deconstructedURL.uri + span.attributes[keys.serverHostname] = deconstructedURL.connectionTarget.host + span.attributes[keys.serverPort] = deconstructedURL.connectionTarget.port + } + let response = try await body() // set response span attributes TracingSupport.handleResponseStatusCode(span, response.status, keys: tracing.attributeKeys) + + // set network protocol version + span.attributes[keys.networkProtocolVersion] = "\(response.version.major).\(response.version.minor)" return response } diff --git a/Sources/AsyncHTTPClient/ConnectionTarget.swift b/Sources/AsyncHTTPClient/ConnectionTarget.swift index 207a6e1bb..2977b9fa8 100644 --- a/Sources/AsyncHTTPClient/ConnectionTarget.swift +++ b/Sources/AsyncHTTPClient/ConnectionTarget.swift @@ -14,7 +14,8 @@ import enum NIOCore.SocketAddress -enum ConnectionTarget: Equatable, Hashable { +@usableFromInline +enum ConnectionTarget: Equatable, Hashable, Sendable { // We keep the IP address serialization precisely as it is in the URL. // Some platforms have quirks in their implementations of 'ntop', for example // writing IPv6 addresses as having embedded IPv4 sections (e.g. [::192.168.0.1] vs [::c0a8:1]). @@ -44,6 +45,7 @@ enum ConnectionTarget: Equatable, Hashable { extension ConnectionTarget { /// The host name which will be send as an HTTP `Host` header. /// Only returns nil if the `self` is a `unixSocket`. + @usableFromInline var host: String? { switch self { case .ipAddress(let serialization, _): return serialization @@ -54,6 +56,7 @@ extension ConnectionTarget { /// The host name which will be send as an HTTP host header. /// Only returns nil if the `self` is a `unixSocket`. + @usableFromInline var port: Int? { switch self { case .ipAddress(_, let address): return address.port! diff --git a/Sources/AsyncHTTPClient/DeconstructedURL.swift b/Sources/AsyncHTTPClient/DeconstructedURL.swift index f7d0b1977..e07e9f81c 100644 --- a/Sources/AsyncHTTPClient/DeconstructedURL.swift +++ b/Sources/AsyncHTTPClient/DeconstructedURL.swift @@ -14,11 +14,16 @@ import struct Foundation.URL -struct DeconstructedURL { +@usableFromInline +struct DeconstructedURL: Sendable { + @usableFromInline var scheme: Scheme + @usableFromInline var connectionTarget: ConnectionTarget + @usableFromInline var uri: String + @usableFromInline init( scheme: Scheme, connectionTarget: ConnectionTarget, @@ -31,6 +36,7 @@ struct DeconstructedURL { } extension DeconstructedURL { + @usableFromInline init(url: String) throws { guard let url = URL(string: url) else { throw HTTPClientError.invalidURL diff --git a/Sources/AsyncHTTPClient/HTTPClient.swift b/Sources/AsyncHTTPClient/HTTPClient.swift index 80df3b946..bca4b47a5 100644 --- a/Sources/AsyncHTTPClient/HTTPClient.swift +++ b/Sources/AsyncHTTPClient/HTTPClient.swift @@ -1111,7 +1111,15 @@ public final class HTTPClient: Sendable { @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 responseStatusCode: String = "http.response.status_code" + + @usableFromInline package var networkProtocolVersion: String = "network.protocol.version" + + @usableFromInline package var urlPath: String = "url.path" + @usableFromInline package var urlScheme: String = "url.scheme" + + @usableFromInline package var serverHostname: String = "server.hostname" + @usableFromInline package var serverPort: String = "server.port" @usableFromInline package var httpFlavor: String = "http.flavor" diff --git a/Sources/AsyncHTTPClient/Scheme.swift b/Sources/AsyncHTTPClient/Scheme.swift index 16065a3c1..fdf672726 100644 --- a/Sources/AsyncHTTPClient/Scheme.swift +++ b/Sources/AsyncHTTPClient/Scheme.swift @@ -13,7 +13,8 @@ //===----------------------------------------------------------------------===// /// List of schemes `HTTPClient` currently supports -enum Scheme: String { +@usableFromInline +enum Scheme: String, Sendable { case http case https case unix From f096a44693ad4c4d1bb10d7a6db8bc538480fb7c Mon Sep 17 00:00:00 2001 From: Aleksander Lorenc Date: Thu, 22 Jan 2026 09:09:45 +0100 Subject: [PATCH 2/7] Add and use OTelSemanticConventions --- Package.swift | 3 ++ .../AsyncAwait/HTTPClient+tracing.swift | 25 +++++++++++----- Sources/AsyncHTTPClient/HTTPClient.swift | 29 ------------------- .../AsyncHTTPClient/RequestBag+Tracing.swift | 2 +- Sources/AsyncHTTPClient/TracingSupport.swift | 7 +++-- .../HTTPClientTracingTests.swift | 5 ++-- 6 files changed, 28 insertions(+), 43 deletions(-) diff --git a/Package.swift b/Package.swift index aad0c1c53..ad400fbd9 100644 --- a/Package.swift +++ b/Package.swift @@ -44,6 +44,7 @@ let package = Package( .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"), + .package(url: "https://github.com/swift-otel/swift-otel-semantic-conventions.git", from: "1.39.0"), ], targets: [ .target( @@ -72,6 +73,7 @@ let package = Package( // Observability support .product(name: "Logging", package: "swift-log"), .product(name: "Tracing", package: "swift-distributed-tracing"), + .product(name: "OTelSemanticConventions", package: "swift-otel-semantic-conventions"), ], swiftSettings: strictConcurrencySettings ), @@ -94,6 +96,7 @@ let package = Package( .product(name: "Logging", package: "swift-log"), .product(name: "InMemoryLogging", package: "swift-log"), .product(name: "Tracing", package: "swift-distributed-tracing"), + .product(name: "OTelSemanticConventions", package: "swift-otel-semantic-conventions"), .product(name: "InMemoryTracing", package: "swift-distributed-tracing"), ], resources: [ diff --git a/Sources/AsyncHTTPClient/AsyncAwait/HTTPClient+tracing.swift b/Sources/AsyncHTTPClient/AsyncAwait/HTTPClient+tracing.swift index de1fd0aa2..0d013c128 100644 --- a/Sources/AsyncHTTPClient/AsyncAwait/HTTPClient+tracing.swift +++ b/Sources/AsyncHTTPClient/AsyncAwait/HTTPClient+tracing.swift @@ -13,6 +13,7 @@ //===----------------------------------------------------------------------===// import NIOHTTP1 +import OTelSemanticConventions import Tracing @available(macOS 10.15, iOS 13.0, watchOS 6.0, tvOS 13.0, *) @@ -27,24 +28,32 @@ extension HTTPClient { } 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 + span.attributes.http.request.method = .init(rawValue: request.method.rawValue) + + // set request headers + for header in request.headers { + span.attributes.http.request.header.set(header.name, to: [header.value]) + } // set url attributes if let deconstructedURL = try? DeconstructedURL(url: request.url) { - span.attributes[keys.urlScheme] = deconstructedURL.scheme.rawValue - span.attributes[keys.urlPath] = deconstructedURL.uri - span.attributes[keys.serverHostname] = deconstructedURL.connectionTarget.host - span.attributes[keys.serverPort] = deconstructedURL.connectionTarget.port + span.attributes.url.path = deconstructedURL.uri + span.attributes.url.scheme = deconstructedURL.scheme.rawValue + span.attributes.server.address = deconstructedURL.connectionTarget.host + span.attributes.server.port = deconstructedURL.connectionTarget.port } let response = try await body() // set response span attributes - TracingSupport.handleResponseStatusCode(span, response.status, keys: tracing.attributeKeys) + TracingSupport.handleResponseStatusCode(span, response.status) + + for header in response.headers { + span.attributes.http.response.header.set(header.name, to: [header.value]) + } // set network protocol version - span.attributes[keys.networkProtocolVersion] = "\(response.version.major).\(response.version.minor)" + span.attributes.network.protocol.version = "\(response.version.major).\(response.version.minor)" return response } diff --git a/Sources/AsyncHTTPClient/HTTPClient.swift b/Sources/AsyncHTTPClient/HTTPClient.swift index bca4b47a5..22a054734 100644 --- a/Sources/AsyncHTTPClient/HTTPClient.swift +++ b/Sources/AsyncHTTPClient/HTTPClient.swift @@ -1089,41 +1089,12 @@ public final class HTTPClient: Sendable { } } - // TODO: Open up customization of keys we use? - /// Configuration for tracing attributes set by the HTTPClient. - @usableFromInline - package var attributeKeys: AttributeKeys - public init() { if #available(macOS 10.15, iOS 13, tvOS 13, watchOS 6, *) { self._tracer = InstrumentationSystem.tracer } else { 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.response.status_code" - - @usableFromInline package var networkProtocolVersion: String = "network.protocol.version" - - @usableFromInline package var urlPath: String = "url.path" - @usableFromInline package var urlScheme: String = "url.scheme" - - @usableFromInline package var serverHostname: String = "server.hostname" - @usableFromInline package var serverPort: String = "server.port" - - @usableFromInline package var httpFlavor: String = "http.flavor" - - @usableFromInline package init() {} } } diff --git a/Sources/AsyncHTTPClient/RequestBag+Tracing.swift b/Sources/AsyncHTTPClient/RequestBag+Tracing.swift index 729b6256a..2ee53d2ae 100644 --- a/Sources/AsyncHTTPClient/RequestBag+Tracing.swift +++ b/Sources/AsyncHTTPClient/RequestBag+Tracing.swift @@ -65,7 +65,7 @@ extension RequestBag.LoopBoundState { return } - TracingSupport.handleResponseStatusCode(span, response.status, keys: tracing.attributeKeys) + TracingSupport.handleResponseStatusCode(span, response.status) span.end() self.activeSpan = nil diff --git a/Sources/AsyncHTTPClient/TracingSupport.swift b/Sources/AsyncHTTPClient/TracingSupport.swift index feb564ffb..a5cdd0da4 100644 --- a/Sources/AsyncHTTPClient/TracingSupport.swift +++ b/Sources/AsyncHTTPClient/TracingSupport.swift @@ -17,6 +17,7 @@ import NIOConcurrencyHelpers import NIOCore import NIOHTTP1 import NIOSSL +import OTelSemanticConventions import Tracing // MARK: - Centralized span attribute handling @@ -26,13 +27,13 @@ struct TracingSupport { @inlinable static func handleResponseStatusCode( _ span: Span, - _ status: HTTPResponseStatus, - keys: HTTPClient.TracingConfiguration.AttributeKeys + _ status: HTTPResponseStatus ) { if status.code >= 400 { span.setStatus(.init(code: .error)) } - span.attributes[keys.responseStatusCode] = SpanAttribute.int64(Int64(status.code)) + + span.attributes.http.response.statusCode = Int(status.code) } } diff --git a/Tests/AsyncHTTPClientTests/HTTPClientTracingTests.swift b/Tests/AsyncHTTPClientTests/HTTPClientTracingTests.swift index 047c66e6d..8b49367d1 100644 --- a/Tests/AsyncHTTPClientTests/HTTPClientTracingTests.swift +++ b/Tests/AsyncHTTPClientTests/HTTPClientTracingTests.swift @@ -26,6 +26,7 @@ import NIOPosix import NIOSSL import NIOTestUtils import NIOTransportServices +import OTelSemanticConventions import Tracing import XCTest @@ -109,7 +110,7 @@ final class HTTPClientTracingTests: XCTestCaseHTTPClientTestsBaseClass { XCTAssertEqual(span.operationName, "POST") XCTAssertTrue(span.errors.isEmpty, "Should have recorded error") - XCTAssertEqual(span.attributes.get(client.tracing.attributeKeys.responseStatusCode), 404) + XCTAssertEqual(span.attributes.get(OTelAttribute.http.response.statusCode), 404) } func testTrace_execute_async() async throws { @@ -145,6 +146,6 @@ final class HTTPClientTracingTests: XCTestCaseHTTPClientTestsBaseClass { XCTAssertEqual(span.operationName, "GET") XCTAssertTrue(span.errors.isEmpty, "Should have recorded error") - XCTAssertEqual(span.attributes.get(client.tracing.attributeKeys.responseStatusCode), 404) + XCTAssertEqual(span.attributes.get(OTelAttribute.http.response.statusCode), 404) } } From 52ee55bbe3e2f6f2e5727a90917432a6d1bde868 Mon Sep 17 00:00:00 2001 From: Aleksander Lorenc Date: Fri, 23 Jan 2026 19:10:56 +0100 Subject: [PATCH 3/7] Add tests. Add redactions to query, path and headers. --- .../AsyncAwait/HTTPClient+tracing.swift | 53 +++- Sources/AsyncHTTPClient/HTTPClient.swift | 9 + Sources/AsyncHTTPClient/TracingSupport.swift | 23 ++ .../HTTPClientTracingAttributeTests.swift | 296 ++++++++++++++++++ 4 files changed, 371 insertions(+), 10 deletions(-) create mode 100644 Tests/AsyncHTTPClientTests/HTTPClientTracingAttributeTests.swift diff --git a/Sources/AsyncHTTPClient/AsyncAwait/HTTPClient+tracing.swift b/Sources/AsyncHTTPClient/AsyncAwait/HTTPClient+tracing.swift index 0d013c128..ac0ab6828 100644 --- a/Sources/AsyncHTTPClient/AsyncAwait/HTTPClient+tracing.swift +++ b/Sources/AsyncHTTPClient/AsyncAwait/HTTPClient+tracing.swift @@ -15,6 +15,7 @@ import NIOHTTP1 import OTelSemanticConventions import Tracing +import struct Foundation.URL @available(macOS 10.15, iOS 13.0, watchOS 6.0, tvOS 13.0, *) extension HTTPClient { @@ -30,17 +31,42 @@ extension HTTPClient { return try await tracer.withSpan(request.method.rawValue, ofKind: .client) { span in span.attributes.http.request.method = .init(rawValue: request.method.rawValue) - // set request headers - for header in request.headers { - span.attributes.http.request.header.set(header.name, to: [header.value]) + // set explicitly allowed request headers + let allowedRequestHeaderNames = Set(request.headers.map(\.name)).intersection(configuration.tracing.allowedHeaders) + + for headerName in allowedRequestHeaderNames { + let values = request.headers[headerName] + + if !values.isEmpty { + span.attributes.http.request.header.set(headerName, to: values) + } } // set url attributes - if let deconstructedURL = try? DeconstructedURL(url: request.url) { - span.attributes.url.path = deconstructedURL.uri - span.attributes.url.scheme = deconstructedURL.scheme.rawValue - span.attributes.server.address = deconstructedURL.connectionTarget.host - span.attributes.server.port = deconstructedURL.connectionTarget.port + if let url = URL(string: request.url) { + span.attributes.url.path = TracingSupport.sanitizePath( + url.path, + redactionComponents: self.configuration.tracing.sensitivePathComponents + ) + + if let scheme = url.scheme { + span.attributes.url.scheme = scheme + } + if let query = url.query { + span.attributes.url.query = TracingSupport.sanitizeQuery( + query, + redactionComponents: self.configuration.tracing.sensitiveQueryComponents + ) + } + if let fragment = url.fragment { + span.attributes.url.fragment = fragment + } + if let host = url.host { + span.attributes.server.address = host + } + if let port = url.port { + span.attributes.server.port = port + } } let response = try await body() @@ -48,8 +74,15 @@ extension HTTPClient { // set response span attributes TracingSupport.handleResponseStatusCode(span, response.status) - for header in response.headers { - span.attributes.http.response.header.set(header.name, to: [header.value]) + // set explicitly allowed response headers + let allowedResponseHeaderNames = Set(response.headers.map(\.name)).intersection(configuration.tracing.allowedHeaders) + + for headerName in allowedResponseHeaderNames { + let values = response.headers[headerName] + + if !values.isEmpty { + span.attributes.http.response.header.set(headerName, to: values) + } } // set network protocol version diff --git a/Sources/AsyncHTTPClient/HTTPClient.swift b/Sources/AsyncHTTPClient/HTTPClient.swift index 22a054734..5206617f9 100644 --- a/Sources/AsyncHTTPClient/HTTPClient.swift +++ b/Sources/AsyncHTTPClient/HTTPClient.swift @@ -1070,6 +1070,15 @@ public final class HTTPClient: Sendable { @usableFromInline var _tracer: Optional // erasure trick so we don't have to make Configuration @available + public var allowedHeaders: Set = [] + public var sensitivePathComponents: Set = [] + public var sensitiveQueryComponents: Set = [ + "AWSAccessKeyId", + "Signature", + "sig", + "X-Goog-Signature" + ] + /// Tracer that should be used by the HTTPClient. /// /// This is selected at configuration creation time, and if no tracer is passed explicitly, diff --git a/Sources/AsyncHTTPClient/TracingSupport.swift b/Sources/AsyncHTTPClient/TracingSupport.swift index a5cdd0da4..2932e7c4a 100644 --- a/Sources/AsyncHTTPClient/TracingSupport.swift +++ b/Sources/AsyncHTTPClient/TracingSupport.swift @@ -12,6 +12,7 @@ // //===----------------------------------------------------------------------===// +import Foundation import Logging import NIOConcurrencyHelpers import NIOCore @@ -35,6 +36,28 @@ struct TracingSupport { span.attributes.http.response.statusCode = Int(status.code) } + + @inlinable + static func sanitizePath(_ path: String, redactionComponents: Set) -> String { + redactionComponents.reduce(path) { path, component in + path.replacingOccurrences(of: component, with: "REDACTED") + } + } + + @inlinable + static func sanitizeQuery(_ query: String, redactionComponents: Set) -> String { + query.components(separatedBy: "&").map { + let nameAndValue = $0 + .trimmingCharacters(in: .whitespaces) + .components(separatedBy: "=") + + if redactionComponents.contains(nameAndValue[0]) { + return "\(nameAndValue[0])=REDACTED" + } + + return $0 + }.joined(separator: "&") + } } // MARK: - HTTPHeadersInjector diff --git a/Tests/AsyncHTTPClientTests/HTTPClientTracingAttributeTests.swift b/Tests/AsyncHTTPClientTests/HTTPClientTracingAttributeTests.swift new file mode 100644 index 000000000..d84b52123 --- /dev/null +++ b/Tests/AsyncHTTPClientTests/HTTPClientTracingAttributeTests.swift @@ -0,0 +1,296 @@ +//===----------------------------------------------------------------------===// +// +// 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 HTTPClientTracingInternalTests.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 OTelSemanticConventions +import Tracing +import XCTest + +#if canImport(Network) +import Network +#endif + +final class HTTPClientTracingAttributeTests: XCTestCaseHTTPClientTestsBaseClass { + + func testTraceAttributes_url() async throws { + let tracer = InMemoryTracer() + var config = HTTPClient.Configuration() + config.httpVersion = .automatic + config.tracing.tracer = tracer + + let client = HTTPClient( + eventLoopGroupProvider: .singleton, + configuration: config + ) + + let url = self.defaultHTTPBinURLPrefix + "echo-method?foo=bar&Signature=secretSignature" + var request = HTTPClientRequest(url: url) + + request.headers.add(name: "Authorization", value: "Bearer secret") + request.headers.add(name: "Password", value: "SuperSecretPassword") + + 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.attributes.get(OTelAttribute.url.path), "/echo-method") + XCTAssertEqual(span.attributes.get(OTelAttribute.url.scheme), "http") + XCTAssertEqual(span.attributes.get(OTelAttribute.url.query), "foo=bar&Signature=REDACTED") + + XCTAssertNoThrow(try client.syncShutdown()) + } + + func testTraceAttributes_server() async throws { + let tracer = InMemoryTracer() + var config = HTTPClient.Configuration() + config.httpVersion = .automatic + config.tracing.tracer = tracer + + let client = HTTPClient( + eventLoopGroupProvider: .singleton, + configuration: config + ) + + let url = self.defaultHTTPBinURLPrefix + "echo-method?foo=bar&Signature=secretSignature" + var request = HTTPClientRequest(url: url) + + request.headers.add(name: "Authorization", value: "Bearer secret") + request.headers.add(name: "Password", value: "SuperSecretPassword") + + 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 + } + guard let defaultHTTPBinPort = self.defaultHTTPBin.socketAddress.port, let defaultHTTPBinAddress = self.defaultHTTPBin.socketAddress.ipAddress else { + XCTFail("Default HTTPBin ip address or port is not set!") + return + } + + XCTAssertEqual(span.attributes.get(OTelAttribute.server.address), .string(defaultHTTPBinAddress.description)) + XCTAssertEqual(span.attributes.get(OTelAttribute.server.port), .int64(Int64(defaultHTTPBinPort))) + + XCTAssertNoThrow(try client.syncShutdown()) + } + + func testTraceAttributes_http() async throws { + let tracer = InMemoryTracer() + var config = HTTPClient.Configuration() + + // By default no headers are allowed to be traced + config.tracing.allowedHeaders = ["Authorization"] + config.httpVersion = .automatic + config.tracing.tracer = tracer + + let client = HTTPClient( + eventLoopGroupProvider: .singleton, + configuration: config + ) + + let url = self.defaultHTTPBinURLPrefix + "echo-method?foo=bar&Signature=secretSignature" + var request = HTTPClientRequest(url: url) + + request.headers.add(name: "Authorization", value: "Bearer secret") + request.headers.add(name: "Password", value: "SuperSecretPassword") + + 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.attributes.get(OTelAttribute.http.request.method), "GET") + XCTAssertEqual(span.attributes.get("\(OTelAttribute.http.request.header).authorization"), .stringArray(["Bearer secret"])) + XCTAssertNil(span.attributes.get("\(OTelAttribute.http.request.header).password")) + XCTAssertEqual(span.attributes.get(OTelAttribute.http.response.statusCode), 200) + + XCTAssertNoThrow(try client.syncShutdown()) + } + + func testTraceAttributes_pathRedaction() async throws { + let tracer = InMemoryTracer() + var config = HTTPClient.Configuration() + config.httpVersion = .automatic + config.tracing.sensitivePathComponents = ["nested-path"] + config.tracing.tracer = tracer + + let client = HTTPClient( + eventLoopGroupProvider: .singleton, + configuration: config + ) + + let url = self.defaultHTTPBinURLPrefix + "echo-method/nested-path" + var request = HTTPClientRequest(url: url) + + request.headers.add(name: "Authorization", value: "Bearer secret") + request.headers.add(name: "Password", value: "SuperSecretPassword") + + 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.attributes.get(OTelAttribute.url.path), "/echo-method/REDACTED") + + XCTAssertNoThrow(try client.syncShutdown()) + } + + func testTraceAttributes_queryRedaction() async throws { + let tracer = InMemoryTracer() + var config = HTTPClient.Configuration() + config.httpVersion = .automatic + + // Add foo to sensitive query components + config.tracing.sensitiveQueryComponents.insert("foo") + config.tracing.tracer = tracer + + let client = HTTPClient( + eventLoopGroupProvider: .singleton, + configuration: config + ) + + let url = self.defaultHTTPBinURLPrefix + "echo-method?foo=bar&Signature=secretSignature&bar=bar" + var request = HTTPClientRequest(url: url) + + request.headers.add(name: "Authorization", value: "Bearer secret") + request.headers.add(name: "Password", value: "SuperSecretPassword") + + 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.attributes.get(OTelAttribute.url.query), "foo=REDACTED&Signature=REDACTED&bar=bar") + + XCTAssertNoThrow(try client.syncShutdown()) + } + + func testTraceAttributes_httpHeadersDisallowedByDefault() async throws { + let tracer = InMemoryTracer() + var config = HTTPClient.Configuration() + + config.httpVersion = .automatic + config.tracing.tracer = tracer + + let client = HTTPClient( + eventLoopGroupProvider: .singleton, + configuration: config + ) + + let url = self.defaultHTTPBinURLPrefix + "echo-method?foo=bar&Signature=secretSignature" + var request = HTTPClientRequest(url: url) + + request.headers.add(name: "Authorization", value: "Bearer secret") + request.headers.add(name: "Password", value: "SuperSecretPassword") + + 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") + + XCTAssertNil(span.attributes.get("\(OTelAttribute.http.request.header).authorization")) + XCTAssertNil(span.attributes.get("\(OTelAttribute.http.request.header).password")) + + XCTAssertNoThrow(try client.syncShutdown()) + } + + func testTraceAttributes_httpHeaders() async throws { + let tracer = InMemoryTracer() + var config = HTTPClient.Configuration() + + config.tracing.allowedHeaders = ["Authorization", "Password", "X-Method-Used"] + + config.httpVersion = .automatic + config.tracing.tracer = tracer + + let client = HTTPClient( + eventLoopGroupProvider: .singleton, + configuration: config + ) + + let url = self.defaultHTTPBinURLPrefix + "echo-method?foo=bar&Signature=secretSignature" + var request = HTTPClientRequest(url: url) + + request.headers.add(name: "Authorization", value: "Bearer secret") + request.headers.add(name: "Password", value: "SuperSecretPassword") + + 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") + XCTAssertEqual(span.attributes.get("\(OTelAttribute.http.request.header).authorization"), .stringArray(["Bearer secret"])) + XCTAssertEqual(span.attributes.get("\(OTelAttribute.http.request.header).password"), .stringArray(["SuperSecretPassword"])) + XCTAssertEqual(span.attributes.get("\(OTelAttribute.http.response.header).x_method_used"), .stringArray(["GET"])) + + XCTAssertNoThrow(try client.syncShutdown()) + } +} From 16b4984e37f5621ab5da002cb712faa5dd691546 Mon Sep 17 00:00:00 2001 From: Aleksander Lorenc Date: Fri, 23 Jan 2026 19:19:13 +0100 Subject: [PATCH 4/7] Remove unnecessary changes to DeconstructedURL, ConnectionTarget and Scheme --- Sources/AsyncHTTPClient/ConnectionTarget.swift | 5 +---- Sources/AsyncHTTPClient/DeconstructedURL.swift | 6 ------ Sources/AsyncHTTPClient/Scheme.swift | 1 - 3 files changed, 1 insertion(+), 11 deletions(-) diff --git a/Sources/AsyncHTTPClient/ConnectionTarget.swift b/Sources/AsyncHTTPClient/ConnectionTarget.swift index 2977b9fa8..207a6e1bb 100644 --- a/Sources/AsyncHTTPClient/ConnectionTarget.swift +++ b/Sources/AsyncHTTPClient/ConnectionTarget.swift @@ -14,8 +14,7 @@ import enum NIOCore.SocketAddress -@usableFromInline -enum ConnectionTarget: Equatable, Hashable, Sendable { +enum ConnectionTarget: Equatable, Hashable { // We keep the IP address serialization precisely as it is in the URL. // Some platforms have quirks in their implementations of 'ntop', for example // writing IPv6 addresses as having embedded IPv4 sections (e.g. [::192.168.0.1] vs [::c0a8:1]). @@ -45,7 +44,6 @@ enum ConnectionTarget: Equatable, Hashable, Sendable { extension ConnectionTarget { /// The host name which will be send as an HTTP `Host` header. /// Only returns nil if the `self` is a `unixSocket`. - @usableFromInline var host: String? { switch self { case .ipAddress(let serialization, _): return serialization @@ -56,7 +54,6 @@ extension ConnectionTarget { /// The host name which will be send as an HTTP host header. /// Only returns nil if the `self` is a `unixSocket`. - @usableFromInline var port: Int? { switch self { case .ipAddress(_, let address): return address.port! diff --git a/Sources/AsyncHTTPClient/DeconstructedURL.swift b/Sources/AsyncHTTPClient/DeconstructedURL.swift index e07e9f81c..3467171af 100644 --- a/Sources/AsyncHTTPClient/DeconstructedURL.swift +++ b/Sources/AsyncHTTPClient/DeconstructedURL.swift @@ -14,16 +14,11 @@ import struct Foundation.URL -@usableFromInline struct DeconstructedURL: Sendable { - @usableFromInline var scheme: Scheme - @usableFromInline var connectionTarget: ConnectionTarget - @usableFromInline var uri: String - @usableFromInline init( scheme: Scheme, connectionTarget: ConnectionTarget, @@ -36,7 +31,6 @@ struct DeconstructedURL: Sendable { } extension DeconstructedURL { - @usableFromInline init(url: String) throws { guard let url = URL(string: url) else { throw HTTPClientError.invalidURL diff --git a/Sources/AsyncHTTPClient/Scheme.swift b/Sources/AsyncHTTPClient/Scheme.swift index fdf672726..9002ca0e2 100644 --- a/Sources/AsyncHTTPClient/Scheme.swift +++ b/Sources/AsyncHTTPClient/Scheme.swift @@ -13,7 +13,6 @@ //===----------------------------------------------------------------------===// /// List of schemes `HTTPClient` currently supports -@usableFromInline enum Scheme: String, Sendable { case http case https From c580487cac8e7d9777258e49faf255f15487a748 Mon Sep 17 00:00:00 2001 From: Aleksander Lorenc Date: Fri, 23 Jan 2026 19:20:18 +0100 Subject: [PATCH 5/7] Remove unnecessary changes to DeconstructedURL, ConnectionTarget and Scheme --- Sources/AsyncHTTPClient/DeconstructedURL.swift | 2 +- Sources/AsyncHTTPClient/Scheme.swift | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/Sources/AsyncHTTPClient/DeconstructedURL.swift b/Sources/AsyncHTTPClient/DeconstructedURL.swift index 3467171af..f7d0b1977 100644 --- a/Sources/AsyncHTTPClient/DeconstructedURL.swift +++ b/Sources/AsyncHTTPClient/DeconstructedURL.swift @@ -14,7 +14,7 @@ import struct Foundation.URL -struct DeconstructedURL: Sendable { +struct DeconstructedURL { var scheme: Scheme var connectionTarget: ConnectionTarget var uri: String diff --git a/Sources/AsyncHTTPClient/Scheme.swift b/Sources/AsyncHTTPClient/Scheme.swift index 9002ca0e2..16065a3c1 100644 --- a/Sources/AsyncHTTPClient/Scheme.swift +++ b/Sources/AsyncHTTPClient/Scheme.swift @@ -13,7 +13,7 @@ //===----------------------------------------------------------------------===// /// List of schemes `HTTPClient` currently supports -enum Scheme: String, Sendable { +enum Scheme: String { case http case https case unix From 25dfc390ac3955fca77977805ded76c01f75fbca Mon Sep 17 00:00:00 2001 From: Aleksander Lorenc Date: Wed, 25 Feb 2026 19:30:27 +0100 Subject: [PATCH 6/7] Remove OTelSemanticConventions dependency --- Package.swift | 3 -- .../AsyncAwait/HTTPClient+tracing.swift | 24 +++++------ Sources/AsyncHTTPClient/HTTPClient.swift | 28 ++++++++++++ .../AsyncHTTPClient/RequestBag+Tracing.swift | 2 +- Sources/AsyncHTTPClient/TracingSupport.swift | 6 +-- .../HTTPClientTracingAttributeTests.swift | 43 +++++++++++-------- .../HTTPClientTracingTests.swift | 5 +-- 7 files changed, 72 insertions(+), 39 deletions(-) diff --git a/Package.swift b/Package.swift index ad400fbd9..aad0c1c53 100644 --- a/Package.swift +++ b/Package.swift @@ -44,7 +44,6 @@ let package = Package( .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"), - .package(url: "https://github.com/swift-otel/swift-otel-semantic-conventions.git", from: "1.39.0"), ], targets: [ .target( @@ -73,7 +72,6 @@ let package = Package( // Observability support .product(name: "Logging", package: "swift-log"), .product(name: "Tracing", package: "swift-distributed-tracing"), - .product(name: "OTelSemanticConventions", package: "swift-otel-semantic-conventions"), ], swiftSettings: strictConcurrencySettings ), @@ -96,7 +94,6 @@ let package = Package( .product(name: "Logging", package: "swift-log"), .product(name: "InMemoryLogging", package: "swift-log"), .product(name: "Tracing", package: "swift-distributed-tracing"), - .product(name: "OTelSemanticConventions", package: "swift-otel-semantic-conventions"), .product(name: "InMemoryTracing", package: "swift-distributed-tracing"), ], resources: [ diff --git a/Sources/AsyncHTTPClient/AsyncAwait/HTTPClient+tracing.swift b/Sources/AsyncHTTPClient/AsyncAwait/HTTPClient+tracing.swift index ac0ab6828..86399610c 100644 --- a/Sources/AsyncHTTPClient/AsyncAwait/HTTPClient+tracing.swift +++ b/Sources/AsyncHTTPClient/AsyncAwait/HTTPClient+tracing.swift @@ -13,7 +13,6 @@ //===----------------------------------------------------------------------===// import NIOHTTP1 -import OTelSemanticConventions import Tracing import struct Foundation.URL @@ -29,7 +28,8 @@ extension HTTPClient { } return try await tracer.withSpan(request.method.rawValue, ofKind: .client) { span in - span.attributes.http.request.method = .init(rawValue: request.method.rawValue) + let keys = self.configuration.tracing.attributeKeys + span.attributes[keys.requestMethod] = request.method.rawValue // set explicitly allowed request headers let allowedRequestHeaderNames = Set(request.headers.map(\.name)).intersection(configuration.tracing.allowedHeaders) @@ -38,41 +38,41 @@ extension HTTPClient { let values = request.headers[headerName] if !values.isEmpty { - span.attributes.http.request.header.set(headerName, to: values) + span.attributes["\(keys.requestHeader).\(headerName)"] = values } } // set url attributes if let url = URL(string: request.url) { - span.attributes.url.path = TracingSupport.sanitizePath( + span.attributes[keys.urlPath] = TracingSupport.sanitizePath( url.path, redactionComponents: self.configuration.tracing.sensitivePathComponents ) if let scheme = url.scheme { - span.attributes.url.scheme = scheme + span.attributes[keys.urlScheme] = scheme } if let query = url.query { - span.attributes.url.query = TracingSupport.sanitizeQuery( + span.attributes[keys.urlQuery] = TracingSupport.sanitizeQuery( query, redactionComponents: self.configuration.tracing.sensitiveQueryComponents ) } if let fragment = url.fragment { - span.attributes.url.fragment = fragment + span.attributes[keys.urlFragment] = fragment } if let host = url.host { - span.attributes.server.address = host + span.attributes[keys.serverHostname] = host } if let port = url.port { - span.attributes.server.port = port + span.attributes[keys.serverPort] = port } } let response = try await body() // set response span attributes - TracingSupport.handleResponseStatusCode(span, response.status) + TracingSupport.handleResponseStatusCode(span, response.status, keys: tracing.attributeKeys) // set explicitly allowed response headers let allowedResponseHeaderNames = Set(response.headers.map(\.name)).intersection(configuration.tracing.allowedHeaders) @@ -81,12 +81,12 @@ extension HTTPClient { let values = response.headers[headerName] if !values.isEmpty { - span.attributes.http.response.header.set(headerName, to: values) + span.attributes["\(keys.responseHeader).\(headerName)"] = values } } // set network protocol version - span.attributes.network.protocol.version = "\(response.version.major).\(response.version.minor)" + span.attributes[keys.networkProtocolVersion] = "\(response.version.major).\(response.version.minor)" return response } diff --git a/Sources/AsyncHTTPClient/HTTPClient.swift b/Sources/AsyncHTTPClient/HTTPClient.swift index 5206617f9..7ae90c9bf 100644 --- a/Sources/AsyncHTTPClient/HTTPClient.swift +++ b/Sources/AsyncHTTPClient/HTTPClient.swift @@ -1098,12 +1098,40 @@ public final class HTTPClient: Sendable { } } + // TODO: Open up customization of keys we use? + /// Configuration for tracing attributes set by the HTTPClient. + @usableFromInline + package var attributeKeys: AttributeKeys + public init() { if #available(macOS 10.15, iOS 13, tvOS 13, watchOS 6, *) { self._tracer = InstrumentationSystem.tracer } else { 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.response.status_code" + @usableFromInline package var networkProtocolVersion: String = "network.protocol.version" + @usableFromInline package var requestHeader: String = "http.request.header" + @usableFromInline package var responseHeader: String = "http.response.header" + @usableFromInline package var urlPath: String = "url.path" + @usableFromInline package var urlScheme: String = "url.scheme" + @usableFromInline package var urlQuery: String = "url.query" + @usableFromInline package var urlFragment: String = "url.fragment" + @usableFromInline package var serverHostname: String = "server.hostname" + @usableFromInline package var serverPort: String = "server.port" + @usableFromInline package var httpFlavor: String = "http.flavor" + + @usableFromInline package init() {} } } diff --git a/Sources/AsyncHTTPClient/RequestBag+Tracing.swift b/Sources/AsyncHTTPClient/RequestBag+Tracing.swift index 2ee53d2ae..729b6256a 100644 --- a/Sources/AsyncHTTPClient/RequestBag+Tracing.swift +++ b/Sources/AsyncHTTPClient/RequestBag+Tracing.swift @@ -65,7 +65,7 @@ extension RequestBag.LoopBoundState { return } - TracingSupport.handleResponseStatusCode(span, response.status) + TracingSupport.handleResponseStatusCode(span, response.status, keys: tracing.attributeKeys) span.end() self.activeSpan = nil diff --git a/Sources/AsyncHTTPClient/TracingSupport.swift b/Sources/AsyncHTTPClient/TracingSupport.swift index 2932e7c4a..723b4b6c3 100644 --- a/Sources/AsyncHTTPClient/TracingSupport.swift +++ b/Sources/AsyncHTTPClient/TracingSupport.swift @@ -18,7 +18,6 @@ import NIOConcurrencyHelpers import NIOCore import NIOHTTP1 import NIOSSL -import OTelSemanticConventions import Tracing // MARK: - Centralized span attribute handling @@ -28,13 +27,14 @@ struct TracingSupport { @inlinable static func handleResponseStatusCode( _ span: Span, - _ status: HTTPResponseStatus + _ status: HTTPResponseStatus, + keys: HTTPClient.TracingConfiguration.AttributeKeys ) { if status.code >= 400 { span.setStatus(.init(code: .error)) } - span.attributes.http.response.statusCode = Int(status.code) + span.attributes[keys.responseStatusCode] = SpanAttribute.int64(Int64(status.code)) } @inlinable diff --git a/Tests/AsyncHTTPClientTests/HTTPClientTracingAttributeTests.swift b/Tests/AsyncHTTPClientTests/HTTPClientTracingAttributeTests.swift index d84b52123..665618f29 100644 --- a/Tests/AsyncHTTPClientTests/HTTPClientTracingAttributeTests.swift +++ b/Tests/AsyncHTTPClientTests/HTTPClientTracingAttributeTests.swift @@ -26,7 +26,6 @@ import NIOPosix import NIOSSL import NIOTestUtils import NIOTransportServices -import OTelSemanticConventions import Tracing import XCTest @@ -64,9 +63,11 @@ final class HTTPClientTracingAttributeTests: XCTestCaseHTTPClientTestsBaseClass return } - XCTAssertEqual(span.attributes.get(OTelAttribute.url.path), "/echo-method") - XCTAssertEqual(span.attributes.get(OTelAttribute.url.scheme), "http") - XCTAssertEqual(span.attributes.get(OTelAttribute.url.query), "foo=bar&Signature=REDACTED") + let keys = HTTPClient.TracingConfiguration.AttributeKeys() + + XCTAssertEqual(span.attributes.get(keys.urlPath), "/echo-method") + XCTAssertEqual(span.attributes.get(keys.urlScheme), "http") + XCTAssertEqual(span.attributes.get(keys.urlQuery), "foo=bar&Signature=REDACTED") XCTAssertNoThrow(try client.syncShutdown()) } @@ -103,8 +104,10 @@ final class HTTPClientTracingAttributeTests: XCTestCaseHTTPClientTestsBaseClass return } - XCTAssertEqual(span.attributes.get(OTelAttribute.server.address), .string(defaultHTTPBinAddress.description)) - XCTAssertEqual(span.attributes.get(OTelAttribute.server.port), .int64(Int64(defaultHTTPBinPort))) + let keys = HTTPClient.TracingConfiguration.AttributeKeys() + + XCTAssertEqual(span.attributes.get(keys.serverHostname), .string(defaultHTTPBinAddress.description)) + XCTAssertEqual(span.attributes.get(keys.serverPort), .int64(Int64(defaultHTTPBinPort))) XCTAssertNoThrow(try client.syncShutdown()) } @@ -140,10 +143,12 @@ final class HTTPClientTracingAttributeTests: XCTestCaseHTTPClientTestsBaseClass return } - XCTAssertEqual(span.attributes.get(OTelAttribute.http.request.method), "GET") - XCTAssertEqual(span.attributes.get("\(OTelAttribute.http.request.header).authorization"), .stringArray(["Bearer secret"])) - XCTAssertNil(span.attributes.get("\(OTelAttribute.http.request.header).password")) - XCTAssertEqual(span.attributes.get(OTelAttribute.http.response.statusCode), 200) + let keys = HTTPClient.TracingConfiguration.AttributeKeys() + + XCTAssertEqual(span.attributes.get(keys.requestMethod), "GET") + XCTAssertEqual(span.attributes.get("\(keys.requestHeader).authorization"), .stringArray(["Bearer secret"])) + XCTAssertNil(span.attributes.get("\(keys.requestHeader).password")) + XCTAssertEqual(span.attributes.get(keys.responseStatusCode), 200) XCTAssertNoThrow(try client.syncShutdown()) } @@ -177,7 +182,9 @@ final class HTTPClientTracingAttributeTests: XCTestCaseHTTPClientTestsBaseClass return } - XCTAssertEqual(span.attributes.get(OTelAttribute.url.path), "/echo-method/REDACTED") + let keys = HTTPClient.TracingConfiguration.AttributeKeys() + + XCTAssertEqual(span.attributes.get(keys.urlPath), "/echo-method/REDACTED") XCTAssertNoThrow(try client.syncShutdown()) } @@ -213,7 +220,9 @@ final class HTTPClientTracingAttributeTests: XCTestCaseHTTPClientTestsBaseClass return } - XCTAssertEqual(span.attributes.get(OTelAttribute.url.query), "foo=REDACTED&Signature=REDACTED&bar=bar") + let keys = HTTPClient.TracingConfiguration.AttributeKeys() + + XCTAssertEqual(span.attributes.get(keys.urlQuery), "foo=REDACTED&Signature=REDACTED&bar=bar") XCTAssertNoThrow(try client.syncShutdown()) } @@ -249,8 +258,8 @@ final class HTTPClientTracingAttributeTests: XCTestCaseHTTPClientTestsBaseClass XCTAssertEqual(span.operationName, "GET") - XCTAssertNil(span.attributes.get("\(OTelAttribute.http.request.header).authorization")) - XCTAssertNil(span.attributes.get("\(OTelAttribute.http.request.header).password")) + XCTAssertNil(span.attributes.get("http.request.header.authorization")) + XCTAssertNil(span.attributes.get("http.request.header.password")) XCTAssertNoThrow(try client.syncShutdown()) } @@ -287,9 +296,9 @@ final class HTTPClientTracingAttributeTests: XCTestCaseHTTPClientTestsBaseClass } XCTAssertEqual(span.operationName, "GET") - XCTAssertEqual(span.attributes.get("\(OTelAttribute.http.request.header).authorization"), .stringArray(["Bearer secret"])) - XCTAssertEqual(span.attributes.get("\(OTelAttribute.http.request.header).password"), .stringArray(["SuperSecretPassword"])) - XCTAssertEqual(span.attributes.get("\(OTelAttribute.http.response.header).x_method_used"), .stringArray(["GET"])) + XCTAssertEqual(span.attributes.get("http.request.header.authorization"), .stringArray(["Bearer secret"])) + XCTAssertEqual(span.attributes.get("http.request.header.password"), .stringArray(["SuperSecretPassword"])) + XCTAssertEqual(span.attributes.get("http.response.header.x_method_used"), .stringArray(["GET"])) XCTAssertNoThrow(try client.syncShutdown()) } diff --git a/Tests/AsyncHTTPClientTests/HTTPClientTracingTests.swift b/Tests/AsyncHTTPClientTests/HTTPClientTracingTests.swift index 8b49367d1..047c66e6d 100644 --- a/Tests/AsyncHTTPClientTests/HTTPClientTracingTests.swift +++ b/Tests/AsyncHTTPClientTests/HTTPClientTracingTests.swift @@ -26,7 +26,6 @@ import NIOPosix import NIOSSL import NIOTestUtils import NIOTransportServices -import OTelSemanticConventions import Tracing import XCTest @@ -110,7 +109,7 @@ final class HTTPClientTracingTests: XCTestCaseHTTPClientTestsBaseClass { XCTAssertEqual(span.operationName, "POST") XCTAssertTrue(span.errors.isEmpty, "Should have recorded error") - XCTAssertEqual(span.attributes.get(OTelAttribute.http.response.statusCode), 404) + XCTAssertEqual(span.attributes.get(client.tracing.attributeKeys.responseStatusCode), 404) } func testTrace_execute_async() async throws { @@ -146,6 +145,6 @@ final class HTTPClientTracingTests: XCTestCaseHTTPClientTestsBaseClass { XCTAssertEqual(span.operationName, "GET") XCTAssertTrue(span.errors.isEmpty, "Should have recorded error") - XCTAssertEqual(span.attributes.get(OTelAttribute.http.response.statusCode), 404) + XCTAssertEqual(span.attributes.get(client.tracing.attributeKeys.responseStatusCode), 404) } } From 762f1c8284f2b6fe43f77b4a6d34a4f242772f6b Mon Sep 17 00:00:00 2001 From: Aleksander Lorenc Date: Wed, 25 Feb 2026 19:33:36 +0100 Subject: [PATCH 7/7] Group keys --- Sources/AsyncHTTPClient/HTTPClient.swift | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/Sources/AsyncHTTPClient/HTTPClient.swift b/Sources/AsyncHTTPClient/HTTPClient.swift index 7ae90c9bf..133e7995d 100644 --- a/Sources/AsyncHTTPClient/HTTPClient.swift +++ b/Sources/AsyncHTTPClient/HTTPClient.swift @@ -1118,18 +1118,21 @@ public final class HTTPClient: Sendable { package struct AttributeKeys: Sendable { @usableFromInline package var requestMethod: String = "http.request.method" @usableFromInline package var requestBodySize: String = "http.request.body.size" + @usableFromInline package var requestHeader: String = "http.request.header" + @usableFromInline package var responseHeader: String = "http.response.header" @usableFromInline package var responseBodySize: String = "http.response.body.size" @usableFromInline package var responseStatusCode: String = "http.response.status_code" + @usableFromInline package var httpFlavor: String = "http.flavor" + @usableFromInline package var networkProtocolVersion: String = "network.protocol.version" - @usableFromInline package var requestHeader: String = "http.request.header" - @usableFromInline package var responseHeader: String = "http.response.header" + @usableFromInline package var urlPath: String = "url.path" @usableFromInline package var urlScheme: String = "url.scheme" @usableFromInline package var urlQuery: String = "url.query" @usableFromInline package var urlFragment: String = "url.fragment" + @usableFromInline package var serverHostname: String = "server.hostname" @usableFromInline package var serverPort: String = "server.port" - @usableFromInline package var httpFlavor: String = "http.flavor" @usableFromInline package init() {} }