@logbrew/react-native 0.1.0 → 0.1.1

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 (47) hide show
  1. package/README.md +407 -18
  2. package/apollo.cjs +301 -0
  3. package/apollo.d.cts +70 -0
  4. package/apollo.d.ts +70 -0
  5. package/apollo.js +294 -0
  6. package/examples/apollo-link-spans.mjs +149 -0
  7. package/examples/index.mjs +29 -1
  8. package/examples/instrumentation-kit.mjs +304 -0
  9. package/examples/lifecycle-spans.mjs +131 -0
  10. package/examples/native-bridge-scope.mjs +107 -0
  11. package/examples/navigation-resource-spans.mjs +156 -0
  12. package/examples/package.json +8 -1
  13. package/examples/real-user-smoke.mjs +106 -9
  14. package/examples/resource-fetch-spans.mjs +133 -0
  15. package/examples/trace-correlation.mjs +135 -0
  16. package/index.cjs +625 -111
  17. package/index.d.cts +236 -0
  18. package/index.d.ts +236 -0
  19. package/index.js +613 -95
  20. package/index.native.js +18 -0
  21. package/instrumentation.cjs +639 -0
  22. package/instrumentation.d.cts +84 -0
  23. package/instrumentation.d.ts +84 -0
  24. package/instrumentation.js +634 -0
  25. package/lifecycle.cjs +129 -0
  26. package/lifecycle.d.cts +50 -0
  27. package/lifecycle.d.ts +50 -0
  28. package/lifecycle.js +121 -0
  29. package/metadata.cjs +175 -0
  30. package/metadata.js +165 -0
  31. package/metro.cjs +310 -0
  32. package/metro.d.cts +37 -0
  33. package/metro.d.ts +37 -0
  34. package/metro.js +6 -0
  35. package/native-bridge.cjs +127 -0
  36. package/native-bridge.d.cts +60 -0
  37. package/native-bridge.d.ts +60 -0
  38. package/native-bridge.js +125 -0
  39. package/package.json +113 -4
  40. package/release-artifacts.cjs +344 -0
  41. package/release-artifacts.d.cts +55 -0
  42. package/release-artifacts.d.ts +53 -0
  43. package/release-artifacts.js +8 -0
  44. package/resource-fetch.cjs +469 -0
  45. package/resource-fetch.d.cts +60 -0
  46. package/resource-fetch.d.ts +60 -0
  47. package/resource-fetch.js +464 -0
