@logbrew/react-native 0.1.13 → 0.1.15

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/index.cjs CHANGED
@@ -10,12 +10,13 @@ const {
10
10
  } = require("@logbrew/sdk");
11
11
  const {
12
12
  runtimeReactNativeDebugIdMap,
13
+ sanitizeReactNativeIssueExceptionChain,
13
14
  sanitizeReactNativeIssueMetadata,
14
15
  sanitizeReactNativeIssueStackFrames
15
16
  } = require("./metadata.cjs");
16
17
 
17
18
  const DEFAULT_SDK_NAME = "logbrew-react-native";
18
- const DEFAULT_SDK_VERSION = "0.1.0";
19
+ const DEFAULT_SDK_VERSION = "0.1.15";
19
20
  const DEFAULT_ENDPOINT = "https://api.logbrew.co/v1/events";
20
21
  const MAX_ACTION_NAME_LENGTH = 64;
21
22
  const MAX_PRODUCT_ANALYTICS_SURFACE_LENGTH = 256;
@@ -636,11 +637,13 @@ function createReactNativeErrorEvent(error, {
636
637
  trace: traceContext
637
638
  });
638
639
  const stackFrames = sanitizeReactNativeIssueStackFrames(attributes.stackFrames);
640
+ const exceptionChain = sanitizeReactNativeIssueExceptionChain(attributes.exceptionChain);
639
641
  return {
640
642
  id: id ?? idFactory({ error, message: details.message, screen }),
641
643
  timestamp: timestamp ?? now(),
642
644
  attributes: {
643
645
  ...attributes,
646
+ ...(exceptionChain ? { exceptionChain } : {}),
644
647
  ...(stackFrames ? { stackFrames } : {}),
645
648
  title: `React Native error: ${details.message}`,
646
649
  message: details.message,
package/index.js CHANGED
@@ -10,12 +10,13 @@ import {
10
10
  } from "@logbrew/sdk";
11
11
  import {
12
12
  runtimeReactNativeDebugIdMap,
13
+ sanitizeReactNativeIssueExceptionChain,
13
14
  sanitizeReactNativeIssueMetadata,
14
15
  sanitizeReactNativeIssueStackFrames
15
16
  } from "./metadata.js";
16
17
 
17
18
  const DEFAULT_SDK_NAME = "logbrew-react-native";
18
- const DEFAULT_SDK_VERSION = "0.1.0";
19
+ const DEFAULT_SDK_VERSION = "0.1.15";
19
20
  const DEFAULT_ENDPOINT = "https://api.logbrew.co/v1/events";
20
21
  const MAX_ACTION_NAME_LENGTH = 64;
21
22
  const MAX_PRODUCT_ANALYTICS_SURFACE_LENGTH = 256;
@@ -625,11 +626,13 @@ export function createReactNativeErrorEvent(error, {
625
626
  trace: traceContext
626
627
  });
627
628
  const stackFrames = sanitizeReactNativeIssueStackFrames(attributes.stackFrames);
629
+ const exceptionChain = sanitizeReactNativeIssueExceptionChain(attributes.exceptionChain);
628
630
  return {
629
631
  id: id ?? idFactory({ error, message: details.message, screen }),
630
632
  timestamp: timestamp ?? now(),
631
633
  attributes: {
632
634
  ...attributes,
635
+ ...(exceptionChain ? { exceptionChain } : {}),
633
636
  ...(stackFrames ? { stackFrames } : {}),
634
637
  title: `React Native error: ${details.message}`,
635
638
  message: details.message,
@@ -3,7 +3,7 @@ import Foundation
3
3
  @objc(LBRNAppleNativeDiagnostics)
4
4
  public final class LBRNAppleNativeDiagnostics: NSObject, @unchecked Sendable {
5
5
  private static let shared = LBRNAppleNativeDiagnostics()
6
- private static let sdkVersion = "0.1.13"
6
+ private static let sdkVersion = "0.1.15"
7
7
 
8
8
  private let lock = NSLock()
9
9
  private let replayQueue = DispatchQueue(label: "co.logbrew.react-native.apple-diagnostics-replay")
@@ -110,24 +110,30 @@ public extension IssueAttributes {
110
110
  ) -> IssueAttributes {
111
111
  let exceptionType = safeSwiftErrorType(error)
112
112
  let fileModule = fileID.split(separator: "/", maxSplits: 1).first.map(String.init)
113
+ let exceptionMechanism = IssueExceptionMechanism(type: mechanism, handled: handled)
114
+ let rootFrame = IssueStackFrame(
115
+ filename: fileID,
116
+ line: line,
117
+ column: column,
118
+ function: function,
119
+ module: fileModule,
120
+ inApp: true,
121
+ )
113
122
  return IssueAttributes(
114
123
  title: title ?? exceptionType,
115
124
  level: level,
116
125
  message: message,
117
126
  exception: IssueException(
118
127
  type: exceptionType,
119
- mechanism: IssueExceptionMechanism(type: mechanism, handled: handled),
128
+ mechanism: exceptionMechanism,
129
+ ),
130
+ exceptionChain: swiftExceptionChain(
131
+ from: error,
132
+ rootType: exceptionType,
133
+ rootMechanism: exceptionMechanism,
134
+ rootFrame: rootFrame,
120
135
  ),
121
- stackFrames: [
122
- IssueStackFrame(
123
- filename: fileID,
124
- line: line,
125
- column: column,
126
- function: function,
127
- module: fileModule,
128
- inApp: true,
129
- ),
130
- ],
136
+ stackFrames: [rootFrame],
131
137
  breadcrumbs: breadcrumbs,
132
138
  breadcrumbsTruncated: breadcrumbsTruncated,
133
139
  metadata: metadata,
@@ -171,6 +177,13 @@ let maximumIssueBreadcrumbs = 64
171
177
  func normalizeIssueAttributes(_ value: IssueAttributes) throws -> IssueAttributes {
172
178
  let exception = try value.exception.map(validateIssueException)
173
179
  let stackFrames = try normalizeIssueStackFrames(value.stackFrames)
180
+ let exceptionChain = try value.exceptionChain.map {
181
+ try normalizeIssueExceptionChain(
182
+ $0,
183
+ exception: exception,
184
+ stackFrames: stackFrames,
185
+ )
186
+ }
174
187
  let breadcrumbs = try normalizeIssueBreadcrumbs(value.breadcrumbs)
175
188
  let breadcrumbsWereCapped = (value.breadcrumbs?.count ?? 0) > maximumIssueBreadcrumbs
176
189
  let context = try value.context.map { try validateTelemetryContext($0) }
@@ -180,6 +193,7 @@ func normalizeIssueAttributes(_ value: IssueAttributes) throws -> IssueAttribute
180
193
  level: value.level,
181
194
  message: value.message,
182
195
  exception: exception,
196
+ exceptionChain: exceptionChain,
183
197
  stackFrames: stackFrames,
184
198
  breadcrumbs: breadcrumbs,
185
199
  breadcrumbsTruncated: breadcrumbsWereCapped ? true : value.breadcrumbsTruncated,
@@ -204,6 +218,7 @@ func issueAttributes(
204
218
  level: value.level,
205
219
  message: value.message,
206
220
  exception: value.exception,
221
+ exceptionChain: value.exceptionChain,
207
222
  stackFrames: value.stackFrames,
208
223
  breadcrumbs: capped,
209
224
  breadcrumbsTruncated: value.breadcrumbsTruncated == true
@@ -256,7 +271,7 @@ func validateIssueBreadcrumb(_ value: IssueBreadcrumb) throws -> IssueBreadcrumb
256
271
  )
257
272
  }
258
273
 
259
- private func normalizeIssueStackFrames(_ values: [IssueStackFrame]?) throws -> [IssueStackFrame]? {
274
+ func normalizeIssueStackFrames(_ values: [IssueStackFrame]?) throws -> [IssueStackFrame]? {
260
275
  guard let values else {
261
276
  return nil
262
277
  }
@@ -355,7 +370,7 @@ private func sanitizedIssueFilename(_ value: String) throws -> String {
355
370
  )
356
371
  }
357
372
 
358
- private func issueText(
373
+ func issueText(
359
374
  _ value: String,
360
375
  label: String,
361
376
  maximum: Int,
@@ -370,16 +385,6 @@ private func issueText(
370
385
  return normalized
371
386
  }
372
387
 
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 {
388
+ func issueValidationError(_ message: String) -> SdkError {
384
389
  SdkError(code: "validation_error", message: message)
385
390
  }
@@ -0,0 +1,355 @@
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
+ /// How one runtime exception relates to its earlier parent node.
6
+ public enum IssueExceptionRelationship: String, Codable, Equatable, Sendable {
7
+ case reported
8
+ case cause
9
+ case context
10
+ case aggregateMember = "aggregate_member"
11
+ case suppressed
12
+ }
13
+
14
+ /// Explicit capture state for one exception message.
15
+ public enum IssueExceptionMessageState: String, Codable, Equatable, Sendable {
16
+ case captured
17
+ case truncated
18
+ case redacted
19
+ case notCaptured = "not_captured"
20
+ }
21
+
22
+ /// Explicit capture state for one exception stack.
23
+ public enum IssueExceptionStackFramesState: String, Codable, Equatable, Sendable {
24
+ case captured
25
+ case truncated
26
+ case notCaptured = "not_captured"
27
+ }
28
+
29
+ /// One parent-first runtime exception with its own bounded evidence states.
30
+ public struct IssueExceptionChainEntry: Codable, Equatable, Sendable {
31
+ public let id: Int
32
+ public let parentId: Int?
33
+ public let relationship: IssueExceptionRelationship
34
+ public let type: String
35
+ public let message: String?
36
+ public let messageState: IssueExceptionMessageState
37
+ public let module: String?
38
+ public let mechanism: IssueExceptionMechanism?
39
+ public let stackFrames: [IssueStackFrame]?
40
+ public let stackFramesState: IssueExceptionStackFramesState
41
+
42
+ public init(
43
+ id: Int,
44
+ parentId: Int? = nil,
45
+ relationship: IssueExceptionRelationship,
46
+ type: String,
47
+ message: String? = nil,
48
+ messageState: IssueExceptionMessageState = .notCaptured,
49
+ module: String? = nil,
50
+ mechanism: IssueExceptionMechanism? = nil,
51
+ stackFrames: [IssueStackFrame]? = nil,
52
+ stackFramesState: IssueExceptionStackFramesState = .notCaptured,
53
+ ) {
54
+ self.id = id
55
+ self.parentId = parentId
56
+ self.relationship = relationship
57
+ self.type = type
58
+ self.message = message
59
+ self.messageState = messageState
60
+ self.module = module
61
+ self.mechanism = mechanism
62
+ self.stackFrames = stackFrames
63
+ self.stackFramesState = stackFramesState
64
+ }
65
+ }
66
+
67
+ /// At most eight parent-first runtime exceptions.
68
+ public struct IssueExceptionChain: Codable, Equatable, Sendable {
69
+ public let entries: [IssueExceptionChainEntry]
70
+ public let truncated: Bool
71
+
72
+ public init(entries: [IssueExceptionChainEntry], truncated: Bool = false) {
73
+ self.entries = entries
74
+ self.truncated = truncated
75
+ }
76
+ }
77
+
78
+ private struct PendingSwiftException {
79
+ let error: any Error
80
+ let parentId: Int?
81
+ let relationship: IssueExceptionRelationship
82
+ let mechanism: IssueExceptionMechanism
83
+ }
84
+
85
+ private let maximumIssueExceptions = 8
86
+ private let multipleUnderlyingErrorsKey = "NSMultipleUnderlyingErrors"
87
+
88
+ func swiftExceptionChain(
89
+ from error: any Error,
90
+ rootType: String,
91
+ rootMechanism: IssueExceptionMechanism,
92
+ rootFrame: IssueStackFrame,
93
+ ) -> IssueExceptionChain {
94
+ var pending = [PendingSwiftException(
95
+ error: error,
96
+ parentId: nil,
97
+ relationship: .reported,
98
+ mechanism: rootMechanism,
99
+ )]
100
+ var entries: [IssueExceptionChainEntry] = []
101
+ var seenErrors: [NSError] = []
102
+ var truncated = false
103
+ while !pending.isEmpty {
104
+ let current = pending.removeFirst()
105
+ let nsError = current.error as NSError
106
+ if swiftErrorWasSeen(nsError, in: seenErrors) {
107
+ truncated = true
108
+ continue
109
+ }
110
+ guard entries.count < maximumIssueExceptions else {
111
+ truncated = true
112
+ break
113
+ }
114
+ seenErrors.append(nsError)
115
+ let id = entries.count
116
+ entries.append(swiftExceptionEntry(current, id: id, rootType: rootType, rootFrame: rootFrame))
117
+ pending.append(contentsOf: swiftPendingChildren(nsError, parentId: id))
118
+ }
119
+ return IssueExceptionChain(entries: entries, truncated: truncated || !pending.isEmpty)
120
+ }
121
+
122
+ private func swiftErrorWasSeen(_ error: NSError, in seenErrors: [NSError]) -> Bool {
123
+ let identity = ObjectIdentifier(error)
124
+ return seenErrors.contains { ObjectIdentifier($0) == identity }
125
+ }
126
+
127
+ private func swiftExceptionEntry(
128
+ _ current: PendingSwiftException,
129
+ id: Int,
130
+ rootType: String,
131
+ rootFrame: IssueStackFrame,
132
+ ) -> IssueExceptionChainEntry {
133
+ let type = id == 0 ? rootType : safeSwiftErrorType(current.error)
134
+ return IssueExceptionChainEntry(
135
+ id: id,
136
+ parentId: current.parentId,
137
+ relationship: current.relationship,
138
+ type: type,
139
+ messageState: .redacted,
140
+ module: swiftExceptionModule(type),
141
+ mechanism: current.mechanism,
142
+ stackFrames: id == 0 ? [rootFrame] : nil,
143
+ stackFramesState: id == 0 ? .captured : .notCaptured,
144
+ )
145
+ }
146
+
147
+ private func swiftPendingChildren(
148
+ _ error: NSError,
149
+ parentId: Int,
150
+ ) -> [PendingSwiftException] {
151
+ swiftUnderlyingErrors(error).map { child in
152
+ PendingSwiftException(
153
+ error: child.error,
154
+ parentId: parentId,
155
+ relationship: child.relationship,
156
+ mechanism: IssueExceptionMechanism(
157
+ type: child.relationship == .aggregateMember
158
+ ? "swift.aggregate_member"
159
+ : "swift.underlying_error",
160
+ handled: true,
161
+ ),
162
+ )
163
+ }
164
+ }
165
+
166
+ private func swiftUnderlyingErrors(
167
+ _ error: NSError,
168
+ ) -> [(error: any Error, relationship: IssueExceptionRelationship)] {
169
+ if let aggregate = error.userInfo[multipleUnderlyingErrorsKey] as? [any Error], !aggregate.isEmpty {
170
+ return aggregate.map { ($0, .aggregateMember) }
171
+ }
172
+ if let underlying = error.userInfo[NSUnderlyingErrorKey] as? any Error {
173
+ return [(underlying, .cause)]
174
+ }
175
+ return []
176
+ }
177
+
178
+ func safeSwiftErrorType(_ error: any Error) -> String {
179
+ let reflected = String(reflecting: Swift.type(of: error))
180
+ let normalized = reflected.trimmingCharacters(in: .whitespacesAndNewlines)
181
+ guard !normalized.isEmpty, !containsForbiddenControl(normalized) else {
182
+ return "Swift.Error"
183
+ }
184
+ let filtered = normalized.filter { $0 != "?" && $0 != "#" }
185
+ return String(filtered.prefix(256))
186
+ }
187
+
188
+ private func swiftExceptionModule(_ type: String) -> String? {
189
+ guard let delimiter = type.lastIndex(of: ".") else {
190
+ return nil
191
+ }
192
+ let value = String(type[..<delimiter])
193
+ return value.isEmpty ? nil : String(value.prefix(512))
194
+ }
195
+
196
+ func normalizeIssueExceptionChain(
197
+ _ value: IssueExceptionChain,
198
+ exception: IssueException?,
199
+ stackFrames: [IssueStackFrame]?,
200
+ ) throws -> IssueExceptionChain {
201
+ guard (1 ... maximumIssueExceptions).contains(value.entries.count) else {
202
+ throw issueValidationError("issue exceptionChain entries must contain 1-8 exceptions")
203
+ }
204
+ let entries = try value.entries.enumerated().map { index, entry in
205
+ try validateExceptionChainTopology(entry, at: index)
206
+ return try normalizeIssueExceptionChainEntry(entry)
207
+ }
208
+ try validateReportedException(entries[0], exception: exception, stackFrames: stackFrames)
209
+ return IssueExceptionChain(entries: entries, truncated: value.truncated)
210
+ }
211
+
212
+ private func validateExceptionChainTopology(
213
+ _ entry: IssueExceptionChainEntry,
214
+ at index: Int,
215
+ ) throws {
216
+ guard entry.id == index else {
217
+ throw issueValidationError(
218
+ "issue exceptionChain ids must be contiguous and match array order",
219
+ )
220
+ }
221
+ if index == 0 {
222
+ guard entry.relationship == .reported, entry.parentId == nil else {
223
+ throw issueValidationError(
224
+ "issue exceptionChain entry 0 must be the parentless reported exception",
225
+ )
226
+ }
227
+ return
228
+ }
229
+ guard entry.relationship != .reported,
230
+ let parentId = entry.parentId,
231
+ parentId >= 0,
232
+ parentId < index
233
+ else {
234
+ throw issueValidationError("issue exceptionChain parent relationship is invalid")
235
+ }
236
+ }
237
+
238
+ private func validateReportedException(
239
+ _ root: IssueExceptionChainEntry,
240
+ exception: IssueException?,
241
+ stackFrames: [IssueStackFrame]?,
242
+ ) throws {
243
+ guard let exception,
244
+ root.type == exception.type,
245
+ root.mechanism == exception.mechanism
246
+ else {
247
+ throw issueValidationError(
248
+ "issue exceptionChain reported exception must match exception",
249
+ )
250
+ }
251
+ let stackMatches = switch root.stackFramesState {
252
+ case .notCaptured:
253
+ stackFrames == nil
254
+ case .captured, .truncated:
255
+ root.stackFrames == stackFrames
256
+ }
257
+ guard stackMatches else {
258
+ throw issueValidationError(
259
+ "issue exceptionChain reported stack must match stackFrames",
260
+ )
261
+ }
262
+ }
263
+
264
+ private func normalizeIssueExceptionChainEntry(
265
+ _ value: IssueExceptionChainEntry,
266
+ ) throws -> IssueExceptionChainEntry {
267
+ let type = try issueText(
268
+ value.type,
269
+ label: "issue exceptionChain type",
270
+ maximum: 256,
271
+ disallowLocationDelimiters: true,
272
+ )
273
+ let message = try normalizedExceptionMessage(value)
274
+ let module = try value.module.map {
275
+ try issueText(
276
+ $0,
277
+ label: "issue exceptionChain module",
278
+ maximum: 512,
279
+ disallowLocationDelimiters: true,
280
+ )
281
+ }
282
+ let mechanism = try normalizedExceptionMechanism(value.mechanism, type: type)
283
+ let stack = try normalizedExceptionStack(value)
284
+ return IssueExceptionChainEntry(
285
+ id: value.id,
286
+ parentId: value.parentId,
287
+ relationship: value.relationship,
288
+ type: type,
289
+ message: message,
290
+ messageState: value.messageState,
291
+ module: module,
292
+ mechanism: mechanism,
293
+ stackFrames: stack.frames,
294
+ stackFramesState: stack.state,
295
+ )
296
+ }
297
+
298
+ private func normalizedExceptionMessage(
299
+ _ value: IssueExceptionChainEntry,
300
+ ) throws -> String? {
301
+ switch value.messageState {
302
+ case .captured, .truncated:
303
+ guard let message = value.message else {
304
+ throw issueValidationError("issue exceptionChain message must match messageState")
305
+ }
306
+ return try issueText(
307
+ message,
308
+ label: "issue exceptionChain message",
309
+ maximum: 1024,
310
+ )
311
+ case .redacted, .notCaptured:
312
+ guard value.message == nil else {
313
+ throw issueValidationError("issue exceptionChain message must match messageState")
314
+ }
315
+ return nil
316
+ }
317
+ }
318
+
319
+ private func normalizedExceptionMechanism(
320
+ _ mechanism: IssueExceptionMechanism?,
321
+ type: String,
322
+ ) throws -> IssueExceptionMechanism? {
323
+ guard let mechanism else {
324
+ return nil
325
+ }
326
+ let normalized = try validateIssueException(
327
+ IssueException(type: type, mechanism: mechanism),
328
+ )
329
+ guard let result = normalized.mechanism else {
330
+ throw issueValidationError("issue exception mechanism type is invalid")
331
+ }
332
+ return result
333
+ }
334
+
335
+ private func normalizedExceptionStack(
336
+ _ value: IssueExceptionChainEntry,
337
+ ) throws -> (frames: [IssueStackFrame]?, state: IssueExceptionStackFramesState) {
338
+ switch value.stackFramesState {
339
+ case .captured, .truncated:
340
+ guard let frames = value.stackFrames, !frames.isEmpty else {
341
+ throw issueValidationError(
342
+ "issue exceptionChain stackFrames must match stackFramesState",
343
+ )
344
+ }
345
+ let normalized = try normalizeIssueStackFrames(frames)
346
+ return (normalized, frames.count > 32 ? .truncated : value.stackFramesState)
347
+ case .notCaptured:
348
+ guard value.stackFrames == nil else {
349
+ throw issueValidationError(
350
+ "issue exceptionChain stackFrames must match stackFramesState",
351
+ )
352
+ }
353
+ return (nil, .notCaptured)
354
+ }
355
+ }
@@ -205,6 +205,7 @@ private extension IssueAttributes {
205
205
  level: level,
206
206
  message: message,
207
207
  exception: exception,
208
+ exceptionChain: exceptionChain,
208
209
  stackFrames: stackFrames,
209
210
  breadcrumbs: breadcrumbs,
210
211
  breadcrumbsTruncated: breadcrumbsTruncated,
@@ -220,6 +221,7 @@ private extension IssueAttributes {
220
221
  level: level,
221
222
  message: message,
222
223
  exception: exception,
224
+ exceptionChain: exceptionChain,
223
225
  stackFrames: stackFrames,
224
226
  breadcrumbs: breadcrumbs,
225
227
  breadcrumbsTruncated: breadcrumbsTruncated,
@@ -277,6 +279,7 @@ private extension MetricAttributes {
277
279
  temporality: temporality,
278
280
  metadata: metadata,
279
281
  context: context,
282
+ description: description,
280
283
  )
281
284
  }
282
285
 
@@ -289,6 +292,7 @@ private extension MetricAttributes {
289
292
  temporality: temporality,
290
293
  metadata: correlationMetadata(metadata, context: context),
291
294
  context: context,
295
+ description: description,
292
296
  )
293
297
  }
294
298
  }
@@ -171,6 +171,7 @@ public struct IssueAttributes: Codable, Equatable, Sendable {
171
171
  public let level: IssueLevel
172
172
  public let message: String?
173
173
  public let exception: IssueException?
174
+ public let exceptionChain: IssueExceptionChain?
174
175
  public let stackFrames: [IssueStackFrame]?
175
176
  public let breadcrumbs: [IssueBreadcrumb]?
176
177
  public let breadcrumbsTruncated: Bool?
@@ -183,6 +184,7 @@ public struct IssueAttributes: Codable, Equatable, Sendable {
183
184
  level: IssueLevel,
184
185
  message: String? = nil,
185
186
  exception: IssueException? = nil,
187
+ exceptionChain: IssueExceptionChain? = nil,
186
188
  stackFrames: [IssueStackFrame]? = nil,
187
189
  breadcrumbs: [IssueBreadcrumb]? = nil,
188
190
  breadcrumbsTruncated: Bool? = nil,
@@ -193,6 +195,7 @@ public struct IssueAttributes: Codable, Equatable, Sendable {
193
195
  self.level = level
194
196
  self.message = message
195
197
  self.exception = exception
198
+ self.exceptionChain = exceptionChain
196
199
  self.stackFrames = stackFrames
197
200
  self.breadcrumbs = breadcrumbs
198
201
  self.breadcrumbsTruncated = breadcrumbsTruncated
@@ -207,6 +210,7 @@ public struct IssueAttributes: Codable, Equatable, Sendable {
207
210
  level: IssueLevel,
208
211
  message: String? = nil,
209
212
  exception: IssueException? = nil,
213
+ exceptionChain: IssueExceptionChain? = nil,
210
214
  stackFrames: [IssueStackFrame]? = nil,
211
215
  breadcrumbs: [IssueBreadcrumb]? = nil,
212
216
  breadcrumbsTruncated: Bool? = nil,
@@ -218,6 +222,7 @@ public struct IssueAttributes: Codable, Equatable, Sendable {
218
222
  self.level = level
219
223
  self.message = message
220
224
  self.exception = exception
225
+ self.exceptionChain = exceptionChain
221
226
  self.stackFrames = stackFrames
222
227
  self.breadcrumbs = breadcrumbs
223
228
  self.breadcrumbsTruncated = breadcrumbsTruncated
@@ -307,6 +312,7 @@ public struct ActionAttributes: Codable, Equatable, Sendable {
307
312
 
308
313
  public struct MetricAttributes: Codable, Equatable, Sendable {
309
314
  public let name: String
315
+ public let description: String?
310
316
  public let kind: MetricKind
311
317
  public let value: Double
312
318
  public let unit: String
@@ -322,8 +328,10 @@ public struct MetricAttributes: Codable, Equatable, Sendable {
322
328
  temporality: MetricTemporality,
323
329
  metadata: Metadata? = nil,
324
330
  context: TelemetryContext? = nil,
331
+ description: String? = nil,
325
332
  ) {
326
333
  self.name = name
334
+ self.description = description
327
335
  self.kind = kind
328
336
  self.value = value
329
337
  self.unit = unit
@@ -121,6 +121,7 @@ func validateMetric(_ attributes: MetricAttributes) throws -> MetricAttributes {
121
121
  }
122
122
  }
123
123
  try validateMetadata(attributes.metadata, label: "metric metadata")
124
+ let description = try attributes.description.map(normalizeMetricDescription)
124
125
  return try MetricAttributes(
125
126
  name: attributes.name,
126
127
  kind: attributes.kind,
@@ -129,9 +130,27 @@ func validateMetric(_ attributes: MetricAttributes) throws -> MetricAttributes {
129
130
  temporality: attributes.temporality,
130
131
  metadata: attributes.metadata,
131
132
  context: attributes.context.map { try validateTelemetryContext($0) },
133
+ description: description,
132
134
  )
133
135
  }
134
136
 
137
+ private func normalizeMetricDescription(_ value: String) throws -> String {
138
+ let normalized = value.trimmingCharacters(in: .whitespacesAndNewlines)
139
+ let invalidCharacter = normalized.unicodeScalars.contains { scalar in
140
+ scalar.value <= 0x1F
141
+ || (0x7F ... 0x9F).contains(scalar.value)
142
+ || scalar.value == 0x2028
143
+ || scalar.value == 0x2029
144
+ }
145
+ guard !normalized.isEmpty, normalized.unicodeScalars.count <= 1024, !invalidCharacter else {
146
+ throw SdkError(
147
+ code: "validation_error",
148
+ message: "metric description must be a non-blank string of at most 1024 non-control characters",
149
+ )
150
+ }
151
+ return normalized
152
+ }
153
+
135
154
  func requireNonEmpty(_ label: String, _ value: String) throws {
136
155
  if value.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
137
156
  throw SdkError(code: "validation_error", message: "\(label) must be non-empty")
@@ -0,0 +1,44 @@
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 nativeCrashExceptionChain(for exception: IssueException) -> IssueExceptionChain {
6
+ IssueExceptionChain(entries: [
7
+ IssueExceptionChainEntry(
8
+ id: 0,
9
+ relationship: .reported,
10
+ type: exception.type,
11
+ messageState: .notCaptured,
12
+ mechanism: exception.mechanism,
13
+ stackFramesState: .notCaptured,
14
+ ),
15
+ ])
16
+ }
17
+
18
+ func nativeCrashExceptionChainMatches(
19
+ _ value: Any?,
20
+ expected exception: IssueException,
21
+ ) -> Bool {
22
+ guard let value else {
23
+ // Preserve retry identity for an event queued by an older SDK before chains existed.
24
+ return true
25
+ }
26
+ guard let chain = value as? [String: Any] else {
27
+ return false
28
+ }
29
+ guard let mechanism = exception.mechanism else {
30
+ return false
31
+ }
32
+ let expected = [
33
+ "entries": [[
34
+ "id": 0,
35
+ "relationship": "reported",
36
+ "type": exception.type,
37
+ "messageState": "not_captured",
38
+ "mechanism": ["type": mechanism.type, "handled": mechanism.handled],
39
+ "stackFramesState": "not_captured",
40
+ ]],
41
+ "truncated": false,
42
+ ] as NSDictionary
43
+ return chain as NSDictionary == expected
44
+ }
@@ -234,13 +234,8 @@ public final class NativeCrashRecord: NSObject, @unchecked Sendable {
234
234
  return IssueAttributes(
235
235
  title: hangState == nil ? "Native application crash" : "Native application hang",
236
236
  level: hangState == .recovered ? .error : .fatal,
237
- exception: IssueException(
238
- type: hangState == nil ? "AppleNativeCrash" : "AppleNativeHang",
239
- mechanism: IssueExceptionMechanism(
240
- type: mechanism.name,
241
- handled: hangState == .recovered,
242
- ),
243
- ),
237
+ exception: issueException,
238
+ exceptionChain: nativeCrashExceptionChain(for: issueException),
244
239
  metadata: metadata,
245
240
  nativeStackFrames: nativeStackFrames,
246
241
  )
@@ -263,6 +258,10 @@ public final class NativeCrashRecord: NSObject, @unchecked Sendable {
263
258
  attributes["level"] as? String == issueAttributes.level.canonicalValue,
264
259
  attributes["message"] == nil,
265
260
  exceptionMatches(attributes["exception"]),
261
+ nativeCrashExceptionChainMatches(
262
+ attributes["exceptionChain"],
263
+ expected: issueException,
264
+ ),
266
265
  attributes["stackFrames"] == nil,
267
266
  attributes["breadcrumbs"] == nil,
268
267
  attributes["breadcrumbsTruncated"] == nil,
@@ -306,6 +305,16 @@ public final class NativeCrashRecord: NSObject, @unchecked Sendable {
306
305
  ] as NSDictionary
307
306
  }
308
307
 
308
+ private var issueException: IssueException {
309
+ IssueException(
310
+ type: hangState == nil ? "AppleNativeCrash" : "AppleNativeHang",
311
+ mechanism: IssueExceptionMechanism(
312
+ type: mechanism.name,
313
+ handled: hangState == .recovered,
314
+ ),
315
+ )
316
+ }
317
+
309
318
  private func exceptionMatches(_ value: Any?) -> Bool {
310
319
  guard let value else {
311
320
  // Preserve exact retry identity for an event queued by an older SDK before typed exceptions existed.
@@ -10,28 +10,30 @@
10
10
  "swift/logbrew-swift/Sources/LogBrew/DurableDeliveryStore.swift": "2de6a08473722ebd99b2d1762ed3356fefc0181f5cd886367fbfd52c75c6644b",
11
11
  "swift/logbrew-swift/Sources/LogBrew/DurableDeliveryStoreRecovery.swift": "a843f784e7645f916bb0f401e88fa7d49fa8c4351dc2f26c1fbf32be3f4aaa40",
12
12
  "swift/logbrew-swift/Sources/LogBrew/EventEncoding.swift": "b88b6b2d17f7d0cee9ac1a1f34ef35b192cd33d213ef3c9e1c2d21cbcc304a97",
13
- "swift/logbrew-swift/Sources/LogBrew/IssueDiagnostics.swift": "797e6e25f203930e85f827bf211b58c73e6b10938b9f0943f2fe6f6757186ba9",
13
+ "swift/logbrew-swift/Sources/LogBrew/IssueDiagnostics.swift": "fc805effd3da6084e8cac2bbfe8fdc098e07d45d6f3ce2e7df02ebe0e445591f",
14
+ "swift/logbrew-swift/Sources/LogBrew/IssueExceptionChain.swift": "10f81cbce95b47da0a44ef9e515b150e60d71dfcc8b04be24b037b1222a373a5",
14
15
  "swift/logbrew-swift/Sources/LogBrew/LifecycleTrace.swift": "738cd3f129fac821a892404d1d42ee92bd806b6583d3dbb5379e88b14ab23c16",
15
- "swift/logbrew-swift/Sources/LogBrew/LogBrewClient.swift": "2e6dd7c9ef07f181a2798c36a820338e3f81369b1b9c7b85c5c3b6f44103732c",
16
+ "swift/logbrew-swift/Sources/LogBrew/LogBrewClient.swift": "0f7e23a21fd8f76af031205847cd0b8b2648a069abac8d0ddc8c16da017893b7",
16
17
  "swift/logbrew-swift/Sources/LogBrew/LogBrewLogger.swift": "d3266353a5f5139a2a1ca1a86bacce793518eb1489bf67ed9e3720b57b1c29b2",
17
18
  "swift/logbrew-swift/Sources/LogBrew/LogBrewTrace.swift": "f67edf4ca464e6a6eb1c7a22cd4997928cd27b6c2d07535855a18a053d8de924",
18
19
  "swift/logbrew-swift/Sources/LogBrew/Metadata.swift": "65840ebb3f3b79fbfc1f5faa3026c2583319e89ff26061676dc552c03ee15dbe",
19
20
  "swift/logbrew-swift/Sources/LogBrew/NativeStackFrame.swift": "ecbd418969ec4a31503d9916a8bf27f22e6863356c6fabe457c1e61027a8d805",
20
21
  "swift/logbrew-swift/Sources/LogBrew/ProductTimeline.swift": "209906aa0e8f096a347e35832840522710eadfb530053253cd73c748518e362d",
21
- "swift/logbrew-swift/Sources/LogBrew/PublicTypes.swift": "d64de009cd9f8f43fc059cd8fefc2a282c781ba8543be2f56cb48cb757dbe924",
22
+ "swift/logbrew-swift/Sources/LogBrew/PublicTypes.swift": "1151d5402a9f5441f5d5d998fde57c8df88cb3180d2daa3e0fc49e883ab274f2",
22
23
  "swift/logbrew-swift/Sources/LogBrew/TelemetryContext.swift": "ce3ac93a62a345f8c9a8f4c69ce84efc991bf02a688f49bad5e68edcff2f6e08",
23
24
  "swift/logbrew-swift/Sources/LogBrew/TelemetryContextValidation.swift": "f631beef20a8a86f975787b4092a9798fedbe2c9e3f0d2740f81831b4adef287",
24
25
  "swift/logbrew-swift/Sources/LogBrew/TraceEvidence.swift": "40aaa42976164c53d9f4d9e9e30d236534238da43bbe90832a673c8809ad54b6",
25
26
  "swift/logbrew-swift/Sources/LogBrew/Transport.swift": "73eec81aaebec4bc922b2a372429c88fcd3e85606a7862cd08e1795eb2a8e6cd",
26
27
  "swift/logbrew-swift/Sources/LogBrew/URLSessionTrace.swift": "d0076c72671716c41bd7498d0509e453b8182c418156e169196fee9f26fc66b1",
27
28
  "swift/logbrew-swift/Sources/LogBrew/URLSessionTracer.swift": "0cdf08f92d6111c0dc2883b87837b81f69b5c2f9a8e5501780a114c23a741af7",
28
- "swift/logbrew-swift/Sources/LogBrew/Validation.swift": "5551ee5237cbdf1c9ed4ee93025f95313e4dd84f31ce7cb774d1dc01175213c7",
29
+ "swift/logbrew-swift/Sources/LogBrew/Validation.swift": "07ff5c2daeb829b1df4f50edb0b79727f9a81b1c9b5cde5f995f5ad6ce660aa7",
29
30
  "swift/logbrew-swift/Sources/LogBrewCrash/CrashEngine.swift": "7a46035fcaa72dd7504b4e274a92070d73dd1c45fe20e008474340d6d7c44ede",
30
31
  "swift/logbrew-swift/Sources/LogBrewCrash/CrashReportSanitizer.swift": "a872ad63532999e74af1e9685a9382d0e02b93c6f9b942b371d47f56f8b961c0",
31
32
  "swift/logbrew-swift/Sources/LogBrewCrash/CrashStorageDirectory.swift": "7cd566703cbf704dc99155451ee93dcace12c35c805abc09a6dcf23975cf43f9",
32
33
  "swift/logbrew-swift/Sources/LogBrewCrash/NativeArtifactIdentity.swift": "3d785ad717dcf7302451531c18b5e1183983270a6cd78dd543a407e3facc6ae0",
33
34
  "swift/logbrew-swift/Sources/LogBrewCrash/NativeCrashCapture.swift": "e9ab5f1850a096f40592a025d16c5ffa2fdff0222004a37056898cf1cb4b0dd9",
34
- "swift/logbrew-swift/Sources/LogBrewCrash/NativeCrashPublic.swift": "ee209aa25b1c6bcadf84604f145fc55c251c8697e745c87283703dbdd5bbf5ba",
35
+ "swift/logbrew-swift/Sources/LogBrewCrash/NativeCrashExceptionEvidence.swift": "08819652ce0796bee9f502de1202d236887afbcb7794d4db9adc5b6c2928ca2c",
36
+ "swift/logbrew-swift/Sources/LogBrewCrash/NativeCrashPublic.swift": "fe0db58c4ad348a2601d984cdf097147f0b2093baad64861aef48cc052697d9c",
35
37
  "swift/logbrew-swift/Sources/LogBrewCrash/NativeCrashReplay.swift": "3eb3d93b32cf14de416f56fa30d6d65e6f268b5ec9d1a67081d7525c4efd4f80",
36
38
  "swift/logbrew-swift/Sources/LogBrewCrash/NativeHangIncidentStore.swift": "02413ca7e003917faadcc9aa612b5d98897c75b39f67c3c845948dd9349a9d8f",
37
39
  "swift/logbrew-swift/Sources/LogBrewCrash/NativeHangWatchdog.swift": "37b25904cd6576e20c40500be2c153156403b57ad7b4186a1b6b35b414145ecb",
package/metadata.cjs CHANGED
@@ -68,6 +68,22 @@ function sanitizeReactNativeIssueStackFrames(stackFrames) {
68
68
  }));
69
69
  }
70
70
 
71
+ function sanitizeReactNativeIssueExceptionChain(exceptionChain) {
72
+ if (!exceptionChain || !Array.isArray(exceptionChain.entries)) {
73
+ return undefined;
74
+ }
75
+ return {
76
+ ...exceptionChain,
77
+ entries: exceptionChain.entries.map((entry) => {
78
+ const stackFrames = sanitizeReactNativeIssueStackFrames(entry.stackFrames);
79
+ return {
80
+ ...entry,
81
+ ...(stackFrames ? { stackFrames } : {})
82
+ };
83
+ })
84
+ };
85
+ }
86
+
71
87
  function runtimeReactNativeDebugIdMap() {
72
88
  try {
73
89
  const registry = globalThis?.[REACT_NATIVE_DEBUG_ID_REGISTRY];
@@ -170,6 +186,7 @@ module.exports = {
170
186
  createSafeReactNativeMetadata,
171
187
  runtimeReactNativeDebugIdMap,
172
188
  safeReactNativeMetadataFactoryResult,
189
+ sanitizeReactNativeIssueExceptionChain,
173
190
  sanitizeReactNativeIssueMetadata,
174
191
  sanitizeReactNativeIssueStackFrames
175
192
  };
package/metadata.js CHANGED
@@ -66,6 +66,22 @@ export function sanitizeReactNativeIssueStackFrames(stackFrames) {
66
66
  }));
67
67
  }
68
68
 
69
+ export function sanitizeReactNativeIssueExceptionChain(exceptionChain) {
70
+ if (!exceptionChain || !Array.isArray(exceptionChain.entries)) {
71
+ return undefined;
72
+ }
73
+ return {
74
+ ...exceptionChain,
75
+ entries: exceptionChain.entries.map((entry) => {
76
+ const stackFrames = sanitizeReactNativeIssueStackFrames(entry.stackFrames);
77
+ return {
78
+ ...entry,
79
+ ...(stackFrames ? { stackFrames } : {})
80
+ };
81
+ })
82
+ };
83
+ }
84
+
69
85
  export function runtimeReactNativeDebugIdMap() {
70
86
  try {
71
87
  const registry = globalThis?.[REACT_NATIVE_DEBUG_ID_REGISTRY];
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@logbrew/react-native",
3
- "version": "0.1.13",
3
+ "version": "0.1.15",
4
4
  "description": "React Native offline delivery, Apple native diagnostics, screen, error, trace, action, and network timeline helpers for LogBrew.",
5
5
  "type": "module",
6
6
  "main": "./index.cjs",