@goliapkg/sentori-react-native 0.4.0 → 0.5.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.
@@ -1 +1 @@
1
- {"version":3,"file":"network.d.ts","sourceRoot":"","sources":["../../src/handlers/network.ts"],"names":[],"mappings":"AAMA,eAAO,MAAM,qBAAqB,QAAO,IAyCxC,CAAC"}
1
+ {"version":3,"file":"network.d.ts","sourceRoot":"","sources":["../../src/handlers/network.ts"],"names":[],"mappings":"AAQA,eAAO,MAAM,qBAAqB,QAAO,IAKxC,CAAC"}
@@ -1,27 +1,49 @@
1
+ import { startSpan } from '@goliapkg/sentori-core';
1
2
  import { addBreadcrumb } from '../breadcrumbs';
2
3
  let _installed = false;
3
4
  const AUTH_PARAMS = ['token', 'key', 'password', 'secret', 'access_token'];
4
5
  export const installNetworkHandler = () => {
5
6
  if (_installed)
6
7
  return;
8
+ _installed = true;
9
+ patchFetch();
10
+ patchXhr();
11
+ };
12
+ // ── fetch ──────────────────────────────────────────────────────────
13
+ function patchFetch() {
7
14
  if (typeof globalThis.fetch !== 'function')
8
15
  return;
9
- _installed = true;
10
16
  const original = globalThis.fetch;
11
17
  globalThis.fetch = (async (input, init) => {
12
18
  const start = Date.now();
13
19
  const url = extractUrl(input);
20
+ const scrubbed = scrubUrl(url);
14
21
  const method = (init?.method ??
15
22
  (typeof input !== 'string' && 'method' in input
16
23
  ? input.method
17
24
  : 'GET'));
25
+ // Phase 35 sub-C: also open an http.client span so the request
26
+ // shows up in the trace waterfall. Breadcrumbs stay — they're
27
+ // attached to error events at capture time and serve a different
28
+ // surface (the "last 100 things" timeline on the issue page).
29
+ const span = startSpan('http.client', {
30
+ name: `${method.toUpperCase()} ${scrubbed}`,
31
+ tags: { 'http.method': method.toUpperCase(), 'http.url': scrubbed },
32
+ });
33
+ // Inject traceparent header on outbound requests.
34
+ const reqInit = { ...(init ?? {}) };
35
+ const headers = mergeHeaders(input, init);
36
+ headers.set('traceparent', toTraceparent(span.traceId, span.spanId));
37
+ reqInit.headers = headers;
18
38
  try {
19
- const resp = await original(input, init);
39
+ const resp = await original(input, reqInit);
40
+ span.setTag('http.status', String(resp.status));
41
+ span.finish({ status: resp.status >= 400 ? 'error' : 'ok' });
20
42
  addBreadcrumb({
21
43
  type: 'net',
22
44
  data: {
23
45
  method,
24
- url: scrubUrl(url),
46
+ url: scrubbed,
25
47
  status: resp.status,
26
48
  durationMs: Date.now() - start,
27
49
  },
@@ -29,11 +51,15 @@ export const installNetworkHandler = () => {
29
51
  return resp;
30
52
  }
31
53
  catch (e) {
54
+ const isAbort = isAbortError(e);
55
+ if (e instanceof Error)
56
+ span.setTag('error.message', e.message);
57
+ span.finish({ status: isAbort ? 'cancelled' : 'error' });
32
58
  addBreadcrumb({
33
59
  type: 'net',
34
60
  data: {
35
61
  method,
36
- url: scrubUrl(url),
62
+ url: scrubbed,
37
63
  status: 0,
38
64
  durationMs: Date.now() - start,
39
65
  error: String(e),
@@ -42,7 +68,97 @@ export const installNetworkHandler = () => {
42
68
  throw e;
43
69
  }
44
70
  });
45
- };
71
+ }
72
+ function patchXhr() {
73
+ const XHR = globalThis.XMLHttpRequest;
74
+ if (typeof XHR !== 'function')
75
+ return;
76
+ const proto = XHR.prototype;
77
+ if (proto.__sentoriPatched)
78
+ return;
79
+ proto.__sentoriPatched = true;
80
+ const originalOpen = proto.open;
81
+ const originalSend = proto.send;
82
+ const originalSetHeader = proto.setRequestHeader;
83
+ proto.open = function (method, url, ...rest) {
84
+ this.__sentoriMethod = String(method).toUpperCase();
85
+ this.__sentoriUrl = typeof url === 'string' ? url : String(url);
86
+ // @ts-expect-error variadic forwarding to the native signature
87
+ return originalOpen.call(this, method, url, ...rest);
88
+ };
89
+ proto.send = function (body) {
90
+ const method = this.__sentoriMethod ?? 'GET';
91
+ const url = scrubUrl(this.__sentoriUrl ?? '');
92
+ const span = startSpan('http.client', {
93
+ name: `${method} ${url}`,
94
+ tags: { 'http.method': method, 'http.url': url },
95
+ });
96
+ this.__sentoriSpan = span;
97
+ this.__sentoriStart = Date.now();
98
+ // setRequestHeader must be called between open() and send(); we're
99
+ // inside send() before the underlying call, so this is legal.
100
+ try {
101
+ originalSetHeader.call(this, 'traceparent', toTraceparent(span.traceId, span.spanId));
102
+ }
103
+ catch {
104
+ // Some XHR polyfills are strict about header timing; if it
105
+ // rejects, drop the header rather than fail the request.
106
+ }
107
+ const finish = () => {
108
+ const s = this.__sentoriSpan;
109
+ if (!s)
110
+ return;
111
+ this.__sentoriSpan = undefined;
112
+ const status = this.status;
113
+ s.setTag('http.status', String(status));
114
+ // status 0 means network error / aborted / CORS block — treat
115
+ // as error. The `abort` event handler below downgrades aborts.
116
+ s.finish({ status: status === 0 || status >= 400 ? 'error' : 'ok' });
117
+ addBreadcrumb({
118
+ type: 'net',
119
+ data: {
120
+ method,
121
+ url,
122
+ status,
123
+ durationMs: Date.now() - (this.__sentoriStart ?? Date.now()),
124
+ },
125
+ });
126
+ };
127
+ this.addEventListener('loadend', finish);
128
+ this.addEventListener('abort', () => {
129
+ const s = this.__sentoriSpan;
130
+ if (!s)
131
+ return;
132
+ this.__sentoriSpan = undefined;
133
+ s.finish({ status: 'cancelled' });
134
+ addBreadcrumb({
135
+ type: 'net',
136
+ data: { method, url, status: 0, durationMs: Date.now() - (this.__sentoriStart ?? Date.now()), error: 'aborted' },
137
+ });
138
+ });
139
+ return originalSend.call(this, body);
140
+ };
141
+ }
142
+ function mergeHeaders(input, init) {
143
+ const out = new Headers();
144
+ if (typeof input !== 'string' && !(input instanceof URL)) {
145
+ input.headers.forEach((v, k) => out.set(k, v));
146
+ }
147
+ if (init?.headers) {
148
+ new Headers(init.headers).forEach((v, k) => out.set(k, v));
149
+ }
150
+ return out;
151
+ }
152
+ function toTraceparent(traceId, spanId) {
153
+ const trace = traceId.replace(/-/g, '').toLowerCase();
154
+ const parent = spanId.replace(/-/g, '').toLowerCase().slice(0, 16);
155
+ return `00-${trace}-${parent}-01`;
156
+ }
157
+ function isAbortError(err) {
158
+ if (typeof err !== 'object' || err === null)
159
+ return false;
160
+ return err.name === 'AbortError';
161
+ }
46
162
  const extractUrl = (input) => {
47
163
  if (typeof input === 'string')
48
164
  return input;
@@ -1 +1 @@
1
- {"version":3,"file":"network.js","sourceRoot":"","sources":["../../src/handlers/network.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,aAAa,EAAE,MAAM,gBAAgB,CAAC;AAE/C,IAAI,UAAU,GAAG,KAAK,CAAC;AAEvB,MAAM,WAAW,GAAG,CAAC,OAAO,EAAE,KAAK,EAAE,UAAU,EAAE,QAAQ,EAAE,cAAc,CAAC,CAAC;AAE3E,MAAM,CAAC,MAAM,qBAAqB,GAAG,GAAS,EAAE;IAC9C,IAAI,UAAU;QAAE,OAAO;IACvB,IAAI,OAAO,UAAU,CAAC,KAAK,KAAK,UAAU;QAAE,OAAO;IACnD,UAAU,GAAG,IAAI,CAAC;IAElB,MAAM,QAAQ,GAAG,UAAU,CAAC,KAAK,CAAC;IAElC,UAAU,CAAC,KAAK,GAAG,CAAC,KAAK,EAAE,KAAwB,EAAE,IAAkB,EAAE,EAAE;QACzE,MAAM,KAAK,GAAG,IAAI,CAAC,GAAG,EAAE,CAAC;QACzB,MAAM,GAAG,GAAG,UAAU,CAAC,KAAK,CAAC,CAAC;QAC9B,MAAM,MAAM,GAAG,CAAC,IAAI,EAAE,MAAM;YAC1B,CAAC,OAAO,KAAK,KAAK,QAAQ,IAAI,QAAQ,IAAK,KAAiB;gBAC1D,CAAC,CAAE,KAAiB,CAAC,MAAM;gBAC3B,CAAC,CAAC,KAAK,CAAC,CAAW,CAAC;QAExB,IAAI,CAAC;YACH,MAAM,IAAI,GAAG,MAAM,QAAQ,CAAC,KAAK,EAAE,IAAI,CAAC,CAAC;YACzC,aAAa,CAAC;gBACZ,IAAI,EAAE,KAAK;gBACX,IAAI,EAAE;oBACJ,MAAM;oBACN,GAAG,EAAE,QAAQ,CAAC,GAAG,CAAC;oBAClB,MAAM,EAAE,IAAI,CAAC,MAAM;oBACnB,UAAU,EAAE,IAAI,CAAC,GAAG,EAAE,GAAG,KAAK;iBAC/B;aACF,CAAC,CAAC;YACH,OAAO,IAAI,CAAC;QACd,CAAC;QAAC,OAAO,CAAC,EAAE,CAAC;YACX,aAAa,CAAC;gBACZ,IAAI,EAAE,KAAK;gBACX,IAAI,EAAE;oBACJ,MAAM;oBACN,GAAG,EAAE,QAAQ,CAAC,GAAG,CAAC;oBAClB,MAAM,EAAE,CAAC;oBACT,UAAU,EAAE,IAAI,CAAC,GAAG,EAAE,GAAG,KAAK;oBAC9B,KAAK,EAAE,MAAM,CAAC,CAAC,CAAC;iBACjB;aACF,CAAC,CAAC;YACH,MAAM,CAAC,CAAC;QACV,CAAC;IACH,CAAC,CAAiB,CAAC;AACrB,CAAC,CAAC;AAEF,MAAM,UAAU,GAAG,CAAC,KAAwB,EAAU,EAAE;IACtD,IAAI,OAAO,KAAK,KAAK,QAAQ;QAAE,OAAO,KAAK,CAAC;IAC5C,IAAI,KAAK,YAAY,GAAG;QAAE,OAAO,KAAK,CAAC,IAAI,CAAC;IAC5C,OAAQ,KAAiB,CAAC,GAAG,CAAC;AAChC,CAAC,CAAC;AAEF,MAAM,QAAQ,GAAG,CAAC,GAAW,EAAU,EAAE;IACvC,IAAI,CAAC;QACH,MAAM,CAAC,GAAG,IAAI,GAAG,CAAC,GAAG,CAAC,CAAC;QACvB,IAAI,QAAQ,GAAG,KAAK,CAAC;QACrB,KAAK,MAAM,CAAC,IAAI,WAAW,EAAE,CAAC;YAC5B,IAAI,CAAC,CAAC,YAAY,CAAC,GAAG,CAAC,CAAC,CAAC,EAAE,CAAC;gBAC1B,CAAC,CAAC,YAAY,CAAC,GAAG,CAAC,CAAC,EAAE,YAAY,CAAC,CAAC;gBACpC,QAAQ,GAAG,IAAI,CAAC;YAClB,CAAC;QACH,CAAC;QACD,OAAO,QAAQ,CAAC,CAAC,CAAC,CAAC,CAAC,QAAQ,EAAE,CAAC,CAAC,CAAC,GAAG,CAAC;IACvC,CAAC;IAAC,MAAM,CAAC;QACP,OAAO,GAAG,CAAC;IACb,CAAC;AACH,CAAC,CAAC"}
1
+ {"version":3,"file":"network.js","sourceRoot":"","sources":["../../src/handlers/network.ts"],"names":[],"mappings":"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"}
package/lib/index.d.ts CHANGED
@@ -18,5 +18,6 @@ export { setUser, getUser, captureError, captureException } from './capture';
18
18
  export { ErrorBoundary } from './error-boundary';
19
19
  export { startAnrWatchdog, stopAnrWatchdog, triggerNativeCrash, } from './native';
20
20
  export { endSession, markSessionCrashed, startSession, } from './session-tracker';
21
+ export { type NavigationRefLike, useTraceNavigation } from './navigation';
21
22
  export type { Event, SentoriError, Frame, Breadcrumb, BreadcrumbType, Device, DeviceOS, App, User, Tags, EventKind, Platform, } from './types';
22
23
  //# sourceMappingURL=index.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAGA,OAAO,EAAE,aAAa,EAAE,MAAM,kBAAkB,CAAC;AAOjD,eAAO,MAAM,OAAO;;;;;;;;;;;CAWnB,CAAC;AAEF,eAAe,OAAO,CAAC;AAEvB,OAAO,EAAE,IAAI,EAAE,IAAI,IAAI,WAAW,EAAE,MAAM,QAAQ,CAAC;AACnD,OAAO,EAAE,aAAa,EAAE,MAAM,eAAe,CAAC;AAC9C,OAAO,EAAE,OAAO,EAAE,OAAO,EAAE,YAAY,EAAE,gBAAgB,EAAE,MAAM,WAAW,CAAC;AAC7E,OAAO,EAAE,aAAa,EAAE,MAAM,kBAAkB,CAAC;AACjD,OAAO,EACL,gBAAgB,EAChB,eAAe,EACf,kBAAkB,GACnB,MAAM,UAAU,CAAC;AAClB,OAAO,EACL,UAAU,EACV,kBAAkB,EAClB,YAAY,GACb,MAAM,mBAAmB,CAAC;AAE3B,YAAY,EACV,KAAK,EACL,YAAY,EACZ,KAAK,EACL,UAAU,EACV,cAAc,EACd,MAAM,EACN,QAAQ,EACR,GAAG,EACH,IAAI,EACJ,IAAI,EACJ,SAAS,EACT,QAAQ,GACT,MAAM,SAAS,CAAC"}
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAGA,OAAO,EAAE,aAAa,EAAE,MAAM,kBAAkB,CAAC;AAOjD,eAAO,MAAM,OAAO;;;;;;;;;;;CAWnB,CAAC;AAEF,eAAe,OAAO,CAAC;AAEvB,OAAO,EAAE,IAAI,EAAE,IAAI,IAAI,WAAW,EAAE,MAAM,QAAQ,CAAC;AACnD,OAAO,EAAE,aAAa,EAAE,MAAM,eAAe,CAAC;AAC9C,OAAO,EAAE,OAAO,EAAE,OAAO,EAAE,YAAY,EAAE,gBAAgB,EAAE,MAAM,WAAW,CAAC;AAC7E,OAAO,EAAE,aAAa,EAAE,MAAM,kBAAkB,CAAC;AACjD,OAAO,EACL,gBAAgB,EAChB,eAAe,EACf,kBAAkB,GACnB,MAAM,UAAU,CAAC;AAClB,OAAO,EACL,UAAU,EACV,kBAAkB,EAClB,YAAY,GACb,MAAM,mBAAmB,CAAC;AAC3B,OAAO,EAAE,KAAK,iBAAiB,EAAE,kBAAkB,EAAE,MAAM,cAAc,CAAC;AAE1E,YAAY,EACV,KAAK,EACL,YAAY,EACZ,KAAK,EACL,UAAU,EACV,cAAc,EACd,MAAM,EACN,QAAQ,EACR,GAAG,EACH,IAAI,EACJ,IAAI,EACJ,SAAS,EACT,QAAQ,GACT,MAAM,SAAS,CAAC"}
package/lib/index.js CHANGED
@@ -22,4 +22,5 @@ export { setUser, getUser, captureError, captureException } from './capture';
22
22
  export { ErrorBoundary } from './error-boundary';
23
23
  export { startAnrWatchdog, stopAnrWatchdog, triggerNativeCrash, } from './native';
24
24
  export { endSession, markSessionCrashed, startSession, } from './session-tracker';
25
+ export { useTraceNavigation } from './navigation';
25
26
  //# sourceMappingURL=index.js.map
package/lib/index.js.map CHANGED
@@ -1 +1 @@
1
- {"version":3,"file":"index.js","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,IAAI,EAAE,MAAM,QAAQ,CAAC;AAC9B,OAAO,EAAE,aAAa,EAAE,MAAM,eAAe,CAAC;AAC9C,OAAO,EAAE,OAAO,EAAE,OAAO,EAAE,YAAY,EAAE,gBAAgB,EAAE,MAAM,WAAW,CAAC;AAC7E,OAAO,EAAE,aAAa,EAAE,MAAM,kBAAkB,CAAC;AACjD,OAAO,EACL,UAAU,EACV,kBAAkB,EAClB,YAAY,GACb,MAAM,mBAAmB,CAAC;AAE3B,MAAM,CAAC,MAAM,OAAO,GAAG;IACrB,IAAI;IACJ,aAAa;IACb,OAAO;IACP,OAAO;IACP,YAAY;IACZ,gBAAgB;IAChB,aAAa;IACb,YAAY;IACZ,UAAU;IACV,kBAAkB;CACnB,CAAC;AAEF,eAAe,OAAO,CAAC;AAEvB,OAAO,EAAE,IAAI,EAAE,IAAI,IAAI,WAAW,EAAE,MAAM,QAAQ,CAAC;AACnD,OAAO,EAAE,aAAa,EAAE,MAAM,eAAe,CAAC;AAC9C,OAAO,EAAE,OAAO,EAAE,OAAO,EAAE,YAAY,EAAE,gBAAgB,EAAE,MAAM,WAAW,CAAC;AAC7E,OAAO,EAAE,aAAa,EAAE,MAAM,kBAAkB,CAAC;AACjD,OAAO,EACL,gBAAgB,EAChB,eAAe,EACf,kBAAkB,GACnB,MAAM,UAAU,CAAC;AAClB,OAAO,EACL,UAAU,EACV,kBAAkB,EAClB,YAAY,GACb,MAAM,mBAAmB,CAAC"}
1
+ {"version":3,"file":"index.js","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,IAAI,EAAE,MAAM,QAAQ,CAAC;AAC9B,OAAO,EAAE,aAAa,EAAE,MAAM,eAAe,CAAC;AAC9C,OAAO,EAAE,OAAO,EAAE,OAAO,EAAE,YAAY,EAAE,gBAAgB,EAAE,MAAM,WAAW,CAAC;AAC7E,OAAO,EAAE,aAAa,EAAE,MAAM,kBAAkB,CAAC;AACjD,OAAO,EACL,UAAU,EACV,kBAAkB,EAClB,YAAY,GACb,MAAM,mBAAmB,CAAC;AAE3B,MAAM,CAAC,MAAM,OAAO,GAAG;IACrB,IAAI;IACJ,aAAa;IACb,OAAO;IACP,OAAO;IACP,YAAY;IACZ,gBAAgB;IAChB,aAAa;IACb,YAAY;IACZ,UAAU;IACV,kBAAkB;CACnB,CAAC;AAEF,eAAe,OAAO,CAAC;AAEvB,OAAO,EAAE,IAAI,EAAE,IAAI,IAAI,WAAW,EAAE,MAAM,QAAQ,CAAC;AACnD,OAAO,EAAE,aAAa,EAAE,MAAM,eAAe,CAAC;AAC9C,OAAO,EAAE,OAAO,EAAE,OAAO,EAAE,YAAY,EAAE,gBAAgB,EAAE,MAAM,WAAW,CAAC;AAC7E,OAAO,EAAE,aAAa,EAAE,MAAM,kBAAkB,CAAC;AACjD,OAAO,EACL,gBAAgB,EAChB,eAAe,EACf,kBAAkB,GACnB,MAAM,UAAU,CAAC;AAClB,OAAO,EACL,UAAU,EACV,kBAAkB,EAClB,YAAY,GACb,MAAM,mBAAmB,CAAC;AAC3B,OAAO,EAA0B,kBAAkB,EAAE,MAAM,cAAc,CAAC"}
@@ -0,0 +1,29 @@
1
+ /** Minimal contract: anything with `addListener('state', cb)` and
2
+ * `getCurrentRoute()` works. The real @react-navigation/native
3
+ * NavigationContainer ref matches this shape. */
4
+ export type NavigationRefLike = {
5
+ addListener: (event: 'state', listener: () => void) => () => void;
6
+ getCurrentRoute: () => {
7
+ name: string;
8
+ } | undefined;
9
+ };
10
+ /**
11
+ * Subscribe to react-navigation state changes and emit a
12
+ * `react.navigation` span per transition. First mount records the
13
+ * initial route as the start anchor but does NOT emit a span (the
14
+ * convention from `useSentoriRouter` in sentori-react).
15
+ *
16
+ * import { NavigationContainer, useNavigationContainerRef } from '@react-navigation/native'
17
+ * import { useTraceNavigation } from '@goliapkg/sentori-react-native'
18
+ *
19
+ * function App() {
20
+ * const navigationRef = useNavigationContainerRef()
21
+ * useTraceNavigation(navigationRef)
22
+ * return <NavigationContainer ref={navigationRef}>{...}</NavigationContainer>
23
+ * }
24
+ *
25
+ * Each span carries `{ from, to }` as tags and uses the destination
26
+ * route name as the span name.
27
+ */
28
+ export declare function useTraceNavigation(navigationRef: NavigationRefLike): void;
29
+ //# sourceMappingURL=navigation.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"navigation.d.ts","sourceRoot":"","sources":["../src/navigation.ts"],"names":[],"mappings":"AAiBA;;kDAEkD;AAClD,MAAM,MAAM,iBAAiB,GAAG;IAC9B,WAAW,EAAE,CAAC,KAAK,EAAE,OAAO,EAAE,QAAQ,EAAE,MAAM,IAAI,KAAK,MAAM,IAAI,CAAC;IAClE,eAAe,EAAE,MAAM;QAAE,IAAI,EAAE,MAAM,CAAA;KAAE,GAAG,SAAS,CAAC;CACrD,CAAC;AAEF;;;;;;;;;;;;;;;;;GAiBG;AACH,wBAAgB,kBAAkB,CAAC,aAAa,EAAE,iBAAiB,GAAG,IAAI,CAyCzE"}
@@ -0,0 +1,72 @@
1
+ // Phase 35 sub-C: react-navigation auto-instrumentation.
2
+ //
3
+ // Mount `useTraceNavigation(navigationRef)` next to your
4
+ // `<NavigationContainer ref={navigationRef}>` and every route
5
+ // transition becomes a `react.navigation` span. Span names are
6
+ // `<from> → <to>` so the trace list reads as a navigation log.
7
+ //
8
+ // react-navigation is an OPTIONAL peer dependency — apps that
9
+ // don't use it never have to install it. The hook itself doesn't
10
+ // import from @react-navigation/native; consumers pass in the ref
11
+ // they already have, and we read its state via the public
12
+ // `getCurrentRoute()` API. That keeps the dep edge optional.
13
+ import { useEffect, useRef } from 'react';
14
+ import { startSpan } from '@goliapkg/sentori-core';
15
+ /**
16
+ * Subscribe to react-navigation state changes and emit a
17
+ * `react.navigation` span per transition. First mount records the
18
+ * initial route as the start anchor but does NOT emit a span (the
19
+ * convention from `useSentoriRouter` in sentori-react).
20
+ *
21
+ * import { NavigationContainer, useNavigationContainerRef } from '@react-navigation/native'
22
+ * import { useTraceNavigation } from '@goliapkg/sentori-react-native'
23
+ *
24
+ * function App() {
25
+ * const navigationRef = useNavigationContainerRef()
26
+ * useTraceNavigation(navigationRef)
27
+ * return <NavigationContainer ref={navigationRef}>{...}</NavigationContainer>
28
+ * }
29
+ *
30
+ * Each span carries `{ from, to }` as tags and uses the destination
31
+ * route name as the span name.
32
+ */
33
+ export function useTraceNavigation(navigationRef) {
34
+ // Latest route name we've observed. `null` means "no transition
35
+ // recorded yet" (initial mount).
36
+ const lastRouteRef = useRef(null);
37
+ // Span that started when this route was entered. Finished when the
38
+ // NEXT route transition arrives.
39
+ const openSpanRef = useRef(null);
40
+ useEffect(() => {
41
+ if (typeof navigationRef.addListener !== 'function')
42
+ return;
43
+ if (typeof navigationRef.getCurrentRoute !== 'function')
44
+ return;
45
+ // Seed the "last route" reference from the current state so the
46
+ // first transition emits a span with the right `from`.
47
+ const initial = navigationRef.getCurrentRoute()?.name ?? null;
48
+ lastRouteRef.current = initial;
49
+ const unsubscribe = navigationRef.addListener('state', () => {
50
+ const next = navigationRef.getCurrentRoute()?.name ?? null;
51
+ const prev = lastRouteRef.current;
52
+ if (next === null || next === prev)
53
+ return;
54
+ // Close the prior span (if any) before opening the new one so
55
+ // the trace looks like a sequence, not nested.
56
+ openSpanRef.current?.finish({ status: 'ok' });
57
+ const span = startSpan('react.navigation', {
58
+ name: prev ? `${prev} → ${next}` : next,
59
+ tags: { 'nav.from': prev ?? '', 'nav.to': next },
60
+ });
61
+ openSpanRef.current = span;
62
+ lastRouteRef.current = next;
63
+ });
64
+ return () => {
65
+ unsubscribe();
66
+ // Close any still-open span on unmount so we don't leak it.
67
+ openSpanRef.current?.finish({ status: 'ok' });
68
+ openSpanRef.current = null;
69
+ };
70
+ }, [navigationRef]);
71
+ }
72
+ //# sourceMappingURL=navigation.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"navigation.js","sourceRoot":"","sources":["../src/navigation.ts"],"names":[],"mappings":"AAAA,yDAAyD;AACzD,EAAE;AACF,yDAAyD;AACzD,8DAA8D;AAC9D,+DAA+D;AAC/D,+DAA+D;AAC/D,EAAE;AACF,8DAA8D;AAC9D,iEAAiE;AACjE,kEAAkE;AAClE,0DAA0D;AAC1D,6DAA6D;AAE7D,OAAO,EAAE,SAAS,EAAE,MAAM,EAAE,MAAM,OAAO,CAAC;AAE1C,OAAO,EAAE,SAAS,EAAmB,MAAM,wBAAwB,CAAC;AAUpE;;;;;;;;;;;;;;;;;GAiBG;AACH,MAAM,UAAU,kBAAkB,CAAC,aAAgC;IACjE,gEAAgE;IAChE,iCAAiC;IACjC,MAAM,YAAY,GAAG,MAAM,CAAgB,IAAI,CAAC,CAAC;IACjD,mEAAmE;IACnE,iCAAiC;IACjC,MAAM,WAAW,GAAG,MAAM,CAAoB,IAAI,CAAC,CAAC;IAEpD,SAAS,CAAC,GAAG,EAAE;QACb,IAAI,OAAO,aAAa,CAAC,WAAW,KAAK,UAAU;YAAE,OAAO;QAC5D,IAAI,OAAO,aAAa,CAAC,eAAe,KAAK,UAAU;YAAE,OAAO;QAEhE,gEAAgE;QAChE,uDAAuD;QACvD,MAAM,OAAO,GAAG,aAAa,CAAC,eAAe,EAAE,EAAE,IAAI,IAAI,IAAI,CAAC;QAC9D,YAAY,CAAC,OAAO,GAAG,OAAO,CAAC;QAE/B,MAAM,WAAW,GAAG,aAAa,CAAC,WAAW,CAAC,OAAO,EAAE,GAAG,EAAE;YAC1D,MAAM,IAAI,GAAG,aAAa,CAAC,eAAe,EAAE,EAAE,IAAI,IAAI,IAAI,CAAC;YAC3D,MAAM,IAAI,GAAG,YAAY,CAAC,OAAO,CAAC;YAClC,IAAI,IAAI,KAAK,IAAI,IAAI,IAAI,KAAK,IAAI;gBAAE,OAAO;YAE3C,8DAA8D;YAC9D,+CAA+C;YAC/C,WAAW,CAAC,OAAO,EAAE,MAAM,CAAC,EAAE,MAAM,EAAE,IAAI,EAAE,CAAC,CAAC;YAE9C,MAAM,IAAI,GAAG,SAAS,CAAC,kBAAkB,EAAE;gBACzC,IAAI,EAAE,IAAI,CAAC,CAAC,CAAC,GAAG,IAAI,MAAM,IAAI,EAAE,CAAC,CAAC,CAAC,IAAI;gBACvC,IAAI,EAAE,EAAE,UAAU,EAAE,IAAI,IAAI,EAAE,EAAE,QAAQ,EAAE,IAAI,EAAE;aACjD,CAAC,CAAC;YACH,WAAW,CAAC,OAAO,GAAG,IAAI,CAAC;YAC3B,YAAY,CAAC,OAAO,GAAG,IAAI,CAAC;QAC9B,CAAC,CAAC,CAAC;QAEH,OAAO,GAAG,EAAE;YACV,WAAW,EAAE,CAAC;YACd,4DAA4D;YAC5D,WAAW,CAAC,OAAO,EAAE,MAAM,CAAC,EAAE,MAAM,EAAE,IAAI,EAAE,CAAC,CAAC;YAC9C,WAAW,CAAC,OAAO,GAAG,IAAI,CAAC;QAC7B,CAAC,CAAC;IACJ,CAAC,EAAE,CAAC,aAAa,CAAC,CAAC,CAAC;AACtB,CAAC"}
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@goliapkg/sentori-react-native",
3
- "version": "0.4.0",
4
- "description": "Sentori SDK for React Native — JS-layer error capture, native crash handlers (iOS / Android), batched transport.",
3
+ "version": "0.5.1",
4
+ "description": "Sentori SDK for React Native — JS-layer error capture, native crash handlers (iOS / Android), batched transport, fetch + react-navigation tracing.",
5
5
  "license": "MIT",
6
6
  "homepage": "https://sentori.golia.jp",
7
7
  "repository": {
@@ -64,6 +64,6 @@
64
64
  "access": "public"
65
65
  },
66
66
  "dependencies": {
67
- "@goliapkg/sentori-core": "0.2.0"
67
+ "@goliapkg/sentori-core": "0.3.0"
68
68
  }
69
69
  }
@@ -0,0 +1,148 @@
1
+ import { clearSpans, drainSpans } from '@goliapkg/sentori-core';
2
+ import { afterEach, beforeEach, describe, expect, test } from 'bun:test';
3
+
4
+ import { type NavigationRefLike, useTraceNavigation } from '../navigation';
5
+
6
+ // We test the hook without a React renderer (sdk/react-native has no
7
+ // react-test-renderer or @testing-library dev-dep). Instead, we drive
8
+ // the same lifecycle by hand: the hook is a one-line useEffect, so
9
+ // we extract its effect body via a tiny harness that mirrors the
10
+ // observable behaviour.
11
+
12
+ // FakeNav mirrors @react-navigation/native's NavigationContainerRef
13
+ // surface — `addListener('state', cb)` + `getCurrentRoute()`.
14
+ class FakeNav implements NavigationRefLike {
15
+ private listeners: Array<() => void> = [];
16
+ private route: { name: string } | undefined;
17
+
18
+ setInitialRoute(name: string): void {
19
+ this.route = { name };
20
+ }
21
+
22
+ addListener(_event: 'state', listener: () => void): () => void {
23
+ this.listeners.push(listener);
24
+ return () => {
25
+ this.listeners = this.listeners.filter((l) => l !== listener);
26
+ };
27
+ }
28
+
29
+ getCurrentRoute(): { name: string } | undefined {
30
+ return this.route;
31
+ }
32
+
33
+ go(name: string): void {
34
+ this.route = { name };
35
+ this.listeners.forEach((l) => l());
36
+ }
37
+ }
38
+
39
+ // Re-implements the hook's effect body for test purposes. Mirroring
40
+ // the real hook 1:1 — when production code changes, this changes too.
41
+ // The point isn't to share the implementation but to verify the
42
+ // observable contract (spans pushed in the right order).
43
+ function harness(navigationRef: NavigationRefLike): () => void {
44
+ let lastRoute: null | string =
45
+ navigationRef.getCurrentRoute()?.name ?? null;
46
+ let openSpan: ReturnType<typeof import('@goliapkg/sentori-core').startSpan> | null = null;
47
+
48
+ // Inline import to avoid hoisting.
49
+ // eslint-disable-next-line @typescript-eslint/no-require-imports
50
+ const { startSpan } = require('@goliapkg/sentori-core') as typeof import('@goliapkg/sentori-core');
51
+
52
+ const unsub = navigationRef.addListener('state', () => {
53
+ const next = navigationRef.getCurrentRoute()?.name ?? null;
54
+ if (next === null || next === lastRoute) return;
55
+
56
+ openSpan?.finish({ status: 'ok' });
57
+ const span = startSpan('react.navigation', {
58
+ name: lastRoute ? `${lastRoute} → ${next}` : next,
59
+ tags: { 'nav.from': lastRoute ?? '', 'nav.to': next },
60
+ });
61
+ openSpan = span;
62
+ lastRoute = next;
63
+ });
64
+
65
+ return () => {
66
+ unsub();
67
+ openSpan?.finish({ status: 'ok' });
68
+ openSpan = null;
69
+ };
70
+ }
71
+
72
+ beforeEach(() => clearSpans());
73
+ afterEach(() => clearSpans());
74
+
75
+ describe('useTraceNavigation', () => {
76
+ test('hook + production module both export', () => {
77
+ // Sanity: the exported symbol exists with the right shape.
78
+ expect(typeof useTraceNavigation).toBe('function');
79
+ });
80
+
81
+ test('initial mount does NOT emit a span', () => {
82
+ const nav = new FakeNav();
83
+ nav.setInitialRoute('Home');
84
+ const cleanup = harness(nav);
85
+ expect(drainSpans()).toHaveLength(0);
86
+ cleanup();
87
+ });
88
+
89
+ test('one transition + one more closes the first span', () => {
90
+ const nav = new FakeNav();
91
+ nav.setInitialRoute('Home');
92
+ const cleanup = harness(nav);
93
+
94
+ nav.go('Settings');
95
+ nav.go('Profile');
96
+
97
+ const spans = drainSpans();
98
+ expect(spans.length).toBeGreaterThanOrEqual(1);
99
+ const first = spans[0]!;
100
+ expect(first.op).toBe('react.navigation');
101
+ expect(first.name).toBe('Home → Settings');
102
+ expect(first.tags).toEqual({ 'nav.from': 'Home', 'nav.to': 'Settings' });
103
+ expect(first.status).toBe('ok');
104
+
105
+ cleanup();
106
+ });
107
+
108
+ test('same-route state event does not emit a span', () => {
109
+ const nav = new FakeNav();
110
+ nav.setInitialRoute('Home');
111
+ const cleanup = harness(nav);
112
+
113
+ nav.go('Home');
114
+ expect(drainSpans()).toHaveLength(0);
115
+
116
+ cleanup();
117
+ });
118
+
119
+ test('chained transitions emit per-hop spans', () => {
120
+ const nav = new FakeNav();
121
+ nav.setInitialRoute('A');
122
+ const cleanup = harness(nav);
123
+
124
+ nav.go('B');
125
+ nav.go('C');
126
+ nav.go('D');
127
+
128
+ const spans = drainSpans();
129
+ expect(spans.length).toBeGreaterThanOrEqual(2);
130
+ expect(spans[0]?.name).toBe('A → B');
131
+ expect(spans[1]?.name).toBe('B → C');
132
+
133
+ cleanup();
134
+ });
135
+
136
+ test('cleanup closes the still-open span', () => {
137
+ const nav = new FakeNav();
138
+ nav.setInitialRoute('Home');
139
+ const cleanup = harness(nav);
140
+
141
+ nav.go('Settings');
142
+ cleanup();
143
+
144
+ const spans = drainSpans();
145
+ expect(spans).toHaveLength(1);
146
+ expect(spans[0]?.name).toBe('Home → Settings');
147
+ });
148
+ });
@@ -0,0 +1,192 @@
1
+ import { clearSpans, drainSpans } from '@goliapkg/sentori-core';
2
+ import {
3
+ afterEach,
4
+ beforeAll,
5
+ beforeEach,
6
+ describe,
7
+ expect,
8
+ test,
9
+ } from 'bun:test';
10
+
11
+ import { installNetworkHandler } from '../handlers/network';
12
+
13
+ // network.ts installs ONCE per process. To test reliably we:
14
+ // 1. set up a single static recorder on globalThis.fetch
15
+ // 2. install the wrapper exactly once (in beforeAll)
16
+ // 3. between tests, only mutate the recorder's queue — NEVER
17
+ // re-assign globalThis.fetch, because the wrapper captured the
18
+ // first reference at install time.
19
+
20
+ const recorderCalls: Array<{ init?: RequestInit; url: string }> = [];
21
+ let recorderQueue: Array<{ status: number } | Error> = [];
22
+
23
+ const recorder = (async (input: Request | string | URL, init?: RequestInit) => {
24
+ const url =
25
+ typeof input === 'string'
26
+ ? input
27
+ : input instanceof URL
28
+ ? input.toString()
29
+ : input.url;
30
+ recorderCalls.push({ init, url });
31
+ const r = recorderQueue.shift() ?? { status: 200 };
32
+ if (r instanceof Error) throw r;
33
+ return new Response('', { status: r.status });
34
+ }) as unknown as typeof fetch;
35
+
36
+ // Fake XMLHttpRequest so installNetworkHandler()'s patchXhr() has a
37
+ // prototype to patch. RN's native XHR isn't present in bun:test.
38
+ type FakeListener = () => void;
39
+ class FakeXHR {
40
+ status = 0;
41
+ private listeners: Record<string, FakeListener[]> = {};
42
+ private requestHeaders: Record<string, string> = {};
43
+ private opened = false;
44
+
45
+ open(_method: string, _url: string | URL): void {
46
+ this.opened = true;
47
+ }
48
+ setRequestHeader(name: string, value: string): void {
49
+ if (!this.opened) throw new Error('setRequestHeader before open');
50
+ this.requestHeaders[name.toLowerCase()] = value;
51
+ }
52
+ send(_body?: unknown): void {}
53
+ addEventListener(event: string, fn: FakeListener): void {
54
+ (this.listeners[event] ??= []).push(fn);
55
+ }
56
+ getHeader(name: string): string | undefined {
57
+ return this.requestHeaders[name.toLowerCase()];
58
+ }
59
+ fire(event: string): void {
60
+ for (const fn of this.listeners[event] ?? []) fn();
61
+ }
62
+ }
63
+
64
+ beforeAll(() => {
65
+ (globalThis as { fetch: typeof fetch }).fetch = recorder;
66
+ (globalThis as { XMLHttpRequest: unknown }).XMLHttpRequest = FakeXHR as unknown;
67
+ installNetworkHandler(); // patches globalThis.fetch + XMLHttpRequest.prototype
68
+ });
69
+
70
+ beforeEach(() => {
71
+ clearSpans();
72
+ recorderCalls.length = 0;
73
+ recorderQueue = [{ status: 200 }];
74
+ });
75
+
76
+ afterEach(() => {
77
+ clearSpans();
78
+ });
79
+
80
+ describe('RN network handler tracing', () => {
81
+ test('wrapped fetch emits an http.client span', async () => {
82
+ const resp = await fetch('https://api.example.com/v1/users/me', {
83
+ method: 'GET',
84
+ });
85
+ expect(resp.status).toBe(200);
86
+
87
+ const spans = drainSpans();
88
+ expect(spans).toHaveLength(1);
89
+ expect(spans[0]?.op).toBe('http.client');
90
+ expect(spans[0]?.name).toBe('GET https://api.example.com/v1/users/me');
91
+ expect(spans[0]?.tags).toMatchObject({
92
+ 'http.method': 'GET',
93
+ 'http.status': '200',
94
+ 'http.url': 'https://api.example.com/v1/users/me',
95
+ });
96
+ expect(spans[0]?.status).toBe('ok');
97
+ });
98
+
99
+ test('injects W3C traceparent header', async () => {
100
+ await fetch('https://api.example.com/x');
101
+ expect(recorderCalls).toHaveLength(1);
102
+ const headers = new Headers(recorderCalls[0]?.init?.headers);
103
+ const tp = headers.get('traceparent');
104
+ expect(tp).not.toBeNull();
105
+ expect(tp).toMatch(/^00-[0-9a-f]{32}-[0-9a-f]{16}-01$/);
106
+ });
107
+
108
+ test('5xx → span.status = "error"', async () => {
109
+ recorderQueue = [{ status: 503 }];
110
+ await fetch('https://api.example.com/x');
111
+ expect(drainSpans()[0]?.status).toBe('error');
112
+ });
113
+
114
+ test('throws → status = "error" with error.message tag', async () => {
115
+ recorderQueue = [new TypeError('NetworkError: offline')];
116
+ await expect(fetch('https://api.example.com/x')).rejects.toThrow('NetworkError');
117
+ const sp = drainSpans()[0]!;
118
+ expect(sp.status).toBe('error');
119
+ expect(sp.tags['error.message']).toContain('offline');
120
+ });
121
+
122
+ test('AbortError → status = "cancelled"', async () => {
123
+ recorderQueue = [Object.assign(new Error('aborted'), { name: 'AbortError' })];
124
+ await expect(fetch('https://api.example.com/x')).rejects.toThrow('aborted');
125
+ expect(drainSpans()[0]?.status).toBe('cancelled');
126
+ });
127
+
128
+ test('preserves caller-supplied headers alongside traceparent', async () => {
129
+ await fetch('https://api.example.com/x', {
130
+ headers: { Authorization: 'Bearer xyz', 'X-Custom': '1' },
131
+ });
132
+ const h = new Headers(recorderCalls[0]?.init?.headers);
133
+ expect(h.get('authorization')).toBe('Bearer xyz');
134
+ expect(h.get('x-custom')).toBe('1');
135
+ expect(h.get('traceparent')).toBeTruthy();
136
+ });
137
+ });
138
+
139
+ describe('RN XHR tracing (axios goes through XHR on RN)', () => {
140
+ test('patched XHR emits an http.client span on loadend', () => {
141
+ const x = new FakeXHR();
142
+ x.open('POST', 'https://api.example.com/v1/orders');
143
+ x.send('{}');
144
+ x.status = 201;
145
+ x.fire('loadend');
146
+
147
+ const sp = drainSpans()[0]!;
148
+ expect(sp.op).toBe('http.client');
149
+ expect(sp.name).toBe('POST https://api.example.com/v1/orders');
150
+ expect(sp.tags).toMatchObject({
151
+ 'http.method': 'POST',
152
+ 'http.status': '201',
153
+ 'http.url': 'https://api.example.com/v1/orders',
154
+ });
155
+ expect(sp.status).toBe('ok');
156
+ });
157
+
158
+ test('injects W3C traceparent request header', () => {
159
+ const x = new FakeXHR();
160
+ x.open('GET', 'https://api.example.com/x');
161
+ x.send();
162
+ expect(x.getHeader('traceparent')).toMatch(/^00-[0-9a-f]{32}-[0-9a-f]{16}-01$/);
163
+ x.status = 200;
164
+ x.fire('loadend');
165
+ });
166
+
167
+ test('5xx → span.status = "error"', () => {
168
+ const x = new FakeXHR();
169
+ x.open('GET', 'https://api.example.com/x');
170
+ x.send();
171
+ x.status = 502;
172
+ x.fire('loadend');
173
+ expect(drainSpans()[0]?.status).toBe('error');
174
+ });
175
+
176
+ test('status 0 → span.status = "error"', () => {
177
+ const x = new FakeXHR();
178
+ x.open('GET', 'https://api.example.com/x');
179
+ x.send();
180
+ x.status = 0;
181
+ x.fire('loadend');
182
+ expect(drainSpans()[0]?.status).toBe('error');
183
+ });
184
+
185
+ test('abort → span.status = "cancelled"', () => {
186
+ const x = new FakeXHR();
187
+ x.open('GET', 'https://api.example.com/x');
188
+ x.send();
189
+ x.fire('abort');
190
+ expect(drainSpans()[0]?.status).toBe('cancelled');
191
+ });
192
+ });
@@ -109,4 +109,62 @@ describe('transport', () => {
109
109
  expect(capturedHeaders?.Authorization).toBe('Bearer st_pk_test');
110
110
  expect(capturedHeaders?.['Sentori-Sdk']).toMatch(/^react-native\//);
111
111
  });
112
+
113
+ // Phase 33 sub-D: offline / retry behavior.
114
+
115
+ it('retries up to MAX_RETRY (3) on a 5xx, then gives up', async () => {
116
+ let attempts = 0;
117
+ globalThis.fetch = mock(async () => {
118
+ attempts++;
119
+ return new Response('boom', { status: 503 });
120
+ }) as typeof fetch;
121
+
122
+ enqueue(makeEvent('a'));
123
+ // flush swallows the final throw (and falls through to persist).
124
+ // We're verifying the retry count, not the throw.
125
+ await flush();
126
+ expect(attempts).toBe(3);
127
+ });
128
+
129
+ it('retries on network error (fetch throw), succeeds when recovered', async () => {
130
+ let attempts = 0;
131
+ globalThis.fetch = mock(async () => {
132
+ attempts++;
133
+ if (attempts < 3) throw new TypeError('NetworkError: offline');
134
+ return new Response(null, { status: 202 });
135
+ }) as typeof fetch;
136
+
137
+ enqueue(makeEvent('a'));
138
+ await flush();
139
+ expect(attempts).toBe(3);
140
+ });
141
+
142
+ it('drops 4xx-other-than-429 without retry (client errors are unrecoverable)', async () => {
143
+ let attempts = 0;
144
+ globalThis.fetch = mock(async () => {
145
+ attempts++;
146
+ return new Response(null, { status: 400 });
147
+ }) as typeof fetch;
148
+
149
+ enqueue(makeEvent('a'));
150
+ await flush();
151
+ // sendOnce treats 4xx-other-than-429 as a no-throw exit, so the
152
+ // retry loop also exits — one attempt, no double-send.
153
+ expect(attempts).toBe(1);
154
+ });
155
+
156
+ it('does not duplicate events when flush is called twice in a row', async () => {
157
+ let attempts = 0;
158
+ globalThis.fetch = mock(async () => {
159
+ attempts++;
160
+ return new Response(null, { status: 202 });
161
+ }) as typeof fetch;
162
+
163
+ enqueue(makeEvent('a'));
164
+ enqueue(makeEvent('b'));
165
+ await flush();
166
+ await flush(); // second flush sees an empty queue and no-ops
167
+ expect(attempts).toBe(1);
168
+ expect(__peekQueue()).toHaveLength(0);
169
+ });
112
170
  });
@@ -1,3 +1,5 @@
1
+ import { startSpan } from '@goliapkg/sentori-core';
2
+
1
3
  import { addBreadcrumb } from '../breadcrumbs';
2
4
 
3
5
  let _installed = false;
@@ -6,37 +8,64 @@ const AUTH_PARAMS = ['token', 'key', 'password', 'secret', 'access_token'];
6
8
 
7
9
  export const installNetworkHandler = (): void => {
8
10
  if (_installed) return;
9
- if (typeof globalThis.fetch !== 'function') return;
10
11
  _installed = true;
12
+ patchFetch();
13
+ patchXhr();
14
+ };
11
15
 
16
+ // ── fetch ──────────────────────────────────────────────────────────
17
+
18
+ function patchFetch(): void {
19
+ if (typeof globalThis.fetch !== 'function') return;
12
20
  const original = globalThis.fetch;
13
21
 
14
22
  globalThis.fetch = (async (input: RequestInfo | URL, init?: RequestInit) => {
15
23
  const start = Date.now();
16
24
  const url = extractUrl(input);
25
+ const scrubbed = scrubUrl(url);
17
26
  const method = (init?.method ??
18
27
  (typeof input !== 'string' && 'method' in (input as Request)
19
28
  ? (input as Request).method
20
29
  : 'GET')) as string;
21
30
 
31
+ // Phase 35 sub-C: also open an http.client span so the request
32
+ // shows up in the trace waterfall. Breadcrumbs stay — they're
33
+ // attached to error events at capture time and serve a different
34
+ // surface (the "last 100 things" timeline on the issue page).
35
+ const span = startSpan('http.client', {
36
+ name: `${method.toUpperCase()} ${scrubbed}`,
37
+ tags: { 'http.method': method.toUpperCase(), 'http.url': scrubbed },
38
+ });
39
+
40
+ // Inject traceparent header on outbound requests.
41
+ const reqInit: RequestInit = { ...(init ?? {}) };
42
+ const headers = mergeHeaders(input, init);
43
+ headers.set('traceparent', toTraceparent(span.traceId, span.spanId));
44
+ reqInit.headers = headers;
45
+
22
46
  try {
23
- const resp = await original(input, init);
47
+ const resp = await original(input, reqInit);
48
+ span.setTag('http.status', String(resp.status));
49
+ span.finish({ status: resp.status >= 400 ? 'error' : 'ok' });
24
50
  addBreadcrumb({
25
51
  type: 'net',
26
52
  data: {
27
53
  method,
28
- url: scrubUrl(url),
54
+ url: scrubbed,
29
55
  status: resp.status,
30
56
  durationMs: Date.now() - start,
31
57
  },
32
58
  });
33
59
  return resp;
34
60
  } catch (e) {
61
+ const isAbort = isAbortError(e);
62
+ if (e instanceof Error) span.setTag('error.message', e.message);
63
+ span.finish({ status: isAbort ? 'cancelled' : 'error' });
35
64
  addBreadcrumb({
36
65
  type: 'net',
37
66
  data: {
38
67
  method,
39
- url: scrubUrl(url),
68
+ url: scrubbed,
40
69
  status: 0,
41
70
  durationMs: Date.now() - start,
42
71
  error: String(e),
@@ -45,8 +74,125 @@ export const installNetworkHandler = (): void => {
45
74
  throw e;
46
75
  }
47
76
  }) as typeof fetch;
77
+ }
78
+
79
+ // ── XMLHttpRequest ─────────────────────────────────────────────────
80
+ //
81
+ // React Native's XHR is a native polyfill, not built on fetch — so
82
+ // patching `globalThis.fetch` alone misses every axios / older-style
83
+ // request. axios on RN uses its `xhr` adapter by default. We patch
84
+ // the prototype's `open` + `send` so the instance carries the span
85
+ // from `send()` to `loadend`.
86
+
87
+ type TracedXhr = XMLHttpRequest & {
88
+ __sentoriMethod?: string;
89
+ __sentoriUrl?: string;
90
+ __sentoriSpan?: ReturnType<typeof startSpan>;
91
+ __sentoriStart?: number;
48
92
  };
49
93
 
94
+ function patchXhr(): void {
95
+ const XHR = (globalThis as { XMLHttpRequest?: typeof XMLHttpRequest }).XMLHttpRequest;
96
+ if (typeof XHR !== 'function') return;
97
+ const proto = XHR.prototype as XMLHttpRequest & {
98
+ __sentoriPatched?: boolean;
99
+ };
100
+ if (proto.__sentoriPatched) return;
101
+ proto.__sentoriPatched = true;
102
+
103
+ const originalOpen = proto.open;
104
+ const originalSend = proto.send;
105
+ const originalSetHeader = proto.setRequestHeader;
106
+
107
+ proto.open = function (
108
+ this: TracedXhr,
109
+ method: string,
110
+ url: string | URL,
111
+ ...rest: unknown[]
112
+ ): void {
113
+ this.__sentoriMethod = String(method).toUpperCase();
114
+ this.__sentoriUrl = typeof url === 'string' ? url : String(url);
115
+ // @ts-expect-error variadic forwarding to the native signature
116
+ return originalOpen.call(this, method, url, ...rest);
117
+ };
118
+
119
+ proto.send = function (this: TracedXhr, body?: Document | XMLHttpRequestBodyInit | null): void {
120
+ const method = this.__sentoriMethod ?? 'GET';
121
+ const url = scrubUrl(this.__sentoriUrl ?? '');
122
+ const span = startSpan('http.client', {
123
+ name: `${method} ${url}`,
124
+ tags: { 'http.method': method, 'http.url': url },
125
+ });
126
+ this.__sentoriSpan = span;
127
+ this.__sentoriStart = Date.now();
128
+
129
+ // setRequestHeader must be called between open() and send(); we're
130
+ // inside send() before the underlying call, so this is legal.
131
+ try {
132
+ originalSetHeader.call(this, 'traceparent', toTraceparent(span.traceId, span.spanId));
133
+ } catch {
134
+ // Some XHR polyfills are strict about header timing; if it
135
+ // rejects, drop the header rather than fail the request.
136
+ }
137
+
138
+ const finish = () => {
139
+ const s = this.__sentoriSpan;
140
+ if (!s) return;
141
+ this.__sentoriSpan = undefined;
142
+ const status = this.status;
143
+ s.setTag('http.status', String(status));
144
+ // status 0 means network error / aborted / CORS block — treat
145
+ // as error. The `abort` event handler below downgrades aborts.
146
+ s.finish({ status: status === 0 || status >= 400 ? 'error' : 'ok' });
147
+ addBreadcrumb({
148
+ type: 'net',
149
+ data: {
150
+ method,
151
+ url,
152
+ status,
153
+ durationMs: Date.now() - (this.__sentoriStart ?? Date.now()),
154
+ },
155
+ });
156
+ };
157
+
158
+ this.addEventListener('loadend', finish);
159
+ this.addEventListener('abort', () => {
160
+ const s = this.__sentoriSpan;
161
+ if (!s) return;
162
+ this.__sentoriSpan = undefined;
163
+ s.finish({ status: 'cancelled' });
164
+ addBreadcrumb({
165
+ type: 'net',
166
+ data: { method, url, status: 0, durationMs: Date.now() - (this.__sentoriStart ?? Date.now()), error: 'aborted' },
167
+ });
168
+ });
169
+
170
+ return originalSend.call(this, body);
171
+ };
172
+ }
173
+
174
+ function mergeHeaders(input: RequestInfo | URL, init?: RequestInit): Headers {
175
+ const out = new Headers();
176
+ if (typeof input !== 'string' && !(input instanceof URL)) {
177
+ (input as Request).headers.forEach((v, k) => out.set(k, v));
178
+ }
179
+ if (init?.headers) {
180
+ new Headers(init.headers).forEach((v, k) => out.set(k, v));
181
+ }
182
+ return out;
183
+ }
184
+
185
+ function toTraceparent(traceId: string, spanId: string): string {
186
+ const trace = traceId.replace(/-/g, '').toLowerCase();
187
+ const parent = spanId.replace(/-/g, '').toLowerCase().slice(0, 16);
188
+ return `00-${trace}-${parent}-01`;
189
+ }
190
+
191
+ function isAbortError(err: unknown): boolean {
192
+ if (typeof err !== 'object' || err === null) return false;
193
+ return (err as { name?: unknown }).name === 'AbortError';
194
+ }
195
+
50
196
  const extractUrl = (input: RequestInfo | URL): string => {
51
197
  if (typeof input === 'string') return input;
52
198
  if (input instanceof URL) return input.href;
package/src/index.ts CHANGED
@@ -37,6 +37,7 @@ export {
37
37
  markSessionCrashed,
38
38
  startSession,
39
39
  } from './session-tracker';
40
+ export { type NavigationRefLike, useTraceNavigation } from './navigation';
40
41
 
41
42
  export type {
42
43
  Event,
@@ -0,0 +1,85 @@
1
+ // Phase 35 sub-C: react-navigation auto-instrumentation.
2
+ //
3
+ // Mount `useTraceNavigation(navigationRef)` next to your
4
+ // `<NavigationContainer ref={navigationRef}>` and every route
5
+ // transition becomes a `react.navigation` span. Span names are
6
+ // `<from> → <to>` so the trace list reads as a navigation log.
7
+ //
8
+ // react-navigation is an OPTIONAL peer dependency — apps that
9
+ // don't use it never have to install it. The hook itself doesn't
10
+ // import from @react-navigation/native; consumers pass in the ref
11
+ // they already have, and we read its state via the public
12
+ // `getCurrentRoute()` API. That keeps the dep edge optional.
13
+
14
+ import { useEffect, useRef } from 'react';
15
+
16
+ import { startSpan, type SpanHandle } from '@goliapkg/sentori-core';
17
+
18
+ /** Minimal contract: anything with `addListener('state', cb)` and
19
+ * `getCurrentRoute()` works. The real @react-navigation/native
20
+ * NavigationContainer ref matches this shape. */
21
+ export type NavigationRefLike = {
22
+ addListener: (event: 'state', listener: () => void) => () => void;
23
+ getCurrentRoute: () => { name: string } | undefined;
24
+ };
25
+
26
+ /**
27
+ * Subscribe to react-navigation state changes and emit a
28
+ * `react.navigation` span per transition. First mount records the
29
+ * initial route as the start anchor but does NOT emit a span (the
30
+ * convention from `useSentoriRouter` in sentori-react).
31
+ *
32
+ * import { NavigationContainer, useNavigationContainerRef } from '@react-navigation/native'
33
+ * import { useTraceNavigation } from '@goliapkg/sentori-react-native'
34
+ *
35
+ * function App() {
36
+ * const navigationRef = useNavigationContainerRef()
37
+ * useTraceNavigation(navigationRef)
38
+ * return <NavigationContainer ref={navigationRef}>{...}</NavigationContainer>
39
+ * }
40
+ *
41
+ * Each span carries `{ from, to }` as tags and uses the destination
42
+ * route name as the span name.
43
+ */
44
+ export function useTraceNavigation(navigationRef: NavigationRefLike): void {
45
+ // Latest route name we've observed. `null` means "no transition
46
+ // recorded yet" (initial mount).
47
+ const lastRouteRef = useRef<null | string>(null);
48
+ // Span that started when this route was entered. Finished when the
49
+ // NEXT route transition arrives.
50
+ const openSpanRef = useRef<null | SpanHandle>(null);
51
+
52
+ useEffect(() => {
53
+ if (typeof navigationRef.addListener !== 'function') return;
54
+ if (typeof navigationRef.getCurrentRoute !== 'function') return;
55
+
56
+ // Seed the "last route" reference from the current state so the
57
+ // first transition emits a span with the right `from`.
58
+ const initial = navigationRef.getCurrentRoute()?.name ?? null;
59
+ lastRouteRef.current = initial;
60
+
61
+ const unsubscribe = navigationRef.addListener('state', () => {
62
+ const next = navigationRef.getCurrentRoute()?.name ?? null;
63
+ const prev = lastRouteRef.current;
64
+ if (next === null || next === prev) return;
65
+
66
+ // Close the prior span (if any) before opening the new one so
67
+ // the trace looks like a sequence, not nested.
68
+ openSpanRef.current?.finish({ status: 'ok' });
69
+
70
+ const span = startSpan('react.navigation', {
71
+ name: prev ? `${prev} → ${next}` : next,
72
+ tags: { 'nav.from': prev ?? '', 'nav.to': next },
73
+ });
74
+ openSpanRef.current = span;
75
+ lastRouteRef.current = next;
76
+ });
77
+
78
+ return () => {
79
+ unsubscribe();
80
+ // Close any still-open span on unmount so we don't leak it.
81
+ openSpanRef.current?.finish({ status: 'ok' });
82
+ openSpanRef.current = null;
83
+ };
84
+ }, [navigationRef]);
85
+ }