Skip to content

Commit 9b09eb6

Browse files
committed
feat: add async-await APIs for exporters
Add new async protocols (AsyncSpanExporter, AsyncLogRecordExporter, AsyncMetricExporter) that refine the existing sync exporter protocols, enabling truly non-blocking exports for network-based exporters. Default implementations bridge to sync methods via base protocol cast, so existing exporters work unchanged. MultiSpanExporter and MultiLogRecordExporter gain concurrent export via withTaskGroup. Multi-exporters partition children into async (Sendable, concurrent via TaskGroup) and sync (sequential, never cross concurrency boundaries), avoiding @unchecked Sendable wrappers. Also adds Sendable conformance to ExportResult and SpanExporterResultCode, marks MultiLogRecordExporter as @unchecked Sendable with immutable storage. Closes #34
1 parent 2c0b05c commit 9b09eb6

File tree

10 files changed

+899
-6
lines changed

10 files changed

+899
-6
lines changed

Sources/OpenTelemetrySdk/Common/ExportResult.swift

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@
55

66
import Foundation
77

8-
public enum ExportResult {
8+
public enum ExportResult: Sendable {
99
/// The export operation finished successfully.
1010
case success
1111
/// The export operation finished with an error.
Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,55 @@
1+
/*
2+
* Copyright The OpenTelemetry Authors
3+
* SPDX-License-Identifier: Apache-2.0
4+
*/
5+
6+
import Foundation
7+
8+
/// An async-await capable log record exporter that refines `LogRecordExporter`.
9+
///
10+
/// Existing exporters continue to work unchanged. New or existing exporters can
11+
/// opt in to truly non-blocking exports by conforming to this protocol and
12+
/// overriding the async methods.
13+
///
14+
/// Default implementations bridge to the synchronous `LogRecordExporter` methods
15+
/// so that adopters only need to override the methods they want to make async.
16+
@available(macOS 10.15, iOS 13.0, watchOS 6.0, tvOS 13.0, *)
17+
public protocol AsyncLogRecordExporter: LogRecordExporter, Sendable {
18+
/// Called to export log records asynchronously.
19+
/// - Parameter logRecords: the list of log records to be exported.
20+
/// - Parameter explicitTimeout: optional timeout for the export operation.
21+
func exportAsync(logRecords: [ReadableLogRecord], explicitTimeout: TimeInterval?) async -> ExportResult
22+
23+
/// Called when the exporter is shut down, asynchronously.
24+
func shutdownAsync(explicitTimeout: TimeInterval?) async
25+
26+
/// Processes all the log records that have not yet been processed, asynchronously.
27+
func forceFlushAsync(explicitTimeout: TimeInterval?) async -> ExportResult
28+
}
29+
30+
@available(macOS 10.15, iOS 13.0, watchOS 6.0, tvOS 13.0, *)
31+
public extension AsyncLogRecordExporter {
32+
func exportAsync(logRecords: [ReadableLogRecord], explicitTimeout: TimeInterval?) async -> ExportResult {
33+
return (self as LogRecordExporter).export(logRecords: logRecords, explicitTimeout: explicitTimeout)
34+
}
35+
36+
func shutdownAsync(explicitTimeout: TimeInterval?) async {
37+
(self as LogRecordExporter).shutdown(explicitTimeout: explicitTimeout)
38+
}
39+
40+
func forceFlushAsync(explicitTimeout: TimeInterval?) async -> ExportResult {
41+
return (self as LogRecordExporter).forceFlush(explicitTimeout: explicitTimeout)
42+
}
43+
44+
func exportAsync(logRecords: [ReadableLogRecord]) async -> ExportResult {
45+
return await exportAsync(logRecords: logRecords, explicitTimeout: nil)
46+
}
47+
48+
func shutdownAsync() async {
49+
await shutdownAsync(explicitTimeout: nil)
50+
}
51+
52+
func forceFlushAsync() async -> ExportResult {
53+
return await forceFlushAsync(explicitTimeout: nil)
54+
}
55+
}

Sources/OpenTelemetrySdk/Logs/Export/MultiLogRecordExporter.swift

Lines changed: 94 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,8 +5,11 @@
55

66
import Foundation
77

8-
public class MultiLogRecordExporter: LogRecordExporter {
9-
var logRecordExporters: [LogRecordExporter]
8+
/// `@unchecked Sendable` because Swift cannot statically verify Sendable for
9+
/// non-final classes. Safety is guaranteed by the immutable (`let`) stored property —
10+
/// no synchronization is needed for concurrent reads of immutable state.
11+
public class MultiLogRecordExporter: LogRecordExporter, @unchecked Sendable {
12+
let logRecordExporters: [LogRecordExporter]
1013

1114
public init(logRecordExporters: [LogRecordExporter]) {
1215
self.logRecordExporters = logRecordExporters
@@ -34,3 +37,92 @@ public class MultiLogRecordExporter: LogRecordExporter {
3437
return result
3538
}
3639
}
40+
41+
@available(macOS 10.15, iOS 13.0, watchOS 6.0, tvOS 13.0, *)
42+
extension MultiLogRecordExporter: AsyncLogRecordExporter {
43+
public func exportAsync(logRecords: [ReadableLogRecord], explicitTimeout: TimeInterval?) async -> ExportResult {
44+
var asyncExporters: [AsyncLogRecordExporter] = []
45+
var syncExporters: [LogRecordExporter] = []
46+
for exporter in logRecordExporters {
47+
if let asyncExporter = exporter as? AsyncLogRecordExporter {
48+
asyncExporters.append(asyncExporter)
49+
} else {
50+
syncExporters.append(exporter)
51+
}
52+
}
53+
54+
var result = await withTaskGroup(of: ExportResult.self, returning: ExportResult.self) { group in
55+
for exporter in asyncExporters {
56+
group.addTask {
57+
await exporter.exportAsync(logRecords: logRecords, explicitTimeout: explicitTimeout)
58+
}
59+
}
60+
var result = ExportResult.success
61+
for await childResult in group {
62+
result.mergeResultCode(newResultCode: childResult)
63+
}
64+
return result
65+
}
66+
67+
for exporter in syncExporters {
68+
result.mergeResultCode(newResultCode: exporter.export(logRecords: logRecords, explicitTimeout: explicitTimeout))
69+
}
70+
71+
return result
72+
}
73+
74+
public func shutdownAsync(explicitTimeout: TimeInterval?) async {
75+
var asyncExporters: [AsyncLogRecordExporter] = []
76+
var syncExporters: [LogRecordExporter] = []
77+
for exporter in logRecordExporters {
78+
if let asyncExporter = exporter as? AsyncLogRecordExporter {
79+
asyncExporters.append(asyncExporter)
80+
} else {
81+
syncExporters.append(exporter)
82+
}
83+
}
84+
85+
await withTaskGroup(of: Void.self) { group in
86+
for exporter in asyncExporters {
87+
group.addTask {
88+
await exporter.shutdownAsync(explicitTimeout: explicitTimeout)
89+
}
90+
}
91+
}
92+
93+
for exporter in syncExporters {
94+
exporter.shutdown(explicitTimeout: explicitTimeout)
95+
}
96+
}
97+
98+
public func forceFlushAsync(explicitTimeout: TimeInterval?) async -> ExportResult {
99+
var asyncExporters: [AsyncLogRecordExporter] = []
100+
var syncExporters: [LogRecordExporter] = []
101+
for exporter in logRecordExporters {
102+
if let asyncExporter = exporter as? AsyncLogRecordExporter {
103+
asyncExporters.append(asyncExporter)
104+
} else {
105+
syncExporters.append(exporter)
106+
}
107+
}
108+
109+
var result = await withTaskGroup(of: ExportResult.self, returning: ExportResult.self) { group in
110+
for exporter in asyncExporters {
111+
group.addTask {
112+
await exporter.forceFlushAsync(explicitTimeout: explicitTimeout)
113+
}
114+
}
115+
var result = ExportResult.success
116+
for await childResult in group {
117+
result.mergeResultCode(newResultCode: childResult)
118+
}
119+
return result
120+
}
121+
122+
for exporter in syncExporters {
123+
result.mergeResultCode(newResultCode: exporter.forceFlush(explicitTimeout: explicitTimeout))
124+
}
125+
126+
return result
127+
}
128+
}
Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
1+
/*
2+
* Copyright The OpenTelemetry Authors
3+
* SPDX-License-Identifier: Apache-2.0
4+
*/
5+
6+
import Foundation
7+
8+
/// An async-await capable metric exporter that refines `MetricExporter`.
9+
///
10+
/// Existing exporters continue to work unchanged. New or existing exporters can
11+
/// opt in to truly non-blocking exports by conforming to this protocol and
12+
/// overriding the async methods.
13+
///
14+
/// Default implementations bridge to the synchronous `MetricExporter` methods
15+
/// so that adopters only need to override the methods they want to make async.
16+
@available(macOS 10.15, iOS 13.0, watchOS 6.0, tvOS 13.0, *)
17+
public protocol AsyncMetricExporter: MetricExporter, Sendable {
18+
/// Called to export metrics asynchronously.
19+
/// - Parameter metrics: the list of metric data to be exported.
20+
func exportAsync(metrics: [MetricData]) async -> ExportResult
21+
22+
/// Flushes any pending metric data, asynchronously.
23+
func flushAsync() async -> ExportResult
24+
25+
/// Shuts down the exporter, asynchronously.
26+
func shutdownAsync() async -> ExportResult
27+
}
28+
29+
@available(macOS 10.15, iOS 13.0, watchOS 6.0, tvOS 13.0, *)
30+
public extension AsyncMetricExporter {
31+
func exportAsync(metrics: [MetricData]) async -> ExportResult {
32+
return (self as MetricExporter).export(metrics: metrics)
33+
}
34+
35+
func flushAsync() async -> ExportResult {
36+
return (self as MetricExporter).flush()
37+
}
38+
39+
func shutdownAsync() async -> ExportResult {
40+
return (self as MetricExporter).shutdown()
41+
}
42+
}
Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,55 @@
1+
/*
2+
* Copyright The OpenTelemetry Authors
3+
* SPDX-License-Identifier: Apache-2.0
4+
*/
5+
6+
import Foundation
7+
8+
/// An async-await capable span exporter that refines `SpanExporter`.
9+
///
10+
/// Existing exporters continue to work unchanged. New or existing exporters can
11+
/// opt in to truly non-blocking exports by conforming to this protocol and
12+
/// overriding the async methods.
13+
///
14+
/// Default implementations bridge to the synchronous `SpanExporter` methods so
15+
/// that adopters only need to override the methods they want to make async.
16+
@available(macOS 10.15, iOS 13.0, watchOS 6.0, tvOS 13.0, *)
17+
public protocol AsyncSpanExporter: SpanExporter {
18+
/// Called to export sampled Spans asynchronously.
19+
/// - Parameter spans: the list of sampled Spans to be exported.
20+
/// - Parameter explicitTimeout: optional timeout for the export operation.
21+
@discardableResult func exportAsync(spans: [SpanData], explicitTimeout: TimeInterval?) async -> SpanExporterResultCode
22+
23+
/// Exports the collection of sampled Spans that have not yet been exported, asynchronously.
24+
func flushAsync(explicitTimeout: TimeInterval?) async -> SpanExporterResultCode
25+
26+
/// Called when the TracerSdkFactory is shut down, asynchronously.
27+
func shutdownAsync(explicitTimeout: TimeInterval?) async
28+
}
29+
30+
@available(macOS 10.15, iOS 13.0, watchOS 6.0, tvOS 13.0, *)
31+
public extension AsyncSpanExporter {
32+
@discardableResult func exportAsync(spans: [SpanData], explicitTimeout: TimeInterval?) async -> SpanExporterResultCode {
33+
return (self as SpanExporter).export(spans: spans, explicitTimeout: explicitTimeout)
34+
}
35+
36+
func flushAsync(explicitTimeout: TimeInterval?) async -> SpanExporterResultCode {
37+
return (self as SpanExporter).flush(explicitTimeout: explicitTimeout)
38+
}
39+
40+
func shutdownAsync(explicitTimeout: TimeInterval?) async {
41+
(self as SpanExporter).shutdown(explicitTimeout: explicitTimeout)
42+
}
43+
44+
@discardableResult func exportAsync(spans: [SpanData]) async -> SpanExporterResultCode {
45+
return await exportAsync(spans: spans, explicitTimeout: nil)
46+
}
47+
48+
func flushAsync() async -> SpanExporterResultCode {
49+
return await flushAsync(explicitTimeout: nil)
50+
}
51+
52+
func shutdownAsync() async {
53+
await shutdownAsync(explicitTimeout: nil)
54+
}
55+
}

Sources/OpenTelemetrySdk/Trace/Export/MultiSpanExporter.swift

Lines changed: 60 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -7,8 +7,11 @@ import Foundation
77

88
/// Implementation of the SpanExporter that simply forwards all received spans to a list of
99
/// SpanExporter.
10-
/// Can be used to export to multiple backends using the same SpanProcessor} like a impleSampledSpansProcessor
11-
/// or a BatchSampledSpansProcessor.
10+
/// Can be used to export to multiple backends using the same SpanProcessor like a SimpleSpanProcessor
11+
/// or a BatchSpanProcessor.
12+
/// `@unchecked Sendable` because Swift cannot statically verify Sendable for
13+
/// non-final classes. Safety is guaranteed by the immutable (`let`) stored property —
14+
/// no synchronization is needed for concurrent reads of immutable state.
1215
public class MultiSpanExporter: SpanExporter, @unchecked Sendable {
1316
let spanExporters: [SpanExporter]
1417

@@ -38,3 +41,58 @@ public class MultiSpanExporter: SpanExporter, @unchecked Sendable {
3841
}
3942
}
4043
}
44+
45+
@available(macOS 10.15, iOS 13.0, watchOS 6.0, tvOS 13.0, *)
46+
extension MultiSpanExporter: AsyncSpanExporter {
47+
public func exportAsync(spans: [SpanData], explicitTimeout: TimeInterval?) async -> SpanExporterResultCode {
48+
await withTaskGroup(of: SpanExporterResultCode.self, returning: SpanExporterResultCode.self) { group in
49+
for exporter in spanExporters {
50+
group.addTask {
51+
if let asyncExporter = exporter as? AsyncSpanExporter {
52+
return await asyncExporter.exportAsync(spans: spans, explicitTimeout: explicitTimeout)
53+
} else {
54+
return exporter.export(spans: spans, explicitTimeout: explicitTimeout)
55+
}
56+
}
57+
}
58+
var currentResultCode = SpanExporterResultCode.success
59+
for await result in group {
60+
currentResultCode.mergeResultCode(newResultCode: result)
61+
}
62+
return currentResultCode
63+
}
64+
}
65+
66+
public func flushAsync(explicitTimeout: TimeInterval?) async -> SpanExporterResultCode {
67+
await withTaskGroup(of: SpanExporterResultCode.self, returning: SpanExporterResultCode.self) { group in
68+
for exporter in spanExporters {
69+
group.addTask {
70+
if let asyncExporter = exporter as? AsyncSpanExporter {
71+
return await asyncExporter.flushAsync(explicitTimeout: explicitTimeout)
72+
} else {
73+
return exporter.flush(explicitTimeout: explicitTimeout)
74+
}
75+
}
76+
}
77+
var currentResultCode = SpanExporterResultCode.success
78+
for await result in group {
79+
currentResultCode.mergeResultCode(newResultCode: result)
80+
}
81+
return currentResultCode
82+
}
83+
}
84+
85+
public func shutdownAsync(explicitTimeout: TimeInterval?) async {
86+
await withTaskGroup(of: Void.self) { group in
87+
for exporter in spanExporters {
88+
group.addTask {
89+
if let asyncExporter = exporter as? AsyncSpanExporter {
90+
await asyncExporter.shutdownAsync(explicitTimeout: explicitTimeout)
91+
} else {
92+
exporter.shutdown(explicitTimeout: explicitTimeout)
93+
}
94+
}
95+
}
96+
}
97+
}
98+
}

Sources/OpenTelemetrySdk/Trace/Export/SpanExporter.swift

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -37,7 +37,7 @@ public extension SpanExporter {
3737
}
3838

3939
/// The possible results for the export method.
40-
public enum SpanExporterResultCode {
40+
public enum SpanExporterResultCode: Sendable {
4141
/// The export operation finished successfully.
4242
case success
4343

0 commit comments

Comments
 (0)