Skip to content

Commit b442fbf

Browse files
authored
Merge branch 'main' into fix-OtlpHttpTraceExporter-request-headers
2 parents 9e2754c + bbdd904 commit b442fbf

File tree

8 files changed

+255
-56
lines changed

8 files changed

+255
-56
lines changed

Examples/Custom HTTPClient/README.md

Lines changed: 166 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,166 @@
1+
# Custom HTTPClient Example
2+
3+
This example demonstrates how to use a custom HTTPClient implementation with OpenTelemetry HTTP exporters for authentication and other custom behaviors.
4+
5+
## Token Refresh HTTPClient
6+
7+
```swift
8+
import Foundation
9+
import OpenTelemetryProtocolExporterHttp
10+
11+
class AuthTokenHTTPClient: HTTPClient {
12+
private let session: URLSession
13+
private var authToken: String?
14+
private let tokenRefreshURL: URL
15+
16+
init(session: URLSession = URLSession.shared, tokenRefreshURL: URL) {
17+
self.session = session
18+
self.tokenRefreshURL = tokenRefreshURL
19+
}
20+
21+
func send(request: URLRequest, completion: @escaping (Result<HTTPURLResponse, Error>) -> Void) {
22+
var authorizedRequest = request
23+
24+
// Add auth token if available
25+
if let token = authToken {
26+
authorizedRequest.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization")
27+
}
28+
29+
let task = session.dataTask(with: authorizedRequest) { [weak self] data, response, error in
30+
if let httpResponse = response as? HTTPURLResponse {
31+
// Check if token expired (401)
32+
if httpResponse.statusCode == 401 {
33+
self?.refreshTokenAndRetry(request: request, completion: completion)
34+
return
35+
}
36+
37+
// Handle response normally
38+
if let error = error {
39+
completion(.failure(error))
40+
} else {
41+
completion(.success(httpResponse))
42+
}
43+
} else if let error = error {
44+
completion(.failure(error))
45+
}
46+
}
47+
task.resume()
48+
}
49+
50+
private func refreshTokenAndRetry(request: URLRequest, completion: @escaping (Result<HTTPURLResponse, Error>) -> Void) {
51+
// Implement your token refresh logic here
52+
var tokenRequest = URLRequest(url: tokenRefreshURL)
53+
tokenRequest.httpMethod = "POST"
54+
55+
let task = session.dataTask(with: tokenRequest) { [weak self] data, response, error in
56+
if let data = data,
57+
let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any],
58+
let newToken = json["access_token"] as? String {
59+
self?.authToken = newToken
60+
// Retry original request with new token
61+
self?.send(request: request, completion: completion)
62+
} else {
63+
completion(.failure(error ?? URLError(.userAuthenticationRequired)))
64+
}
65+
}
66+
task.resume()
67+
}
68+
}
69+
70+
// Usage
71+
let customHTTPClient = AuthTokenHTTPClient(tokenRefreshURL: URL(string: "https://auth.example.com/token")!)
72+
let exporter = OtlpHttpTraceExporter(
73+
endpoint: URL(string: "https://api.example.com/v1/traces")!,
74+
httpClient: customHTTPClient
75+
)
76+
```
77+
78+
## Retry HTTPClient
79+
80+
```swift
81+
class RetryHTTPClient: HTTPClient {
82+
private let baseClient: HTTPClient
83+
private let maxRetries: Int
84+
85+
init(baseClient: HTTPClient = BaseHTTPClient(), maxRetries: Int = 3) {
86+
self.baseClient = baseClient
87+
self.maxRetries = maxRetries
88+
}
89+
90+
func send(request: URLRequest, completion: @escaping (Result<HTTPURLResponse, Error>) -> Void) {
91+
sendWithRetry(request: request, attempt: 0, completion: completion)
92+
}
93+
94+
private func sendWithRetry(request: URLRequest, attempt: Int, completion: @escaping (Result<HTTPURLResponse, Error>) -> Void) {
95+
baseClient.send(request: request) { [weak self] result in
96+
switch result {
97+
case .success(let response):
98+
if response.statusCode >= 500 && attempt < self?.maxRetries ?? 0 {
99+
// Retry on server errors
100+
DispatchQueue.global().asyncAfter(deadline: .now() + pow(2.0, Double(attempt))) {
101+
self?.sendWithRetry(request: request, attempt: attempt + 1, completion: completion)
102+
}
103+
} else {
104+
completion(result)
105+
}
106+
case .failure:
107+
if attempt < self?.maxRetries ?? 0 {
108+
DispatchQueue.global().asyncAfter(deadline: .now() + pow(2.0, Double(attempt))) {
109+
self?.sendWithRetry(request: request, attempt: attempt + 1, completion: completion)
110+
}
111+
} else {
112+
completion(result)
113+
}
114+
}
115+
}
116+
}
117+
}
118+
```
119+
120+
## Custom Headers HTTPClient
121+
122+
```swift
123+
class CustomHeadersHTTPClient: HTTPClient {
124+
private let baseClient: HTTPClient
125+
private let customHeaders: [String: String]
126+
127+
init(baseClient: HTTPClient = BaseHTTPClient(), customHeaders: [String: String]) {
128+
self.baseClient = baseClient
129+
self.customHeaders = customHeaders
130+
}
131+
132+
func send(request: URLRequest, completion: @escaping (Result<HTTPURLResponse, Error>) -> Void) {
133+
var modifiedRequest = request
134+
135+
// Add custom headers
136+
for (key, value) in customHeaders {
137+
modifiedRequest.setValue(value, forHTTPHeaderField: key)
138+
}
139+
140+
baseClient.send(request: modifiedRequest, completion: completion)
141+
}
142+
}
143+
144+
// Usage
145+
let customClient = CustomHeadersHTTPClient(
146+
customHeaders: [
147+
"X-API-Key": "your-api-key",
148+
"X-Client-Version": "1.0.0"
149+
]
150+
)
151+
let exporter = OtlpHttpLogExporter(
152+
endpoint: URL(string: "https://api.example.com/v1/logs")!,
153+
httpClient: customClient
154+
)
155+
```
156+
157+
## Benefits
158+
159+
Using custom HTTPClient implementations allows you to:
160+
161+
- **Authentication**: Implement OAuth2, API key, or other authentication schemes
162+
- **Retries**: Add exponential backoff retry logic for resilient telemetry export
163+
- **Custom Headers**: Add API keys, client versions, or other required headers
164+
- **Request Modification**: Transform requests before sending (e.g., compression, encryption)
165+
- **Response Handling**: Custom error handling and response processing
166+
- **Testing**: Mock HTTP clients for unit testing exporters
Lines changed: 38 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -1,19 +1,30 @@
1-
/*
2-
* Copyright The OpenTelemetry Authors
3-
* SPDX-License-Identifier: Apache-2.0
4-
*/
1+
//
2+
// Copyright The OpenTelemetry Authors
3+
// SPDX-License-Identifier: Apache-2.0
4+
//
55

