diff --git a/Sources/AsyncHTTPClient/AsyncAwait/HTTPClient+tracing.swift b/Sources/AsyncHTTPClient/AsyncAwait/HTTPClient+tracing.swift index 0be737619..50e8cd998 100644 --- a/Sources/AsyncHTTPClient/AsyncAwait/HTTPClient+tracing.swift +++ b/Sources/AsyncHTTPClient/AsyncAwait/HTTPClient+tracing.swift @@ -15,6 +15,12 @@ import NIOHTTP1 import Tracing +#if canImport(FoundationEssentials) +import struct FoundationEssentials.URL +#else +import struct Foundation.URL +#endif + @available(macOS 10.15, iOS 13.0, watchOS 6.0, tvOS 13.0, *) extension HTTPClient { @inlinable @@ -28,14 +34,80 @@ extension HTTPClient { return try await tracer.withSpan(request.method.rawValue, ofKind: .client) { span in let keys = self.configuration.tracing.attributeKeys + let allowedHeaders = Set(self.configuration.tracing.allowedHeaders.map { $0.lowercased() }) span.attributes[keys.requestMethod] = request.method.rawValue - // TODO: set more attributes on the span + + // set explicitly allowed request headers + var allowedRequestHeaders: [String: [String]] = [:] + + for header in request.headers { + guard allowedHeaders.contains(header.name.lowercased()) else { + continue + } + let normalizedHeaderName = normalizedTracingHeaderName(header.name) + allowedRequestHeaders[normalizedHeaderName, default: []].append(header.value) + } + + for (headerName, values) in allowedRequestHeaders { + span.attributes["\(keys.requestHeader).\(headerName)"] = values + } + + // set url attributes + if let url = URL(string: request.url) { + span.attributes[keys.urlPath] = TracingSupport.sanitizePath( + url.path, + redactionComponents: self.configuration.tracing.sensitivePathComponents + ) + + if let scheme = url.scheme { + span.attributes[keys.urlScheme] = scheme + } + if let query = url.query { + span.attributes[keys.urlQuery] = TracingSupport.sanitizeQuery( + query, + redactionComponents: self.configuration.tracing.sensitiveQueryComponents + ) + } + if let fragment = url.fragment { + span.attributes[keys.urlFragment] = fragment + } + if let host = url.host { + span.attributes[keys.serverHostname] = host + } + if let port = url.port { + span.attributes[keys.serverPort] = port + } + } + let response = try await body() // set response span attributes TracingSupport.handleResponseStatusCode(span, response.status, keys: tracing.attributeKeys) + // set explicitly allowed response headers + var allowedResponseHeaders: [String: [String]] = [:] + + for header in response.headers { + guard allowedHeaders.contains(header.name.lowercased()) else { + continue + } + let normalizedHeaderName = normalizedTracingHeaderName(header.name) + allowedResponseHeaders[normalizedHeaderName, default: []].append(header.value) + } + + for (headerName, values) in allowedResponseHeaders { + span.attributes["\(keys.responseHeader).\(headerName)"] = values + } + + // set network protocol version + span.attributes[keys.networkProtocolVersion] = "\(response.version.major).\(response.version.minor)" + return response } } + + @inlinable + func normalizedTracingHeaderName(_ name: String) -> String { + name.lowercased().replacingOccurrences(of: "-", with: "_") + } } diff --git a/Sources/AsyncHTTPClient/HTTPClient.swift b/Sources/AsyncHTTPClient/HTTPClient.swift index dbc40984f..8b97a6b70 100644 --- a/Sources/AsyncHTTPClient/HTTPClient.swift +++ b/Sources/AsyncHTTPClient/HTTPClient.swift @@ -1094,6 +1094,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, @@ -1133,12 +1142,22 @@ 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.status_code" - + @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 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 init() {} } } diff --git a/Sources/AsyncHTTPClient/TracingSupport.swift b/Sources/AsyncHTTPClient/TracingSupport.swift index feb564ffb..4a76dbc32 100644 --- a/Sources/AsyncHTTPClient/TracingSupport.swift +++ b/Sources/AsyncHTTPClient/TracingSupport.swift @@ -19,6 +19,12 @@ import NIOHTTP1 import NIOSSL import Tracing +#if canImport(FoundationEssentials) +import FoundationEssentials +#else +import Foundation +#endif + // MARK: - Centralized span attribute handling @usableFromInline @@ -32,8 +38,31 @@ struct TracingSupport { if status.code >= 400 { span.setStatus(.init(code: .error)) } + span.attributes[keys.responseStatusCode] = SpanAttribute.int64(Int64(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..682f253fd --- /dev/null +++ b/Tests/AsyncHTTPClientTests/HTTPClientTracingAttributeTests.swift @@ -0,0 +1,212 @@ +//===----------------------------------------------------------------------===// +// +// This source file is part of the AsyncHTTPClient open source project +// +// Copyright (c) 2026 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 InMemoryTracing +import NIOCore +import NIOHTTP1 +import Testing +import Tracing + +@Suite("HTTPClient tracing attributes") +struct HTTPClientTracingAttributeTests { + @Test func traceAttributesURL() async throws { + let httpBin = HTTPBin() + defer { #expect(throws: Never.self) { try httpBin.shutdown() } } + + let tracer = InMemoryTracer() + let httpClient = HTTPClient( + eventLoopGroupProvider: .singleton, + configuration: makeConfiguration(tracer: tracer) + ) + defer { #expect(throws: Never.self) { try httpClient.syncShutdown() } } + + let request = makeRequest(url: httpBin.baseURL + "echo-method?foo=bar&Signature=secretSignature") + _ = try await httpClient.execute(request, deadline: .distantFuture) + + #expect( + tracer.activeSpans.isEmpty, + "Still active spans which were not finished (\(tracer.activeSpans.count))! \(tracer.activeSpans)" + ) + let span = try #require(tracer.finishedSpans.first) + let keys = HTTPClient.TracingConfiguration.AttributeKeys() + + #expect(span.attributes.get(keys.urlPath) == "/echo-method") + #expect(span.attributes.get(keys.urlScheme) == "http") + #expect(span.attributes.get(keys.urlQuery) == "foo=bar&Signature=REDACTED") + } + + @Test func traceAttributesServer() async throws { + let httpBin = HTTPBin() + defer { #expect(throws: Never.self) { try httpBin.shutdown() } } + + let tracer = InMemoryTracer() + let httpClient = HTTPClient( + eventLoopGroupProvider: .singleton, + configuration: makeConfiguration(tracer: tracer) + ) + defer { #expect(throws: Never.self) { try httpClient.syncShutdown() } } + + let request = makeRequest(url: httpBin.baseURL + "echo-method?foo=bar&Signature=secretSignature") + _ = try await httpClient.execute(request, deadline: .distantFuture) + + #expect( + tracer.activeSpans.isEmpty, + "Still active spans which were not finished (\(tracer.activeSpans.count))! \(tracer.activeSpans)" + ) + let span = try #require(tracer.finishedSpans.first) + let defaultHTTPBinPort = try #require(httpBin.socketAddress.port) + let defaultHTTPBinAddress = try #require(httpBin.socketAddress.ipAddress) + let keys = HTTPClient.TracingConfiguration.AttributeKeys() + + #expect(span.attributes.get(keys.serverHostname) == .string(defaultHTTPBinAddress.description)) + #expect(span.attributes.get(keys.serverPort) == .int64(Int64(defaultHTTPBinPort))) + } + + @Test func traceAttributesHTTP() async throws { + let httpBin = HTTPBin() + defer { #expect(throws: Never.self) { try httpBin.shutdown() } } + + let tracer = InMemoryTracer() + var configuration = makeConfiguration(tracer: tracer) + configuration.tracing.allowedHeaders = ["Authorization"] + let httpClient = HTTPClient(eventLoopGroupProvider: .singleton, configuration: configuration) + defer { #expect(throws: Never.self) { try httpClient.syncShutdown() } } + + let request = makeRequest(url: httpBin.baseURL + "echo-method?foo=bar&Signature=secretSignature") + _ = try await httpClient.execute(request, deadline: .distantFuture) + + #expect( + tracer.activeSpans.isEmpty, + "Still active spans which were not finished (\(tracer.activeSpans.count))! \(tracer.activeSpans)" + ) + let span = try #require(tracer.finishedSpans.first) + let keys = HTTPClient.TracingConfiguration.AttributeKeys() + + #expect(span.attributes.get(keys.requestMethod) == "GET") + #expect(span.attributes.get("\(keys.requestHeader).authorization") == .stringArray(["Bearer secret"])) + #expect(span.attributes.get("\(keys.requestHeader).password") == nil) + #expect(span.attributes.get(keys.responseStatusCode) == 200) + } + + @Test func traceAttributesPathRedaction() async throws { + let httpBin = HTTPBin() + defer { #expect(throws: Never.self) { try httpBin.shutdown() } } + + let tracer = InMemoryTracer() + var configuration = makeConfiguration(tracer: tracer) + configuration.tracing.sensitivePathComponents = ["nested-path"] + let httpClient = HTTPClient(eventLoopGroupProvider: .singleton, configuration: configuration) + defer { #expect(throws: Never.self) { try httpClient.syncShutdown() } } + + let request = makeRequest(url: httpBin.baseURL + "echo-method/nested-path") + _ = try await httpClient.execute(request, deadline: .distantFuture) + + #expect( + tracer.activeSpans.isEmpty, + "Still active spans which were not finished (\(tracer.activeSpans.count))! \(tracer.activeSpans)" + ) + let span = try #require(tracer.finishedSpans.first) + let keys = HTTPClient.TracingConfiguration.AttributeKeys() + + #expect(span.attributes.get(keys.urlPath) == "/echo-method/REDACTED") + } + + @Test func traceAttributesQueryRedaction() async throws { + let httpBin = HTTPBin() + defer { #expect(throws: Never.self) { try httpBin.shutdown() } } + + let tracer = InMemoryTracer() + var configuration = makeConfiguration(tracer: tracer) + configuration.tracing.sensitiveQueryComponents.insert("foo") + let httpClient = HTTPClient(eventLoopGroupProvider: .singleton, configuration: configuration) + defer { #expect(throws: Never.self) { try httpClient.syncShutdown() } } + + let request = makeRequest(url: httpBin.baseURL + "echo-method?foo=bar&Signature=secretSignature&bar=bar") + _ = try await httpClient.execute(request, deadline: .distantFuture) + + #expect( + tracer.activeSpans.isEmpty, + "Still active spans which were not finished (\(tracer.activeSpans.count))! \(tracer.activeSpans)" + ) + let span = try #require(tracer.finishedSpans.first) + let keys = HTTPClient.TracingConfiguration.AttributeKeys() + + #expect(span.attributes.get(keys.urlQuery) == "foo=REDACTED&Signature=REDACTED&bar=bar") + } + + @Test func traceAttributesHTTPHeadersDisallowedByDefault() async throws { + let httpBin = HTTPBin() + defer { #expect(throws: Never.self) { try httpBin.shutdown() } } + + let tracer = InMemoryTracer() + let httpClient = HTTPClient( + eventLoopGroupProvider: .singleton, + configuration: makeConfiguration(tracer: tracer) + ) + defer { #expect(throws: Never.self) { try httpClient.syncShutdown() } } + + let request = makeRequest(url: httpBin.baseURL + "echo-method?foo=bar&Signature=secretSignature") + _ = try await httpClient.execute(request, deadline: .distantFuture) + + #expect( + tracer.activeSpans.isEmpty, + "Still active spans which were not finished (\(tracer.activeSpans.count))! \(tracer.activeSpans)" + ) + let span = try #require(tracer.finishedSpans.first) + + #expect(span.operationName == "GET") + #expect(span.attributes.get("http.request.header.authorization") == nil) + #expect(span.attributes.get("http.request.header.password") == nil) + } + + @Test func traceAttributesHTTPHeaders() async throws { + let httpBin = HTTPBin() + defer { #expect(throws: Never.self) { try httpBin.shutdown() } } + + let tracer = InMemoryTracer() + var configuration = makeConfiguration(tracer: tracer) + configuration.tracing.allowedHeaders = ["Authorization", "Password", "X-Method-Used"] + let httpClient = HTTPClient(eventLoopGroupProvider: .singleton, configuration: configuration) + defer { #expect(throws: Never.self) { try httpClient.syncShutdown() } } + + let request = makeRequest(url: httpBin.baseURL + "echo-method?foo=bar&Signature=secretSignature") + _ = try await httpClient.execute(request, deadline: .distantFuture) + + #expect( + tracer.activeSpans.isEmpty, + "Still active spans which were not finished (\(tracer.activeSpans.count))! \(tracer.activeSpans)" + ) + let span = try #require(tracer.finishedSpans.first) + + #expect(span.operationName == "GET") + #expect(span.attributes.get("http.request.header.authorization") == .stringArray(["Bearer secret"])) + #expect(span.attributes.get("http.request.header.password") == .stringArray(["SuperSecretPassword"])) + #expect(span.attributes.get("http.response.header.x_method_used") == .stringArray(["GET"])) + } + + private func makeConfiguration(tracer: InMemoryTracer) -> HTTPClient.Configuration { + var configuration = HTTPClient.Configuration() + configuration.httpVersion = .automatic + configuration.tracing.tracer = tracer + return configuration + } + + private func makeRequest(url: String) -> HTTPClientRequest { + var request = HTTPClientRequest(url: url) + request.headers.add(name: "Authorization", value: "Bearer secret") + request.headers.add(name: "Password", value: "SuperSecretPassword") + return request + } +}