@@ -0,0 +1,304 @@
1
+ import { RecordingTransport } from "@logbrew/sdk";
2
+ import {
3
+ createLogBrewReactNativeClient,
4
+ createReactNativeTraceContext
5
+ } from "@logbrew/react-native";
6
+ import { createLogBrewReactNativeInstrumentation } from "@logbrew/react-native/instrumentation";
7
+
8
+ const platform = {
9
+ OS: "ios",
10
+ Version: "18.5",
11
+ isPad: false,
12
+ constants: { isTesting: true }
13
+ };
14
+ const appStateListeners = new Set();
15
+ const appState = {
16
+ currentState: "active",
17
+ addEventListener(_type, listener) {
18
+ appStateListeners.add(listener);
19
+ return {
20
+ remove() {
21
+ appStateListeners.delete(listener);
22
+ }
23
+ };
24
+ }
25
+ };
26
+ const navigationListeners = new Map();
27
+ const navigation = {
28
+ route: { key: "Checkout-abc123", name: "Checkout", path: "/checkout?email=dev@example.test" },
29
+ addListener(name, listener) {
30
+ navigationListeners.set(name, listener);
31
+ return {
32
+ remove() {
33
+ navigationListeners.delete(name);
34
+ }
35
+ };
36
+ },
37
+ getCurrentRoute() {
38
+ return this.route;
39
+ }
40
+ };
41
+ const nativeBridgeCalls = [];
42
+ const nativeBridge = {
43
+ setLogBrewScope(scope) {
44
+ nativeBridgeCalls.push({ kind: "set", scope });
45
+ },
46
+ clearLogBrewScope() {
47
+ nativeBridgeCalls.push({ kind: "clear" });
48
+ }
49
+ };
50
+ const requests = [];
51
+ const globalObject = {
52
+ async fetch(input, init = {}) {
53
+ requests.push({ input, init, source: "global" });
54
+ return { status: 206 };
55
+ }
56
+ };
57
+ class MockXMLHttpRequest {
58
+ static HEADERS_RECEIVED = 2;
59
+ static DONE = 4;
60
+
61
+ constructor() {
62
+ this.headers = {};
63
+ this.listeners = new Map();
64
+ this.readyState = 0;
65
+ this.status = 0;
66
+ }
67
+
68
+ addEventListener(name, listener) {
69
+ this.listeners.set(name, listener);
70
+ }
71
+
72
+ open(method, url) {
73
+ this.method = method;
74
+ this.url = url;
75
+ }
76
+
77
+ send(body) {
78
+ requests.push({
79
+ body,
80
+ headers: { ...this.headers },
81
+ method: this.method,
82
+ source: "xhr",
83
+ url: this.url
84
+ });
85
+ this.readyState = MockXMLHttpRequest.HEADERS_RECEIVED;
86
+ this.onreadystatechange?.();
87
+ this.listeners.get("readystatechange")?.();
88
+ this.status = 207;
89
+ this.readyState = MockXMLHttpRequest.DONE;
90
+ this.onreadystatechange?.();
91
+ this.listeners.get("readystatechange")?.();
92
+ }
93
+
94
+ getResponseHeader(name) {
95
+ if (String(name).toLowerCase() === "content-length") {
96
+ return "2048";
97
+ }
98
+ return null;
99
+ }
100
+
101
+ setRequestHeader(name, value) {
102
+ this.headers[String(name).toLowerCase()] = String(value);
103
+ }
104
+ }
105
+ globalObject.XMLHttpRequest = MockXMLHttpRequest;
106
+ const originalGlobalFetch = globalObject.fetch;
107
+ const originalXhrOpen = MockXMLHttpRequest.prototype.open;
108
+ const originalXhrSend = MockXMLHttpRequest.prototype.send;
109
+ const originalXhrSetRequestHeader = MockXMLHttpRequest.prototype.setRequestHeader;
110
+ const client = createLogBrewReactNativeClient({
111
+ clientKey: "LOGBREW_CLIENT_KEY",
112
+ sdkName: "logbrew-react-native-instrumentation-kit",
113
+ sdkVersion: "0.1.0",
114
+ maxRetries: 1
115
+ });
116
+ const trace = createReactNativeTraceContext({
117
+ traceparent: "00-4bf92f3577b34da6a3ce929d0e0e4736-00f067aa0ba902b7-01",
118
+ spanId: "e2ad6b7169206664"
119
+ });
120
+ const timestamps = [
121
+ "2026-06-02T10:40:00Z",
122
+ "2026-06-02T10:40:01Z",
123
+ "2026-06-02T10:40:02Z",
124
+ "2026-06-02T10:40:03Z",
125
+ "2026-06-02T10:40:04Z",
126
+ "2026-06-02T10:40:05Z",
127
+ "2026-06-02T10:40:06Z"
128
+ ];
129
+ const times = [1000, 1125, 1260, 1300, 1380, 1600, 1775, 1900, 1920, 1945, 2100, 2115, 2160];
130
+
131
+ const instrumentation = createLogBrewReactNativeInstrumentation(client, {
132
+ appState,
133
+ captureInitialLifecycleState: true,
134
+ captureInitialNavigationRoute: true,
135
+ fetchImpl: async (input, init = {}) => {
136
+ requests.push({ input, init, source: "resourceFetch" });
137
+ return { status: 202 };
138
+ },
139
+ globalObject,
140
+ instrumentGlobalFetch: true,
141
+ instrumentGlobalXMLHttpRequest: true,
142
+ logger: "NativeCheckout",
143
+ metadata: { flow: "checkout", nested: { dropped: true }, traceId: "spoofed" },
144
+ nativeBridge,
145
+ navigationContainer: navigation,
146
+ now: () => timestamps.shift(),
147
+ nowMs: () => times.shift(),
148
+ platform,
149
+ screen: "Checkout",
150
+ sessionId: "session_mobile_001",
151
+ trace,
152
+ tracePropagationTargets: ["https://api.example.test/"]
153
+ });
154
+
155
+ await instrumentation.withNativeBridgeScope(async (scope) => {
156
+ client.log("evt_instrumentation_native_bridge", "2026-06-02T10:40:02Z", {
157
+ message: "native bridge work started",
158
+ level: "info",
159
+ logger: "NativeCheckout",
160
+ metadata: {
161
+ ...scope.metadata,
162
+ ...scope.trace
163
+ }
164
+ });
165
+ });
166
+
167
+ await instrumentation.resourceFetch("https://api.example.test/api/checkout?email=dev@example.test#pay", {
168
+ method: "POST",
169
+ headers: { accept: "application/json" }
170
+ });
171
+
172
+ navigationListeners.get("__unsafe_action__")?.({ data: { action: { type: "NAVIGATE" } } });
173
+ navigation.route = {
174
+ key: "CheckoutComplete-def456",
175
+ name: "CheckoutComplete",
176
+ path: "/checkout/complete?email=dev@example.test#done"
177
+ };
178
+ navigationListeners.get("state")?.();
179
+
180
+ appState.currentState = "background";
181
+ for (const listener of appStateListeners) {
182
+ listener("background");
183
+ }
184
+
185
+ if (globalObject.fetch === originalGlobalFetch) {
186
+ throw new Error("instrumentGlobalFetch should patch the supplied global object");
187
+ }
188
+ await globalObject.fetch("https://api.example.test/api/global?email=dev@example.test#pay", {
189
+ method: "GET"
190
+ });
191
+ if (MockXMLHttpRequest.prototype.open === originalXhrOpen) {
192
+ throw new Error("instrumentGlobalXMLHttpRequest should patch the supplied XMLHttpRequest prototype");
193
+ }
194
+ const xhr = new globalObject.XMLHttpRequest();
195
+ xhr.open("PUT", "https://api.example.test/api/xhr?email=dev@example.test#pay");
196
+ xhr.setRequestHeader("Accept", "application/json");
197
+ xhr.send("ignored-body");
198
+
199
+ instrumentation.remove();
200
+ navigation.route = { key: "Ignored-ghi789", name: "Ignored", path: "/ignored" };
201
+ navigationListeners.get("state")?.();
202
+ for (const listener of appStateListeners) {
203
+ listener("active");
204
+ }
205
+ instrumentation.stop();
206
+ if (globalObject.fetch !== originalGlobalFetch) {
207
+ throw new Error("instrumentation remove should put global fetch back");
208
+ }
209
+ if (
210
+ MockXMLHttpRequest.prototype.open !== originalXhrOpen ||
211
+ MockXMLHttpRequest.prototype.send !== originalXhrSend ||
212
+ MockXMLHttpRequest.prototype.setRequestHeader !== originalXhrSetRequestHeader
213
+ ) {
214
+ throw new Error("instrumentation remove should put XHR open/send back and leave setRequestHeader untouched");
215
+ }
216
+
217
+ const events = JSON.parse(client.previewJson()).events;
218
+ if (events.length !== 8) {
219
+ throw new Error(`expected eight instrumentation events, got ${events.length}`);
220
+ }
221
+ if (requests[0].init.headers.traceparent !== `00-${trace.traceId}-${trace.spanId}-01`) {
222
+ throw new Error(`expected propagated traceparent, got ${requests[0].init.headers.traceparent}`);
223
+ }
224
+ if (requests[1].init.headers.traceparent !== `00-${trace.traceId}-${trace.spanId}-01`) {
225
+ throw new Error(`expected global fetch traceparent, got ${requests[1].init.headers.traceparent}`);
226
+ }
227
+ if (requests[2].headers.traceparent !== `00-${trace.traceId}-${trace.spanId}-01`) {
228
+ throw new Error(`expected global XHR traceparent, got ${requests[2].headers.traceparent}`);
229
+ }
230
+ if (appStateListeners.size !== 0 || navigationListeners.size !== 0) {
231
+ throw new Error("instrumentation remove should detach lifecycle and navigation listeners");
232
+ }
233
+ if (nativeBridgeCalls.map((call) => call.kind).join(",") !== "set,set,clear,clear") {
234
+ throw new Error(`unexpected native bridge calls: ${JSON.stringify(nativeBridgeCalls)}`);
235
+ }
236
+ for (const event of events) {
237
+ const metadata = event.attributes.metadata ?? {};
238
+ if (event.type !== "span" && event.type !== "log") {
239
+ throw new Error(`unexpected instrumentation event type: ${event.type}`);
240
+ }
241
+ if (metadata.traceId !== trace.traceId || metadata.spanId !== trace.spanId) {
242
+ throw new Error(`instrumentation event should share trace metadata: ${JSON.stringify(event)}`);
243
+ }
244
+ if (metadata.nested !== undefined) {
245
+ throw new Error(`nested metadata should be dropped: ${JSON.stringify(metadata)}`);
246
+ }
247
+ }
248
+ const sources = events.map((event) => event.attributes.metadata?.source);
249
+ if (!sources.includes("react-native.lifecycle") || !sources.includes("react-native.navigation")) {
250
+ throw new Error(`expected lifecycle and navigation sources: ${JSON.stringify(sources)}`);
251
+ }
252
+ if (!sources.includes("react-native.resource") || !sources.includes("react-native.instrumentation")) {
253
+ throw new Error(`expected resource and instrumentation sources: ${JSON.stringify(sources)}`);
254
+ }
255
+ const resource = events.find((event) => event.attributes.metadata?.source === "react-native.resource")?.attributes;
256
+ if (
257
+ resource?.name !== "POST /api/checkout" ||
258
+ resource.durationMs !== 175 ||
259
+ resource.metadata.routeTemplate !== "/api/checkout" ||
260
+ resource.metadata.responseStartDurationMs !== 135
261
+ ) {
262
+ throw new Error(`unexpected resource span: ${JSON.stringify(resource)}`);
263
+ }
264
+ const globalResource = events.find((event) => event.attributes.name === "GET /api/global")?.attributes;
265
+ if (
266
+ globalResource?.durationMs !== 45 ||
267
+ globalResource.metadata.routeTemplate !== "/api/global" ||
268
+ globalResource.metadata.responseStartDurationMs !== 20 ||
269
+ globalResource.metadata.statusCode !== 206
270
+ ) {
271
+ throw new Error(`unexpected global fetch span: ${JSON.stringify(globalResource)}`);
272
+ }
273
+ const xhrResource = events.find((event) => event.attributes.name === "PUT /api/xhr")?.attributes;
274
+ if (
275
+ xhrResource?.metadata.routeTemplate !== "/api/xhr" ||
276
+ xhrResource.metadata.statusCode !== 207 ||
277
+ xhrResource.metadata.responseStartDurationMs !== 15 ||
278
+ xhrResource.metadata.responseSizeBytes !== 2048 ||
279
+ xhrResource.metadata.body !== undefined
280
+ ) {
281
+ throw new Error(`unexpected global XHR span: ${JSON.stringify(xhrResource)}`);
282
+ }
283
+ const navigationSpan = events.find((event) => event.attributes.name === "navigation:CheckoutComplete")?.attributes;
284
+ if (navigationSpan?.durationMs !== 220 || navigationSpan.metadata.routePath !== "/checkout/complete") {
285
+ throw new Error(`unexpected navigation span: ${JSON.stringify(navigationSpan)}`);
286
+ }
287
+ const lifecycleSpan = events.find((event) => event.attributes.name === "app_state:active->background")?.attributes;
288
+ if (lifecycleSpan?.durationMs !== 775 || lifecycleSpan.metadata.toAppState !== "background") {
289
+ throw new Error(`unexpected lifecycle span: ${JSON.stringify(lifecycleSpan)}`);
290
+ }
291
+
292
+ const preview = client.previewJson();
293
+ const response = await client.shutdown(RecordingTransport.alwaysAccept());
294
+ console.log(preview);
295
+ console.error(JSON.stringify({
296
+ ok: true,
297
+ calls: nativeBridgeCalls.map((call) => call.kind),
298
+ events: events.length,
299
+ globalFetchPutBack: globalObject.fetch === originalGlobalFetch,
300
+ globalXMLHttpRequestPutBack: MockXMLHttpRequest.prototype.open === originalXhrOpen,
301
+ propagatedTraceparent: requests[0].init.headers.traceparent,
302
+ status: response.statusCode,
303
+ traceId: trace.traceId
304
+ }));
@@ -0,0 +1,131 @@
1
+ import { RecordingTransport } from "@logbrew/sdk";
2
+ import {
3
+ createLogBrewReactNativeClient,
4
+ createReactNativeTraceContext
5
+ } from "@logbrew/react-native";
6
+ import { createAppStateLifecycleSpanListener } from "@logbrew/react-native/lifecycle";
7
+
8
+ const platform = {
9
+ OS: "ios",
10
+ Version: "18.0",
11
+ isPad: false,
12
+ constants: { isTesting: true }
13
+ };
14
+ const listeners = new Set();
15
+ const appState = {
16
+ currentState: "active",
17
+ addEventListener(type, listener) {
18
+ if (type !== "change") {
19
+ throw new Error(`unexpected listener type: ${type}`);
20
+ }
21
+ listeners.add(listener);
22
+ return {
23
+ remove() {
24
+ listeners.delete(listener);
25
+ }
26
+ };
27
+ }
28
+ };
29
+ const client = createLogBrewReactNativeClient({
30
+ clientKey: "LOGBREW_CLIENT_KEY",
31
+ sdkName: "logbrew-react-native-lifecycle-spans",
32
+ sdkVersion: "0.1.0",
33
+ maxRetries: 1
34
+ });
35
+ const trace = createReactNativeTraceContext({
36
+ traceparent: "00-4bf92f3577b34da6a3ce929d0e0e4736-00f067aa0ba902b7-01",
37
+ spanId: "c2ad6b7169204442"
38
+ });
39
+ const timestamps = [
40
+ "2026-06-02T10:30:00Z",
41
+ "2026-06-02T10:30:01Z",
42
+ "2026-06-02T10:30:02Z",
43
+ "2026-06-02T10:30:03Z"
44
+ ];
45
+ const timeMs = [1000, 1120, 1400, 1415];
46
+
47
+ const stopLifecycle = createAppStateLifecycleSpanListener(client, appState, {
48
+ captureInitialState: true,
49
+ metadata: { flow: "checkout", nested: { dropped: true } },
50
+ now: () => timestamps.shift(),
51
+ nowMs: () => timeMs.shift(),
52
+ platform,
53
+ screen: "Checkout",
54
+ sessionId: "session_mobile_001",
55
+ trace
56
+ });
57
+
58
+ emitAppState("inactive");
59
+ emitAppState("background");
60
+ emitAppState("active");
61
+ stopLifecycle();
62
+ emitAppState("background");
63
+
64
+ const events = JSON.parse(client.previewJson()).events;
65
+ if (events.length !== 4) {
66
+ throw new Error(`expected four lifecycle spans, got ${events.length}`);
67
+ }
68
+ for (const event of events) {
69
+ if (event.type !== "span") {
70
+ throw new Error(`expected span event, got ${event.type}`);
71
+ }
72
+ const metadata = event.attributes.metadata ?? {};
73
+ if (event.attributes.traceId !== trace.traceId || metadata.traceId !== trace.traceId) {
74
+ throw new Error(`lifecycle span should share trace: ${JSON.stringify(event)}`);
75
+ }
76
+ if (metadata.source !== "react-native.lifecycle" || metadata.screen !== "Checkout") {
77
+ throw new Error(`unexpected lifecycle metadata: ${JSON.stringify(metadata)}`);
78
+ }
79
+ if (metadata.nested !== undefined) {
80
+ throw new Error("nested lifecycle metadata should be dropped");
81
+ }
82
+ }
83
+ const initial = events[0].attributes;
84
+ if (initial.name !== "app_state:active" || initial.metadata.toAppState !== "active") {
85
+ throw new Error(`unexpected initial lifecycle span: ${JSON.stringify(initial)}`);
86
+ }
87
+ const inactive = events[1].attributes;
88
+ if (
89
+ inactive.name !== "app_state:active->inactive" ||
90
+ inactive.durationMs !== 120 ||
91
+ inactive.metadata.fromAppState !== "active" ||
92
+ inactive.metadata.toAppState !== "inactive"
93
+ ) {
94
+ throw new Error(`unexpected inactive lifecycle span: ${JSON.stringify(inactive)}`);
95
+ }
96
+ const background = events[2].attributes;
97
+ if (
98
+ background.name !== "app_state:inactive->background" ||
99
+ background.durationMs !== 280 ||
100
+ background.metadata.appState !== "background"
101
+ ) {
102
+ throw new Error(`unexpected background lifecycle span: ${JSON.stringify(background)}`);
103
+ }
104
+ const foreground = events[3].attributes;
105
+ if (
106
+ foreground.name !== "app_state:background->active" ||
107
+ foreground.durationMs !== 15 ||
108
+ foreground.metadata.appState !== "active"
109
+ ) {
110
+ throw new Error(`unexpected foreground lifecycle span: ${JSON.stringify(foreground)}`);
111
+ }
112
+
113
+ const preview = client.previewJson();
114
+ const response = await client.shutdown(RecordingTransport.alwaysAccept());
115
+ console.log(preview);
116
+ console.error(JSON.stringify({
117
+ ok: true,
118
+ events: events.length,
119
+ status: response.statusCode,
120
+ inactiveSpan: inactive.name,
121
+ backgroundSpan: background.name,
122
+ listenerRemoved: listeners.size === 0,
123
+ traceId: trace.traceId
124
+ }));
125
+
126
+ function emitAppState(state) {
127
+ appState.currentState = state;
128
+ for (const listener of Array.from(listeners)) {
129
+ listener(state);
130
+ }
131
+ }
@@ -0,0 +1,107 @@
1
+ import { RecordingTransport } from "@logbrew/sdk";
2
+ import {
3
+ createLogBrewReactNativeClient,
4
+ createReactNativeTraceContext
5
+ } from "@logbrew/react-native";
6
+ import {
7
+ createLogBrewNativeBridgeScope,
8
+ syncLogBrewNativeBridgeScope,
9
+ withLogBrewNativeBridgeScope
10
+ } from "@logbrew/react-native/native-bridge";
11
+
12
+ const client = createLogBrewReactNativeClient({
13
+ clientKey: "LOGBREW_CLIENT_KEY",
14
+ sdkName: "logbrew-react-native-native-bridge",
15
+ sdkVersion: "0.1.0",
16
+ maxRetries: 1
17
+ });
18
+ const trace = createReactNativeTraceContext({
19
+ traceparent: "00-4bf92f3577b34da6a3ce929d0e0e4736-00f067aa0ba902b7-01",
20
+ spanId: "d2ad6b7169205553"
21
+ });
22
+ const calls = [];
23
+ const nativeBridge = {
24
+ currentScope: undefined,
25
+ setLogBrewScope(scope) {
26
+ calls.push({ kind: "set", scope });
27
+ this.currentScope = scope;
28
+ },
29
+ clearLogBrewScope() {
30
+ calls.push({ kind: "clear" });
31
+ this.currentScope = undefined;
32
+ }
33
+ };
34
+
35
+ const previewScope = createLogBrewNativeBridgeScope({
36
+ logger: "NativeCheckout",
37
+ metadata: { routeTemplate: "/native/checkout", nested: { dropped: true }, traceId: "spoofed" },
38
+ screen: "Checkout",
39
+ sessionId: "session_mobile_001",
40
+ trace
41
+ });
42
+ if (previewScope.trace.traceId !== trace.traceId || previewScope.trace.spanId !== trace.spanId) {
43
+ throw new Error(`unexpected preview trace scope: ${JSON.stringify(previewScope)}`);
44
+ }
45
+ if (previewScope.metadata.nested !== undefined || previewScope.metadata.traceId !== undefined) {
46
+ throw new Error(`bridge preview metadata should be primitive-only: ${JSON.stringify(previewScope.metadata)}`);
47
+ }
48
+
49
+ const syncedScope = syncLogBrewNativeBridgeScope(nativeBridge, {
50
+ logger: "NativeCheckout",
51
+ metadata: { routeTemplate: "/native/preview" },
52
+ screen: "Checkout",
53
+ trace
54
+ });
55
+ if (nativeBridge.currentScope?.trace?.traceId !== trace.traceId) {
56
+ throw new Error("native bridge should receive synced trace scope");
57
+ }
58
+ if (syncedScope.metadata.routeTemplate !== "/native/preview") {
59
+ throw new Error(`unexpected synced scope metadata: ${JSON.stringify(syncedScope.metadata)}`);
60
+ }
61
+
62
+ await withLogBrewNativeBridgeScope(nativeBridge, {
63
+ logger: "NativeCheckout",
64
+ metadata: { routeTemplate: "/native/checkout", nested: { dropped: true } },
65
+ screen: "Checkout",
66
+ sessionId: "session_mobile_001",
67
+ trace
68
+ }, async (scope) => {
69
+ if (nativeBridge.currentScope?.trace?.spanId !== trace.spanId) {
70
+ throw new Error("native bridge scope should be active during async callback");
71
+ }
72
+ client.log("evt_native_bridge_scope", "2026-06-02T10:30:00Z", {
73
+ message: "native bridge scope synced",
74
+ level: "info",
75
+ logger: "NativeCheckout",
76
+ metadata: {
77
+ ...scope.metadata,
78
+ ...scope.trace
79
+ }
80
+ });
81
+ });
82
+ if (nativeBridge.currentScope !== undefined) {
83
+ throw new Error("native bridge scope should be cleared after callback");
84
+ }
85
+ const events = JSON.parse(client.previewJson()).events;
86
+ const event = events[0]?.attributes;
87
+ if (
88
+ events.length !== 1 ||
89
+ event.metadata.traceId !== trace.traceId ||
90
+ event.metadata.spanId !== trace.spanId ||
91
+ event.metadata.parentSpanId !== trace.parentSpanId ||
92
+ event.metadata.nested !== undefined
93
+ ) {
94
+ throw new Error(`unexpected native bridge event: ${JSON.stringify(events)}`);
95
+ }
96
+
97
+ const preview = client.previewJson();
98
+ const response = await client.shutdown(RecordingTransport.alwaysAccept());
99
+ console.log(preview);
100
+ console.error(JSON.stringify({
101
+ ok: true,
102
+ calls: calls.map((call) => call.kind),
103
+ events: events.length,
104
+ status: response.statusCode,
105
+ traceId: trace.traceId,
106
+ spanId: trace.spanId
107
+ }));
@@ -0,0 +1,156 @@
1
+ import { RecordingTransport } from "@logbrew/sdk";
2
+ import {
3
+ captureReactNativeResourceSpan,
4
+ createLogBrewReactNativeClient,
5
+ createReactNavigationSpanListener,
6
+ createReactNativeResourceSpanEvent,
7
+ createReactNativeTraceContext,
8
+ withLogBrewTrace
9
+ } from "@logbrew/react-native";
10
+
11
+ const platform = {
12
+ OS: "android",
13
+ Version: "16",
14
+ isPad: false,
15
+ constants: { isTesting: true }
16
+ };
17
+ const appState = { currentState: "active" };
18
+ const client = createLogBrewReactNativeClient({
19
+ clientKey: "LOGBREW_CLIENT_KEY",
20
+ sdkName: "logbrew-react-native-navigation-resource-spans",
21
+ sdkVersion: "0.1.0",
22
+ maxRetries: 1
23
+ });
24
+ const trace = createReactNativeTraceContext({
25
+ traceparent: "00-4bf92f3577b34da6a3ce929d0e0e4736-00f067aa0ba902b7-01",
26
+ spanId: "c2ad6b7169204442"
27
+ });
28
+ const routeListeners = new Map();
29
+ const navigation = {
30
+ route: { key: "Checkout-abc123", name: "Checkout", path: "/checkout?email=dev@example.test" },
31
+ addListener(name, listener) {
32
+ routeListeners.set(name, listener);
33
+ return {
34
+ remove() {
35
+ routeListeners.delete(name);
36
+ }
37
+ };
38
+ },
39
+ getCurrentRoute() {
40
+ return this.route;
41
+ }
42
+ };
43
+ const timestamps = [
44
+ "2026-06-02T10:20:00Z",
45
+ "2026-06-02T10:20:01Z",
46
+ "2026-06-02T10:20:02Z"
47
+ ];
48
+ const timeMs = [1000, 1128, 1171];
49
+
50
+ withLogBrewTrace(trace, () => {
51
+ const stopNavigation = createReactNavigationSpanListener(client, navigation, {
52
+ captureInitialRoute: true,
53
+ metadata: { flow: "checkout", nested: { dropped: true } },
54
+ now: () => timestamps.shift(),
55
+ nowMs: () => timeMs.shift(),
56
+ platform,
57
+ appState
58
+ });
59
+
60
+ routeListeners.get("__unsafe_action__")?.({ data: { action: { type: "NAVIGATE" } } });
61
+ navigation.route = {
62
+ key: "CheckoutComplete-def456",
63
+ name: "CheckoutComplete",
64
+ path: "/checkout/complete?email=dev@example.test#done"
65
+ };
66
+ routeListeners.get("state")?.();
67
+ stopNavigation();
68
+
69
+ captureReactNativeResourceSpan(client, {
70
+ id: "evt_resource_checkout_post",
71
+ timestamp: "2026-06-02T10:20:03Z",
72
+ durationMs: 171,
73
+ method: "post",
74
+ routeTemplate: "/api/checkout?email=dev@example.test#pay",
75
+ statusCode: 202,
76
+ responseSizeBytes: 512,
77
+ screen: "CheckoutComplete",
78
+ sessionId: "session_mobile_001",
79
+ platform,
80
+ appState
81
+ });
82
+ });
83
+
84
+ const directResource = createReactNativeResourceSpanEvent({
85
+ id: "evt_resource_checkout_retry",
86
+ timestamp: "2026-06-02T10:20:04Z",
87
+ durationMs: 88,
88
+ method: "get",
89
+ routeTemplate: "/api/cart?itemId=123#items",
90
+ statusCode: 503,
91
+ screen: "Checkout",
92
+ trace
93
+ });
94
+ client.span(directResource.id, directResource.timestamp, directResource.attributes);
95
+
96
+ const events = JSON.parse(client.previewJson()).events;
97
+ if (events.length !== 4) {
98
+ throw new Error(`expected four span events, got ${events.length}`);
99
+ }
100
+ for (const event of events) {
101
+ if (event.type !== "span") {
102
+ throw new Error(`expected span event, got ${event.type}`);
103
+ }
104
+ if (event.attributes.traceId !== trace.traceId) {
105
+ throw new Error(`span should share trace: ${JSON.stringify(event)}`);
106
+ }
107
+ const metadata = event.attributes.metadata ?? {};
108
+ if (metadata.traceId !== trace.traceId || metadata.spanId !== trace.spanId) {
109
+ throw new Error(`span metadata should include trace fields: ${JSON.stringify(metadata)}`);
110
+ }
111
+ if (metadata.routeKey !== undefined || metadata.previousRouteKey !== undefined) {
112
+ throw new Error("route keys should be opt-in to avoid high-cardinality defaults");
113
+ }
114
+ if (metadata.nested !== undefined) {
115
+ throw new Error("nested metadata should be dropped");
116
+ }
117
+ }
118
+ const initialNavigation = events[0].attributes;
119
+ if (initialNavigation.name !== "navigation:Checkout" || initialNavigation.metadata.previousRouteName !== undefined) {
120
+ throw new Error(`unexpected initial navigation span: ${JSON.stringify(initialNavigation)}`);
121
+ }
122
+ const changedNavigation = events[1].attributes;
123
+ if (
124
+ changedNavigation.name !== "navigation:CheckoutComplete" ||
125
+ changedNavigation.durationMs !== 128 ||
126
+ changedNavigation.metadata.routePath !== "/checkout/complete" ||
127
+ changedNavigation.metadata.actionType !== "NAVIGATE" ||
128
+ changedNavigation.metadata.previousRouteName !== "Checkout"
129
+ ) {
130
+ throw new Error(`unexpected route change span: ${JSON.stringify(changedNavigation)}`);
131
+ }
132
+ const resourceSpan = events[2].attributes;
133
+ if (
134
+ resourceSpan.name !== "POST /api/checkout" ||
135
+ resourceSpan.status !== "ok" ||
136
+ resourceSpan.metadata.routeTemplate !== "/api/checkout" ||
137
+ resourceSpan.metadata.responseSizeBytes !== 512
138
+ ) {
139
+ throw new Error(`unexpected resource span: ${JSON.stringify(resourceSpan)}`);
140
+ }
141
+ const failedResourceSpan = events[3].attributes;
142
+ if (failedResourceSpan.name !== "GET /api/cart" || failedResourceSpan.status !== "error") {
143
+ throw new Error(`unexpected failed resource span: ${JSON.stringify(failedResourceSpan)}`);
144
+ }
145
+
146
+ const preview = client.previewJson();
147
+ const response = await client.shutdown(RecordingTransport.alwaysAccept());
148
+ console.log(preview);
149
+ console.error(JSON.stringify({
150
+ ok: true,
151
+ events: events.length,
152
+ status: response.statusCode,
153
+ navigationSpan: changedNavigation.name,
154
+ resourceSpan: resourceSpan.name,
155
+ traceId: trace.traceId
156
+ }));