@logbrew/react-native 0.1.0 → 0.1.2
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/README.md +431 -18
- package/apollo.cjs +301 -0
- package/apollo.d.cts +70 -0
- package/apollo.d.ts +70 -0
- package/apollo.js +294 -0
- package/examples/apollo-link-spans.mjs +149 -0
- package/examples/index.mjs +29 -1
- package/examples/instrumentation-kit.mjs +304 -0
- package/examples/lifecycle-spans.mjs +131 -0
- package/examples/native-bridge-scope.mjs +107 -0
- package/examples/navigation-resource-spans.mjs +156 -0
- package/examples/package.json +8 -1
- package/examples/real-user-smoke.mjs +106 -9
- package/examples/resource-fetch-spans.mjs +133 -0
- package/examples/trace-correlation.mjs +135 -0
- package/global-errors.cjs +366 -0
- package/global-errors.d.cts +63 -0
- package/global-errors.d.ts +63 -0
- package/global-errors.js +7 -0
- package/index.cjs +625 -111
- package/index.d.cts +236 -0
- package/index.d.ts +236 -0
- package/index.js +613 -95
- package/index.native.js +18 -0
- package/instrumentation.cjs +639 -0
- package/instrumentation.d.cts +84 -0
- package/instrumentation.d.ts +84 -0
- package/instrumentation.js +634 -0
- package/lifecycle.cjs +129 -0
- package/lifecycle.d.cts +50 -0
- package/lifecycle.d.ts +50 -0
- package/lifecycle.js +121 -0
- package/metadata.cjs +175 -0
- package/metadata.js +165 -0
- package/metro.cjs +310 -0
- package/metro.d.cts +37 -0
- package/metro.d.ts +37 -0
- package/metro.js +6 -0
- package/native-bridge.cjs +127 -0
- package/native-bridge.d.cts +60 -0
- package/native-bridge.d.ts +60 -0
- package/native-bridge.js +125 -0
- package/package.json +128 -4
- package/release-artifacts.cjs +344 -0
- package/release-artifacts.d.cts +55 -0
- package/release-artifacts.d.ts +53 -0
- package/release-artifacts.js +8 -0
- package/resource-fetch.cjs +469 -0
- package/resource-fetch.d.cts +60 -0
- package/resource-fetch.d.ts +60 -0
- 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
|
+
}));
|