@logbrew/react-native 0.1.11 → 0.1.13
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LogBrewReactNative.podspec +25 -3
- package/README.md +106 -3
- package/apple-native-diagnostics.d.ts +52 -0
- package/apple-native-diagnostics.js +286 -0
- package/expo.cjs +46 -0
- package/expo.d.ts +16 -0
- package/expo.js +5 -0
- package/fatal-replay.cjs +12 -1
- package/global-errors.cjs +3 -1
- package/index.cjs +84 -2
- package/index.d.cts +4 -0
- package/index.d.ts +4 -0
- package/index.js +84 -2
- package/index.native.d.ts +4 -0
- package/index.native.js +14 -1
- package/ios/AppleDiagnostics/LBRNAppleDiagnosticsModule.h +4 -0
- package/ios/AppleDiagnostics/LBRNAppleDiagnosticsModule.mm +56 -0
- package/ios/AppleDiagnostics/LBRNAppleNativeDiagnostics.swift +293 -0
- package/ios/GeneratedAppleDiagnostics/LogBrew/AutomaticTelemetryContext.swift +72 -0
- package/ios/GeneratedAppleDiagnostics/LogBrew/DeliveryEngine.swift +270 -0
- package/ios/GeneratedAppleDiagnostics/LogBrew/DeliveryEngineAutomatic.swift +331 -0
- package/ios/GeneratedAppleDiagnostics/LogBrew/DeliveryEngineDurable.swift +148 -0
- package/ios/GeneratedAppleDiagnostics/LogBrew/DeliveryEngineQueue.swift +343 -0
- package/ios/GeneratedAppleDiagnostics/LogBrew/DeliveryLifecycle.swift +70 -0
- package/ios/GeneratedAppleDiagnostics/LogBrew/DurableDeliveryStore.swift +258 -0
- package/ios/GeneratedAppleDiagnostics/LogBrew/DurableDeliveryStoreRecovery.swift +330 -0
- package/ios/GeneratedAppleDiagnostics/LogBrew/EventEncoding.swift +114 -0
- package/ios/GeneratedAppleDiagnostics/LogBrew/IssueDiagnostics.swift +385 -0
- package/ios/GeneratedAppleDiagnostics/LogBrew/LifecycleTrace.swift +138 -0
- package/ios/GeneratedAppleDiagnostics/LogBrew/LogBrewClient.swift +358 -0
- package/ios/GeneratedAppleDiagnostics/LogBrew/LogBrewLogger.swift +262 -0
- package/ios/GeneratedAppleDiagnostics/LogBrew/LogBrewTrace.swift +357 -0
- package/ios/GeneratedAppleDiagnostics/LogBrew/Metadata.swift +86 -0
- package/ios/GeneratedAppleDiagnostics/LogBrew/NativeStackFrame.swift +27 -0
- package/ios/GeneratedAppleDiagnostics/LogBrew/ProductTimeline.swift +228 -0
- package/ios/GeneratedAppleDiagnostics/LogBrew/PublicTypes.swift +334 -0
- package/ios/GeneratedAppleDiagnostics/LogBrew/TelemetryContext.swift +197 -0
- package/ios/GeneratedAppleDiagnostics/LogBrew/TelemetryContextValidation.swift +337 -0
- package/ios/GeneratedAppleDiagnostics/LogBrew/TraceEvidence.swift +79 -0
- package/ios/GeneratedAppleDiagnostics/LogBrew/Transport.swift +204 -0
- package/ios/GeneratedAppleDiagnostics/LogBrew/URLSessionTrace.swift +277 -0
- package/ios/GeneratedAppleDiagnostics/LogBrew/URLSessionTracer.swift +151 -0
- package/ios/GeneratedAppleDiagnostics/LogBrew/Validation.swift +165 -0
- package/ios/GeneratedAppleDiagnostics/LogBrewCrash/CrashEngine.swift +125 -0
- package/ios/GeneratedAppleDiagnostics/LogBrewCrash/CrashReportSanitizer.swift +139 -0
- package/ios/GeneratedAppleDiagnostics/LogBrewCrash/CrashStorageDirectory.swift +124 -0
- package/ios/GeneratedAppleDiagnostics/LogBrewCrash/NativeArtifactIdentity.swift +137 -0
- package/ios/GeneratedAppleDiagnostics/LogBrewCrash/NativeCrashCapture.swift +229 -0
- package/ios/GeneratedAppleDiagnostics/LogBrewCrash/NativeCrashPublic.swift +386 -0
- package/ios/GeneratedAppleDiagnostics/LogBrewCrash/NativeCrashReplay.swift +317 -0
- package/ios/GeneratedAppleDiagnostics/LogBrewCrash/NativeHangIncidentStore.swift +321 -0
- package/ios/GeneratedAppleDiagnostics/LogBrewCrash/NativeHangWatchdog.swift +386 -0
- package/ios/GeneratedAppleDiagnostics/LogBrewCrash/NativeHangWatchdogContracts.swift +84 -0
- package/ios/GeneratedAppleDiagnostics/LogBrewCrash/NativeHangWatchdogSystem.swift +139 -0
- package/ios/GeneratedAppleDiagnostics/LogBrewCrash/NativeMainThreadStackCapture.swift +84 -0
- package/ios/GeneratedAppleDiagnostics/LogBrewCrash/NativeStackFrameSanitizer.swift +170 -0
- package/ios/GeneratedAppleDiagnostics/SOURCE-MANIFEST.json +43 -0
- package/package.json +43 -5
- package/promise-rejections.cjs +3 -1
- package/scripts/sync-apple-native-sources.mjs +93 -0
- package/src/NativeLogBrewAppleDiagnostics.ts +12 -0
|
@@ -0,0 +1,343 @@
|
|
|
1
|
+
// Generated by scripts/sync-apple-native-sources.mjs.
|
|
2
|
+
// Edit the canonical Swift source, then regenerate this package boundary.
|
|
3
|
+
import Foundation
|
|
4
|
+
|
|
5
|
+
extension DeliveryEngine {
|
|
6
|
+
func enqueue(_ event: Event) throws {
|
|
7
|
+
let eventBytes = try encodeEvent(event).count
|
|
8
|
+
let singleRequestBytes = try encodeBatch([event]).count
|
|
9
|
+
var shouldSchedule = false
|
|
10
|
+
var admissionError: SdkError?
|
|
11
|
+
var durableRecordName: String?
|
|
12
|
+
|
|
13
|
+
storageLock.lock()
|
|
14
|
+
defer { storageLock.unlock() }
|
|
15
|
+
stateLock.lock()
|
|
16
|
+
if closed || state == .shuttingDown || state == .closed {
|
|
17
|
+
admissionError = SdkError(code: "shutdown_error", message: "client is already shut down")
|
|
18
|
+
} else if state == .paused, pauseReason == .storage {
|
|
19
|
+
admissionError = SdkError(
|
|
20
|
+
code: "storage_corrupt",
|
|
21
|
+
message: "durable delivery data requires explicit recovery",
|
|
22
|
+
)
|
|
23
|
+
} else if singleRequestBytes > Self.maxRequestBytes {
|
|
24
|
+
droppedEvents += 1
|
|
25
|
+
lastOutcome = .dropped
|
|
26
|
+
admissionError = SdkError(code: "event_too_large", message: "event exceeds the delivery request byte limit")
|
|
27
|
+
} else if queue.count >= Self.maxQueuedEvents || queuedBytes > Self.maxQueuedBytes - eventBytes {
|
|
28
|
+
droppedEvents += 1
|
|
29
|
+
lastOutcome = .dropped
|
|
30
|
+
admissionError = SdkError(code: "queue_full", message: "delivery queue capacity exceeded")
|
|
31
|
+
} else {
|
|
32
|
+
let store = durableStore
|
|
33
|
+
stateLock.unlock()
|
|
34
|
+
do {
|
|
35
|
+
durableRecordName = try store?.append(event, encodedBytes: eventBytes)
|
|
36
|
+
} catch {
|
|
37
|
+
recordStorageFailure()
|
|
38
|
+
throw storageError(error)
|
|
39
|
+
}
|
|
40
|
+
stateLock.lock()
|
|
41
|
+
queue.append(
|
|
42
|
+
QueuedEvent(
|
|
43
|
+
event: event,
|
|
44
|
+
encodedBytes: eventBytes,
|
|
45
|
+
durableRecordName: durableRecordName,
|
|
46
|
+
),
|
|
47
|
+
)
|
|
48
|
+
queuedBytes += eventBytes
|
|
49
|
+
shouldSchedule = scheduleCaptureLocked()
|
|
50
|
+
}
|
|
51
|
+
stateLock.unlock()
|
|
52
|
+
|
|
53
|
+
if shouldSchedule {
|
|
54
|
+
scheduleTimerUpdate()
|
|
55
|
+
}
|
|
56
|
+
if let admissionError {
|
|
57
|
+
throw admissionError
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
func flushAll(transport: any Transport) throws -> TransportResponse {
|
|
62
|
+
var totalAttempts = 0
|
|
63
|
+
var statusCode = 204
|
|
64
|
+
|
|
65
|
+
while let prefix = try freezePrefix() {
|
|
66
|
+
let response = try deliver(prefix, transport: transport)
|
|
67
|
+
totalAttempts += response.attempts
|
|
68
|
+
statusCode = response.statusCode
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
return TransportResponse(statusCode: statusCode, attempts: totalAttempts)
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
func flushThroughEvent(
|
|
75
|
+
_ eventID: String,
|
|
76
|
+
transport: any Transport,
|
|
77
|
+
) throws -> Bool {
|
|
78
|
+
guard withStateLock({ queue.contains { $0.event.id == eventID } }) else {
|
|
79
|
+
return false
|
|
80
|
+
}
|
|
81
|
+
while let prefix = try freezePrefix() {
|
|
82
|
+
let containsEvent = prefix.eventIDs.contains(eventID)
|
|
83
|
+
_ = try deliver(prefix, transport: transport)
|
|
84
|
+
if containsEvent {
|
|
85
|
+
return true
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
return false
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
private func deliver(
|
|
92
|
+
_ prefix: FrozenPrefix,
|
|
93
|
+
transport: any Transport,
|
|
94
|
+
) throws -> TransportResponse {
|
|
95
|
+
var totalAttempts = 0
|
|
96
|
+
for attemptIndex in 0 ... maxRetries {
|
|
97
|
+
let result = attempt(transport: transport, prefix: prefix)
|
|
98
|
+
totalAttempts += result.attempts
|
|
99
|
+
recordManualAttempts(result.attempts)
|
|
100
|
+
switch result {
|
|
101
|
+
case let .accepted(status, _):
|
|
102
|
+
try acknowledge(prefix)
|
|
103
|
+
return TransportResponse(statusCode: status, attempts: totalAttempts)
|
|
104
|
+
case .retryable where attemptIndex < maxRetries:
|
|
105
|
+
continue
|
|
106
|
+
case let .retryable(_, error):
|
|
107
|
+
recordManualFailure(outcome: .retryableFailure, pauseReason: .retryExhausted)
|
|
108
|
+
throw error
|
|
109
|
+
case let .terminal(reason, _, error):
|
|
110
|
+
recordManualFailure(outcome: .terminalFailure, pauseReason: reason)
|
|
111
|
+
throw error
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
throw SdkError(code: "transport_error", message: "exhausted retries")
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
func freezePrefix() throws -> FrozenPrefix? {
|
|
118
|
+
storageLock.lock()
|
|
119
|
+
defer { storageLock.unlock() }
|
|
120
|
+
|
|
121
|
+
stateLock.lock()
|
|
122
|
+
if let frozenPrefix {
|
|
123
|
+
inFlight = true
|
|
124
|
+
stateLock.unlock()
|
|
125
|
+
return frozenPrefix
|
|
126
|
+
}
|
|
127
|
+
guard !queue.isEmpty else {
|
|
128
|
+
stateLock.unlock()
|
|
129
|
+
return nil
|
|
130
|
+
}
|
|
131
|
+
let queueSnapshot = queue
|
|
132
|
+
let store = durableStore
|
|
133
|
+
stateLock.unlock()
|
|
134
|
+
|
|
135
|
+
let prefix = try makePrefix(queueSnapshot: queueSnapshot, store: store)
|
|
136
|
+
stateLock.lock()
|
|
137
|
+
frozenPrefix = prefix
|
|
138
|
+
inFlight = true
|
|
139
|
+
stateLock.unlock()
|
|
140
|
+
return prefix
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
private func makePrefix(
|
|
144
|
+
queueSnapshot: [QueuedEvent],
|
|
145
|
+
store: DurableDeliveryStore?,
|
|
146
|
+
) throws -> FrozenPrefix {
|
|
147
|
+
var selected: [Event] = []
|
|
148
|
+
var body = Data()
|
|
149
|
+
for queuedEvent in queueSnapshot.prefix(Self.maxRequestEvents) {
|
|
150
|
+
let candidate = selected + [queuedEvent.event]
|
|
151
|
+
let candidateBody = try encodeBatch(candidate)
|
|
152
|
+
if candidateBody.count > Self.maxRequestBytes {
|
|
153
|
+
break
|
|
154
|
+
}
|
|
155
|
+
selected = candidate
|
|
156
|
+
body = candidateBody
|
|
157
|
+
}
|
|
158
|
+
guard !selected.isEmpty else {
|
|
159
|
+
throw SdkError(code: "event_too_large", message: "event exceeds the delivery request byte limit")
|
|
160
|
+
}
|
|
161
|
+
let selectedQueue = Array(queueSnapshot.prefix(selected.count))
|
|
162
|
+
let prefixBytes = selectedQueue.reduce(0) { $0 + $1.encodedBytes }
|
|
163
|
+
let recordNames = selectedQueue.compactMap(\.durableRecordName)
|
|
164
|
+
if let store {
|
|
165
|
+
guard recordNames.count == selected.count else {
|
|
166
|
+
recordStorageFailure()
|
|
167
|
+
throw storageError(DurableStoreFailure.corrupt)
|
|
168
|
+
}
|
|
169
|
+
do {
|
|
170
|
+
try store.persistPrefix(body: body, eventRecordNames: recordNames, encodedBytes: prefixBytes)
|
|
171
|
+
} catch {
|
|
172
|
+
recordStorageFailure()
|
|
173
|
+
throw storageError(error)
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
return FrozenPrefix(
|
|
177
|
+
count: selected.count,
|
|
178
|
+
bytes: prefixBytes,
|
|
179
|
+
body: body,
|
|
180
|
+
eventIDs: selected.map(\.id),
|
|
181
|
+
durableRecordNames: recordNames,
|
|
182
|
+
)
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
func acknowledge(_ prefix: FrozenPrefix) throws {
|
|
186
|
+
do {
|
|
187
|
+
try acknowledgeDurableFiles(prefix)
|
|
188
|
+
} catch {
|
|
189
|
+
recordStorageFailure()
|
|
190
|
+
throw error
|
|
191
|
+
}
|
|
192
|
+
stateLock.lock()
|
|
193
|
+
acknowledgeLocked(prefix)
|
|
194
|
+
acceptedEvents += prefix.count
|
|
195
|
+
consecutiveFailures = 0
|
|
196
|
+
lastOutcome = .accepted
|
|
197
|
+
stateLock.unlock()
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
func acknowledgeDurableFiles(_ prefix: FrozenPrefix) throws {
|
|
201
|
+
storageLock.lock()
|
|
202
|
+
defer { storageLock.unlock() }
|
|
203
|
+
let store = withStateLock { durableStore }
|
|
204
|
+
guard let store else {
|
|
205
|
+
return
|
|
206
|
+
}
|
|
207
|
+
do {
|
|
208
|
+
try store.acknowledge(body: prefix.body, eventRecordNames: prefix.durableRecordNames)
|
|
209
|
+
} catch {
|
|
210
|
+
throw storageError(error)
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
func acknowledgeLocked(_ prefix: FrozenPrefix) {
|
|
215
|
+
guard frozenPrefix?.body == prefix.body, queue.count >= prefix.count else {
|
|
216
|
+
return
|
|
217
|
+
}
|
|
218
|
+
queue.removeFirst(prefix.count)
|
|
219
|
+
queuedBytes -= prefix.bytes
|
|
220
|
+
frozenPrefix = nil
|
|
221
|
+
inFlight = false
|
|
222
|
+
retryAttempt = 0
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
func attempt(transport: any Transport, prefix: FrozenPrefix) -> AttemptResult {
|
|
226
|
+
do {
|
|
227
|
+
let response = try transport.send(apiKey: apiKey, body: prefix.body)
|
|
228
|
+
return classify(response)
|
|
229
|
+
} catch let transportError as TransportError {
|
|
230
|
+
let error = SdkError(code: transportError.code, message: transportError.message)
|
|
231
|
+
if transportError.retryable {
|
|
232
|
+
return .retryable(attempts: 1, error: error)
|
|
233
|
+
}
|
|
234
|
+
return .terminal(reason: .nonRetryable, attempts: 1, error: error)
|
|
235
|
+
} catch {
|
|
236
|
+
return .terminal(
|
|
237
|
+
reason: .nonRetryable,
|
|
238
|
+
attempts: 1,
|
|
239
|
+
error: SdkError(code: "transport_error", message: "unexpected transport response"),
|
|
240
|
+
)
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
private func classify(_ response: TransportResponse) -> AttemptResult {
|
|
245
|
+
guard response.attempts > 0 else {
|
|
246
|
+
return .terminal(
|
|
247
|
+
reason: .nonRetryable,
|
|
248
|
+
attempts: 1,
|
|
249
|
+
error: SdkError(code: "transport_error", message: "unexpected transport response"),
|
|
250
|
+
)
|
|
251
|
+
}
|
|
252
|
+
let attempts = max(1, response.attempts)
|
|
253
|
+
if (200 ..< 300).contains(response.statusCode) {
|
|
254
|
+
return .accepted(statusCode: response.statusCode, attempts: attempts)
|
|
255
|
+
}
|
|
256
|
+
let error = transportError(statusCode: response.statusCode)
|
|
257
|
+
if response.statusCode == 408 || response.statusCode >= 500 {
|
|
258
|
+
return .retryable(attempts: attempts, error: error)
|
|
259
|
+
}
|
|
260
|
+
return .terminal(reason: pauseReason(for: response.statusCode), attempts: attempts, error: error)
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
private func transportError(statusCode: Int) -> SdkError {
|
|
264
|
+
if statusCode == 401 {
|
|
265
|
+
return SdkError(code: "unauthenticated", message: "transport rejected the API key")
|
|
266
|
+
}
|
|
267
|
+
return SdkError(code: "transport_error", message: "unexpected transport status \(statusCode)")
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
private func pauseReason(for statusCode: Int) -> DeliveryPauseReason {
|
|
271
|
+
switch statusCode {
|
|
272
|
+
case 401, 403:
|
|
273
|
+
.authentication
|
|
274
|
+
case 429:
|
|
275
|
+
.quota
|
|
276
|
+
case 400, 404, 409, 413, 422:
|
|
277
|
+
.validation
|
|
278
|
+
default:
|
|
279
|
+
.nonRetryable
|
|
280
|
+
}
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
func recordManualFailure(outcome: DeliveryOutcome, pauseReason reason: DeliveryPauseReason) {
|
|
284
|
+
stateLock.lock()
|
|
285
|
+
inFlight = false
|
|
286
|
+
consecutiveFailures += 1
|
|
287
|
+
lastOutcome = outcome
|
|
288
|
+
if automaticTransport != nil, state != .shuttingDown, state != .closed {
|
|
289
|
+
state = .paused
|
|
290
|
+
pauseReason = reason
|
|
291
|
+
nextWakeNanoseconds = nil
|
|
292
|
+
}
|
|
293
|
+
stateLock.unlock()
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
private func recordManualAttempts(_ attempts: Int) {
|
|
297
|
+
stateLock.lock()
|
|
298
|
+
deliveryAttempts += attempts
|
|
299
|
+
stateLock.unlock()
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
func encodeEvent(_ event: Event) throws -> Data {
|
|
303
|
+
let encoder = JSONEncoder()
|
|
304
|
+
encoder.outputFormatting = [.sortedKeys]
|
|
305
|
+
return try encoder.encode(event)
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
func encodeBatch(_ events: [Event]) throws -> Data {
|
|
309
|
+
let encoder = JSONEncoder()
|
|
310
|
+
encoder.outputFormatting = [.prettyPrinted, .sortedKeys]
|
|
311
|
+
return try encoder.encode(EventBatch(sdk: sdk, events: events))
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
func withStateLock<T>(_ operation: () -> T) -> T {
|
|
315
|
+
stateLock.lock()
|
|
316
|
+
defer { stateLock.unlock() }
|
|
317
|
+
return operation()
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
func recordStorageFailure() {
|
|
321
|
+
stateLock.lock()
|
|
322
|
+
inFlight = false
|
|
323
|
+
state = .paused
|
|
324
|
+
pauseReason = .storage
|
|
325
|
+
lastOutcome = .terminalFailure
|
|
326
|
+
consecutiveFailures += 1
|
|
327
|
+
nextWakeNanoseconds = nil
|
|
328
|
+
stateLock.unlock()
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
func storageError(_ error: Error) -> SdkError {
|
|
332
|
+
if let failure = error as? DurableStoreFailure, failure == .corrupt {
|
|
333
|
+
return SdkError(code: "storage_corrupt", message: "durable delivery data requires explicit recovery")
|
|
334
|
+
}
|
|
335
|
+
if let failure = error as? DurableStoreFailure, failure == .capacity {
|
|
336
|
+
return SdkError(code: "queue_full", message: "durable delivery capacity exceeded")
|
|
337
|
+
}
|
|
338
|
+
if let failure = error as? DurableStoreFailure, failure == .owned {
|
|
339
|
+
return SdkError(code: "storage_error", message: "durable delivery storage is already in use")
|
|
340
|
+
}
|
|
341
|
+
return SdkError(code: "storage_error", message: "durable delivery storage is unavailable")
|
|
342
|
+
}
|
|
343
|
+
}
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
// Generated by scripts/sync-apple-native-sources.mjs.
|
|
2
|
+
// Edit the canonical Swift source, then regenerate this package boundary.
|
|
3
|
+
import Foundation
|
|
4
|
+
|
|
5
|
+
public enum DeliveryState: String, Codable, Sendable {
|
|
6
|
+
case manual
|
|
7
|
+
case running
|
|
8
|
+
case retrying
|
|
9
|
+
case paused
|
|
10
|
+
case shuttingDown = "shutting_down"
|
|
11
|
+
case closed
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
public enum DeliveryOutcome: String, Codable, Sendable {
|
|
15
|
+
case none
|
|
16
|
+
case accepted
|
|
17
|
+
case retryableFailure = "retryable_failure"
|
|
18
|
+
case terminalFailure = "terminal_failure"
|
|
19
|
+
case dropped
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
public enum DeliveryPauseReason: String, Codable, Sendable {
|
|
23
|
+
case none
|
|
24
|
+
case authentication
|
|
25
|
+
case quota
|
|
26
|
+
case validation
|
|
27
|
+
case nonRetryable = "non_retryable"
|
|
28
|
+
case retryExhausted = "retry_exhausted"
|
|
29
|
+
case storage
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
public struct DurableDeliveryOptions: Equatable, Sendable {
|
|
33
|
+
public let directory: URL
|
|
34
|
+
|
|
35
|
+
public init(directory: URL) {
|
|
36
|
+
self.directory = directory
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
public struct AutomaticDeliveryOptions: Equatable, Sendable {
|
|
41
|
+
public let interval: TimeInterval
|
|
42
|
+
public let threshold: Int
|
|
43
|
+
public let retryBaseDelay: TimeInterval
|
|
44
|
+
public let maxRetryDelay: TimeInterval
|
|
45
|
+
|
|
46
|
+
public init(
|
|
47
|
+
interval: TimeInterval = 5,
|
|
48
|
+
threshold: Int = 100,
|
|
49
|
+
retryBaseDelay: TimeInterval = 0.25,
|
|
50
|
+
maxRetryDelay: TimeInterval = 30,
|
|
51
|
+
) {
|
|
52
|
+
self.interval = interval
|
|
53
|
+
self.threshold = threshold
|
|
54
|
+
self.retryBaseDelay = retryBaseDelay
|
|
55
|
+
self.maxRetryDelay = maxRetryDelay
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
public struct DeliveryHealth: Codable, Equatable, Sendable {
|
|
60
|
+
public let state: DeliveryState
|
|
61
|
+
public let queuedEvents: Int
|
|
62
|
+
public let queuedBytes: Int
|
|
63
|
+
public let inFlight: Bool
|
|
64
|
+
public let acceptedEvents: Int
|
|
65
|
+
public let droppedEvents: Int
|
|
66
|
+
public let deliveryAttempts: Int
|
|
67
|
+
public let consecutiveFailures: Int
|
|
68
|
+
public let lastOutcome: DeliveryOutcome
|
|
69
|
+
public let pauseReason: DeliveryPauseReason
|
|
70
|
+
}
|
|
@@ -0,0 +1,258 @@
|
|
|
1
|
+
// Generated by scripts/sync-apple-native-sources.mjs.
|
|
2
|
+
// Edit the canonical Swift source, then regenerate this package boundary.
|
|
3
|
+
import Foundation
|
|
4
|
+
|
|
5
|
+
enum DurableStoreFailure: Equatable, Error {
|
|
6
|
+
case capacity
|
|
7
|
+
case corrupt
|
|
8
|
+
case invalidLocation
|
|
9
|
+
case inputOutput
|
|
10
|
+
case owned
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
final class DurableDeliveryStore: @unchecked Sendable {
|
|
14
|
+
static let directoryName = "logbrew-delivery-v1"
|
|
15
|
+
|
|
16
|
+
struct RecoveredEvent {
|
|
17
|
+
let event: Event
|
|
18
|
+
let encodedBytes: Int
|
|
19
|
+
let recordName: String
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
struct RecoveredPrefix {
|
|
23
|
+
let body: Data
|
|
24
|
+
let eventRecordNames: [String]
|
|
25
|
+
let encodedBytes: Int
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
struct Recovery {
|
|
29
|
+
let events: [RecoveredEvent]
|
|
30
|
+
let prefix: RecoveredPrefix?
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
struct EventRecord: Codable {
|
|
34
|
+
let version: Int
|
|
35
|
+
let sequence: UInt64
|
|
36
|
+
let sdk: SDKInfo
|
|
37
|
+
let encodedBytes: Int
|
|
38
|
+
let checksum: String
|
|
39
|
+
let event: Event
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
struct PrefixRecord: Codable {
|
|
43
|
+
let version: Int
|
|
44
|
+
let sdk: SDKInfo
|
|
45
|
+
let eventRecordNames: [String]
|
|
46
|
+
let encodedBytes: Int
|
|
47
|
+
let body: Data
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
static let version = 1
|
|
51
|
+
static let prefixName = "frozen-prefix.json"
|
|
52
|
+
static let lockName = ".lock"
|
|
53
|
+
static let maxEventRecordBytes = DeliveryEngine.maxRequestBytes * 2
|
|
54
|
+
static let maxPrefixRecordBytes = DeliveryEngine.maxRequestBytes * 2
|
|
55
|
+
|
|
56
|
+
private let fileManager: FileManager
|
|
57
|
+
private let directory: URL
|
|
58
|
+
private let sdk: SDKInfo
|
|
59
|
+
private let lockHandle: FileHandle
|
|
60
|
+
private var recoveredEvents: [RecoveredEvent]
|
|
61
|
+
private var recoveredPrefix: RecoveredPrefix?
|
|
62
|
+
private var nextSequence: UInt64
|
|
63
|
+
private var failed = false
|
|
64
|
+
|
|
65
|
+
init(parent: URL, sdk: SDKInfo, fileManager: FileManager = .default) throws {
|
|
66
|
+
self.fileManager = fileManager
|
|
67
|
+
self.sdk = sdk
|
|
68
|
+
directory = parent.appendingPathComponent(Self.directoryName, isDirectory: true)
|
|
69
|
+
|
|
70
|
+
try Self.validateParent(parent, fileManager: fileManager)
|
|
71
|
+
try Self.prepareOwnedDirectory(directory, fileManager: fileManager)
|
|
72
|
+
lockHandle = try Self.acquireLock(in: directory, fileManager: fileManager)
|
|
73
|
+
|
|
74
|
+
do {
|
|
75
|
+
let recovery = try Self.loadRecovery(from: directory, sdk: sdk, fileManager: fileManager)
|
|
76
|
+
recoveredEvents = recovery.events
|
|
77
|
+
recoveredPrefix = recovery.prefix
|
|
78
|
+
if let last = recovery.events.last {
|
|
79
|
+
let lastSequence = try Self.sequence(from: last)
|
|
80
|
+
guard lastSequence < UInt64.max else {
|
|
81
|
+
throw DurableStoreFailure.corrupt
|
|
82
|
+
}
|
|
83
|
+
nextSequence = lastSequence + 1
|
|
84
|
+
} else {
|
|
85
|
+
nextSequence = 1
|
|
86
|
+
}
|
|
87
|
+
} catch {
|
|
88
|
+
Self.releaseLock(lockHandle)
|
|
89
|
+
throw error
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
deinit {
|
|
94
|
+
Self.releaseLock(lockHandle)
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
func recovery() -> Recovery {
|
|
98
|
+
Recovery(events: recoveredEvents, prefix: recoveredPrefix)
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
func append(_ event: Event, encodedBytes: Int) throws -> String {
|
|
102
|
+
try requireHealthy()
|
|
103
|
+
guard recoveredEvents.count < DeliveryEngine.maxQueuedEvents,
|
|
104
|
+
recoveredEvents.reduce(0, { $0 + $1.encodedBytes }) <= DeliveryEngine.maxQueuedBytes - encodedBytes
|
|
105
|
+
else {
|
|
106
|
+
throw DurableStoreFailure.capacity
|
|
107
|
+
}
|
|
108
|
+
guard nextSequence < UInt64.max else {
|
|
109
|
+
throw DurableStoreFailure.corrupt
|
|
110
|
+
}
|
|
111
|
+
let name = Self.eventName(sequence: nextSequence)
|
|
112
|
+
let eventData = try Self.encodeEvent(event)
|
|
113
|
+
guard eventData.count == encodedBytes else {
|
|
114
|
+
throw DurableStoreFailure.corrupt
|
|
115
|
+
}
|
|
116
|
+
let record = EventRecord(
|
|
117
|
+
version: Self.version,
|
|
118
|
+
sequence: nextSequence,
|
|
119
|
+
sdk: sdk,
|
|
120
|
+
encodedBytes: encodedBytes,
|
|
121
|
+
checksum: Self.checksum(eventData),
|
|
122
|
+
event: event,
|
|
123
|
+
)
|
|
124
|
+
let data = try Self.encode(record)
|
|
125
|
+
guard data.count <= Self.maxEventRecordBytes else {
|
|
126
|
+
throw DurableStoreFailure.capacity
|
|
127
|
+
}
|
|
128
|
+
do {
|
|
129
|
+
try write(data, named: name)
|
|
130
|
+
} catch {
|
|
131
|
+
failed = true
|
|
132
|
+
throw error
|
|
133
|
+
}
|
|
134
|
+
recoveredEvents.append(RecoveredEvent(event: event, encodedBytes: encodedBytes, recordName: name))
|
|
135
|
+
nextSequence += 1
|
|
136
|
+
return name
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
func appendExisting(_ events: [(event: Event, encodedBytes: Int)]) throws -> [String] {
|
|
140
|
+
guard recoveredEvents.isEmpty, recoveredPrefix == nil else {
|
|
141
|
+
throw DurableStoreFailure.owned
|
|
142
|
+
}
|
|
143
|
+
var names: [String] = []
|
|
144
|
+
do {
|
|
145
|
+
for item in events {
|
|
146
|
+
try names.append(append(item.event, encodedBytes: item.encodedBytes))
|
|
147
|
+
}
|
|
148
|
+
return names
|
|
149
|
+
} catch {
|
|
150
|
+
for name in names {
|
|
151
|
+
try? fileManager.removeItem(at: directory.appendingPathComponent(name, isDirectory: false))
|
|
152
|
+
}
|
|
153
|
+
recoveredEvents.removeAll()
|
|
154
|
+
recoveredPrefix = nil
|
|
155
|
+
nextSequence = 1
|
|
156
|
+
throw error
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
func persistPrefix(body: Data, eventRecordNames: [String], encodedBytes: Int) throws {
|
|
161
|
+
try requireHealthy()
|
|
162
|
+
guard !eventRecordNames.isEmpty,
|
|
163
|
+
eventRecordNames.count <= DeliveryEngine.maxRequestEvents,
|
|
164
|
+
body.count <= DeliveryEngine.maxRequestBytes,
|
|
165
|
+
recoveredEvents.prefix(eventRecordNames.count).map(\.recordName) == eventRecordNames
|
|
166
|
+
else {
|
|
167
|
+
throw DurableStoreFailure.corrupt
|
|
168
|
+
}
|
|
169
|
+
if let recoveredPrefix {
|
|
170
|
+
guard recoveredPrefix.body == body,
|
|
171
|
+
recoveredPrefix.eventRecordNames == eventRecordNames,
|
|
172
|
+
recoveredPrefix.encodedBytes == encodedBytes
|
|
173
|
+
else {
|
|
174
|
+
throw DurableStoreFailure.corrupt
|
|
175
|
+
}
|
|
176
|
+
return
|
|
177
|
+
}
|
|
178
|
+
let record = PrefixRecord(
|
|
179
|
+
version: Self.version,
|
|
180
|
+
sdk: sdk,
|
|
181
|
+
eventRecordNames: eventRecordNames,
|
|
182
|
+
encodedBytes: encodedBytes,
|
|
183
|
+
body: body,
|
|
184
|
+
)
|
|
185
|
+
let data = try Self.encode(record)
|
|
186
|
+
guard data.count <= Self.maxPrefixRecordBytes else {
|
|
187
|
+
throw DurableStoreFailure.capacity
|
|
188
|
+
}
|
|
189
|
+
do {
|
|
190
|
+
try write(data, named: Self.prefixName)
|
|
191
|
+
} catch {
|
|
192
|
+
failed = true
|
|
193
|
+
throw error
|
|
194
|
+
}
|
|
195
|
+
recoveredPrefix = RecoveredPrefix(
|
|
196
|
+
body: body,
|
|
197
|
+
eventRecordNames: eventRecordNames,
|
|
198
|
+
encodedBytes: encodedBytes,
|
|
199
|
+
)
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
func acknowledge(body: Data, eventRecordNames: [String]) throws {
|
|
203
|
+
try requireHealthy()
|
|
204
|
+
guard let prefix = recoveredPrefix,
|
|
205
|
+
prefix.body == body,
|
|
206
|
+
prefix.eventRecordNames == eventRecordNames,
|
|
207
|
+
recoveredEvents.prefix(eventRecordNames.count).map(\.recordName) == eventRecordNames
|
|
208
|
+
else {
|
|
209
|
+
throw DurableStoreFailure.corrupt
|
|
210
|
+
}
|
|
211
|
+
do {
|
|
212
|
+
try fileManager.removeItem(at: directory.appendingPathComponent(Self.prefixName, isDirectory: false))
|
|
213
|
+
for name in eventRecordNames {
|
|
214
|
+
try fileManager.removeItem(at: directory.appendingPathComponent(name, isDirectory: false))
|
|
215
|
+
}
|
|
216
|
+
} catch {
|
|
217
|
+
failed = true
|
|
218
|
+
throw DurableStoreFailure.inputOutput
|
|
219
|
+
}
|
|
220
|
+
recoveredEvents.removeFirst(eventRecordNames.count)
|
|
221
|
+
recoveredPrefix = nil
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
static func purge(parent: URL, fileManager: FileManager = .default) throws {
|
|
225
|
+
try validateParent(parent, fileManager: fileManager)
|
|
226
|
+
let directory = parent.appendingPathComponent(directoryName, isDirectory: true)
|
|
227
|
+
guard fileManager.fileExists(atPath: directory.path) else {
|
|
228
|
+
return
|
|
229
|
+
}
|
|
230
|
+
try validateDirectory(directory, fileManager: fileManager)
|
|
231
|
+
let handle = try acquireLock(in: directory, fileManager: fileManager)
|
|
232
|
+
defer { releaseLock(handle) }
|
|
233
|
+
do {
|
|
234
|
+
try fileManager.removeItem(at: directory)
|
|
235
|
+
} catch {
|
|
236
|
+
throw DurableStoreFailure.inputOutput
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
private func requireHealthy() throws {
|
|
241
|
+
if failed {
|
|
242
|
+
throw DurableStoreFailure.corrupt
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
private func write(_ data: Data, named name: String) throws {
|
|
247
|
+
guard Self.isOwnedName(name) else {
|
|
248
|
+
throw DurableStoreFailure.corrupt
|
|
249
|
+
}
|
|
250
|
+
let destination = directory.appendingPathComponent(name, isDirectory: false)
|
|
251
|
+
do {
|
|
252
|
+
try data.write(to: destination, options: .atomic)
|
|
253
|
+
try Self.hardenOwnedFile(destination, fileManager: fileManager)
|
|
254
|
+
} catch {
|
|
255
|
+
throw DurableStoreFailure.inputOutput
|
|
256
|
+
}
|
|
257
|
+
}
|
|
258
|
+
}
|