@logbrew/react-native 0.1.23 → 0.1.24

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 (35) hide show
  1. package/README.md +62 -5
  2. package/android/CMakeLists.txt +7 -0
  3. package/android/build.gradle +12 -0
  4. package/android/src/main/cpp/android_diagnostics.cpp +363 -0
  5. package/android/src/main/java/co/logbrew/reactnative/AndroidDiagnosticsRuntime.java +214 -0
  6. package/android/src/main/java/co/logbrew/reactnative/AndroidNativeDiagnostics.java +363 -0
  7. package/android/src/main/java/co/logbrew/reactnative/AndroidNativeSignalStore.java +233 -0
  8. package/android/src/main/java/co/logbrew/reactnative/AndroidParentDirectorySync.java +12 -12
  9. package/android/src/main/java/co/logbrew/reactnative/EventRecordStore.java +15 -5
  10. package/android/src/main/java/co/logbrew/reactnative/FatalStoreModuleImpl.java +111 -156
  11. package/android/src/newarch/java/co/logbrew/reactnative/FatalStoreModule.java +15 -20
  12. package/android/src/oldarch/java/co/logbrew/reactnative/FatalStoreModule.java +15 -20
  13. package/android-native-diagnostics.d.ts +29 -0
  14. package/android-native-diagnostics.js +176 -0
  15. package/fatal-replay.cjs +35 -3
  16. package/global-errors.d.cts +3 -3
  17. package/global-errors.d.ts +3 -3
  18. package/global-errors.native.js +1 -12
  19. package/index.cjs +1 -1
  20. package/index.native.d.ts +4 -0
  21. package/index.native.js +21 -3
  22. package/ios/AppleDiagnostics/LBRNAppleNativeDiagnostics.swift +1 -1
  23. package/ios/GeneratedAppleDiagnostics/LogBrew/LogBrewLogger.swift +2 -10
  24. package/ios/GeneratedAppleDiagnostics/LogBrew/OperationTrace.swift +56 -0
  25. package/ios/GeneratedAppleDiagnostics/LogBrew/URLSessionTracer.swift +48 -56
  26. package/ios/GeneratedAppleDiagnostics/LogBrew/Validation.swift +6 -0
  27. package/ios/GeneratedAppleDiagnostics/LogBrewCrash/NativeCrashCorrelation.swift +0 -1
  28. package/ios/GeneratedAppleDiagnostics/SOURCE-MANIFEST.json +5 -4
  29. package/ios/LBRNFatalStoreModule.mm +15 -86
  30. package/package.json +17 -5
  31. package/persistent-delivery.native.js +4 -3
  32. package/src/NativeLogBrewFatalStore.ts +5 -4
  33. package/android/src/main/java/co/logbrew/reactnative/FatalRecordStore.java +0 -623
  34. package/ios/LBRNFatalRecordStore.h +0 -25
  35. package/ios/LBRNFatalRecordStore.m +0 -542
