@logbrew/react-native 0.1.10 → 0.1.12

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.
Files changed (72) hide show
  1. package/LogBrewReactNative.podspec +25 -3
  2. package/README.md +174 -4
  3. package/android/src/main/java/co/logbrew/reactnative/EventRecordStore.java +611 -0
  4. package/android/src/main/java/co/logbrew/reactnative/FatalStoreModuleImpl.java +129 -0
  5. package/android/src/newarch/java/co/logbrew/reactnative/FatalStoreModule.java +26 -0
  6. package/android/src/oldarch/java/co/logbrew/reactnative/FatalStoreModule.java +26 -0
  7. package/apple-native-diagnostics.d.ts +52 -0
  8. package/apple-native-diagnostics.js +286 -0
  9. package/expo.cjs +46 -0
  10. package/expo.d.ts +16 -0
  11. package/expo.js +5 -0
  12. package/fatal-replay.cjs +12 -1
  13. package/global-errors.cjs +3 -1
  14. package/index.cjs +86 -2
  15. package/index.d.cts +7 -0
  16. package/index.d.ts +7 -0
  17. package/index.js +86 -2
  18. package/index.native.d.ts +47 -1
  19. package/index.native.js +81 -1
  20. package/ios/AppleDiagnostics/LBRNAppleDiagnosticsModule.h +4 -0
  21. package/ios/AppleDiagnostics/LBRNAppleDiagnosticsModule.mm +56 -0
  22. package/ios/AppleDiagnostics/LBRNAppleNativeDiagnostics.swift +293 -0
  23. package/ios/GeneratedAppleDiagnostics/LogBrew/AutomaticTelemetryContext.swift +72 -0
  24. package/ios/GeneratedAppleDiagnostics/LogBrew/DeliveryEngine.swift +270 -0
  25. package/ios/GeneratedAppleDiagnostics/LogBrew/DeliveryEngineAutomatic.swift +331 -0
  26. package/ios/GeneratedAppleDiagnostics/LogBrew/DeliveryEngineDurable.swift +148 -0
  27. package/ios/GeneratedAppleDiagnostics/LogBrew/DeliveryEngineQueue.swift +343 -0
  28. package/ios/GeneratedAppleDiagnostics/LogBrew/DeliveryLifecycle.swift +70 -0
  29. package/ios/GeneratedAppleDiagnostics/LogBrew/DurableDeliveryStore.swift +258 -0
  30. package/ios/GeneratedAppleDiagnostics/LogBrew/DurableDeliveryStoreRecovery.swift +330 -0
  31. package/ios/GeneratedAppleDiagnostics/LogBrew/EventEncoding.swift +114 -0
  32. package/ios/GeneratedAppleDiagnostics/LogBrew/IssueDiagnostics.swift +385 -0
  33. package/ios/GeneratedAppleDiagnostics/LogBrew/LifecycleTrace.swift +138 -0
  34. package/ios/GeneratedAppleDiagnostics/LogBrew/LogBrewClient.swift +358 -0
  35. package/ios/GeneratedAppleDiagnostics/LogBrew/LogBrewLogger.swift +262 -0
  36. package/ios/GeneratedAppleDiagnostics/LogBrew/LogBrewTrace.swift +357 -0
  37. package/ios/GeneratedAppleDiagnostics/LogBrew/Metadata.swift +86 -0
  38. package/ios/GeneratedAppleDiagnostics/LogBrew/NativeStackFrame.swift +27 -0
  39. package/ios/GeneratedAppleDiagnostics/LogBrew/ProductTimeline.swift +228 -0
  40. package/ios/GeneratedAppleDiagnostics/LogBrew/PublicTypes.swift +334 -0
  41. package/ios/GeneratedAppleDiagnostics/LogBrew/TelemetryContext.swift +197 -0
  42. package/ios/GeneratedAppleDiagnostics/LogBrew/TelemetryContextValidation.swift +337 -0
  43. package/ios/GeneratedAppleDiagnostics/LogBrew/TraceEvidence.swift +79 -0
  44. package/ios/GeneratedAppleDiagnostics/LogBrew/Transport.swift +204 -0
  45. package/ios/GeneratedAppleDiagnostics/LogBrew/URLSessionTrace.swift +277 -0
  46. package/ios/GeneratedAppleDiagnostics/LogBrew/URLSessionTracer.swift +151 -0
  47. package/ios/GeneratedAppleDiagnostics/LogBrew/Validation.swift +165 -0
  48. package/ios/GeneratedAppleDiagnostics/LogBrewCrash/CrashEngine.swift +125 -0
  49. package/ios/GeneratedAppleDiagnostics/LogBrewCrash/CrashReportSanitizer.swift +139 -0
  50. package/ios/GeneratedAppleDiagnostics/LogBrewCrash/CrashStorageDirectory.swift +124 -0
  51. package/ios/GeneratedAppleDiagnostics/LogBrewCrash/NativeArtifactIdentity.swift +137 -0
  52. package/ios/GeneratedAppleDiagnostics/LogBrewCrash/NativeCrashCapture.swift +229 -0
  53. package/ios/GeneratedAppleDiagnostics/LogBrewCrash/NativeCrashPublic.swift +386 -0
  54. package/ios/GeneratedAppleDiagnostics/LogBrewCrash/NativeCrashReplay.swift +317 -0
  55. package/ios/GeneratedAppleDiagnostics/LogBrewCrash/NativeHangIncidentStore.swift +321 -0
  56. package/ios/GeneratedAppleDiagnostics/LogBrewCrash/NativeHangWatchdog.swift +386 -0
  57. package/ios/GeneratedAppleDiagnostics/LogBrewCrash/NativeHangWatchdogContracts.swift +84 -0
  58. package/ios/GeneratedAppleDiagnostics/LogBrewCrash/NativeHangWatchdogSystem.swift +139 -0
  59. package/ios/GeneratedAppleDiagnostics/LogBrewCrash/NativeMainThreadStackCapture.swift +84 -0
  60. package/ios/GeneratedAppleDiagnostics/LogBrewCrash/NativeStackFrameSanitizer.swift +170 -0
  61. package/ios/GeneratedAppleDiagnostics/SOURCE-MANIFEST.json +43 -0
  62. package/ios/LBRNEventRecordStore.h +29 -0
  63. package/ios/LBRNEventRecordStore.m +746 -0
  64. package/ios/LBRNFatalStoreModule.mm +159 -14
  65. package/ios/LBRNPrivateStorage.h +7 -0
  66. package/ios/LBRNPrivateStorage.m +38 -0
  67. package/package.json +47 -4
  68. package/persistent-delivery.native.js +282 -0
  69. package/promise-rejections.cjs +3 -1
  70. package/scripts/sync-apple-native-sources.mjs +93 -0
  71. package/src/NativeLogBrewAppleDiagnostics.ts +12 -0
  72. package/src/NativeLogBrewFatalStore.ts +9 -0
