Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
45 changes: 45 additions & 0 deletions Sources/NautilusTelemetry/Tracing/Redaction.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
// Created by Ladd Van Tol on 12/15/25.
// Copyright © 2025 Airbnb Inc. All rights reserved.

import Foundation

public enum Redaction {

// MARK: Public

/// Provides a default implementation of URL redaction that hides common sensitive elements.
/// - Parameter url: A URL to redact
/// - Returns: A string representing the URL with sensitive data redacted. Returns nil if the URL cannot be decomposed.
static public func defaultUrlRedaction(_ url: URL) -> String? {
guard var components = URLComponents(url: url, resolvingAgainstBaseURL: true) else { return nil }

// https://opentelemetry.io/docs/specs/semconv/registry/attributes/url/#url-full

if components.user != nil {
components.user = Redaction.redacted
}

if components.password != nil {
components.password = Redaction.redacted
}

if let queryItems = components.queryItems {
// Redact AWS security parameters by default
let prefixes: Set<String> = ["x-amz-"]
components.queryItems = queryItems.map { queryItem in
let queryItemName = queryItem.name.lowercased()
if prefixes.contains(where: { queryItemName.hasPrefix($0) }) {
return URLQueryItem(name: queryItem.name, value: Redaction.redacted)
} else {
return queryItem
}
}
}

return components.url?.absoluteString
}

// MARK: Internal

static public let redacted = "REDACTED"
}
50 changes: 35 additions & 15 deletions Sources/NautilusTelemetry/Tracing/Span+URLSession.swift
Original file line number Diff line number Diff line change
Expand Up @@ -124,20 +124,16 @@ extension Span {
/// - Parameters:
/// - _: the URLSession instance.
/// - task: the task.
/// - captureHeaders: a set of request headers to capture, or nil to capture none.
public func urlSession(_: URLSession, didCreateTask task: URLSessionTask, captureHeaders: Set<String>? = nil) {
/// - captureHeaders: a set of request headers to capture, or nil to capture none. Must be lowercase strings.
/// - urlRedaction: A closure to map an URL into a String, redacting sensitive data. If not provided, a default implementation is used.
public func urlSession(
_: URLSession,
didCreateTask task: URLSessionTask,
captureHeaders: Set<String>? = nil,
urlRedaction: ((URL) -> String?) = Redaction.defaultUrlRedaction
) {
if let request = task.currentRequest {
addAttribute("http.request.method", request.httpMethod ?? "_OTHER")

if let url = request.url {
addAttribute("server.address", url.host)
addAttribute("server.port", url.port)
addAttribute("url.full", url.absoluteString)
addAttribute("url.scheme", url.scheme)
}

addAttribute("user_agent.original", request.value(forHTTPHeaderField: "user-agent"))
addHeaders(request: request, captureHeaders: captureHeaders)
addRequestAttributes(request, captureHeaders: captureHeaders, urlRedaction: urlRedaction)
}
}

Expand All @@ -147,7 +143,7 @@ extension Span {
/// - task: the task.
/// - error: an optional error.
/// - recordAsStatusCodeFailure: whether to record as a failure due to status code when error == nil.
/// - captureHeaders: a set of response headers to capture, or nil to capture none.
/// - captureHeaders: a set of response headers to capture, or nil to capture none. Must be lowercase strings.
public func urlSession(
_: URLSession,
task: URLSessionTask,
Expand Down Expand Up @@ -199,7 +195,7 @@ extension Span {
/// Adds specified headers from a HTTPURLResponse to this span
/// - Parameters:
/// - request: HTTPURLResponse containing headers
/// - captureHeaders: Headers to capture. Must be lowercase strings.
/// - captureHeaders: a set of response headers to capture, or nil to capture none. Must be lowercase strings.
public func addHeaders(response: HTTPURLResponse, captureHeaders: Set<String>? = nil) {
guard let captureHeaders else { return }
validate(captureHeaders: captureHeaders)
Expand Down Expand Up @@ -290,6 +286,30 @@ extension Span {
return Int64(duration * 1_000_000_000)
}

/// Add URLRequest attributes to the span
/// - Parameters:
/// - request: request to fetch attributes from
/// - captureHeaders: a list of headers to capture. If nil, none will be captured
/// - urlRedaction: A closure to map an URL into a String, redacting sensitive data. If not provided, a default implementation is used.
func addRequestAttributes(
_ request: URLRequest,
captureHeaders: Set<String>? = nil,
urlRedaction: ((URL) -> String?) = Redaction.defaultUrlRedaction
) {
addAttribute("http.request.method", request.httpMethod ?? "_OTHER")
addAttribute("user_agent.original", request.value(forHTTPHeaderField: "user-agent"))

if let url = request.url {
addAttribute("server.address", url.host)
addAttribute("server.port", url.port)
addAttribute("url.scheme", url.scheme)

addAttribute("url.full", urlRedaction(url))
}

addHeaders(request: request, captureHeaders: captureHeaders)
}

// MARK: Private

/// Derived from https://developer.mozilla.org/en-US/docs/Web/HTTP/Reference/Status
Expand Down
12 changes: 3 additions & 9 deletions Sources/NautilusTelemetry/Tracing/Tracer+URLRequest.swift
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ extension Tracer {
/// - Parameters:
/// - request: the URLRequest. The `traceparent` header will be added if needed.
/// - template: optional [`url.template`](https://opentelemetry.io/docs/specs/semconv/registry/attributes/url/#url-template) value.
/// - captureHeaders: a set of request headers to capture, or nil to capture none.
/// - captureHeaders: a set of request headers to capture, or nil to capture none. Must be lowercase strings.
/// - attributes: optional attributes.
/// - baggage: Optional ``Baggage``, describing parent span. If nil, will be inferred from task/thread local baggage.
/// - Returns: A newly created span.
Expand Down Expand Up @@ -42,7 +42,7 @@ extension Tracer {
/// - Parameters:
/// - request: the URLRequest. The `traceparent` header will be added if needed.
/// - template: optional [`url.template`](https://opentelemetry.io/docs/specs/semconv/registry/attributes/url/#url-template) value.
/// - captureHeaders: a set of request headers to capture, or nil to capture none.
/// - captureHeaders: a set of request headers to capture, or nil to capture none. Must be lowercase strings.
/// - attributes: optional attributes.
/// - baggage: Optional ``Baggage``, describing parent span. If nil, will be inferred from task/thread local baggage.
/// - Returns: A newly created span.
Expand Down Expand Up @@ -74,14 +74,8 @@ extension Tracer {
template: String? = nil,
isSampling: Bool
) {
span.addAttribute("http.request.method", request.httpMethod ?? "_OTHER")
span.addAttribute("user_agent.original", request.value(forHTTPHeaderField: "user-agent"))
span.addRequestAttributes(request)

if let url = request.url {
span.addAttribute("server.address", url.host)
span.addAttribute("server.port", url.port)
span.addAttribute("url.full", url.absoluteString)
}
if let template {
span.addAttribute("url.template", template)
}
Expand Down
37 changes: 37 additions & 0 deletions Tests/NautilusTelemetryTests/Tracing/RedactionTests.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
// Created by Ladd Van Tol on 12/15/25.
// Copyright © 2025 Airbnb Inc. All rights reserved.

import Foundation
import XCTest
@testable import NautilusTelemetry

final class RedactionTests: XCTestCase {

func testDefaultUrlRedactionRedactsUserAndPassword() throws {
let url = try XCTUnwrap(URL(string: "https://user:password@example.com/path"))

let redacted = Redaction.defaultUrlRedaction(url)

XCTAssertEqual(redacted, "https://REDACTED:REDACTED@example.com/path")
}

func testDefaultUrlRedactionRedactsAmzQueryParams() throws {
let url =
try XCTUnwrap(
URL(string: "https://example.com/path?X-Amz-Security-Token=secret1&X-Amz-Signature=secret&other=value&X-Amz-Date=123")
)

let redacted = try XCTUnwrap(Redaction.defaultUrlRedaction(url))

XCTAssert(redacted.contains("X-Amz-Security-Token=REDACTED"))
XCTAssert(redacted.contains("X-Amz-Signature=REDACTED"))
XCTAssert(redacted.contains("other=value"))
XCTAssert(redacted.contains("X-Amz-Date=REDACTED"))
}

func testDefaultUrlRedactionPreservesRegularQueryParams() throws {
let url = try XCTUnwrap(URL(string: "https://example.com/path?foo=bar&baz=qux"))
let redacted = Redaction.defaultUrlRedaction(url)
XCTAssertEqual(redacted, "https://example.com/path?foo=bar&baz=qux")
}
}
50 changes: 50 additions & 0 deletions Tests/NautilusTelemetryTests/Tracing/Span+URLSessionTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -124,4 +124,54 @@ final class SpanURLSessionTests: XCTestCase {
XCTAssertEqual(attributes["duration_zero"], 0)
XCTAssertEqual(attributes["duration_one_second"], 1_000_000_000)
}

func testUrlSchemeAttributeCaptured() throws {
let span = tracer.startSpan(name: #function)
let url = try makeURL("/test")
let urlRequest = URLRequest(url: url)

span.addRequestAttributes(urlRequest)

let attributes = try XCTUnwrap(span.attributes)
XCTAssertEqual(attributes["url.scheme"], url.scheme)
}

func testCustomUrlRedaction() throws {
let span = tracer.startSpan(name: #function)
let url = try makeURL("/sensitive/path?key=secret")
let urlRequest = URLRequest(url: url)

let customRedaction: (URL) -> String? = { _ in "https://redacted.example.com" }

span.addRequestAttributes(urlRequest, urlRedaction: customRedaction)

let attributes = try XCTUnwrap(span.attributes)
XCTAssertEqual(attributes["url.full"], "https://redacted.example.com")
}

func testUrlSessionDidCreateTaskWithCustomRedaction() throws {
let span = tracer.startSpan(name: #function)
let url = try XCTUnwrap(URL(string: "https://user:pass@example.com/path"))
let urlRequest = URLRequest(url: url)
let task = urlSession.dataTask(with: urlRequest)

let customRedaction: (URL) -> String? = { _ in "https://custom.redacted.com" }

span.urlSession(urlSession, didCreateTask: task, urlRedaction: customRedaction)

let attributes = try XCTUnwrap(span.attributes)
XCTAssertEqual(attributes["url.full"], "https://custom.redacted.com")
}

func testUrlSessionDidCreateTaskUsesDefaultRedaction() throws {
let span = tracer.startSpan(name: #function)
let url = try XCTUnwrap(URL(string: "https://user:password@example.com/path"))
let urlRequest = URLRequest(url: url)
let task = urlSession.dataTask(with: urlRequest)

span.urlSession(urlSession, didCreateTask: task)

let attributes = try XCTUnwrap(span.attributes as? [String: String])
XCTAssertEqual(attributes["url.full"], "https://REDACTED:REDACTED@example.com/path")
}
}