diff --git a/Sources/AsyncHTTPClient/AsyncAwait/HTTPClient+tracing.swift b/Sources/AsyncHTTPClient/AsyncAwait/HTTPClient+tracing.swift index 0be737619..86399610c 100644 --- a/Sources/AsyncHTTPClient/AsyncAwait/HTTPClient+tracing.swift +++ b/Sources/AsyncHTTPClient/AsyncAwait/HTTPClient+tracing.swift @@ -14,6 +14,7 @@ import NIOHTTP1 import Tracing +import struct Foundation.URL @available(macOS 10.15, iOS 13.0, watchOS 6.0, tvOS 13.0, *) extension HTTPClient { @@ -29,12 +30,64 @@ 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 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["\(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 + 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["\(keys.responseHeader).\(headerName)"] = values + } + } + + // set network protocol version + 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 80df3b946..133e7995d 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, @@ -1109,12 +1118,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..723b4b6c3 100644 --- a/Sources/AsyncHTTPClient/TracingSupport.swift +++ b/Sources/AsyncHTTPClient/TracingSupport.swift @@ -12,6 +12,7 @@ // //===----------------------------------------------------------------------===// +import Foundation import Logging import NIOConcurrencyHelpers import NIOCore @@ -32,8 +33,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..665618f29 --- /dev/null +++ b/Tests/AsyncHTTPClientTests/HTTPClientTracingAttributeTests.swift @@ -0,0 +1,305 @@ +//===----------------------------------------------------------------------===// +// +// 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 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 + } + + 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()) + } + + 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 + } + + 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()) + } + + 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 + } + + 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()) + } + + 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 + } + + let keys = HTTPClient.TracingConfiguration.AttributeKeys() + + XCTAssertEqual(span.attributes.get(keys.urlPath), "/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 + } + + let keys = HTTPClient.TracingConfiguration.AttributeKeys() + + XCTAssertEqual(span.attributes.get(keys.urlQuery), "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("http.request.header.authorization")) + XCTAssertNil(span.attributes.get("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("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()) + } +}