@@ -0,0 +1,176 @@
1
+ import { SdkError } from "@logbrew/sdk";
2
+ import { NativeModules, Platform, TurboModuleRegistry } from "react-native";
3
+
4
+ const INSTALL_KEYS = new Set([
5
+ "anrThresholdMs",
6
+ "clientKey",
7
+ "environment",
8
+ "fatalHandlerOwnership",
9
+ "projectId",
10
+ "release",
11
+ "service"
12
+ ]);
13
+ const RECEIPT_KEYS = new Set(["pending", "status"]);
14
+
15
+ export function installLogBrewAndroidNativeDiagnostics(configuration = {}) {
16
+ requireAndroid();
17
+ return receipt(
18
+ "install",
19
+ call("installAndroidDiagnostics", normalizeConfiguration(configuration)),
20
+ new Set(["already_installed", "installed"])
21
+ );
22
+ }
23
+
24
+ export function getLogBrewAndroidNativeDiagnosticsStatus() {
25
+ requireAndroid();
26
+ return receipt(
27
+ "status",
28
+ call("androidDiagnosticsStatus"),
29
+ new Set(["not_installed", "ready"])
30
+ );
31
+ }
32
+
33
+ export function uninstallLogBrewAndroidNativeDiagnostics() {
34
+ requireAndroid();
35
+ return receipt(
36
+ "uninstall",
37
+ call("uninstallAndroidDiagnostics"),
38
+ new Set(["not_installed", "uninstalled"])
39
+ );
40
+ }
41
+
42
+ function normalizeConfiguration(value) {
43
+ if (!isObject(value)) {
44
+ throw configurationError("configuration must be an object");
45
+ }
46
+ for (const key of Object.keys(value)) {
47
+ if (!INSTALL_KEYS.has(key)) {
48
+ throw configurationError("configuration contains an unsupported key");
49
+ }
50
+ }
51
+ if (value.fatalHandlerOwnership !== "logbrew") {
52
+ throw configurationError(
53
+ "fatalHandlerOwnership must be logbrew after removing every other Android fatal handler"
54
+ );
55
+ }
56
+ const projectId = exactText(value.projectId, 64, "projectId");
57
+ if (!/^[0-9a-f]{8}(?:-[0-9a-f]{4}){3}-[0-9a-f]{12}$/u.test(projectId)) {
58
+ throw configurationError("projectId must be a lowercase UUID");
59
+ }
60
+ const threshold = value.anrThresholdMs ?? 5000;
61
+ if (!Number.isSafeInteger(threshold) || threshold < 2000 || threshold > 60000) {
62
+ throw configurationError("anrThresholdMs must be an integer from 2000 through 60000");
63
+ }
64
+ return {
65
+ anrThresholdMs: threshold,
66
+ clientKey: exactText(value.clientKey, 4096, "clientKey"),
67
+ environment: exactText(value.environment, 128, "environment"),
68
+ fatalHandlerOwnership: "logbrew",
69
+ projectId,
70
+ release: exactText(value.release, 256, "release"),
71
+ service: exactScope(value.service, "service")
72
+ };
73
+ }
74
+
75
+ function exactScope(value, name) {
76
+ const text = exactText(value, 128, name);
77
+ if (!/^[A-Za-z0-9][A-Za-z0-9._/-]{0,127}$/u.test(text)
78
+ || text.includes("..")
79
+ || text.includes("//")) {
80
+ throw configurationError(`${name} must be a bounded deployment identifier`);
81
+ }
82
+ return text;
83
+ }
84
+
85
+ function exactText(value, maximumBytes, name) {
86
+ if (typeof value !== "string"
87
+ || value.length === 0
88
+ || value.trim() !== value
89
+ || controlCharacter(value)
90
+ || utf8Length(value) > maximumBytes) {
91
+ throw configurationError(`${name} must be a bounded non-empty string`);
92
+ }
93
+ return value;
94
+ }
95
+
96
+ function requireAndroid() {
97
+ if (Platform?.OS !== "android") {
98
+ throw new SdkError(
99
+ "unsupported_platform",
100
+ "LogBrew Android native diagnostics require an Android native build"
101
+ );
102
+ }
103
+ }
104
+
105
+ function call(method, ...args) {
106
+ let nativeModule;
107
+ try {
108
+ nativeModule = TurboModuleRegistry?.get?.("LogBrewFatalStore")
109
+ ?? NativeModules?.LogBrewFatalStore;
110
+ } catch {
111
+ nativeModule = undefined;
112
+ }
113
+ if (typeof nativeModule?.[method] !== "function") {
114
+ throw new SdkError(
115
+ "native_diagnostics_unavailable",
116
+ `linked LogBrew Android diagnostics do not implement ${method}`
117
+ );
118
+ }
119
+ try {
120
+ return nativeModule[method](...args);
121
+ } catch {
122
+ throw new SdkError(
123
+ "native_diagnostics_failed",
124
+ `LogBrew Android native diagnostics ${method} failed`
125
+ );
126
+ }
127
+ }
128
+
129
+ function receipt(operation, value, statuses) {
130
+ const keys = isObject(value) ? Object.keys(value) : [];
131
+ if (isObject(value)
132
+ && value.status === "error"
133
+ && typeof value.code === "string"
134
+ && /^[a-z0-9_]{1,128}$/u.test(value.code)) {
135
+ throw new SdkError(
136
+ value.code,
137
+ `LogBrew Android native diagnostics ${operation} failed with ${value.code}`
138
+ );
139
+ }
140
+ if (!isObject(value)
141
+ || keys.length !== RECEIPT_KEYS.size
142
+ || !keys.every((key) => RECEIPT_KEYS.has(key))
143
+ || !statuses.has(value.status)
144
+ || !Number.isSafeInteger(value.pending)
145
+ || value.pending < 0) {
146
+ throw new SdkError(
147
+ "native_diagnostics_invalid_response",
148
+ `LogBrew Android native diagnostics ${operation} returned an invalid response`
149
+ );
150
+ }
151
+ return Object.freeze({ pending: value.pending, status: value.status });
152
+ }
153
+
154
+ function isObject(value) {
155
+ return value !== null && !Array.isArray(value) && typeof value === "object";
156
+ }
157
+
158
+ function controlCharacter(value) {
159
+ return Array.from(value).some((character) => {
160
+ const code = character.codePointAt(0);
161
+ return code <= 31 || (code >= 127 && code <= 159);
162
+ });
163
+ }
164
+
165
+ function utf8Length(value) {
166
+ let length = 0;
167
+ for (const character of value) {
168
+ const code = character.codePointAt(0);
169
+ length += code <= 127 ? 1 : code <= 2047 ? 2 : code <= 65535 ? 3 : 4;
170
+ }
171
+ return length;
172
+ }
173
+
174
+ function configurationError(message) {
175
+ return new SdkError("configuration_error", `LogBrew Android native diagnostics ${message}`);
176
+ }
package/fatal-replay.cjs CHANGED
@@ -5,6 +5,7 @@ const MAX_ADMITTED_FATAL_IDS = 256;
5
5
  const MAX_FATAL_FILENAME_BYTES = 512;
