Skip to content

Commit d7eb282

Browse files
author
Ignacio Bonafonte
authored
Merge pull request #280 from shelefg/persistence_exporter
Introduce persistence exporter decorators for MetricExporter and Span This submission builds upon #279 , and introduces a persistence decorator for signal exporters. Most of the code in the submission was imported and adjusted from the DataDogExporter library. It contains: A persistence layer (see /Storage). The export worker (see /Export). The decorators & persistence and export configuration. For example, decorating metric and span exporters with persistence functionality can be done as follows: let metricExporter = ... // create some MetricExporter let persistenceMetricExporter = try PersistenceMetricExporterDecorator( metricExporter: metricExporter, storageURL: metricsSubdirectoryURL, writerQueue: DispatchQueue(label: "metricWriterQueue"), readerQueue: DispatchQueue(label: "metricReaderQueue"), exportQueue: DispatchQueue(label: "metricExportQueue"), exportCondition: { return true }) let spanExporter = ... // create some SpanExporter let persistenceTraceExporter = try PersistenceSpanExporterDecorator( spanExporter: spanExporter, storageURL: tracesSubdirectoryURL, writerQueue: DispatchQueue(label: "spanWriterQueue"), readerQueue: DispatchQueue(label: "spanWriterQueue"), exportQueue: DispatchQueue(label: "spanWriterQueue"), exportCondition: { return true }) The PersistenceMetricExporterDecorator and PersistenceSpanExporterDecorator will asynchronously: Encode the exported Metric's and SpanData's objects to JSON. Write the data to files in the folder specified by storageURL Read back these objects from the disk and forward them to be exported by the corresponding decorated exporters.
2 parents 5e02eda + 0c16427 commit d7eb282

32 files changed

+3058
-0
lines changed

