@logbrew/react-native 0.1.11 → 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 (61) hide show
  1. package/LogBrewReactNative.podspec +25 -3
  2. package/README.md +106 -3
  3. package/apple-native-diagnostics.d.ts +52 -0
  4. package/apple-native-diagnostics.js +286 -0
  5. package/expo.cjs +46 -0
  6. package/expo.d.ts +16 -0
  7. package/expo.js +5 -0
  8. package/fatal-replay.cjs +12 -1
  9. package/global-errors.cjs +3 -1
  10. package/index.cjs +84 -2
  11. package/index.d.cts +4 -0
  12. package/index.d.ts +4 -0
  13. package/index.js +84 -2
  14. package/index.native.d.ts +4 -0
  15. package/index.native.js +14 -1
  16. package/ios/AppleDiagnostics/LBRNAppleDiagnosticsModule.h +4 -0
  17. package/ios/AppleDiagnostics/LBRNAppleDiagnosticsModule.mm +56 -0
  18. package/ios/AppleDiagnostics/LBRNAppleNativeDiagnostics.swift +293 -0
  19. package/ios/GeneratedAppleDiagnostics/LogBrew/AutomaticTelemetryContext.swift +72 -0
  20. package/ios/GeneratedAppleDiagnostics/LogBrew/DeliveryEngine.swift +270 -0
  21. package/ios/GeneratedAppleDiagnostics/LogBrew/DeliveryEngineAutomatic.swift +331 -0
  22. package/ios/GeneratedAppleDiagnostics/LogBrew/DeliveryEngineDurable.swift +148 -0
  23. package/ios/GeneratedAppleDiagnostics/LogBrew/DeliveryEngineQueue.swift +343 -0
  24. package/ios/GeneratedAppleDiagnostics/LogBrew/DeliveryLifecycle.swift +70 -0
  25. package/ios/GeneratedAppleDiagnostics/LogBrew/DurableDeliveryStore.swift +258 -0
  26. package/ios/GeneratedAppleDiagnostics/LogBrew/DurableDeliveryStoreRecovery.swift +330 -0
  27. package/ios/GeneratedAppleDiagnostics/LogBrew/EventEncoding.swift +114 -0
  28. package/ios/GeneratedAppleDiagnostics/LogBrew/IssueDiagnostics.swift +385 -0
  29. package/ios/GeneratedAppleDiagnostics/LogBrew/LifecycleTrace.swift +138 -0
  30. package/ios/GeneratedAppleDiagnostics/LogBrew/LogBrewClient.swift +358 -0
  31. package/ios/GeneratedAppleDiagnostics/LogBrew/LogBrewLogger.swift +262 -0
  32. package/ios/GeneratedAppleDiagnostics/LogBrew/LogBrewTrace.swift +357 -0
  33. package/ios/GeneratedAppleDiagnostics/LogBrew/Metadata.swift +86 -0
  34. package/ios/GeneratedAppleDiagnostics/LogBrew/NativeStackFrame.swift +27 -0
  35. package/ios/GeneratedAppleDiagnostics/LogBrew/ProductTimeline.swift +228 -0
  36. package/ios/GeneratedAppleDiagnostics/LogBrew/PublicTypes.swift +334 -0
  37. package/ios/GeneratedAppleDiagnostics/LogBrew/TelemetryContext.swift +197 -0
  38. package/ios/GeneratedAppleDiagnostics/LogBrew/TelemetryContextValidation.swift +337 -0
  39. package/ios/GeneratedAppleDiagnostics/LogBrew/TraceEvidence.swift +79 -0
  40. package/ios/GeneratedAppleDiagnostics/LogBrew/Transport.swift +204 -0
  41. package/ios/GeneratedAppleDiagnostics/LogBrew/URLSessionTrace.swift +277 -0
  42. package/ios/GeneratedAppleDiagnostics/LogBrew/URLSessionTracer.swift +151 -0
  43. package/ios/GeneratedAppleDiagnostics/LogBrew/Validation.swift +165 -0
  44. package/ios/GeneratedAppleDiagnostics/LogBrewCrash/CrashEngine.swift +125 -0
  45. package/ios/GeneratedAppleDiagnostics/LogBrewCrash/CrashReportSanitizer.swift +139 -0
  46. package/ios/GeneratedAppleDiagnostics/LogBrewCrash/CrashStorageDirectory.swift +124 -0
  47. package/ios/GeneratedAppleDiagnostics/LogBrewCrash/NativeArtifactIdentity.swift +137 -0
  48. package/ios/GeneratedAppleDiagnostics/LogBrewCrash/NativeCrashCapture.swift +229 -0
  49. package/ios/GeneratedAppleDiagnostics/LogBrewCrash/NativeCrashPublic.swift +386 -0
  50. package/ios/GeneratedAppleDiagnostics/LogBrewCrash/NativeCrashReplay.swift +317 -0
  51. package/ios/GeneratedAppleDiagnostics/LogBrewCrash/NativeHangIncidentStore.swift +321 -0
  52. package/ios/GeneratedAppleDiagnostics/LogBrewCrash/NativeHangWatchdog.swift +386 -0
  53. package/ios/GeneratedAppleDiagnostics/LogBrewCrash/NativeHangWatchdogContracts.swift +84 -0
  54. package/ios/GeneratedAppleDiagnostics/LogBrewCrash/NativeHangWatchdogSystem.swift +139 -0
  55. package/ios/GeneratedAppleDiagnostics/LogBrewCrash/NativeMainThreadStackCapture.swift +84 -0
  56. package/ios/GeneratedAppleDiagnostics/LogBrewCrash/NativeStackFrameSanitizer.swift +170 -0
  57. package/ios/GeneratedAppleDiagnostics/SOURCE-MANIFEST.json +43 -0
  58. package/package.json +42 -4
  59. package/promise-rejections.cjs +3 -1
  60. package/scripts/sync-apple-native-sources.mjs +93 -0
  61. package/src/NativeLogBrewAppleDiagnostics.ts +12 -0
