@logbrew/react-native 0.1.21 → 0.1.23

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.
@@ -16,6 +16,7 @@ Pod::Spec.new do |spec|
16
16
  }
17
17
  spec.default_subspecs = "Core"
18
18
  spec.swift_versions = ["5.0"]
19
+ spec.frameworks = "Security"
19
20
  spec.dependency "React-Core"
20
21
 
21
22
  spec.subspec "Core" do |core|
package/README.md CHANGED
@@ -327,7 +327,11 @@ setLogBrewAppleNativeCrashContext({
327
327
  schemaVersion: 1,
328
328
  trace: { traceId: trace.traceId, spanId: trace.spanId, sampled: trace.sampled },
329
329
  session: { id: "session_123" },
330
- subject: { id: "subject_456", kind: "user" }
330
+ subject: { id: "subject_456", kind: "user" },
331
+ impact: {
332
+ failedAction: "checkout.submit",
333
+ userVisibleOutcome: "The order was not confirmed."
334
+ }
331
335
  });
332
336
 
333
337
  void replayLogBrewAppleNativeDiagnostics().catch((error) => {
@@ -342,10 +346,15 @@ allowed, but only one integration may install native fatal capture in a given
342
346
  process. LogBrew cannot transfer or remove that ownership before process
343
347
  restart.
344
348
 
345
- Update this snapshot when the active trace, session, or subject changes, and
349
+ Update this snapshot when the active trace, session, subject, or failed action changes, and
346
350
  clear it with `setLogBrewAppleNativeCrashContext(null)` on logout or session
347
351
  end. The crash report keeps one atomic snapshot from the crashed process, so a
348
- later app launch cannot replace it. Session and subject values must be opaque app-owned identifiers made from ASCII letters, numbers, `_`, or `-`. Do not use names, email addresses, IP addresses, or device identifiers. Resource context, tags, arbitrary fields, and values over the fixed 1 KiB snapshot limit fail before storage.
352
+ later app launch cannot replace it. `impact` is explicit app knowledge. Use a
353
+ stable action name and an optional safe user-visible result. Never include raw
354
+ input, request data, authentication values, or identity. Session and subject values must
355
+ be opaque app-owned identifiers made from ASCII letters, numbers, `_`, or `-`.
356
+ Resource context, tags, arbitrary fields, and values over the fixed 4 KiB
357
+ snapshot limit fail before storage.
349
358
 
350
359
  The iOS `createLogBrewReactNativeClient()` entry also mirrors its validated
351
360
  breadcrumb history into native crash capture. Install Apple diagnostics first,
@@ -579,11 +588,17 @@ export function App({ client }) {
579
588
 
580
589
  Screen views carry the versioned `screen_view` analytics classification, and explicit product actions carry `interaction`. The SDK uses the app-owned screen name as a bounded surface and does not inspect view hierarchies, selectors, or input values. Caller metadata cannot replace the reserved classification. See the repository [product analytics capture contract](../../docs/product-analytics-contract.md).
581
590
 
582
- The package ships a `react-native` entry that imports `AppState` and `Platform` for Metro, while the default Node entry accepts those dependencies explicitly. That keeps mobile setup explicit instead of pretending a Node process is a native runtime.
591
+ The package ships a `react-native` entry that binds platform state and linked native services for Metro, while the default server entry accepts runtime dependencies explicitly. That keeps mobile setup explicit without treating a server process as a native runtime.
583
592
 
584
593
  ## Trace Propagation
585
594
 
586
- Use an active trace when one product operation should connect screen views, logs, handled errors, actions, network milestones, explicit spans, and outbound request headers. `createReactNativeTraceContext()` continues a valid W3C `traceparent` with a fresh local span ID and falls back to a local root when the incoming value is missing or malformed:
595
+ Use an active trace when one product operation should connect screen views, logs, handled errors, actions, network milestones, explicit spans, and outbound request headers. `createReactNativeTraceContext()` continues a valid W3C `traceparent` with a fresh local span ID and falls back to a local root when the incoming value is missing or malformed.
596
+
597
+ The React Native entry creates trace and span IDs with the linked platform's
598
+ cryptographic random source, or Expo Crypto in a managed runtime. It fails
599
+ closed when neither source exists and never falls back to `Math.random` or
600
+ predictable bytes. Callers may still inject `randomValues` when deterministic
601
+ input control is required.
587
602
 
588
603
  ```js
589
604
  import {
@@ -12,6 +12,7 @@ import java.io.File;
12
12
  import java.nio.charset.StandardCharsets;
13
13
  import java.security.MessageDigest;
14
14
  import java.security.NoSuchAlgorithmException;
15
+ import java.security.SecureRandom;
15
16
  import java.util.ArrayList;
16
17
  import java.util.Arrays;
17
18
  import java.util.HashMap;
@@ -22,6 +23,7 @@ import java.util.Set;
22
23
 
23
24
  final class FatalStoreModuleImpl {
24
25
  static final String NAME = "LogBrewFatalStore";
26
+ private static final SecureRandom SECURE_RANDOM = new SecureRandom();
25
27
 
26
28
  private static final Set<String> RECORD_KEYS =
27
29
  new HashSet<>(
@@ -51,6 +53,20 @@ final class FatalStoreModuleImpl {
51
53
  new AndroidParentDirectorySync());
52
54
  }
53
55
 
56
+ String secureRandomHex(double length) {
57
+ Integer byteCount = integer(length);
58
+ if (byteCount == null || byteCount < 1 || byteCount > 64) {
59
+ return "";
60
+ }
61
+ byte[] bytes = new byte[byteCount];
62
+ SECURE_RANDOM.nextBytes(bytes);
63
+ StringBuilder output = new StringBuilder(byteCount * 2);
64
+ for (byte value : bytes) {
65
+ output.append(String.format(java.util.Locale.ROOT, "%02x", value & 0xff));
66
+ }
67
+ return output.toString();
68
+ }
69
+
54
70
  WritableMap loadEventRecords(String queueKey) {
55
71
  try {
56
72
  EventRecordStore eventStore = eventStore(queueKey);
@@ -17,6 +17,11 @@ final class FatalStoreModule extends NativeLogBrewFatalStoreSpec {
17
17
  return FatalStoreModuleImpl.NAME;
18
18
  }
19
19
 
20
+ @Override
21
+ public String secureRandomHex(double length) {
22
+ return implementation.secureRandomHex(length);
23
+ }
24
+
20
25
  @Override
21
26
  public WritableMap writeFatalRecord(ReadableMap record) {
22
27
  return implementation.writeFatalRecord(record);
@@ -19,6 +19,11 @@ final class FatalStoreModule extends ReactContextBaseJavaModule {
19
19
  return FatalStoreModuleImpl.NAME;
20
20
  }
21
21
 
22
+ @ReactMethod(isBlockingSynchronousMethod = true)
23
+ public String secureRandomHex(double length) {
24
+ return implementation.secureRandomHex(length);
25
+ }
26
+
22
27
  @ReactMethod(isBlockingSynchronousMethod = true)
23
28
  public WritableMap writeFatalRecord(ReadableMap record) {
24
29
  return implementation.writeFatalRecord(record);
@@ -36,6 +36,11 @@ export type LogBrewAppleNativeDiagnosticsReplayResult = Readonly<{
36
36
 
37
37
  export type LogBrewAppleNativeCrashContext = {
38
38
  schemaVersion: 1;
39
+ /** App-reported failed action and optional user-visible result. Never include raw input or identity. */
40
+ impact?: {
41
+ failedAction: string;
42
+ userVisibleOutcome?: string;
43
+ };
39
44
  trace?: TelemetryTraceContext;
40
45
  session?: TelemetrySessionContext;
41
46
  subject?: TelemetrySubjectContext;
@@ -144,7 +144,11 @@ function normalizeConfiguration(configuration) {
144
144
  }
145
145
 
146
146
  function normalizeCorrelationContext(context) {
147
- const source = exactObject(context, ["schemaVersion", "session", "subject", "trace"], "context");
147
+ const source = exactObject(
148
+ context,
149
+ ["impact", "schemaVersion", "session", "subject", "trace"],
150
+ "context"
151
+ );
148
152
  if (source.schemaVersion !== 1) {
149
153
  throw configurationError("context schemaVersion must be 1");
150
154
  }
@@ -185,8 +189,26 @@ function normalizeCorrelationContext(context) {
185
189
  }
186
190
  output.subject = { id: opaqueCorrelationId(subject.id, "subject id"), kind: subject.kind };
187
191
  }
192
+ if (source.impact !== undefined) {
193
+ const impact = exactObject(
194
+ source.impact,
195
+ ["failedAction", "userVisibleOutcome"],
196
+ "context impact"
197
+ );
198
+ output.impact = {
199
+ failedAction: diagnosticText(impact.failedAction, 256, true, "failedAction")
200
+ };
201
+ if (impact.userVisibleOutcome !== undefined) {
202
+ output.impact.userVisibleOutcome = diagnosticText(
203
+ impact.userVisibleOutcome,
204
+ 512,
205
+ false,
206
+ "userVisibleOutcome"
207
+ );
208
+ }
209
+ }
188
210
  if (Object.keys(output).length === 1) {
189
- throw configurationError("context must include trace, session, or subject");
211
+ throw configurationError("context must include trace, session, subject, or impact");
190
212
  }
191
213
  return output;
192
214
  }
@@ -218,6 +240,17 @@ function opaqueCorrelationId(value, name) {
218
240
  return value;
219
241
  }
220
242
 
243
+ function diagnosticText(value, maximum, rejectLocationText, name) {
244
+ const normalized = typeof value === "string" ? value.trim() : "";
245
+ if (normalized.length === 0
246
+ || Array.from(normalized).length > maximum
247
+ || hasControlCharacter(normalized)
248
+ || (rejectLocationText && /[?#]/u.test(normalized))) {
249
+ throw configurationError(`context impact ${name} is invalid`);
250
+ }
251
+ return normalized;
252
+ }
253
+
221
254
  function requireApplePlatform() {
222
255
  if (Platform?.OS !== "ios") {
223
256
  throw new SdkError(
@@ -366,7 +399,7 @@ function exactDeliveryEndpoint(value) {
366
399
  function hasControlCharacter(value) {
367
400
  for (const scalar of value) {
368
401
  const codePoint = scalar.codePointAt(0);
369
- if (codePoint <= 0x1f || codePoint === 0x7f) {
402
+ if (codePoint <= 0x1f || (codePoint >= 0x7f && codePoint <= 0x9f)) {
370
403
  return true;
371
404
  }
372
405
  }
package/index.cjs CHANGED
@@ -16,8 +16,9 @@ const {
16
16
  } = require("./metadata.cjs");
17
17
 
18
18
  const DEFAULT_SDK_NAME = "logbrew-react-native";
19
- const DEFAULT_SDK_VERSION = "0.1.21";
19
+ const DEFAULT_SDK_VERSION = "0.1.23";
20
20
  const DEFAULT_ENDPOINT = "https://api.logbrew.co/v1/events";
21
+ const NATIVE_RANDOM_HEX = Symbol.for("co.logbrew.react-native.secure-random-hex");
21
22
  const MAX_ACTION_NAME_LENGTH = 64;
22
23
  const MAX_PRODUCT_ANALYTICS_SURFACE_LENGTH = 256;
23
24
  const SCREEN_ACTION_PREFIX = "screen:";
@@ -913,11 +914,32 @@ function boundedBreadcrumbMessage(value) {
913
914
  }
914
915
 
915
916
  function defaultRandomValues(length) {
916
- if (!globalThis.crypto || typeof globalThis.crypto.getRandomValues !== "function") {
917
- throw new SdkError("configuration_error", "createReactNativeTraceparent requires crypto.getRandomValues or randomValues");
918
- }
919
917
  const bytes = new Uint8Array(length);
920
- return globalThis.crypto.getRandomValues(bytes);
918
+ if (typeof globalThis.crypto?.getRandomValues === "function") {
919
+ return globalThis.crypto.getRandomValues(bytes);
920
+ }
921
+ if (typeof globalThis.expo?.modules?.ExpoCrypto?.getRandomValues === "function") {
922
+ globalThis.expo.modules.ExpoCrypto.getRandomValues(bytes);
923
+ return bytes;
924
+ }
925
+ const nativeRandom = globalThis[NATIVE_RANDOM_HEX];
926
+ if (typeof nativeRandom !== "function") {
927
+ throw secureRandomError();
928
+ }
929
+ let nativeHex;
930
+ try {
931
+ nativeHex = nativeRandom(length);
932
+ } catch {
933
+ throw secureRandomError();
934
+ }
935
+ if (typeof nativeHex !== "string" || nativeHex.length !== length * 2 || !/^[0-9a-f]+$/iu.test(nativeHex)) {
936
+ throw secureRandomError();
937
+ }
938
+ return Uint8Array.from({ length }, (_, index) => Number.parseInt(nativeHex.slice(index * 2, index * 2 + 2), 16));
939
+ }
940
+
941
+ function secureRandomError() {
942
+ return new SdkError("configuration_error", "createReactNativeTraceparent requires secure random values");
921
943
  }
922
944
 
923
945
  function headersWithTraceparent(headers, traceparent) {
package/index.native.js CHANGED
@@ -1,4 +1,4 @@
1
- import { AppState, Platform } from "react-native";
1
+ import { AppState, NativeModules, Platform, TurboModuleRegistry } from "react-native";
2
2
  import {
3
3
  captureAppStateChange,
4
4
  captureReactNativeAction,
@@ -34,6 +34,13 @@ export {
34
34
 
35
35
  export * from "./index.js";
36
36
 
37
+ const nativeRuntime = TurboModuleRegistry?.get?.("LogBrewFatalStore")
38
+ ?? NativeModules?.LogBrewFatalStore;
39
+ if (typeof nativeRuntime?.secureRandomHex === "function") {
40
+ globalThis[Symbol.for("co.logbrew.react-native.secure-random-hex")] =
41
+ nativeRuntime.secureRandomHex.bind(nativeRuntime);
42
+ }
43
+
37
44
  export function createLogBrewReactNativeClient(config = {}) {
38
45
  const input = config !== null && typeof config === "object" ? config : {};
39
46
  const {
@@ -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.21"
6
+ private static let sdkVersion = "0.1.23"
7
7
 
8
8
  private let lock = NSLock()
9
9
  private let replayQueue = DispatchQueue(label: "co.logbrew.react-native.apple-diagnostics-replay")
@@ -149,8 +149,11 @@ public final class LBRNAppleNativeDiagnostics: NSObject, @unchecked Sendable {
149
149
  return failure("native_diagnostics_not_installed")
150
150
  }
151
151
  do {
152
- let context = try rawContext.map { try NativeCrashCorrelation.validated($0) }
153
- try capture.setCorrelationContext(context)
152
+ let snapshot = try rawContext.map { try NativeCrashCorrelation.validated($0) }
153
+ try capture.setDiagnosticContext(
154
+ context: snapshot?.correlationContext,
155
+ impact: snapshot?.impact,
156
+ )
154
157
  return ["status": rawContext == nil ? "cleared" : "updated"]
155
158
  } catch let error as NativeCrashError {
156
159
  return failure(error.code.rawValue)
@@ -77,7 +77,8 @@ public struct IssueDiagnosticEvidence: Codable, Equatable, Sendable {
77
77
  }
78
78
  }
79
79
 
80
- func validateIssueDiagnosticEvidence(_ value: IssueDiagnosticEvidence) throws -> IssueDiagnosticEvidence {
80
+ @_spi(CrashReplay)
81
+ public func validateIssueDiagnosticEvidence(_ value: IssueDiagnosticEvidence) throws -> IssueDiagnosticEvidence {
81
82
  let cause = try value.likelyRootCause.map {
82
83
  try issueText($0, label: "issue evidence likelyRootCause", maximum: 1024)
83
84
  }
@@ -23,6 +23,9 @@ struct CrashReportSanitizer {
23
23
  let nativeStackFrames = NativeStackFrameSanitizer().frames(from: rawReport)
24
24
  let artifactIdentity = try NativeArtifactIdentityValue.persistedIdentity(in: rawReport)
25
25
  let correlation = NativeCrashCorrelation.captured(in: rawReport)
26
+ let correlationState = correlation.0?.correlationContext == nil && correlation.1 == .captured
27
+ ? NativeCrashContextState.notCaptured
28
+ : correlation.1
26
29
  let breadcrumbs = NativeCrashBreadcrumbs.captured(in: rawReport)
27
30
  return NativeCrashRecord(
28
31
  eventID: uuid.uuidString.lowercased(),
@@ -33,9 +36,10 @@ struct CrashReportSanitizer {
33
36
  context: nativeCrashContext(
34
37
  system: rawReport["system"] as? [String: Any],
35
38
  artifactIdentity: artifactIdentity,
36
- correlation: correlation.0,
39
+ correlation: correlation.0?.correlationContext,
37
40
  ),
38
- correlationState: correlation.1,
41
+ correlationState: correlationState,
42
+ impact: correlation.0?.impact,
39
43
  breadcrumbs: breadcrumbs.0?.breadcrumbs,
40
44
  breadcrumbsTruncated: breadcrumbs.0?.truncated,
41
45
  breadcrumbState: breadcrumbs.1,
@@ -109,17 +109,17 @@ public final class NativeCrashCapture: NSObject, @unchecked Sendable {
109
109
  /// Replaces the crash-time trace, session, and opaque subject snapshot in one bounded write.
110
110
  @nonobjc
111
111
  public func setCorrelationContext(_ context: TelemetryContext?) throws {
112
- lock.lock()
113
- defer { lock.unlock() }
114
- try verifyProcessLocked()
115
- guard store != nil, lifecycle != .stopped else {
116
- throw NativeCrashError(.notInstalled)
112
+ try setDiagnosticContext(context: context, impact: nil)
113
+ }
114
+
115
+ @nonobjc
116
+ func setDiagnosticContext(
117
+ context: TelemetryContext?,
118
+ impact: IssueImpactEvidence?,
119
+ ) throws {
120
+ try setSnapshot(forKey: NativeCrashCorrelation.reportKey) {
121
+ try NativeCrashCorrelation.encoded(context: context, impact: impact)
117
122
  }
118
- try verifyStorageLocked()
119
- try driver.setUserInfo(
120
- NativeCrashCorrelation.encoded(context),
121
- forKey: NativeCrashCorrelation.reportKey,
122
- )
123
123
  }
124
124
 
125
125
  /// Replaces the crash-time breadcrumb snapshot in one bounded write.
@@ -127,6 +127,15 @@ public final class NativeCrashCapture: NSObject, @unchecked Sendable {
127
127
  public func setBreadcrumbs(
128
128
  _ breadcrumbs: [IssueBreadcrumb]?,
129
129
  truncated: Bool = false,
130
+ ) throws {
131
+ try setSnapshot(forKey: NativeCrashBreadcrumbs.reportKey) {
132
+ try NativeCrashBreadcrumbs.encoded(breadcrumbs, truncated: truncated)
133
+ }
134
+ }
135
+
136
+ private func setSnapshot(
137
+ forKey key: String,
138
+ encoded: () throws -> String?,
130
139
  ) throws {
131
140
  lock.lock()
132
141
  defer { lock.unlock() }
@@ -135,10 +144,7 @@ public final class NativeCrashCapture: NSObject, @unchecked Sendable {
135
144
  throw NativeCrashError(.notInstalled)
136
145
  }
137
146
  try verifyStorageLocked()
138
- try driver.setUserInfo(
139
- NativeCrashBreadcrumbs.encoded(breadcrumbs, truncated: truncated),
140
- forKey: NativeCrashBreadcrumbs.reportKey,
141
- )
147
+ try driver.setUserInfo(encoded(), forKey: key)
142
148
  }
143
149
 
144
150
  @objc(replayPendingReportsWithHandler:error:)
@@ -8,16 +8,60 @@ enum NativeCrashContextState: String {
8
8
  case unavailable
9
9
  }
10
10
 
11
+ struct NativeCrashDiagnosticSnapshot: Codable, Equatable {
12
+ let schemaVersion: Int
13
+ let trace: TelemetryTraceContext?
14
+ let session: TelemetrySessionContext?
15
+ let subject: TelemetrySubjectContext?
16
+ let impact: IssueImpactEvidence?
17
+
18
+ init(context: TelemetryContext?, impact: IssueImpactEvidence?) throws {
19
+ let context = try context.map(validateNativeCrashCorrelationContext)
20
+ let impact = try impact.map { value in
21
+ guard value.affectedUserSegment == nil,
22
+ let value = try validateIssueDiagnosticEvidence(
23
+ IssueDiagnosticEvidence(impact: value),
24
+ ).impact,
25
+ value.failedAction != nil
26
+ else {
27
+ throw NativeCrashError(.invalidConfiguration)
28
+ }
29
+ return value
30
+ }
31
+ guard context != nil || impact != nil else {
32
+ throw NativeCrashError(.invalidConfiguration)
33
+ }
34
+ schemaVersion = 1
35
+ trace = context?.trace
36
+ session = context?.session
37
+ subject = context?.subject
38
+ self.impact = impact
39
+ }
40
+
41
+ var correlationContext: TelemetryContext? {
42
+ trace == nil && session == nil && subject == nil
43
+ ? nil
44
+ : TelemetryContext(trace: trace, session: session, subject: subject)
45
+ }
46
+
47
+ }
48
+
11
49
  enum NativeCrashCorrelation {
12
50
  static let reportKey = "logbrew_native_correlation"
13
- private static let maximumBytes = 1024
51
+ private static let maximumBytes = 4 * 1024
14
52
 
15
- static func encoded(_ context: TelemetryContext?) throws -> String? {
16
- guard let context else {
53
+ static func encoded(
54
+ context: TelemetryContext?,
55
+ impact: IssueImpactEvidence? = nil,
56
+ ) throws -> String? {
57
+ guard context != nil || impact != nil else {
17
58
  return nil
18
59
  }
19
60
  do {
20
- let data = try encoder().encode(validateNativeCrashCorrelationContext(context))
61
+ let data = try nativeCrashEncoded(NativeCrashDiagnosticSnapshot(
62
+ context: context,
63
+ impact: impact,
64
+ ))
21
65
  guard data.count <= maximumBytes, let value = String(data: data, encoding: .utf8) else {
22
66
  throw NativeCrashError(.invalidConfiguration)
23
67
  }
@@ -27,51 +71,23 @@ enum NativeCrashCorrelation {
27
71
  }
28
72
  }
29
73
 
30
- static func captured(in rawReport: [String: Any]) -> (TelemetryContext?, NativeCrashContextState) {
31
- guard let user = rawReport["user"] as? [String: Any], let value = user[reportKey] else {
32
- return (nil, .notCaptured)
33
- }
34
- guard let value = value as? String,
35
- let data = value.data(using: .utf8),
36
- data.count <= maximumBytes,
37
- let object = try? JSONSerialization.jsonObject(with: data),
38
- let context = try? validated(object)
39
- else {
40
- return (nil, .unavailable)
41
- }
42
- return (context, .captured)
74
+ static func captured(
75
+ in rawReport: [String: Any],
76
+ ) -> (NativeCrashDiagnosticSnapshot?, NativeCrashContextState) {
77
+ nativeCrashCaptured(in: rawReport, key: reportKey, maximumBytes: maximumBytes, validate: validated)
43
78
  }
44
79
 
45
- static func validated(_ object: Any) throws -> TelemetryContext {
46
- do {
47
- guard JSONSerialization.isValidJSONObject(object) else {
48
- throw NativeCrashError(.invalidConfiguration)
49
- }
50
- let data = try JSONSerialization.data(withJSONObject: object, options: [.sortedKeys])
51
- guard data.count <= maximumBytes else {
80
+ static func validated(_ object: Any) throws -> NativeCrashDiagnosticSnapshot {
81
+ try nativeCrashValidated(object, maximumBytes: maximumBytes) { decoded in
82
+ guard decoded.schemaVersion == 1 else {
52
83
  throw NativeCrashError(.invalidConfiguration)
53
84
  }
54
- let context = try validateNativeCrashCorrelationContext(
55
- JSONDecoder().decode(TelemetryContext.self, from: data),
85
+ return try NativeCrashDiagnosticSnapshot(
86
+ context: decoded.correlationContext,
87
+ impact: decoded.impact,
56
88
  )
57
- let normalized = try encoder().encode(context)
58
- let normalizedObject = try JSONSerialization.jsonObject(with: normalized)
59
- guard object as? NSDictionary == normalizedObject as? NSDictionary else {
60
- throw NativeCrashError(.invalidConfiguration)
61
- }
62
- return context
63
- } catch let error as NativeCrashError {
64
- throw error
65
- } catch {
66
- throw NativeCrashError(.invalidConfiguration)
67
89
  }
68
90
  }
69
-
70
- private static func encoder() -> JSONEncoder {
71
- let encoder = JSONEncoder()
72
- encoder.outputFormatting = [.sortedKeys, .withoutEscapingSlashes]
73
- return encoder
74
- }
75
91
  }
76
92
 
77
93
  struct NativeCrashBreadcrumbSnapshot: Codable, Equatable {
@@ -98,7 +114,7 @@ enum NativeCrashBreadcrumbs {
98
114
  var breadcrumbs = try values.suffix(maximumIssueBreadcrumbs).map(validateIssueBreadcrumb)
99
115
  var wasTruncated = truncated || breadcrumbs.count < values.count
100
116
  while !breadcrumbs.isEmpty {
101
- let data = try encoder().encode(NativeCrashBreadcrumbSnapshot(
117
+ let data = try nativeCrashEncoded(NativeCrashBreadcrumbSnapshot(
102
118
  breadcrumbs: breadcrumbs,
103
119
  truncated: wasTruncated,
104
120
  ))
@@ -119,56 +135,74 @@ enum NativeCrashBreadcrumbs {
119
135
  static func captured(
120
136
  in rawReport: [String: Any],
121
137
  ) -> (NativeCrashBreadcrumbSnapshot?, NativeCrashContextState) {
122
- guard let user = rawReport["user"] as? [String: Any], let value = user[reportKey] else {
123
- return (nil, .notCaptured)
124
- }
125
- guard let value = value as? String,
126
- let data = value.data(using: .utf8),
127
- data.count <= maximumBytes,
128
- let object = try? JSONSerialization.jsonObject(with: data),
129
- let snapshot = try? validated(object)
130
- else {
131
- return (nil, .unavailable)
132
- }
133
- return (snapshot, .captured)
138
+ nativeCrashCaptured(in: rawReport, key: reportKey, maximumBytes: maximumBytes, validate: validated)
134
139
  }
135
140
 
136
141
  static func validated(_ object: Any) throws -> NativeCrashBreadcrumbSnapshot {
137
- do {
138
- guard JSONSerialization.isValidJSONObject(object) else {
139
- throw NativeCrashError(.invalidConfiguration)
140
- }
141
- let data = try JSONSerialization.data(withJSONObject: object, options: [.sortedKeys])
142
- guard data.count <= maximumBytes else {
143
- throw NativeCrashError(.invalidConfiguration)
144
- }
145
- let decoded = try JSONDecoder().decode(NativeCrashBreadcrumbSnapshot.self, from: data)
142
+ try nativeCrashValidated(object, maximumBytes: maximumBytes) { decoded in
146
143
  guard decoded.schemaVersion == 1,
147
144
  !decoded.breadcrumbs.isEmpty,
148
145
  decoded.breadcrumbs.count <= maximumIssueBreadcrumbs
149
146
  else {
150
147
  throw NativeCrashError(.invalidConfiguration)
151
148
  }
152
- let snapshot = try NativeCrashBreadcrumbSnapshot(
149
+ return try NativeCrashBreadcrumbSnapshot(
153
150
  breadcrumbs: decoded.breadcrumbs.map(validateIssueBreadcrumb),
154
151
  truncated: decoded.truncated,
155
152
  )
156
- let normalized = try encoder().encode(snapshot)
157
- let normalizedObject = try JSONSerialization.jsonObject(with: normalized)
158
- guard object as? NSDictionary == normalizedObject as? NSDictionary else {
159
- throw NativeCrashError(.invalidConfiguration)
160
- }
161
- return snapshot
162
- } catch let error as NativeCrashError {
163
- throw error
164
- } catch {
165
- throw NativeCrashError(.invalidConfiguration)
166
153
  }
167
154
  }
155
+ }
168
156
 
169
- private static func encoder() -> JSONEncoder {
170
- let encoder = JSONEncoder()
171
- encoder.outputFormatting = [.sortedKeys, .withoutEscapingSlashes]
172
- return encoder
157
+ private func nativeCrashCaptured<Value>(
158
+ in rawReport: [String: Any],
159
+ key: String,
160
+ maximumBytes: Int,
161
+ validate: (Any) throws -> Value,
162
+ ) -> (Value?, NativeCrashContextState) {
163
+ guard let user = rawReport["user"] as? [String: Any], let rawValue = user[key] else {
164
+ return (nil, .notCaptured)
165
+ }
166
+ guard let value = rawValue as? String,
167
+ let data = value.data(using: .utf8),
168
+ data.count <= maximumBytes,
169
+ let object = try? JSONSerialization.jsonObject(with: data),
170
+ let snapshot = try? validate(object)
171
+ else {
172
+ return (nil, .unavailable)
173
+ }
174
+ return (snapshot, .captured)
175
+ }
176
+
177
+ private func nativeCrashValidated<Value: Codable>(
178
+ _ object: Any,
179
+ maximumBytes: Int,
180
+ normalize: (Value) throws -> Value,
181
+ ) throws -> Value {
182
+ do {
183
+ guard JSONSerialization.isValidJSONObject(object) else {
184
+ throw NativeCrashError(.invalidConfiguration)
185
+ }
186
+ let source = try JSONSerialization.data(withJSONObject: object, options: [.sortedKeys])
187
+ guard source.count <= maximumBytes else {
188
+ throw NativeCrashError(.invalidConfiguration)
189
+ }
190
+ let value = try normalize(JSONDecoder().decode(Value.self, from: source))
191
+ let normalized = try nativeCrashEncoded(value)
192
+ let normalizedObject = try JSONSerialization.jsonObject(with: normalized)
193
+ guard object as? NSDictionary == normalizedObject as? NSDictionary else {
194
+ throw NativeCrashError(.invalidConfiguration)
195
+ }
196
+ return value
197
+ } catch let error as NativeCrashError {
198
+ throw error
199
+ } catch {
200
+ throw NativeCrashError(.invalidConfiguration)
173
201
  }
174
202
  }
203
+
204
+ private func nativeCrashEncoded(_ value: some Encodable) throws -> Data {
205
+ let encoder = JSONEncoder()
206
+ encoder.outputFormatting = [.sortedKeys, .withoutEscapingSlashes]
207
+ return try encoder.encode(value)
208
+ }