66
import Foundation
77

88
#if canImport(FoundationNetworking)
99
import FoundationNetworking
1010
#endif
1111

12-
/// Client for sending requests over HTTP.
13-
final class HTTPClient {
12+
/// Protocol for sending HTTP requests, allowing custom implementations for authentication and other behaviors.
13+
public protocol HTTPClient {
14+
/// Sends an HTTP request and calls the completion handler with the result.
15+
/// - Parameters:
16+
/// - request: The URLRequest to send
17+
/// - completion: Completion handler called with Result<HTTPURLResponse, Error>
18+
func send(request: URLRequest,
19+
completion: @escaping (Result<HTTPURLResponse, Error>) -> Void)
20+
}
21+
22+
/// Default implementation of HTTPClient using URLSession.
23+
public final class BaseHTTPClient: HTTPClient {
1424
private let session: URLSession
1525

16-
convenience init() {
26+
/// Creates a BaseHTTPClient with default ephemeral session configuration.
27+
public convenience init() {
1728
let configuration: URLSessionConfiguration = .ephemeral
1829
// NOTE: RUMM-610 Default behaviour of `.ephemeral` session is to cache requests.
1930
// To not leak requests memory (including their `.httpBody` which may be significant)
@@ -24,12 +35,14 @@ final class HTTPClient {
2435
self.init(session: URLSession(configuration: configuration))
2536
}
2637

27-
init(session: URLSession) {
38+
/// Creates a BaseHTTPClient with a custom URLSession.
39+
/// - Parameter session: The URLSession to use for HTTP requests
40+
public init(session: URLSession) {
2841
self.session = session
2942
}
3043

31-
func send(request: URLRequest,
32-
completion: @escaping (Result<HTTPURLResponse, Error>) -> Void) {
44+
public func send(request: URLRequest,
45+
completion: @escaping (Result<HTTPURLResponse, Error>) -> Void) {
3346
let task = session.dataTask(with: request) { data, response, error in
3447
completion(httpClientResult(for: (data, response, error)))
3548
}
@@ -38,23 +51,25 @@ final class HTTPClient {
3851
}
3952

4053
/// An error returned if `URLSession` response state is inconsistent (like no data, no response and no error).
41-
/// The code execution in `URLSessionTransport` should never reach its initialization.
42-
struct URLSessionTransportInconsistencyException: Error {}
54+
struct HTTPClientError: Error, CustomStringConvertible {
55+
let description: String
56+
}
4357

44-
/// As `URLSession` returns 3-values-tuple for request execution, this function applies consistency constraints and turns
45-
/// it into only two possible states of `HTTPTransportResult`.
46-
private func httpClientResult(
47-
for urlSessionTaskCompletion: (Data?, URLResponse?, Error?)
48-
) -> Result<HTTPURLResponse, Error> {
49-
let (_, response, error) = urlSessionTaskCompletion
58+
/// Maps `URLSessionDataTask` response to `HTTPClient` response.
59+
func httpClientResult(for taskResult: (Data?, URLResponse?, Error?)) -> Result<HTTPURLResponse, Error> {
60+
let (_, response, error) = taskResult
5061

51-
if let error {
62+
if let error = error {
5263
return .failure(error)
5364
}
5465

55-
if let httpResponse = response as? HTTPURLResponse {
56-
return .success(httpResponse)
66+
guard let httpResponse = response as? HTTPURLResponse else {
67+
return .failure(
68+
HTTPClientError(
69+
description: "Failed to receive HTTPURLResponse: \(String(describing: response))"
70+
)
71+
)
5772
}
5873

59-
return .failure(URLSessionTransportInconsistencyException())
60-
}
74+
return .success(httpResponse)
75+
}

Sources/Exporters/OpenTelemetryProtocolHttp/OtlpHttpExporterBase.swift

Lines changed: 20 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -24,14 +24,30 @@ public class OtlpHttpExporterBase {
2424

2525
// MARK: - Init
2626

27-
public init(endpoint: URL, config: OtlpConfiguration = OtlpConfiguration(), useSession: URLSession? = nil, envVarHeaders: [(String, String)]? = EnvVarHeaders.attributes) {
27+
// New initializer with HTTPClient support
28+
public init(endpoint: URL,
29+
config: OtlpConfiguration = OtlpConfiguration(),
30+
httpClient: HTTPClient = BaseHTTPClient(),
31+
envVarHeaders: [(String, String)]? = EnvVarHeaders.attributes) {
32+
self.envVarHeaders = envVarHeaders
33+
self.endpoint = endpoint
34+
self.config = config
35+
self.httpClient = httpClient
36+
}
37+
38+
// Deprecated initializer for backward compatibility
39+
@available(*, deprecated, message: "Use init(endpoint:config:httpClient:envVarHeaders:) instead")
40+
public init(endpoint: URL,
41+
config: OtlpConfiguration = OtlpConfiguration(),
42+
useSession: URLSession? = nil,
43+
envVarHeaders: [(String, String)]? = EnvVarHeaders.attributes) {
2844
self.envVarHeaders = envVarHeaders
2945
self.endpoint = endpoint
3046
self.config = config
3147
if let providedSession = useSession {
32-
httpClient = HTTPClient(session: providedSession)
48+
self.httpClient = BaseHTTPClient(session: providedSession)
3349
} else {
34-
httpClient = HTTPClient()
50+
self.httpClient = BaseHTTPClient()
3551
}
3652
}
3753

@@ -86,4 +102,4 @@ public class OtlpHttpExporterBase {
86102
}
87103

88104
public func shutdown(explicitTimeout: TimeInterval? = nil) {}
89-
}
105+
}

Sources/Exporters/OpenTelemetryProtocolHttp/logs/OtlpHttpLogExporter.swift

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -23,11 +23,11 @@ public class OtlpHttpLogExporter: OtlpHttpExporterBase, LogRecordExporter {
2323

2424
override public init(endpoint: URL = defaultOltpHttpLoggingEndpoint(),
2525
config: OtlpConfiguration = OtlpConfiguration(),
26-
useSession: URLSession? = nil,
26+
httpClient: HTTPClient = BaseHTTPClient(),
2727
envVarHeaders: [(String, String)]? = EnvVarHeaders.attributes) {
2828
super.init(endpoint: endpoint,
2929
config: config,
30-
useSession: useSession,
30+
httpClient: httpClient,
3131
envVarHeaders: envVarHeaders)
3232
}
3333

@@ -36,14 +36,14 @@ public class OtlpHttpLogExporter: OtlpHttpExporterBase, LogRecordExporter {
3636
/// - endpoint: Exporter endpoint injected as dependency
3737
/// - config: Exporter configuration including type of exporter
3838
/// - meterProvider: Injected `StableMeterProvider` for metric
39-
/// - useSession: Overridden `URLSession` if any
39+
/// - httpClient: Custom HTTPClient implementation
4040
/// - envVarHeaders: Extra header key-values
4141
public convenience init(endpoint: URL = defaultOltpHttpLoggingEndpoint(),
4242
config: OtlpConfiguration = OtlpConfiguration(),
4343
meterProvider: any MeterProvider,
44-
useSession: URLSession? = nil,
44+
httpClient: HTTPClient = BaseHTTPClient(),
4545
envVarHeaders: [(String, String)]? = EnvVarHeaders.attributes) {
46-
self.init(endpoint: endpoint, config: config, useSession: useSession,
46+
self.init(endpoint: endpoint, config: config, httpClient: httpClient,
4747
envVarHeaders: envVarHeaders)
4848
exporterMetrics = ExporterMetrics(type: "log",
4949
meterProvider: meterProvider,
@@ -143,4 +143,4 @@ public class OtlpHttpLogExporter: OtlpHttpExporterBase, LogRecordExporter {
143143

144144
return exporterResult
145145
}
146-
}
146+
}

Sources/Exporters/OpenTelemetryProtocolHttp/metric/OtlpHttpMetricExporter.swift

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -41,12 +41,12 @@ public class OtlpHttpMetricExporter: OtlpHttpExporterBase, MetricExporter {
4141
aggregationTemporalitySelector: AggregationTemporalitySelector =
4242
AggregationTemporality.alwaysCumulative(),
4343
defaultAggregationSelector: DefaultAggregationSelector = AggregationSelector.instance,
44-
useSession: URLSession? = nil,
44+
httpClient: HTTPClient = BaseHTTPClient(),
4545
envVarHeaders: [(String, String)]? = EnvVarHeaders.attributes) {
4646
self.aggregationTemporalitySelector = aggregationTemporalitySelector
4747
self.defaultAggregationSelector = defaultAggregationSelector
4848

49-
super.init(endpoint: endpoint, config: config, useSession: useSession,
49+
super.init(endpoint: endpoint, config: config, httpClient: httpClient,
5050
envVarHeaders: envVarHeaders)
5151
}
5252

@@ -57,7 +57,7 @@ public class OtlpHttpMetricExporter: OtlpHttpExporterBase, MetricExporter {
5757
/// - meterProvider: Injected `StableMeterProvider` for metric
5858
/// - aggregationTemporalitySelector: aggregator
5959
/// - defaultAggregationSelector: default aggregator
60-
/// - useSession: Overridden `URLSession` if any
60+
/// - httpClient: Custom HTTPClient implementation
6161
/// - envVarHeaders: Extra header key-values
6262
public convenience init(endpoint: URL,
6363
config: OtlpConfiguration = OtlpConfiguration(),
@@ -66,13 +66,13 @@ public class OtlpHttpMetricExporter: OtlpHttpExporterBase, MetricExporter {
6666
AggregationTemporality.alwaysCumulative(),
6767
defaultAggregationSelector: DefaultAggregationSelector = AggregationSelector
6868
.instance,
69-
useSession: URLSession? = nil,
69+
httpClient: HTTPClient = BaseHTTPClient(),
7070
envVarHeaders: [(String, String)]? = EnvVarHeaders.attributes) {
7171
self.init(endpoint: endpoint,
7272
config: config,
7373
aggregationTemporalitySelector: aggregationTemporalitySelector,
7474
defaultAggregationSelector: defaultAggregationSelector,
75-
useSession: useSession,
75+
httpClient: httpClient,
7676
envVarHeaders: envVarHeaders)
7777
exporterMetrics = ExporterMetrics(type: "metric",
7878
meterProvider: meterProvider,

0 commit comments

Comments
 (0)