6
6
  const MAX_FATAL_STACK_BYTES = 16 * 1024;
7
7
  const MAX_FATAL_STACK_FRAMES = 24;
8
+ const DURABLE_QUEUE = Symbol.for("co.logbrew.react-native.durable-event-queue");
8
9
 
9
10
  const admittedFatalIdsByClient = new WeakMap();
10
11
  let nextFatalSequence = 0;
@@ -18,12 +19,13 @@ function createFatalController({
18
19
  sanitizers
19
20
  }) {
20
21
  const methods = fatalStoreMethods(fatalStore);
22
+ const directAdmission = !methods && safeReadProperty(client, DURABLE_QUEUE) === true;
21
23
  const state = {
22
24
  acknowledgedRecords: 0,
23
- available: methods !== undefined,
25
+ available: methods !== undefined || directAdmission,
24
26
  corruptRecords: 0,
25
27
  droppedRecords: 0,
26
- lastOutcome: methods ? "idle" : "unavailable",
28
+ lastOutcome: methods || directAdmission ? "idle" : "unavailable",
27
29
  replayedRecords: 0,
28
30
  storedRecords: 0
29
31
  };
@@ -37,6 +39,9 @@ function createFatalController({
37
39
  return fatalHealthSnapshot(state);
38
40
  },
39
41
  replay() {
42
+ if (!methods) {
43
+ return;
44
+ }
40
45
  replayPendingFatal({
41
46
  client,
42
47
  eventMessage,
@@ -49,6 +54,11 @@ function createFatalController({
49
54
  });
50
55
  },
51
56
  store(error) {
57
+ if (!methods) {
58
+ return directAdmission
59
+ ? admitFatalError({ client, error, eventMessage, issue, onDiagnostic, sanitizers, state })
60
+ : false;
61
+ }
52
62
  return storeFatalError({
53
63
  error,
54
64
  fatalStore,
@@ -61,6 +71,26 @@ function createFatalController({
61
71
  };
62
72
  }
63
73
 
74
+ function admitFatalError({ client, error, eventMessage, issue, onDiagnostic, sanitizers, state }) {
75
+ const record = createFatalRecord(error, sanitizers);
76
+ const before = admissionSnapshot(client);
77
+ try {
78
+ issue.call(client, record.id, record.timestamp, fatalEventAttributes(record, eventMessage));
79
+ } catch {
80
+ state.lastOutcome = "storage_error";
81
+ emitDiagnostic(onDiagnostic, "fatal_store_failed");
82
+ return false;
83
+ }
84
+ if (!wasAdmitted(before, admissionSnapshot(client))) {
85
+ state.lastOutcome = "storage_error";
86
+ emitDiagnostic(onDiagnostic, "fatal_store_failed");
87
+ return false;
88
+ }
89
+ state.storedRecords = incrementBounded(state.storedRecords);
90
+ state.lastOutcome = "stored";
91
+ return true;
92
+ }
93
+
64
94
  function fatalStoreMethods(fatalStore) {
65
95
  if (!isObjectLike(fatalStore)) {
66
96
  return undefined;
@@ -327,7 +357,9 @@ function fatalEventAttributes(record, eventMessage) {
327
357
  replayed: true,
328
358
  source: "react-native.global_error"
329
359
  }),
330
- stackFrames: record.stackFrames.map((frame) => Object.freeze({ ...frame })),
360
+ ...(record.stackFrames.length === 0
361
+ ? {}
362
+ : { stackFrames: record.stackFrames.map((frame) => Object.freeze({ ...frame })) }),
331
363
  title: eventMessage
332
364
  });
333
365
  }
@@ -240,9 +240,9 @@ export declare function installLogBrewReactNativePromiseRejectionTracker(
240
240
  /**
241
241
  * Install reversible automatic capture for React Native global JavaScript errors.
242
242
  *
243
- * The React Native conditional export injects its synchronous native fatal store by default.
244
- * Direct Node ESM/CJS callers can inject a compatible store explicitly. Fatal replay is
245
- * stable-ID at-least-once and acknowledges only after observable local queue admission.
243
+ * The React Native conditional export admits fatal reports into its persistent event queue
244
+ * before the previous handler runs. Direct Node ESM/CJS callers can inject a compatible
245
+ * fatal store explicitly.
246
246
  * This helper does not claim local exactly-once delivery or install Promise rejection handling.
247
247
  */
248
248
  export declare function installLogBrewReactNativeGlobalErrorHandler(
@@ -240,9 +240,9 @@ export declare function installLogBrewReactNativePromiseRejectionTracker(
240
240
  /**
241
241
  * Install reversible automatic capture for React Native global JavaScript errors.
242
242
  *
243
- * The React Native conditional export injects its synchronous native fatal store by default.
244
- * Direct Node ESM/CJS callers can inject a compatible store explicitly. Fatal replay is
245
- * stable-ID at-least-once and acknowledges only after observable local queue admission.
243
+ * The React Native conditional export admits fatal reports into its persistent event queue
244
+ * before the previous handler runs. Direct Node ESM/CJS callers can inject a compatible
245
+ * fatal store explicitly.
246
246
  * This helper does not claim local exactly-once delivery or install Promise rejection handling.
247
247
  */
248
248
  export declare function installLogBrewReactNativeGlobalErrorHandler(
@@ -1,5 +1,3 @@
1
- import { NativeModules, TurboModuleRegistry } from "react-native";
2
-
3
1
  import {
4
2
  createLogBrewReactNativePromiseRejectionHandlers,
5
3
  installLogBrewReactNativeGlobalErrorHandler as installPlatformNeutralHandler,
@@ -12,15 +10,6 @@ export {
12
10
 
13
11
  const hermesTrackerAdapters = new WeakMap();
14
12
 
15
- function defaultFatalStore() {
16
- try {
17
- return TurboModuleRegistry?.get?.("LogBrewFatalStore")
18
- ?? NativeModules?.LogBrewFatalStore;
19
- } catch {
20
- return undefined;
21
- }
22
- }
23
-
24
13
  function defaultPromiseRejectionTracker() {
25
14
  let hermes;
26
15
  let enable;
@@ -70,7 +59,7 @@ export function installLogBrewReactNativeGlobalErrorHandler(options = {}) {
70
59
  }
71
60
  return installPlatformNeutralHandler({
72
61
  ...forwarded,
73
- fatalStore: hasInjectedStore ? forwarded.fatalStore : defaultFatalStore()
62
+ fatalStore: hasInjectedStore ? forwarded.fatalStore : undefined
74
63
  });
75
64
  }
76
65
 
package/index.cjs CHANGED
@@ -16,7 +16,7 @@ const {
16
16
  } = require("./metadata.cjs");
17
17
 
18
18
  const DEFAULT_SDK_NAME = "logbrew-react-native";
19
- const DEFAULT_SDK_VERSION = "0.1.23";
19
+ const DEFAULT_SDK_VERSION = "0.1.24";
20
20
  const DEFAULT_ENDPOINT = "https://api.logbrew.co/v1/events";
21
21
  const NATIVE_RANDOM_HEX = Symbol.for("co.logbrew.react-native.secure-random-hex");
22
22
  const MAX_ACTION_NAME_LENGTH = 64;
package/index.native.d.ts CHANGED
@@ -3,6 +3,7 @@ import type { CreateLogBrewReactNativeClientConfig } from "./index.js";
3
3
 
4
4
  export * from "./index.js";
5
5
  export * from "./apple-native-diagnostics.js";
6
+ export * from "./android-native-diagnostics.js";
6
7
  export {
7
8
  installLogBrewReactNativeGlobalErrorHandler,
8
9
  installLogBrewReactNativePromiseRejectionTracker
@@ -43,10 +44,13 @@ declare const defaultExport: Omit<
43
44
  createLogBrewReactNativeClient: typeof createLogBrewReactNativeClient;
44
45
  createDefaultLogBrewReactNativeClient: typeof createDefaultLogBrewReactNativeClient;
45
46
  getLogBrewAppleNativeDiagnosticsStatus: typeof import("./apple-native-diagnostics.js").getLogBrewAppleNativeDiagnosticsStatus;
47
+ getLogBrewAndroidNativeDiagnosticsStatus: typeof import("./android-native-diagnostics.js").getLogBrewAndroidNativeDiagnosticsStatus;
46
48
  installLogBrewAppleNativeDiagnostics: typeof import("./apple-native-diagnostics.js").installLogBrewAppleNativeDiagnostics;
49
+ installLogBrewAndroidNativeDiagnostics: typeof import("./android-native-diagnostics.js").installLogBrewAndroidNativeDiagnostics;
47
50
  purgeLogBrewReactNativePersistentQueue: typeof purgeLogBrewReactNativePersistentQueue;
48
51
  replayLogBrewAppleNativeDiagnostics: typeof import("./apple-native-diagnostics.js").replayLogBrewAppleNativeDiagnostics;
49
52
  setLogBrewAppleNativeCrashContext: typeof import("./apple-native-diagnostics.js").setLogBrewAppleNativeCrashContext;
53
+ uninstallLogBrewAndroidNativeDiagnostics: typeof import("./android-native-diagnostics.js").uninstallLogBrewAndroidNativeDiagnostics;
50
54
  };
51
55
 
52
56
  export default defaultExport;
package/index.native.js CHANGED
@@ -17,6 +17,11 @@ import {
17
17
  setLogBrewAppleNativeCrashContext,
18
18
  syncLogBrewAppleNativeCrashBreadcrumbs
19
19
  } from "./apple-native-diagnostics.js";
20
+ import {
21
+ getLogBrewAndroidNativeDiagnosticsStatus,
22
+ installLogBrewAndroidNativeDiagnostics,
23
+ uninstallLogBrewAndroidNativeDiagnostics
24
+ } from "./android-native-diagnostics.js";
20
25
  import {
21
26
  purgeReactNativePersistentQueue,
22
27
  resolveReactNativePersistentEventStore
@@ -31,6 +36,11 @@ export {
31
36
  replayLogBrewAppleNativeDiagnostics,
32
37
  setLogBrewAppleNativeCrashContext
33
38
  };
39
+ export {
40
+ getLogBrewAndroidNativeDiagnosticsStatus,
41
+ installLogBrewAndroidNativeDiagnostics,
42
+ uninstallLogBrewAndroidNativeDiagnostics
43
+ };
34
44
 
35
45
  export * from "./index.js";
36
46
 
@@ -40,6 +50,7 @@ if (typeof nativeRuntime?.secureRandomHex === "function") {
40
50
  globalThis[Symbol.for("co.logbrew.react-native.secure-random-hex")] =
41
51
  nativeRuntime.secureRandomHex.bind(nativeRuntime);
42
52
  }
53
+ const DURABLE_QUEUE = Symbol.for("co.logbrew.react-native.durable-event-queue");
43
54
 
44
55
  export function createLogBrewReactNativeClient(config = {}) {
45
56
  const input = config !== null && typeof config === "object" ? config : {};
@@ -69,14 +80,18 @@ export function createLogBrewReactNativeClient(config = {}) {
69
80
  hasExplicitPersistentQueue
70
81
  });
71
82
  try {
72
- return bindAppleNativeCrashBreadcrumbs(createPlatformNeutralClient({
83
+ const client = createPlatformNeutralClient({
73
84
  ...forwarded,
74
85
  apiKey,
75
86
  clientKey,
76
87
  eventStore: resolved.eventStore,
77
88
  maxQueueBytes,
78
89
  maxQueueSize
79
- }));
90
+ });
91
+ if (resolved.durable) {
92
+ Object.defineProperty(client, DURABLE_QUEUE, { value: true });
93
+ }
94
+ return bindAppleNativeCrashBreadcrumbs(client);
80
95
  } catch (error) {
81
96
  resolved.abort();
82
97
  throw error;
@@ -205,11 +220,14 @@ const defaultExport = {
205
220
  createDefaultLogBrewReactNativeClient,
206
221
  createLogBrewReactNativeClient,
207
222
  getDefaultReactNativeContext,
223
+ getLogBrewAndroidNativeDiagnosticsStatus,
208
224
  getLogBrewAppleNativeDiagnosticsStatus,
209
225
  installLogBrewAppleNativeDiagnostics,
226
+ installLogBrewAndroidNativeDiagnostics,
210
227
  purgeLogBrewReactNativePersistentQueue,
211
228
  replayLogBrewAppleNativeDiagnostics,
212
- setLogBrewAppleNativeCrashContext
229
+ setLogBrewAppleNativeCrashContext,
230
+ uninstallLogBrewAndroidNativeDiagnostics
213
231
  };
214
232
 
215
233
  export default defaultExport;
@@ -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.23"
6
+ private static let sdkVersion = "0.1.24"
7
7
 
8
8
  private let lock = NSLock()
9
9
  private let replayQueue = DispatchQueue(label: "co.logbrew.react-native.apple-diagnostics-replay")
@@ -14,9 +14,7 @@ public enum LogBrewLoggerLevel: String, Sendable {
14
14
 
15
15
  var logBrewLevel: LogLevel {
16
16
  switch self {
17
- case .trace, .debug:
18
- .info
19
- case .info, .notice:
17
+ case .trace, .debug, .info, .notice:
20
18
  .info
21
19
  case .warning:
22
20
  .warning
@@ -73,7 +71,7 @@ public final class LogBrewLogger {
73
71
  baseMetadata = metadata ?? [:]
74
72
  self.transport = transport
75
73
  self.flushOnLog = flushOnLog
76
- self.timestampProvider = timestampProvider ?? Self.defaultTimestamp
74
+ self.timestampProvider = timestampProvider ?? currentTelemetryTimestamp
77
75
  self.onError = onError
78
76
  }
79
77
 
@@ -246,12 +244,6 @@ public final class LogBrewLogger {
246
244
  }
247
245
  return merged
248
246
  }
249
-
250
- private static func defaultTimestamp() -> String {
251
- let formatter = ISO8601DateFormatter()
252
- formatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds]
253
- return formatter.string(from: Date())
254
- }
255
247
  }
256
248
 
257
249
  private struct LogBrewLoggerCall {
@@ -0,0 +1,56 @@
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 extension LogBrewClient {
6
+ /// Runs one app-owned operation under a root or child trace and records its span.
7
+ @discardableResult
8
+ func withOperation<Result>(
9
+ _ name: String,
10
+ metadata: Metadata? = nil,
11
+ onOperationError: ((any Error) -> Void)? = nil,
12
+ onCaptureError: ((any Error) -> Void)? = nil,
13
+ operation: () async throws -> Result,
14
+ ) async throws -> Result {
15
+ let operationName = name.trimmingCharacters(in: .whitespacesAndNewlines)
16
+ try requireNonEmpty("operation name", operationName)
17
+ let context = LogBrewTrace.current.map(LogBrewTrace.childContext(of:)) ?? LogBrewTrace
18
+ .continueOrCreateContext(fromTraceparent: nil)
19
+ let startedAtMs = ProcessInfo.processInfo.systemUptime * 1000
20
+ func finish(_ error: (any Error)? = nil) {
21
+ do {
22
+ var evidence = metadata ?? [:]
23
+ evidence["source"] = .string("swift.operation")
24
+ if let error {
25
+ evidence["errorType"] = .string(String(reflecting: type(of: error)))
26
+ }
27
+ try span(
28
+ "swift_operation_span_\(context.spanId)",
29
+ timestamp: currentTelemetryTimestamp(),
30
+ attributes: LogBrewTrace.spanAttributes(
31
+ name: operationName,
32
+ status: error == nil ? .ok : .error,
33
+ durationMs: max(0, ProcessInfo.processInfo.systemUptime * 1000 - startedAtMs),
34
+ metadata: evidence,
35
+ context: context,
36
+ ),
37
+ )
38
+ } catch {
39
+ onCaptureError?(error)
40
+ }
41
+ }
42
+
43
+ return try await LogBrewTrace.withContext(context) {
44
+ do {
45
+ let result = try await operation()
46
+ finish()
47
+ return result
48
+ } catch {
49
+ let operationError = error
50
+ onOperationError?(operationError)
51
+ finish(operationError)
52
+ throw operationError
53
+ }
54
+ }
55
+ }
56
+ }
@@ -23,7 +23,6 @@ public final class LogBrewURLSessionTracer: @unchecked Sendable {
23
23
  nowMsProvider: (@Sendable () -> Double)? = nil,
24
24
  onCaptureError: (@Sendable (any Error) -> Void)? = nil,
25
25
  ) throws {
26
- let loader = LogBrewURLSessionDataLoader(session: session)
27
26
  try self.init(
28
27
  client: client,
29
28
  eventIDPrefix: eventIDPrefix,
@@ -31,7 +30,7 @@ public final class LogBrewURLSessionTracer: @unchecked Sendable {
31
30
  nowMsProvider: nowMsProvider,
32
31
  onCaptureError: onCaptureError,
33
32
  dataLoader: { request in
34
- try await loader.data(for: request)
33
+ try await session.data(for: request)
35
34
  },
36
35
  )
37
36
  }
@@ -48,7 +47,7 @@ public final class LogBrewURLSessionTracer: @unchecked Sendable {
48
47
  try requireNonEmpty("URLSession tracer eventIDPrefix", normalizedPrefix)
49
48
  self.client = client
50
49
  self.eventIDPrefix = normalizedPrefix
51
- self.timestampProvider = timestampProvider ?? Self.defaultTimestamp
50
+ self.timestampProvider = timestampProvider ?? currentTelemetryTimestamp
52
51
  self.nowMsProvider = nowMsProvider ?? Self.defaultNowMs
53
52
  self.onCaptureError = onCaptureError
54
53
  self.dataLoader = dataLoader
@@ -60,45 +59,63 @@ public final class LogBrewURLSessionTracer: @unchecked Sendable {
60
59
  routeTemplate: String? = nil,
61
60
  eventID: String? = nil,
62
61
  metadata: Metadata? = nil,
62
+ onRequestError: (@Sendable (any Error) throws -> Void)? = nil,
63
63
  ) async throws -> (Data, URLResponse) {
64
64
  let span = try LogBrewTrace.startURLSessionSpan(for: request, routeTemplate: routeTemplate)
65
65
  let startedAtMs = nowMsProvider()
66
66
 
67
67
  do {
68
68
  let (data, response) = try await dataLoader(span.request)
69
- captureSpan(URLSessionTraceCapture(
70
- eventID: eventID,
71
- span: span,
72
- response: response,
73
- durationMs: durationSince(startedAtMs),
74
- error: nil,
75
- metadata: metadata,
76
- ))
69
+ let durationMs = durationSince(startedAtMs)
70
+ captureSpan(eventID, span: span, response: response, durationMs: durationMs, metadata: metadata)
77
71
  return (data, response)
78
72
  } catch {
79
- captureSpan(URLSessionTraceCapture(
80
- eventID: eventID,
81
- span: span,
82
- response: nil,
83
- durationMs: durationSince(startedAtMs),
84
- error: error,
85
- metadata: metadata,
86
- ))
73
+ let durationMs = durationSince(startedAtMs)
74
+ captureSpan(eventID, span: span, durationMs: durationMs, error: error, metadata: metadata)
75
+ captureRequestError(error, context: span.traceContext, handler: onRequestError)
87
76
  throw error
88
77
  }
89
78
  }
90
79
 
91
- private func captureSpan(_ capture: URLSessionTraceCapture) {
92
- do {
80
+ private func captureRequestError(
81
+ _ requestError: any Error,
82
+ context: LogBrewTraceContext,
83
+ handler: (@Sendable (any Error) throws -> Void)?,
84
+ ) {
85
+ guard let handler else {
86
+ return
87
+ }
88
+ capture {
89
+ try LogBrewTrace.withContext(context) {
90
+ try handler(requestError)
91
+ }
92
+ }
93
+ }
94
+
95
+ private func captureSpan(
96
+ _ eventID: String?,
97
+ span: LogBrewURLSessionSpan,
98
+ response: URLResponse? = nil,
99
+ durationMs: Double,
100
+ error: (any Error)? = nil,
101
+ metadata: Metadata?,
102
+ ) {
103
+ capture {
93
104
  try client.captureURLSessionSpan(
94
- capture.eventID ?? nextEventID(),
105
+ eventID ?? nextEventID(),
95
106
  timestamp: timestampProvider(),
96
- span: capture.span,
97
- statusCode: (capture.response as? HTTPURLResponse)?.statusCode,
98
- durationMs: capture.durationMs,
99
- error: capture.error,
100
- metadata: capture.metadata,
107
+ span: span,
108
+ statusCode: (response as? HTTPURLResponse)?.statusCode,
109
+ durationMs: durationMs,
110
+ error: error,
111
+ metadata: metadata,
101
112
  )
113
+ }
114
+ }
115
+
116
+ private func capture(_ operation: () throws -> Void) {
117
+ do {
118
+ try operation()
102
119
  } catch {
103
120
  onCaptureError?(error)
104
121
  }
@@ -113,39 +130,14 @@ public final class LogBrewURLSessionTracer: @unchecked Sendable {
113
130
  }
114
131
 
115
132
  private func nextEventID() -> String {
116
- lock.lock()
117
- defer {
118
- lock.unlock()
133
+ lock.withLock {
134
+ let eventID = "\(eventIDPrefix)_\(nextEventSequence)"
135
+ nextEventSequence += 1
136
+ return eventID
119
137
  }
120
- let eventID = "\(eventIDPrefix)_\(nextEventSequence)"
121
- nextEventSequence += 1
122
- return eventID
123
- }
124
-
125
- private static func defaultTimestamp() -> String {
126
- let formatter = ISO8601DateFormatter()
127
- formatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds]
128
- return formatter.string(from: Date())
129
138
  }
130
139
 
131
140
  private static func defaultNowMs() -> Double {
132
141
  ProcessInfo.processInfo.systemUptime * 1000
133
142
  }
134
143
  }
135
-
136
- private struct URLSessionTraceCapture {
137
- let eventID: String?
138
- let span: LogBrewURLSessionSpan
139
- let response: URLResponse?
140
- let durationMs: Double
141
- let error: (any Error)?
142
- let metadata: Metadata?
143
- }
144
-
145
- private struct LogBrewURLSessionDataLoader: @unchecked Sendable {
146
- let session: URLSession
147
-
148
- func data(for request: URLRequest) async throws -> (Data, URLResponse) {
149
- try await session.data(for: request)
150
- }
151
- }