@@ -0,0 +1,385 @@
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
+ /// Runtime path that captured an exception and whether it escaped that path.
6
+ public struct IssueExceptionMechanism: Codable, Equatable, Sendable {
7
+ public let type: String
8
+ public let handled: Bool
9
+
10
+ public init(type: String, handled: Bool) {
11
+ self.type = type
12
+ self.handled = handled
13
+ }
14
+ }
15
+
16
+ /// Structured exception identity attached to an issue without copying its private description.
17
+ public struct IssueException: Codable, Equatable, Sendable {
18
+ public let type: String
19
+ public let mechanism: IssueExceptionMechanism?
20
+
21
+ public init(type: String, mechanism: IssueExceptionMechanism? = nil) {
22
+ self.type = type
23
+ self.mechanism = mechanism
24
+ }
25
+ }
26
+
27
+ /// Privacy-bounded source identity for one issue stack frame.
28
+ public struct IssueStackFrame: Codable, Equatable, Sendable {
29
+ public let filename: String
30
+ public let line: Int
31
+ public let column: Int
32
+ public let function: String?
33
+ public let module: String?
34
+ public let inApp: Bool?
35
+ public let debugId: String?
36
+
37
+ public init(
38
+ filename: String,
39
+ line: Int,
40
+ column: Int,
41
+ function: String? = nil,
42
+ module: String? = nil,
43
+ inApp: Bool? = nil,
44
+ debugId: String? = nil,
45
+ ) {
46
+ self.filename = filename
47
+ self.line = line
48
+ self.column = column
49
+ self.function = function
50
+ self.module = module
51
+ self.inApp = inApp
52
+ self.debugId = debugId
53
+ }
54
+ }
55
+
56
+ public enum IssueBreadcrumbLevel: String, Codable, Equatable, Sendable {
57
+ case debug
58
+ case info
59
+ case warning
60
+ case error
61
+ case critical
62
+ }
63
+
64
+ /// One oldest-to-newest, privacy-bounded step that happened before an issue.
65
+ public struct IssueBreadcrumb: Codable, Equatable, Sendable {
66
+ public let timestamp: String
67
+ public let type: String?
68
+ public let category: String
69
+ public let level: IssueBreadcrumbLevel?
70
+ public let message: String?
71
+ public let data: Metadata?
72
+
73
+ public init(
74
+ timestamp: String,
75
+ category: String,
76
+ type: String? = nil,
77
+ level: IssueBreadcrumbLevel? = nil,
78
+ message: String? = nil,
79
+ data: Metadata? = nil,
80
+ ) {
81
+ self.timestamp = timestamp
82
+ self.type = type
83
+ self.category = category
84
+ self.level = level
85
+ self.message = message
86
+ self.data = data
87
+ }
88
+ }
89
+
90
+ public extension IssueAttributes {
91
+ /// Create a handled-error issue with safe type identity and a call-site frame.
92
+ ///
93
+ /// The error description, raw stack text, locals, arguments, and absolute source paths are
94
+ /// deliberately omitted. Pass `message` only when the application has approved it for display.
95
+ static func fromError(
96
+ _ error: any Error,
97
+ title: String? = nil,
98
+ level: IssueLevel = .error,
99
+ message: String? = nil,
100
+ mechanism: String = "swift.error",
101
+ handled: Bool = true,
102
+ breadcrumbs: [IssueBreadcrumb]? = nil,
103
+ breadcrumbsTruncated: Bool? = nil,
104
+ metadata: Metadata? = nil,
105
+ context: TelemetryContext? = nil,
106
+ fileID: String = #fileID,
107
+ line: Int = #line,
108
+ column: Int = #column,
109
+ function: String = #function,
110
+ ) -> IssueAttributes {
111
+ let exceptionType = safeSwiftErrorType(error)
112
+ let fileModule = fileID.split(separator: "/", maxSplits: 1).first.map(String.init)
113
+ return IssueAttributes(
114
+ title: title ?? exceptionType,
115
+ level: level,
116
+ message: message,
117
+ exception: IssueException(
118
+ type: exceptionType,
119
+ mechanism: IssueExceptionMechanism(type: mechanism, handled: handled),
120
+ ),
121
+ stackFrames: [
122
+ IssueStackFrame(
123
+ filename: fileID,
124
+ line: line,
125
+ column: column,
126
+ function: function,
127
+ module: fileModule,
128
+ inApp: true,
129
+ ),
130
+ ],
131
+ breadcrumbs: breadcrumbs,
132
+ breadcrumbsTruncated: breadcrumbsTruncated,
133
+ metadata: metadata,
134
+ context: context,
135
+ )
136
+ }
137
+ }
138
+
139
+ final class IssueBreadcrumbStore: @unchecked Sendable {
140
+ private let lock = NSLock()
141
+ private var breadcrumbs: [IssueBreadcrumb] = []
142
+ private var truncated = false
143
+
144
+ func add(_ breadcrumb: IssueBreadcrumb) throws {
145
+ let breadcrumb = try validateIssueBreadcrumb(breadcrumb)
146
+ lock.lock()
147
+ if breadcrumbs.count == maximumIssueBreadcrumbs {
148
+ breadcrumbs.removeFirst()
149
+ truncated = true
150
+ }
151
+ breadcrumbs.append(breadcrumb)
152
+ lock.unlock()
153
+ }
154
+
155
+ func clear() {
156
+ lock.lock()
157
+ breadcrumbs.removeAll(keepingCapacity: true)
158
+ truncated = false
159
+ lock.unlock()
160
+ }
161
+
162
+ func snapshot() -> (breadcrumbs: [IssueBreadcrumb], truncated: Bool) {
163
+ lock.lock()
164
+ defer { lock.unlock() }
165
+ return (breadcrumbs, truncated)
166
+ }
167
+ }
168
+
169
+ let maximumIssueBreadcrumbs = 64
170
+
171
+ func normalizeIssueAttributes(_ value: IssueAttributes) throws -> IssueAttributes {
172
+ let exception = try value.exception.map(validateIssueException)
173
+ let stackFrames = try normalizeIssueStackFrames(value.stackFrames)
174
+ let breadcrumbs = try normalizeIssueBreadcrumbs(value.breadcrumbs)
175
+ let breadcrumbsWereCapped = (value.breadcrumbs?.count ?? 0) > maximumIssueBreadcrumbs
176
+ let context = try value.context.map { try validateTelemetryContext($0) }
177
+ try validateMetadata(value.metadata, label: "issue metadata")
178
+ return IssueAttributes(
179
+ title: value.title,
180
+ level: value.level,
181
+ message: value.message,
182
+ exception: exception,
183
+ stackFrames: stackFrames,
184
+ breadcrumbs: breadcrumbs,
185
+ breadcrumbsTruncated: breadcrumbsWereCapped ? true : value.breadcrumbsTruncated,
186
+ metadata: value.metadata,
187
+ context: context,
188
+ nativeStackFrames: value.nativeStackFrames,
189
+ )
190
+ }
191
+
192
+ func issueAttributes(
193
+ _ value: IssueAttributes,
194
+ adding snapshot: (breadcrumbs: [IssueBreadcrumb], truncated: Bool),
195
+ ) -> IssueAttributes {
196
+ guard !snapshot.breadcrumbs.isEmpty else {
197
+ return value
198
+ }
199
+ let explicit = value.breadcrumbs ?? []
200
+ let combined = snapshot.breadcrumbs + explicit
201
+ let capped = Array(combined.suffix(maximumIssueBreadcrumbs))
202
+ return IssueAttributes(
203
+ title: value.title,
204
+ level: value.level,
205
+ message: value.message,
206
+ exception: value.exception,
207
+ stackFrames: value.stackFrames,
208
+ breadcrumbs: capped,
209
+ breadcrumbsTruncated: value.breadcrumbsTruncated == true
210
+ || snapshot.truncated
211
+ || combined.count > maximumIssueBreadcrumbs,
212
+ metadata: value.metadata,
213
+ context: value.context,
214
+ nativeStackFrames: value.nativeStackFrames,
215
+ )
216
+ }
217
+
218
+ func validateIssueException(_ value: IssueException) throws -> IssueException {
219
+ let exceptionType = try issueText(
220
+ value.type,
221
+ label: "issue exception type",
222
+ maximum: 256,
223
+ disallowLocationDelimiters: true,
224
+ )
225
+ let mechanism = try value.mechanism.map { mechanism in
226
+ guard validMachineKey(mechanism.type, maximum: 64, separators: ["_", ".", ":", "-"]) else {
227
+ throw issueValidationError("issue exception mechanism type is invalid")
228
+ }
229
+ return IssueExceptionMechanism(type: mechanism.type, handled: mechanism.handled)
230
+ }
231
+ return IssueException(type: exceptionType, mechanism: mechanism)
232
+ }
233
+
234
+ func validateIssueBreadcrumb(_ value: IssueBreadcrumb) throws -> IssueBreadcrumb {
235
+ try requireTimestamp(value.timestamp)
236
+ guard validMachineKey(value.category, maximum: 64, separators: ["_", ".", ":", "-"]) else {
237
+ throw issueValidationError("issue breadcrumb category is invalid")
238
+ }
239
+ let type = try value.type.map { rawValue in
240
+ guard validMachineKey(rawValue, maximum: 64, separators: ["_", ".", ":", "-"]) else {
241
+ throw issueValidationError("issue breadcrumb type is invalid")
242
+ }
243
+ return rawValue
244
+ }
245
+ let message = try value.message.map {
246
+ try issueText($0, label: "issue breadcrumb message", maximum: 512)
247
+ }
248
+ try validateBreadcrumbData(value.data)
249
+ return IssueBreadcrumb(
250
+ timestamp: value.timestamp,
251
+ category: value.category,
252
+ type: type,
253
+ level: value.level,
254
+ message: message,
255
+ data: value.data,
256
+ )
257
+ }
258
+
259
+ private func normalizeIssueStackFrames(_ values: [IssueStackFrame]?) throws -> [IssueStackFrame]? {
260
+ guard let values else {
261
+ return nil
262
+ }
263
+ guard !values.isEmpty else {
264
+ throw issueValidationError("issue stackFrames must not be empty")
265
+ }
266
+ return try values.prefix(32).map(validateIssueStackFrame)
267
+ }
268
+
269
+ private func validateIssueStackFrame(_ value: IssueStackFrame) throws -> IssueStackFrame {
270
+ let filename = try sanitizedIssueFilename(value.filename)
271
+ guard (1 ... Int(Int32.max)).contains(value.line), (1 ... Int(Int32.max)).contains(value.column) else {
272
+ throw issueValidationError("issue stack frame line and column must be positive 32-bit integers")
273
+ }
274
+ let function = try value.function.map {
275
+ try issueText($0, label: "issue stack frame function", maximum: 256)
276
+ }
277
+ let module = try value.module.map {
278
+ try issueText(
279
+ $0,
280
+ label: "issue stack frame module",
281
+ maximum: 512,
282
+ disallowLocationDelimiters: true,
283
+ )
284
+ }
285
+ let debugId = try value.debugId.map { rawValue -> String in
286
+ guard let uuid = UUID(uuidString: rawValue) else {
287
+ throw issueValidationError("issue stack frame debugId must be a UUID")
288
+ }
289
+ return uuid.uuidString.lowercased()
290
+ }
291
+ return IssueStackFrame(
292
+ filename: filename,
293
+ line: value.line,
294
+ column: value.column,
295
+ function: function,
296
+ module: module,
297
+ inApp: value.inApp,
298
+ debugId: debugId,
299
+ )
300
+ }
301
+
302
+ private func normalizeIssueBreadcrumbs(_ values: [IssueBreadcrumb]?) throws -> [IssueBreadcrumb]? {
303
+ guard let values else {
304
+ return nil
305
+ }
306
+ guard !values.isEmpty else {
307
+ throw issueValidationError("issue breadcrumbs must not be empty")
308
+ }
309
+ return try values.suffix(maximumIssueBreadcrumbs).map(validateIssueBreadcrumb)
310
+ }
311
+
312
+ private func validateBreadcrumbData(_ data: Metadata?) throws {
313
+ guard let data else {
314
+ return
315
+ }
316
+ guard data.count <= 8 else {
317
+ throw issueValidationError("issue breadcrumb data must contain at most 8 entries")
318
+ }
319
+ for (key, value) in data {
320
+ guard validMachineKey(key, maximum: 64, separators: ["_", ".", "-"]) else {
321
+ throw issueValidationError("issue breadcrumb data key is invalid")
322
+ }
323
+ switch value {
324
+ case let .string(value):
325
+ _ = try issueText(value, label: "issue breadcrumb data value for \(key)", maximum: 256)
326
+ case let .double(value):
327
+ guard value.isFinite else {
328
+ throw issueValidationError("issue breadcrumb data numeric values must be finite")
329
+ }
330
+ case .int, .bool, .null:
331
+ break
332
+ }
333
+ }
334
+ }
335
+
336
+ private func sanitizedIssueFilename(_ value: String) throws -> String {
337
+ var normalized = value.trimmingCharacters(in: .whitespacesAndNewlines)
338
+ .replacingOccurrences(of: "\\", with: "/")
339
+ if let delimiter = normalized.firstIndex(where: { $0 == "?" || $0 == "#" }) {
340
+ normalized = String(normalized[..<delimiter])
341
+ }
342
+ let isAbsoluteFilePath = normalized.hasPrefix("/")
343
+ || normalized.lowercased().hasPrefix("file://")
344
+ || (normalized.count >= 3
345
+ && normalized[normalized.index(after: normalized.startIndex)] == ":"
346
+ && normalized[normalized.index(normalized.startIndex, offsetBy: 2)] == "/")
347
+ if isAbsoluteFilePath, let last = normalized.split(separator: "/").last {
348
+ normalized = String(last)
349
+ }
350
+ return try issueText(
351
+ normalized,
352
+ label: "issue stack frame filename",
353
+ maximum: 2048,
354
+ disallowLocationDelimiters: true,
355
+ )
356
+ }
357
+
358
+ private func issueText(
359
+ _ value: String,
360
+ label: String,
361
+ maximum: Int,
362
+ disallowLocationDelimiters: Bool = false,
363
+ ) throws -> String {
364
+ let normalized = value.trimmingCharacters(in: .whitespacesAndNewlines)
365
+ guard !normalized.isEmpty, normalized.count <= maximum, !containsForbiddenControl(normalized),
366
+ !disallowLocationDelimiters || (!normalized.contains("?") && !normalized.contains("#"))
367
+ else {
368
+ throw issueValidationError("\(label) is invalid")
369
+ }
370
+ return normalized
371
+ }
372
+
373
+ private func safeSwiftErrorType(_ error: any Error) -> String {
374
+ let reflected = String(reflecting: Swift.type(of: error))
375
+ let normalized = reflected.trimmingCharacters(in: .whitespacesAndNewlines)
376
+ guard !normalized.isEmpty, !containsForbiddenControl(normalized) else {
377
+ return "Swift.Error"
378
+ }
379
+ let filtered = normalized.filter { $0 != "?" && $0 != "#" }
380
+ return String(filtered.prefix(256))
381
+ }
382
+
383
+ private func issueValidationError(_ message: String) -> SdkError {
384
+ SdkError(code: "validation_error", message: message)
385
+ }
@@ -0,0 +1,138 @@
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 final class LogBrewLifecycleTracker {
6
+ private let client: LogBrewClient
7
+ private let eventIDPrefix: String
8
+ private let context: Metadata?
9
+ private let lock = NSLock()
10
+ private var currentState: String
11
+ private var currentStartedAtMs: Double
12
+ private var nextEventSequence = 1
13
+
14
+ public init(
15
+ client: LogBrewClient,
16
+ initialState: String,
17
+ initialTimestampMs: Double,
18
+ eventIDPrefix: String = "evt_lifecycle",
19
+ context: Metadata? = nil,
20
+ ) throws {
21
+ self.client = client
22
+ currentState = try normalizedLifecycleState("lifecycle initialState", initialState)
23
+ currentStartedAtMs = try validatedLifecycleTimestampMs("lifecycle initialTimestampMs", initialTimestampMs)
24
+ self.eventIDPrefix = try normalizedLifecycleEventIDPrefix(eventIDPrefix)
25
+ self.context = context
26
+ }
27
+
28
+ @discardableResult
29
+ public func captureTransition(
30
+ to nextState: String,
31
+ timestamp: String,
32
+ atMs: Double,
33
+ metadata: Metadata? = nil,
34
+ ) throws -> Bool {
35
+ let normalizedNextState = try normalizedLifecycleState("lifecycle nextState", nextState)
36
+ let checkedAtMs = try validatedLifecycleTimestampMs("lifecycle atMs", atMs)
37
+
38
+ lock.lock()
39
+ defer {
40
+ lock.unlock()
41
+ }
42
+ if normalizedNextState == currentState {
43
+ return false
44
+ }
45
+ if checkedAtMs < currentStartedAtMs {
46
+ throw SdkError(
47
+ code: "validation_error",
48
+ message: "lifecycle atMs must be greater than or equal to the current state start",
49
+ )
50
+ }
51
+ let eventID = "\(eventIDPrefix)_\(nextEventSequence)"
52
+ let previousState = currentState
53
+ let durationMs = checkedAtMs - currentStartedAtMs
54
+ try client.captureLifecycleSpan(
55
+ eventID,
56
+ timestamp: timestamp,
57
+ previousState: previousState,
58
+ currentState: normalizedNextState,
59
+ durationMs: durationMs,
60
+ context: context,
61
+ metadata: metadata,
62
+ )
63
+ nextEventSequence += 1
64
+ currentState = normalizedNextState
65
+ currentStartedAtMs = checkedAtMs
66
+ return true
67
+ }
68
+ }
69
+
70
+ public extension LogBrewClient {
71
+ func captureLifecycleSpan(
72
+ _ id: String,
73
+ timestamp: String,
74
+ previousState: String,
75
+ currentState: String,
76
+ durationMs: Double? = nil,
77
+ context: Metadata? = nil,
78
+ metadata: Metadata? = nil,
79
+ ) throws {
80
+ let normalizedPreviousState = try normalizedLifecycleState("lifecycle previousState", previousState)
81
+ let normalizedCurrentState = try normalizedLifecycleState("lifecycle currentState", currentState)
82
+ let checkedDurationMs = try validatedLifecycleDurationMs(durationMs)
83
+ let sourceContext = LogBrewTrace.current
84
+ let spanContext = sourceContext.map(LogBrewTraceContext.child(of:)) ?? LogBrewTraceContext.fallbackRoot()
85
+
86
+ var spanMetadata: Metadata = [:]
87
+ try copyMetadata(context, into: &spanMetadata)
88
+ try copyMetadata(metadata, into: &spanMetadata)
89
+ spanMetadata["source"] = .string("swift.lifecycle")
90
+ spanMetadata["previousState"] = .string(normalizedPreviousState)
91
+ spanMetadata["currentState"] = .string(normalizedCurrentState)
92
+ if checkedDurationMs != nil {
93
+ spanMetadata["durationSource"] = .string("previous_state")
94
+ }
95
+
96
+ try span(
97
+ id,
98
+ timestamp: timestamp,
99
+ attributes: SpanAttributes(
100
+ name: "swift.lifecycle:\(normalizedPreviousState)->\(normalizedCurrentState)",
101
+ traceId: spanContext.traceId,
102
+ spanId: spanContext.spanId,
103
+ parentSpanId: spanContext.parentSpanId,
104
+ status: .ok,
105
+ durationMs: checkedDurationMs,
106
+ metadata: LogBrewTrace.mergeTraceMetadata(spanMetadata, context: spanContext),
107
+ ),
108
+ )
109
+ }
110
+ }
111
+
112
+ private func normalizedLifecycleState(_ label: String, _ state: String) throws -> String {
113
+ try requireNonEmpty(label, state)
114
+ return state.trimmingCharacters(in: .whitespacesAndNewlines)
115
+ }
116
+
117
+ private func validatedLifecycleDurationMs(_ durationMs: Double?) throws -> Double? {
118
+ guard let durationMs else {
119
+ return nil
120
+ }
121
+ guard durationMs.isFinite, durationMs >= 0 else {
122
+ throw SdkError(code: "validation_error", message: "lifecycle durationMs must be finite and non-negative")
123
+ }
124
+ return durationMs
125
+ }
126
+
127
+ private func validatedLifecycleTimestampMs(_ label: String, _ timestampMs: Double) throws -> Double {
128
+ guard timestampMs.isFinite, timestampMs >= 0 else {
129
+ throw SdkError(code: "validation_error", message: "\(label) must be finite and non-negative")
130
+ }
131
+ return timestampMs
132
+ }
133
+
134
+ private func normalizedLifecycleEventIDPrefix(_ prefix: String) throws -> String {
135
+ let normalized = prefix.trimmingCharacters(in: .whitespacesAndNewlines)
136
+ try requireNonEmpty("lifecycle eventIDPrefix", normalized)
137
+ return normalized
138
+ }