@@ -0,0 +1,337 @@
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
+ func mergeTelemetryContexts(
6
+ _ base: TelemetryContext?,
7
+ _ override: TelemetryContext?,
8
+ ) throws -> TelemetryContext? {
9
+ let base = try base.map { try validateTelemetryContext($0, label: "base telemetry context") }
10
+ let override = try override.map { try validateTelemetryContext($0, label: "event telemetry context") }
11
+ guard let base else {
12
+ return override
13
+ }
14
+ guard let override else {
15
+ return base
16
+ }
17
+
18
+ let context = TelemetryContext(
19
+ resource: mergeResources(base.resource, override.resource),
20
+ trace: override.trace ?? base.trace,
21
+ session: override.session ?? base.session,
22
+ subject: override.subject ?? base.subject,
23
+ tags: mergeTags(base.tags, override.tags),
24
+ )
25
+ return try validateTelemetryContext(context, label: "merged telemetry context")
26
+ }
27
+
28
+ func validateTelemetryContext(
29
+ _ value: TelemetryContext,
30
+ label: String = "telemetry context",
31
+ ) throws -> TelemetryContext {
32
+ guard value.schemaVersion == 1 else {
33
+ throw contextValidationError("\(label) schemaVersion must be 1")
34
+ }
35
+ let resource = try value.resource.map { try validateResource($0, label: "\(label) resource") }
36
+ let trace = try value.trace.map { try validateTelemetryTraceContext($0, label: "\(label) trace") }
37
+ let session = try value.session.map { try validateSession($0, label: "\(label) session") }
38
+ let subject = try value.subject.map { try validateSubject($0, label: "\(label) subject") }
39
+ let tags = try value.tags.map { try validateTags($0, label: "\(label) tags") }
40
+ guard resource != nil || trace != nil || session != nil || subject != nil || tags != nil else {
41
+ throw contextValidationError("\(label) must include resource, trace, session, subject, or tags")
42
+ }
43
+ return TelemetryContext(resource: resource, trace: trace, session: session, subject: subject, tags: tags)
44
+ }
45
+
46
+ func telemetryTraceContext(_ context: LogBrewTraceContext) -> TelemetryTraceContext {
47
+ TelemetryTraceContext(
48
+ traceId: context.traceId,
49
+ spanId: context.spanId,
50
+ parentSpanId: context.parentSpanId,
51
+ sampled: context.sampled,
52
+ )
53
+ }
54
+
55
+ func telemetryTraceContext(
56
+ traceId: String,
57
+ spanId: String,
58
+ parentSpanId: String?,
59
+ sampled: Bool?,
60
+ ) -> TelemetryTraceContext? {
61
+ let candidate = TelemetryTraceContext(
62
+ traceId: traceId,
63
+ spanId: spanId,
64
+ parentSpanId: parentSpanId,
65
+ sampled: sampled,
66
+ )
67
+ return try? validateTelemetryTraceContext(candidate, label: "span telemetry context trace")
68
+ }
69
+
70
+ func validateTelemetryTraceContext(
71
+ _ value: TelemetryTraceContext,
72
+ label: String,
73
+ ) throws -> TelemetryTraceContext {
74
+ try TelemetryTraceContext(
75
+ traceId: contextHexId(value.traceId, length: 32, label: "\(label) traceId"),
76
+ spanId: value.spanId.map { try contextHexId($0, length: 16, label: "\(label) spanId") },
77
+ parentSpanId: value.parentSpanId.map {
78
+ try contextHexId($0, length: 16, label: "\(label) parentSpanId")
79
+ },
80
+ sampled: value.sampled,
81
+ )
82
+ }
83
+
84
+ func validMachineKey(_ value: String, maximum: Int, separators: Set<Character>) -> Bool {
85
+ guard !value.isEmpty, value.count <= maximum, value.first?.isASCII == true,
86
+ value.first?.isLetter == true
87
+ else {
88
+ return false
89
+ }
90
+ return value.allSatisfy { character in
91
+ character.isASCII && (character.isLetter || character.isNumber || separators.contains(character))
92
+ }
93
+ }
94
+
95
+ func containsForbiddenControl(_ value: String) -> Bool {
96
+ value.unicodeScalars.contains { scalar in
97
+ scalar.value <= 0x1F || (0x7F ... 0x9F).contains(scalar.value)
98
+ }
99
+ }
100
+
101
+ func requireTelemetryContext(_ value: TelemetryContext?) throws -> TelemetryContext {
102
+ guard let value else {
103
+ throw contextValidationError("merged telemetry context must not be empty")
104
+ }
105
+ return value
106
+ }
107
+
108
+ private func validateResource(_ value: TelemetryResource, label: String) throws -> TelemetryResource {
109
+ let service = try value.service.map { try validateNamedVersion($0, label: "\(label) service") }
110
+ let deployment = try value.deployment.map { try validateDeployment($0, label: "\(label) deployment") }
111
+ let runtime = try value.runtime.map { try validateNamedVersion($0, label: "\(label) runtime") }
112
+ let framework = try value.framework.map { try validateNamedVersion($0, label: "\(label) framework") }
113
+ let operatingSystem = try value.operatingSystem.map {
114
+ try validateOperatingSystem($0, label: "\(label) operatingSystem")
115
+ }
116
+ let device = try value.device.map { try validateDevice($0, label: "\(label) device") }
117
+ let application = try value.application.map { try validateApplication($0, label: "\(label) application") }
118
+ guard service != nil || deployment != nil || runtime != nil || framework != nil || operatingSystem != nil
119
+ || device != nil || application != nil
120
+ else {
121
+ throw contextValidationError("\(label) must not be empty")
122
+ }
123
+ return TelemetryResource(
124
+ service: service,
125
+ deployment: deployment,
126
+ runtime: runtime,
127
+ framework: framework,
128
+ operatingSystem: operatingSystem,
129
+ device: device,
130
+ application: application,
131
+ )
132
+ }
133
+
134
+ private func validateNamedVersion(_ value: TelemetryNamedVersion, label: String) throws -> TelemetryNamedVersion {
135
+ try TelemetryNamedVersion(
136
+ name: contextString(value.name, label: "\(label) name"),
137
+ version: value.version.map { try contextString($0, label: "\(label) version") },
138
+ )
139
+ }
140
+
141
+ private func validateDeployment(_ value: TelemetryDeployment, label: String) throws -> TelemetryDeployment {
142
+ let environment = try value.environment.map { try contextString($0, label: "\(label) environment") }
143
+ let release = try value.release.map { try contextString($0, label: "\(label) release") }
144
+ guard environment != nil || release != nil else {
145
+ throw contextValidationError("\(label) must not be empty")
146
+ }
147
+ return TelemetryDeployment(environment: environment, release: release)
148
+ }
149
+
150
+ private func validateOperatingSystem(
151
+ _ value: TelemetryOperatingSystem,
152
+ label: String,
153
+ ) throws -> TelemetryOperatingSystem {
154
+ try TelemetryOperatingSystem(
155
+ name: contextString(value.name, label: "\(label) name"),
156
+ version: value.version.map { try contextString($0, label: "\(label) version") },
157
+ build: value.build.map { try contextString($0, label: "\(label) build") },
158
+ )
159
+ }
160
+
161
+ private func validateDevice(_ value: TelemetryDevice, label: String) throws -> TelemetryDevice {
162
+ let family = try value.family.map { try contextString($0, label: "\(label) family") }
163
+ let model = try value.model.map { try contextString($0, label: "\(label) model") }
164
+ let architecture = try value.architecture.map { try contextString($0, label: "\(label) architecture") }
165
+ guard family != nil || model != nil || architecture != nil else {
166
+ throw contextValidationError("\(label) must not be empty")
167
+ }
168
+ return TelemetryDevice(family: family, model: model, architecture: architecture)
169
+ }
170
+
171
+ private func validateApplication(_ value: TelemetryApplication, label: String) throws -> TelemetryApplication {
172
+ let name = try value.name.map { try contextString($0, label: "\(label) name") }
173
+ let version = try value.version.map { try contextString($0, label: "\(label) version") }
174
+ let build = try value.build.map { try contextString($0, label: "\(label) build") }
175
+ guard name != nil || version != nil || build != nil else {
176
+ throw contextValidationError("\(label) must not be empty")
177
+ }
178
+ return TelemetryApplication(name: name, version: version, build: build)
179
+ }
180
+
181
+ private func validateSession(_ value: TelemetrySessionContext, label: String) throws -> TelemetrySessionContext {
182
+ let id = try contextString(value.id, label: "\(label) id", maximum: 200)
183
+ let previousId = try value.previousId.map {
184
+ try contextString($0, label: "\(label) previousId", maximum: 200)
185
+ }
186
+ guard previousId != id else {
187
+ throw contextValidationError("\(label) previousId must differ from id")
188
+ }
189
+ return TelemetrySessionContext(id: id, previousId: previousId)
190
+ }
191
+
192
+ private func validateSubject(_ value: TelemetrySubjectContext, label: String) throws -> TelemetrySubjectContext {
193
+ try TelemetrySubjectContext(
194
+ id: contextString(value.id, label: "\(label) id", maximum: 200),
195
+ kind: value.kind,
196
+ )
197
+ }
198
+
199
+ private func validateTags(_ value: [String: String], label: String) throws -> [String: String] {
200
+ guard (1 ... 32).contains(value.count) else {
201
+ throw contextValidationError("\(label) must contain 1-32 entries")
202
+ }
203
+ var output: [String: String] = [:]
204
+ for (key, rawValue) in value {
205
+ guard validMachineKey(key, maximum: 64, separators: ["_", ".", "-"]) else {
206
+ throw contextValidationError("\(label) key is invalid")
207
+ }
208
+ output[key] = try contextString(rawValue, label: "\(label) value for \(key)")
209
+ }
210
+ return output
211
+ }
212
+
213
+ private func mergeResources(_ base: TelemetryResource?, _ override: TelemetryResource?) -> TelemetryResource? {
214
+ guard base != nil || override != nil else {
215
+ return nil
216
+ }
217
+ return TelemetryResource(
218
+ service: mergeNamedVersion(base?.service, override?.service),
219
+ deployment: mergeDeployment(base?.deployment, override?.deployment),
220
+ runtime: mergeNamedVersion(base?.runtime, override?.runtime),
221
+ framework: mergeNamedVersion(base?.framework, override?.framework),
222
+ operatingSystem: mergeOperatingSystem(base?.operatingSystem, override?.operatingSystem),
223
+ device: mergeDevice(base?.device, override?.device),
224
+ application: mergeApplication(base?.application, override?.application),
225
+ )
226
+ }
227
+
228
+ private func mergeNamedVersion(
229
+ _ base: TelemetryNamedVersion?,
230
+ _ override: TelemetryNamedVersion?,
231
+ ) -> TelemetryNamedVersion? {
232
+ guard let base else {
233
+ return override
234
+ }
235
+ guard let override else {
236
+ return base
237
+ }
238
+ return TelemetryNamedVersion(name: override.name, version: override.version ?? base.version)
239
+ }
240
+
241
+ private func mergeDeployment(_ base: TelemetryDeployment?, _ override: TelemetryDeployment?) -> TelemetryDeployment? {
242
+ guard let base else {
243
+ return override
244
+ }
245
+ guard let override else {
246
+ return base
247
+ }
248
+ return TelemetryDeployment(
249
+ environment: override.environment ?? base.environment,
250
+ release: override.release ?? base.release,
251
+ )
252
+ }
253
+
254
+ private func mergeOperatingSystem(
255
+ _ base: TelemetryOperatingSystem?,
256
+ _ override: TelemetryOperatingSystem?,
257
+ ) -> TelemetryOperatingSystem? {
258
+ guard let base else {
259
+ return override
260
+ }
261
+ guard let override else {
262
+ return base
263
+ }
264
+ return TelemetryOperatingSystem(
265
+ name: override.name,
266
+ version: override.version ?? base.version,
267
+ build: override.build ?? base.build,
268
+ )
269
+ }
270
+
271
+ private func mergeDevice(_ base: TelemetryDevice?, _ override: TelemetryDevice?) -> TelemetryDevice? {
272
+ guard let base else {
273
+ return override
274
+ }
275
+ guard let override else {
276
+ return base
277
+ }
278
+ return TelemetryDevice(
279
+ family: override.family ?? base.family,
280
+ model: override.model ?? base.model,
281
+ architecture: override.architecture ?? base.architecture,
282
+ )
283
+ }
284
+
285
+ private func mergeApplication(
286
+ _ base: TelemetryApplication?,
287
+ _ override: TelemetryApplication?,
288
+ ) -> TelemetryApplication? {
289
+ guard let base else {
290
+ return override
291
+ }
292
+ guard let override else {
293
+ return base
294
+ }
295
+ return TelemetryApplication(
296
+ name: override.name ?? base.name,
297
+ version: override.version ?? base.version,
298
+ build: override.build ?? base.build,
299
+ )
300
+ }
301
+
302
+ private func mergeTags(_ base: [String: String]?, _ override: [String: String]?) -> [String: String]? {
303
+ guard base != nil || override != nil else {
304
+ return nil
305
+ }
306
+ var output = base ?? [:]
307
+ for (key, value) in override ?? [:] {
308
+ output[key] = value
309
+ }
310
+ return output
311
+ }
312
+
313
+ private func contextString(_ value: String, label: String, maximum: Int = 256) throws -> String {
314
+ let normalized = value.trimmingCharacters(in: .whitespacesAndNewlines)
315
+ guard !normalized.isEmpty, normalized.count <= maximum, !containsForbiddenControl(normalized) else {
316
+ throw contextValidationError("\(label) is invalid")
317
+ }
318
+ return normalized
319
+ }
320
+
321
+ private func contextHexId(_ value: String, length: Int, label: String) throws -> String {
322
+ let normalized = value.trimmingCharacters(in: .whitespacesAndNewlines).lowercased()
323
+ guard normalized.count == length, normalized.allSatisfy(isASCIIHex),
324
+ normalized.contains(where: { $0 != "0" })
325
+ else {
326
+ throw contextValidationError("\(label) must be a non-zero \(length)-character hex value")
327
+ }
328
+ return normalized
329
+ }
330
+
331
+ private func isASCIIHex(_ value: Character) -> Bool {
332
+ value.isASCII && (value.isNumber || ("a" ... "f").contains(value))
333
+ }
334
+
335
+ private func contextValidationError(_ message: String) -> SdkError {
336
+ SdkError(code: "validation_error", message: message)
337
+ }
@@ -0,0 +1,79 @@
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
+ /// One bounded milestone that happened during a span.
6
+ public struct SpanEventSummary: Codable, Equatable, Sendable {
7
+ public let name: String
8
+ public let timestamp: String?
9
+ public let metadata: Metadata?
10
+
11
+ public init(name: String, timestamp: String? = nil, metadata: Metadata? = nil) {
12
+ self.name = name
13
+ self.timestamp = timestamp
14
+ self.metadata = metadata
15
+ }
16
+ }
17
+
18
+ /// One bounded relationship from this span to another trace or span.
19
+ public struct SpanLinkSummary: Codable, Equatable, Sendable {
20
+ public let traceId: String
21
+ public let spanId: String
22
+ public let sampled: Bool?
23
+ public let metadata: Metadata?
24
+
25
+ public init(
26
+ traceId: String,
27
+ spanId: String,
28
+ sampled: Bool? = nil,
29
+ metadata: Metadata? = nil,
30
+ ) {
31
+ self.traceId = traceId
32
+ self.spanId = spanId
33
+ self.sampled = sampled
34
+ self.metadata = metadata
35
+ }
36
+ }
37
+
38
+ func validateSpanEvents(_ values: [SpanEventSummary]?) throws -> [SpanEventSummary]? {
39
+ guard let values else {
40
+ return nil
41
+ }
42
+ guard values.count <= 8 else {
43
+ throw traceEvidenceError("span events must contain at most 8 entries")
44
+ }
45
+ return try values.map { value in
46
+ try requireNonEmpty("span event name", value.name)
47
+ if let timestamp = value.timestamp {
48
+ try requireTimestamp(timestamp)
49
+ }
50
+ try validateMetadata(value.metadata, label: "span event metadata")
51
+ return value
52
+ }
53
+ }
54
+
55
+ func validateSpanLinks(_ values: [SpanLinkSummary]?) throws -> [SpanLinkSummary]? {
56
+ guard let values else {
57
+ return nil
58
+ }
59
+ guard values.count <= 8 else {
60
+ throw traceEvidenceError("span links must contain at most 8 entries")
61
+ }
62
+ return try values.map { value in
63
+ let trace = try validateTelemetryTraceContext(
64
+ TelemetryTraceContext(traceId: value.traceId, spanId: value.spanId, sampled: value.sampled),
65
+ label: "span link",
66
+ )
67
+ try validateMetadata(value.metadata, label: "span link metadata")
68
+ return SpanLinkSummary(
69
+ traceId: trace.traceId,
70
+ spanId: trace.spanId ?? value.spanId,
71
+ sampled: trace.sampled,
72
+ metadata: value.metadata,
73
+ )
74
+ }
75
+ }
76
+
77
+ private func traceEvidenceError(_ message: String) -> SdkError {
78
+ SdkError(code: "validation_error", message: message)
79
+ }
@@ -0,0 +1,204 @@
1
+ // Generated by scripts/sync-apple-native-sources.mjs.
2
+ // Edit the canonical Swift source, then regenerate this package boundary.
3
+ import Foundation
4
+ #if canImport(FoundationNetworking)
5
+ import FoundationNetworking
6
+ #endif
7
+
8
+ public struct SdkError: Error, Equatable, Sendable, CustomStringConvertible {
9
+ public let code: String
10
+ public let message: String
11
+
12
+ public init(code: String, message: String) {
13
+ self.code = code
14
+ self.message = message
15
+ }
16
+
17
+ public var description: String {
18
+ "\(code): \(message)"
19
+ }
20
+ }
21
+
22
+ public struct TransportError: Error, Equatable, Sendable, CustomStringConvertible {
23
+ public let code: String
24
+ public let message: String
25
+ public let retryable: Bool
26
+
27
+ public init(code: String, message: String, retryable: Bool = false) {
28
+ self.code = code
29
+ self.message = message
30
+ self.retryable = retryable
31
+ }
32
+
33
+ public static func network(_ message: String) -> TransportError {
34
+ TransportError(code: "network_failure", message: message, retryable: true)
35
+ }
36
+
37
+ public var description: String {
38
+ message
39
+ }
40
+ }
41
+
42
+ public struct TransportResponse: Codable, Equatable, Sendable {
43
+ public let statusCode: Int
44
+ public let attempts: Int
45
+
46
+ public init(statusCode: Int, attempts: Int) {
47
+ self.statusCode = statusCode
48
+ self.attempts = attempts
49
+ }
50
+ }
51
+
52
+ public protocol Transport: AnyObject {
53
+ func send(apiKey: String, body: Data) throws -> TransportResponse
54
+ }
55
+
56
+ public typealias HTTPTransportRequester = (URLRequest) throws -> Int
57
+
58
+ public final class HTTPTransport: Transport {
59
+ public static let defaultEndpoint = URL(string: "https://api.logbrew.co/v1/events")!
60
+ public static let defaultTimeout: TimeInterval = 10
61
+
62
+ public let endpoint: URL
63
+ public let headers: [String: String]
64
+ public let timeout: TimeInterval
65
+
66
+ private let requester: HTTPTransportRequester?
67
+ private let session: URLSession
68
+
69
+ public init(
70
+ endpoint: URL = HTTPTransport.defaultEndpoint,
71
+ headers: [String: String] = [:],
72
+ timeout: TimeInterval = HTTPTransport.defaultTimeout,
73
+ session: URLSession = .shared,
74
+ requester: HTTPTransportRequester? = nil,
75
+ ) throws {
76
+ self.endpoint = try Self.validateEndpoint(endpoint)
77
+ self.headers = try Self.copyHeaders(headers)
78
+ self.timeout = try Self.validateTimeout(timeout)
79
+ self.session = session
80
+ self.requester = requester
81
+ }
82
+
83
+ public func send(apiKey: String, body: Data) throws -> TransportResponse {
84
+ try requireNonEmpty("api_key", apiKey)
85
+ var request = URLRequest(url: endpoint, timeoutInterval: timeout)
86
+ request.httpMethod = "POST"
87
+ request.httpBody = body
88
+ request.setValue("application/json", forHTTPHeaderField: "content-type")
89
+ request.setValue("Bearer \(apiKey)", forHTTPHeaderField: "authorization")
90
+ for (name, value) in headers {
91
+ request.setValue(value, forHTTPHeaderField: name)
92
+ }
93
+
94
+ if let requester {
95
+ return try TransportResponse(statusCode: requester(request), attempts: 1)
96
+ }
97
+
98
+ return try TransportResponse(statusCode: sendWithURLSession(request), attempts: 1)
99
+ }
100
+
101
+ private func sendWithURLSession(_ request: URLRequest) throws -> Int {
102
+ let semaphore = DispatchSemaphore(value: 0)
103
+ let result = HTTPTransportResultBox()
104
+ let task = session.dataTask(with: request) { _, response, error in
105
+ defer {
106
+ semaphore.signal()
107
+ }
108
+ if let error {
109
+ result.value = .failure(TransportError.network("http transport failed: \(error.localizedDescription)"))
110
+ return
111
+ }
112
+ guard let httpResponse = response as? HTTPURLResponse else {
113
+ result.value = .failure(TransportError.network("http transport did not return an HTTP response"))
114
+ return
115
+ }
116
+ result.value = .success(httpResponse.statusCode)
117
+ }
118
+ task.resume()
119
+
120
+ if semaphore.wait(timeout: .now() + timeout) == .timedOut {
121
+ task.cancel()
122
+ throw TransportError.network("http transport timed out")
123
+ }
124
+
125
+ guard let value = result.value else {
126
+ throw TransportError.network("http transport completed without a response")
127
+ }
128
+ return try value.get()
129
+ }
130
+
131
+ private static func validateEndpoint(_ endpoint: URL) throws -> URL {
132
+ guard let scheme = endpoint.scheme?.lowercased(), scheme == "http" || scheme == "https" else {
133
+ throw SdkError(code: "configuration_error", message: "HTTP transport endpoint must use http or https")
134
+ }
135
+ guard endpoint.host != nil else {
136
+ throw SdkError(code: "configuration_error", message: "HTTP transport endpoint must include a host")
137
+ }
138
+ return endpoint
139
+ }
140
+
141
+ private static func validateTimeout(_ timeout: TimeInterval) throws -> TimeInterval {
142
+ if timeout <= 0 {
143
+ throw SdkError(code: "configuration_error", message: "HTTP transport timeout must be positive")
144
+ }
145
+ return timeout
146
+ }
147
+
148
+ private static func copyHeaders(_ headers: [String: String]) throws -> [String: String] {
149
+ var safeHeaders: [String: String] = [:]
150
+ for (name, value) in headers {
151
+ try requireNonEmpty("HTTP header name", name)
152
+ try requireNonEmpty("HTTP header value", value)
153
+ safeHeaders[name] = value
154
+ }
155
+ return safeHeaders
156
+ }
157
+ }
158
+
159
+ private final class HTTPTransportResultBox: @unchecked Sendable {
160
+ var value: Result<Int, Error>?
161
+ }
162
+
163
+ public enum ScriptedTransportResponse: Equatable, Sendable {
164
+ case status(Int)
165
+ case failure(TransportError)
166
+ }
167
+
168
+ public final class RecordingTransport: Transport {
169
+ private var scriptedResponses: [ScriptedTransportResponse]
170
+ public private(set) var sentBodies: [String] = []
171
+
172
+ public init(scriptedResponses: [ScriptedTransportResponse] = [.status(202)]) {
173
+ self.scriptedResponses = scriptedResponses.isEmpty ? [.status(202)] : scriptedResponses
174
+ }
175
+
176
+ public static func alwaysAccept() -> RecordingTransport {
177
+ RecordingTransport(scriptedResponses: [.status(202)])
178
+ }
179
+
180
+ public func lastBody() -> String? {
181
+ sentBodies.last
182
+ }
183
+
184
+ public func send(apiKey: String, body: Data) throws -> TransportResponse {
185
+ try requireNonEmpty("api_key", apiKey)
186
+ guard let bodyText = String(data: body, encoding: .utf8) else {
187
+ throw TransportError(code: "invalid_request_body", message: "request body was not valid UTF-8")
188
+ }
189
+ sentBodies.append(bodyText)
190
+
191
+ let next: ScriptedTransportResponse = if scriptedResponses.isEmpty {
192
+ .status(202)
193
+ } else {
194
+ scriptedResponses.removeFirst()
195
+ }
196
+
197
+ switch next {
198
+ case let .status(statusCode):
199
+ return TransportResponse(statusCode: statusCode, attempts: 1)
200
+ case let .failure(error):
201
+ throw error
202
+ }
203
+ }
204
+ }