Package.swift

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@ let package = Package(
2222
.library(name: "StdoutExporter", type: .static, targets: ["StdoutExporter"]),
2323
.library(name: "PrometheusExporter", type: .static, targets: ["PrometheusExporter"]),
2424
.library(name: "OpenTelemetryProtocolExporter", type: .static, targets: ["OpenTelemetryProtocolExporter"]),
25+
.library(name: "PersistenceExporter", type: .static, targets: ["PersistenceExporter"]),
2526
.library(name: "InMemoryExporter", type: .static, targets: ["InMemoryExporter"]),
2627
.library(name: "DatadogExporter", type: .static, targets: ["DatadogExporter"]),
2728
.library(name: "NetworkStatus", type: .static, targets: ["NetworkStatus"]),
@@ -96,6 +97,9 @@ let package = Package(
9697
dependencies: ["OpenTelemetrySdk"],
9798
path: "Sources/Exporters/DatadogExporter",
9899
exclude: ["NOTICE", "README.md"]),
100+
.target(name: "PersistenceExporter",
101+
dependencies: ["OpenTelemetrySdk"],
102+
path: "Sources/Exporters/Persistence"),
99103
.testTarget(name: "NetworkStatusTests",
100104
dependencies: ["NetworkStatus"],
101105
path: "Tests/InstrumentationTests/NetworkStatusTests"),
@@ -142,6 +146,9 @@ let package = Package(
142146
.product(name: "NIO", package: "swift-nio"),
143147
.product(name: "NIOHTTP1", package: "swift-nio")],
144148
path: "Tests/ExportersTests/DatadogExporter"),
149+
.testTarget(name: "PersistenceExporterTests",
150+
dependencies: ["PersistenceExporter"],
151+
path: "Tests/ExportersTests/PersistenceExporter"),
145152
.target(name: "LoggingTracer",
146153
dependencies: ["OpenTelemetryApi"],
147154
path: "Examples/Logging Tracer"),
Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
/*
2+
* Copyright The OpenTelemetry Authors
3+
* SPDX-License-Identifier: Apache-2.0
4+
*/
5+
6+
import Foundation
7+
8+
internal protocol Delay {
9+
var current: TimeInterval { get }
10+
mutating func decrease()
11+
mutating func increase()
12+
}
13+
14+
/// Mutable interval used for periodic data exports.
15+
internal struct DataExportDelay: Delay {
16+
private let defaultDelay: TimeInterval
17+
private let minDelay: TimeInterval
18+
private let maxDelay: TimeInterval
19+
private let changeRate: Double
20+
21+
private var delay: TimeInterval
22+
23+
init(performance: ExportPerformancePreset) {
24+
self.defaultDelay = performance.defaultExportDelay
25+
self.minDelay = performance.minExportDelay
26+
self.maxDelay = performance.maxExportDelay
27+
self.changeRate = performance.exportDelayChangeRate
28+
self.delay = performance.initialExportDelay
29+
}
30+
31+
var current: TimeInterval { delay }
32+
33+
mutating func decrease() {
34+
delay = max(minDelay, delay * (1.0 - changeRate))
35+
}
36+
37+
mutating func increase() {
38+
delay = min(delay * (1.0 + changeRate), maxDelay)
39+
}
40+
}
Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
/*
2+
* Copyright The OpenTelemetry Authors
3+
* SPDX-License-Identifier: Apache-2.0
4+
*/
5+
6+
import Foundation
7+
8+
/// The status of a single export attempt.
9+
internal struct DataExportStatus {
10+
/// If export needs to be retried (`true`) because its associated data was not delivered but it may succeed
11+
/// in the next attempt (i.e. it failed due to device leaving signal range or a temporary server unavailability occured).
12+
/// If set to `false` then data associated with the upload should be deleted as it does not need any more export
13+
/// attempts (i.e. the upload succeeded or failed due to unrecoverable client error).
14+
let needsRetry: Bool
15+
}
Lines changed: 112 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,112 @@
1+
/*
2+
* Copyright The OpenTelemetry Authors
3+
* SPDX-License-Identifier: Apache-2.0
4+
*/
5+
6+
import Foundation
7+
8+
// a protocol for an exporter of `Data` to which a `DataExportWorker` can delegate persisted
9+
// data export
10+
internal protocol DataExporter {
11+
func export(data: Data) -> DataExportStatus
12+
}
13+
14+
// a protocol needed for mocking `DataExportWorker`
15+
internal protocol DataExportWorkerProtocol {
16+
func flush() -> Bool
17+
}
18+
19+
internal class DataExportWorker: DataExportWorkerProtocol {
20+
/// Queue to execute exports.
21+
private let queue: DispatchQueue
22+
/// File reader providing data to export.
23+
private let fileReader: FileReader
24+
/// Data exporter sending data to server.
25+
private let dataExporter: DataExporter
26+
/// Variable system conditions determining if export should be performed.
27+
private let exportCondition: () -> Bool
28+
29+
/// Delay used to schedule consecutive exports.
30+
private var delay: Delay
31+
32+
/// Export work scheduled by this worker.
33+
private var exportWork: DispatchWorkItem?
34+
35+
init(
36+
queue: DispatchQueue,
37+
fileReader: FileReader,
38+
dataExporter: DataExporter,
39+
exportCondition: @escaping () -> Bool,
40+
delay: Delay
41+
) {
42+
self.queue = queue
43+
self.fileReader = fileReader
44+
self.exportCondition = exportCondition
45+
self.dataExporter = dataExporter
46+
self.delay = delay
47+
48+
let exportWork = DispatchWorkItem { [weak self] in
49+
guard let self = self else {
50+
return
51+
}
52+
53+
let isSystemReady = self.exportCondition()
54+
let nextBatch = isSystemReady ? self.fileReader.readNextBatch() : nil
55+
if let batch = nextBatch {
56+
// Export batch
57+
let exportStatus = self.dataExporter.export(data: batch.data)
58+
59+
// Delete or keep batch depending on the export status
60+
if exportStatus.needsRetry {
61+
self.delay.increase()
62+
} else {
63+
self.fileReader.markBatchAsRead(batch)
64+
self.delay.decrease()
65+
}
66+
} else {
67+
self.delay.increase()
68+
}
69+
70+
self.scheduleNextExport(after: self.delay.current)
71+
}
72+
73+
self.exportWork = exportWork
74+
75+
scheduleNextExport(after: self.delay.current)
76+
}
77+
78+
private func scheduleNextExport(after delay: TimeInterval) {
79+
guard let work = exportWork else {
80+
return
81+
}
82+
83+
queue.asyncAfter(deadline: .now() + delay, execute: work)
84+
}
85+
86+
/// This method gets remaining files at once, and exports them
87+
/// It assures that periodic exporter cannot read or export the files while the flush is being processed
88+
internal func flush() -> Bool {
89+
let success = queue.sync {
90+
self.fileReader.onRemainingBatches {
91+
let exportStatus = self.dataExporter.export(data: $0.data)
92+
if !exportStatus.needsRetry {
93+
self.fileReader.markBatchAsRead($0)
94+
}
95+
}
96+
}
97+
return success
98+
}
99+
100+
/// Cancels scheduled exports and stops scheduling next ones.
101+
/// - It does not affect the export that has already begun.
102+
/// - It blocks the caller thread if called in the middle of export execution.
103+
internal func cancelSynchronously() {
104+
queue.sync(flags: .barrier) {
105+
// This cancellation must be performed on the `queue` to ensure that it is not called
106+
// in the middle of a `DispatchWorkItem` execution - otherwise, as the pending block would be
107+
// fully executed, it will schedule another export by calling `nextScheduledWork(after:)` at the end.
108+
self.exportWork?.cancel()
109+
self.exportWork = nil
110+
}
111+
}
112+
}
Lines changed: 131 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,131 @@
1+
/*
2+
* Copyright The OpenTelemetry Authors
3+
* SPDX-License-Identifier: Apache-2.0
4+
*/
5+
6+
import Foundation
7+
import OpenTelemetrySdk
8+
9+
// protocol for exporters that can be decorated with `PersistenceExporterDecorator`
10+
protocol DecoratedExporter {
11+
associatedtype SignalType
12+
13+
func export(values: [SignalType]) -> DataExportStatus
14+
}
15+
16+
// a generic decorator of `DecoratedExporter` adding filesystem persistence of batches of `[T.SignalType]`.
17+
// `T.SignalType` must conform to `Codable`.
18+
internal class PersistenceExporterDecorator<T> where T: DecoratedExporter, T.SignalType: Codable {
19+
20+
// a wrapper of `DecoratedExporter` (T) to add conformance to `DataExporter` that can be
21+
// used with `DataExportWorker`.
22+
private class DecoratedDataExporter: DataExporter {
23+
24+
private let decoratedExporter: T
25+
26+
init(decoratedExporter: T) {
27+
self.decoratedExporter = decoratedExporter
28+
}
29+
30+
func export(data: Data) -> DataExportStatus {
31+
32+
// decode batches of `[T.SignalType]` from the raw data.
33+
// the data is made of batches of comma-suffixed JSON arrays, so in order to utilize
34+
// `JSONDecoder`, add a "[" prefix and "null]" suffix making the data a valid
35+
// JSON array of `[T.SignalType]`.
36+
var arrayData: Data = JSONDataConstants.arrayPrefix
37+
arrayData.append(data)
38+
arrayData.append(JSONDataConstants.arraySuffix)
39+
40+
do {
41+
let decoder = JSONDecoder()
42+
let exportables = try decoder.decode(
43+
[[T.SignalType]?].self,
44+
from: arrayData).compactMap { $0 }.flatMap { $0 }
45+
46+
return decoratedExporter.export(values: exportables)
47+
} catch {
48+
return DataExportStatus(needsRetry: false)
49+
}
50+
}
51+
}
52+
53+
private let performancePreset: PersistencePerformancePreset
54+
55+
private let fileWriter: FileWriter
56+
57+
private let worker: DataExportWorkerProtocol
58+
59+
public convenience init(decoratedExporter: T,
60+
storageURL: URL,
61+
writerQueue: DispatchQueue,
62+
readerQueue: DispatchQueue,
63+
exportQueue: DispatchQueue,
64+
exportCondition: @escaping () -> Bool,
65+
performancePreset: PersistencePerformancePreset = .default) {
66+
67+
// orchestrate writes and reads over the folder given by `storageURL`
68+
let filesOrchestrator = FilesOrchestrator(
69+
directory: Directory(url: storageURL),
70+
performance: performancePreset,
71+
dateProvider: SystemDateProvider()
72+
)
73+
74+
let fileWriter = OrchestratedFileWriter(
75+
orchestrator: filesOrchestrator,
76+
queue: writerQueue
77+
)
78+
79+
let fileReader = OrchestratedFileReader(
80+
orchestrator: filesOrchestrator,
81+
queue: readerQueue
82+
)
83+
84+
self.init(decoratedExporter: decoratedExporter,
85+
fileWriter: fileWriter,
86+
workerFactory: {
87+
return DataExportWorker(
88+
queue: exportQueue,
89+
fileReader: fileReader,
90+
dataExporter: $0,
91+
exportCondition: exportCondition,
92+
delay: DataExportDelay(performance: performancePreset))
93+
},
94+
performancePreset: performancePreset)
95+
}
96+
97+
// internal initializer for testing that accepts a worker factory that allows mocking the worker
98+
internal init(decoratedExporter: T,
99+
fileWriter: FileWriter,
100+
workerFactory createWorker: (DataExporter) -> DataExportWorkerProtocol,
101+
performancePreset: PersistencePerformancePreset) {
102+
self.performancePreset = performancePreset
103+
104+
self.fileWriter = fileWriter
105+
106+
self.worker = createWorker(DecoratedDataExporter(decoratedExporter: decoratedExporter))
107+
}
108+
109+
public func export(values: [T.SignalType]) throws {
110+
let encoder = JSONEncoder()
111+
var data = try encoder.encode(values)
112+
data.append(JSONDataConstants.arraySeparator)
113+
114+
if (performancePreset.synchronousWrite) {
115+
fileWriter.writeSync(data: data)
116+
} else {
117+
fileWriter.write(data: data)
118+
}
119+
}
120+
121+
public func flush() {
122+
fileWriter.flush()
123+
_ = worker.flush()
124+
}
125+
}
126+
127+
fileprivate struct JSONDataConstants {
128+
static let arrayPrefix = "[".data(using: .utf8)!
129+
static let arraySuffix = "null]".data(using: .utf8)!
130+
static let arraySeparator = ",".data(using: .utf8)!
131+
}
Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,58 @@
1+
/*
2+
* Copyright The OpenTelemetry Authors
3+
* SPDX-License-Identifier: Apache-2.0
4+
*/
5+
6+
import Foundation
7+
import OpenTelemetrySdk
8+
9+
// a persistence exporter decorator for `Metric`.
10+
// specialization of `PersistenceExporterDecorator` for `MetricExporter`.
11+
public class PersistenceMetricExporterDecorator: MetricExporter {
12+
13+
struct MetricDecoratedExporter: DecoratedExporter {
14+
typealias SignalType = Metric
15+
16+
private let metricExporter: MetricExporter
17+
18+
init(metricExporter: MetricExporter) {
19+
self.metricExporter = metricExporter
20+
}
21+
22+
func export(values: [Metric]) -> DataExportStatus {
23+
let result = metricExporter.export(metrics: values, shouldCancel: nil)
24+
return DataExportStatus(needsRetry: result == .failureRetryable)
25+
}
26+
}
27+
28+
private let persistenceExporter: PersistenceExporterDecorator<MetricDecoratedExporter>
29+
30+
public init(metricExporter: MetricExporter,
31+
storageURL: URL,
32+
writerQueue: DispatchQueue,
33+
readerQueue: DispatchQueue,
34+
exportQueue: DispatchQueue,
35+
exportCondition: @escaping () -> Bool,
36+
performancePreset: PersistencePerformancePreset = .default) throws {
37+
38+
self.persistenceExporter =
39+
PersistenceExporterDecorator<MetricDecoratedExporter>(
40+
decoratedExporter: MetricDecoratedExporter(metricExporter: metricExporter),
41+
storageURL: storageURL,
42+
writerQueue: writerQueue,
43+
readerQueue: readerQueue,
44+
exportQueue: exportQueue,
45+
exportCondition: exportCondition,
46+
performancePreset: performancePreset)
47+
}
48+
49+
public func export(metrics: [Metric], shouldCancel: (() -> Bool)?) -> MetricExporterResultCode {
50+
do {
51+
try persistenceExporter.export(values: metrics)
52+
53+
return .success
54+
} catch {
55+
return .failureNotRetryable
56+
}
57+
}
58+
}

0 commit comments

Comments
 (0)