@grafana/faro-react-native-tracing 1.3.1 → 2.0.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 (38) hide show
  1. package/README.md +33 -0
  2. package/dist/cjs/exporters/faroTraceExporter.utils.js +33 -0
  3. package/dist/cjs/exporters/faroTraceExporter.utils.js.map +1 -1
  4. package/dist/cjs/instrumentation.js +259 -44
  5. package/dist/cjs/instrumentation.js.map +1 -1
  6. package/dist/cjs/instrumentations/getDefaultOTELInstrumentations.js +28 -20
  7. package/dist/cjs/instrumentations/getDefaultOTELInstrumentations.js.map +1 -1
  8. package/dist/cjs/instrumentations/instrumentationUtils.js +15 -0
  9. package/dist/cjs/instrumentations/instrumentationUtils.js.map +1 -1
  10. package/dist/cjs/processors/httpRequestMonitorSpanProcessor.js +27 -13
  11. package/dist/cjs/processors/httpRequestMonitorSpanProcessor.js.map +1 -1
  12. package/dist/cjs/types.js.map +1 -1
  13. package/dist/esm/exporters/faroTraceExporter.utils.js +32 -0
  14. package/dist/esm/exporters/faroTraceExporter.utils.js.map +1 -1
  15. package/dist/esm/instrumentation.js +237 -43
  16. package/dist/esm/instrumentation.js.map +1 -1
  17. package/dist/esm/instrumentations/getDefaultOTELInstrumentations.js +27 -21
  18. package/dist/esm/instrumentations/getDefaultOTELInstrumentations.js.map +1 -1
  19. package/dist/esm/instrumentations/instrumentationUtils.js +14 -0
  20. package/dist/esm/instrumentations/instrumentationUtils.js.map +1 -1
  21. package/dist/esm/processors/httpRequestMonitorSpanProcessor.js +27 -13
  22. package/dist/esm/processors/httpRequestMonitorSpanProcessor.js.map +1 -1
  23. package/dist/esm/types.js.map +1 -1
  24. package/dist/types/instrumentation.d.ts +12 -4
  25. package/dist/types/instrumentations/getDefaultOTELInstrumentations.d.ts +2 -0
  26. package/dist/types/instrumentations/instrumentationUtils.d.ts +2 -0
  27. package/dist/types/types.d.ts +4 -0
  28. package/package.json +3 -3
  29. package/src/exporters/faroTraceExporter.events.test.ts +90 -0
  30. package/src/exporters/faroTraceExporter.utils.ts +32 -0
  31. package/src/httpSemantics.test.ts +300 -0
  32. package/src/instrumentation.test.ts +740 -0
  33. package/src/instrumentation.ts +299 -67
  34. package/src/instrumentations/getDefaultOTELInstrumentations.test.ts +28 -1
  35. package/src/instrumentations/getDefaultOTELInstrumentations.ts +41 -32
  36. package/src/instrumentations/instrumentationUtils.ts +13 -0
  37. package/src/processors/httpRequestMonitorSpanProcessor.ts +27 -15
  38. package/src/types.ts +4 -0
@@ -0,0 +1,740 @@
1
+ import { context, propagation, ROOT_CONTEXT, trace } from '@opentelemetry/api';
2
+ import type { Context, ContextManager, MeterProvider, TracerProvider } from '@opentelemetry/api';
3
+ import { W3CTraceContextPropagator } from '@opentelemetry/core';
4
+ import type { Instrumentation, InstrumentationConfig } from '@opentelemetry/instrumentation';
5
+ import { FetchInstrumentation } from '@opentelemetry/instrumentation-fetch';
6
+ import { XMLHttpRequestInstrumentation } from '@opentelemetry/instrumentation-xml-http-request';
7
+ import { BasicTracerProvider, InMemorySpanExporter, SimpleSpanProcessor } from '@opentelemetry/sdk-trace-base';
8
+ import type { SpanProcessor } from '@opentelemetry/sdk-trace-base';
9
+ import { StackContextManager } from '@opentelemetry/sdk-trace-web';
10
+
11
+ import { getInternalFaroFromGlobalObject, initializeFaro } from '@grafana/faro-core';
12
+ import type { Faro } from '@grafana/faro-core';
13
+ import { mockConfig, MockTransport } from '@grafana/faro-test-utils';
14
+
15
+ import { FaroTraceExporter } from './exporters/faroTraceExporter';
16
+ import { TracingInstrumentation } from './instrumentation';
17
+ import type { TracingInstrumentationOptions } from './types';
18
+
19
+ jest.mock('@grafana/faro-core', () => ({
20
+ ...jest.requireActual('@grafana/faro-core'),
21
+ getInternalFaroFromGlobalObject: jest.fn(),
22
+ }));
23
+
24
+ jest.mock('@grafana/faro-react-native', () => ({
25
+ notifyHttpRequestEnd: jest.fn(),
26
+ notifyHttpRequestStart: jest.fn(),
27
+ }));
28
+
29
+ type FaroWithOtel = Faro & { otel?: unknown };
30
+
31
+ class FetchTestInstrumentation implements Instrumentation {
32
+ readonly instrumentationName = 'fetch-test';
33
+ readonly instrumentationVersion = '1.0.0';
34
+
35
+ disableCalls = 0;
36
+ enableCalls = 0;
37
+ requestCount = 0;
38
+
39
+ private config: InstrumentationConfig = { enabled: false };
40
+ private originalFetch?: typeof fetch;
41
+ private wrappedFetch?: typeof fetch;
42
+
43
+ disable(): void {
44
+ this.disableCalls += 1;
45
+ if (!this.config.enabled) {
46
+ return;
47
+ }
48
+
49
+ this.config = { ...this.config, enabled: false };
50
+ if (this.originalFetch && this.wrappedFetch && globalThis.fetch === this.wrappedFetch) {
51
+ globalThis.fetch = this.originalFetch;
52
+ }
53
+ this.originalFetch = undefined;
54
+ this.wrappedFetch = undefined;
55
+ }
56
+
57
+ enable(): void {
58
+ this.enableCalls += 1;
59
+ if (this.config.enabled) {
60
+ return;
61
+ }
62
+
63
+ const originalFetch = globalThis.fetch;
64
+ const wrappedFetch: typeof fetch = (...args) => {
65
+ this.requestCount += 1;
66
+ const span = trace.getTracer(this.instrumentationName).startSpan('HTTP GET');
67
+ return originalFetch(...args).then(
68
+ (response) => {
69
+ span.end();
70
+ return response;
71
+ },
72
+ (error: unknown) => {
73
+ span.end();
74
+ throw error;
75
+ }
76
+ );
77
+ };
78
+ this.config = { ...this.config, enabled: true };
79
+ this.originalFetch = originalFetch;
80
+ this.wrappedFetch = wrappedFetch;
81
+ globalThis.fetch = wrappedFetch;
82
+ }
83
+
84
+ getConfig(): InstrumentationConfig {
85
+ return this.config;
86
+ }
87
+
88
+ setConfig(config: InstrumentationConfig): void {
89
+ this.config = { ...config };
90
+ }
91
+
92
+ setMeterProvider(_meterProvider: MeterProvider): void {}
93
+
94
+ setTracerProvider(_tracerProvider: TracerProvider): void {}
95
+ }
96
+
97
+ class ThrowingDisableInstrumentation extends FetchTestInstrumentation {
98
+ override disable(): void {
99
+ super.disable();
100
+ throw new Error('disable failed');
101
+ }
102
+ }
103
+
104
+ class ThrowingEnableInstrumentation extends FetchTestInstrumentation {
105
+ override enable(): void {
106
+ super.enable();
107
+ throw new Error('enable failed');
108
+ }
109
+ }
110
+
111
+ class StatefulContextManager implements ContextManager {
112
+ disableCalls = 0;
113
+ enableCalls = 0;
114
+ enabled = false;
115
+
116
+ active(): Context {
117
+ return ROOT_CONTEXT;
118
+ }
119
+
120
+ bind<T>(_context: Context, target: T): T {
121
+ return target;
122
+ }
123
+
124
+ disable(): this {
125
+ this.disableCalls += 1;
126
+ this.enabled = false;
127
+ return this;
128
+ }
129
+
130
+ enable(): this {
131
+ this.enableCalls += 1;
132
+ this.enabled = true;
133
+ return this;
134
+ }
135
+
136
+ with<A extends unknown[], F extends (...args: A) => ReturnType<F>>(
137
+ _context: Context,
138
+ fn: F,
139
+ thisArg?: ThisParameterType<F>,
140
+ ...args: A
141
+ ): ReturnType<F> {
142
+ return fn.call(thisArg, ...args);
143
+ }
144
+ }
145
+
146
+ function createSpanProcessor(
147
+ shutdown: () => Promise<void> = async () => {},
148
+ forceFlush: () => Promise<void> = async () => {}
149
+ ): SpanProcessor {
150
+ return {
151
+ forceFlush: jest.fn(forceFlush),
152
+ onEnd: jest.fn(),
153
+ onEnding: jest.fn(),
154
+ onStart: jest.fn(),
155
+ shutdown: jest.fn(shutdown),
156
+ };
157
+ }
158
+
159
+ describe('TracingInstrumentation teardown', () => {
160
+ const getInternalFaroMock = jest.mocked(getInternalFaroFromGlobalObject);
161
+ const tracingInstrumentations: TracingInstrumentation[] = [];
162
+ let originalFetch: typeof fetch;
163
+
164
+ function addTracingInstrumentation(
165
+ otelInstrumentation: Instrumentation | Instrumentation[],
166
+ spanProcessor: SpanProcessor,
167
+ options: TracingInstrumentationOptions = {},
168
+ previousFaroOtel?: unknown
169
+ ): { faro: FaroWithOtel; tracingInstrumentation: TracingInstrumentation } {
170
+ const faro = initializeFaro(
171
+ mockConfig({
172
+ instrumentations: [],
173
+ transports: [new MockTransport()],
174
+ })
175
+ ) as FaroWithOtel;
176
+ faro.otel = previousFaroOtel;
177
+ getInternalFaroMock.mockReturnValue(faro);
178
+
179
+ const tracingInstrumentation = new TracingInstrumentation({
180
+ ...options,
181
+ instrumentations: Array.isArray(otelInstrumentation) ? otelInstrumentation : [otelInstrumentation],
182
+ spanProcessor,
183
+ });
184
+ tracingInstrumentations.push(tracingInstrumentation);
185
+ faro.instrumentations.add(tracingInstrumentation);
186
+
187
+ return { faro, tracingInstrumentation };
188
+ }
189
+
190
+ function addDefaultTracingInstrumentation(): {
191
+ faro: FaroWithOtel;
192
+ tracingInstrumentation: TracingInstrumentation;
193
+ } {
194
+ const faro = initializeFaro(
195
+ mockConfig({
196
+ instrumentations: [],
197
+ transports: [new MockTransport()],
198
+ })
199
+ ) as FaroWithOtel;
200
+ getInternalFaroMock.mockReturnValue(faro);
201
+ const tracingInstrumentation = new TracingInstrumentation({ instrumentations: [] });
202
+ tracingInstrumentations.push(tracingInstrumentation);
203
+ faro.instrumentations.add(tracingInstrumentation);
204
+
205
+ return { faro, tracingInstrumentation };
206
+ }
207
+
208
+ beforeEach(() => {
209
+ trace.disable();
210
+ context.disable();
211
+ propagation.disable();
212
+ getInternalFaroMock.mockReset();
213
+
214
+ originalFetch = globalThis.fetch;
215
+ globalThis.fetch = jest.fn().mockResolvedValue({
216
+ clone: () => ({ body: null }),
217
+ ok: true,
218
+ status: 200,
219
+ statusText: 'OK',
220
+ }) as unknown as typeof fetch;
221
+ });
222
+
223
+ afterEach(async () => {
224
+ await Promise.all(tracingInstrumentations.splice(0).map((item) => item.shutdown().catch(() => undefined)));
225
+ jest.restoreAllMocks();
226
+ trace.disable();
227
+ context.disable();
228
+ propagation.disable();
229
+ globalThis.fetch = originalFetch;
230
+ });
231
+
232
+ it('detaches instrumentations before provider shutdown and cleans up once', async () => {
233
+ const order: string[] = [];
234
+ const otelInstrumentation = new FetchTestInstrumentation();
235
+ const originalDisable = otelInstrumentation.disable.bind(otelInstrumentation);
236
+ jest.spyOn(otelInstrumentation, 'disable').mockImplementation(() => {
237
+ order.push('instrumentation');
238
+ originalDisable();
239
+ });
240
+ const spanProcessor = createSpanProcessor(async () => {
241
+ order.push('provider');
242
+ });
243
+ const traceDisableSpy = jest.spyOn(trace, 'disable');
244
+ const contextDisableSpy = jest.spyOn(context, 'disable');
245
+ const propagationDisableSpy = jest.spyOn(propagation, 'disable');
246
+ const { faro, tracingInstrumentation } = addTracingInstrumentation(otelInstrumentation, spanProcessor);
247
+
248
+ expect(faro.otel).toEqual({ trace, context });
249
+ expect(otelInstrumentation.enableCalls).toBe(1);
250
+ await globalThis.fetch('https://example.com');
251
+ expect(otelInstrumentation.requestCount).toBe(1);
252
+
253
+ const firstShutdown = tracingInstrumentation.shutdown();
254
+ const concurrentShutdown = tracingInstrumentation.shutdown();
255
+ await Promise.all([firstShutdown, concurrentShutdown]);
256
+
257
+ expect(order).toEqual(['instrumentation', 'provider']);
258
+ expect(faro.otel).toBeUndefined();
259
+ expect(traceDisableSpy).toHaveBeenCalledTimes(1);
260
+ expect(contextDisableSpy).toHaveBeenCalledTimes(1);
261
+ expect(propagationDisableSpy).toHaveBeenCalledTimes(1);
262
+ await globalThis.fetch('https://example.com/after-shutdown');
263
+ expect(otelInstrumentation.requestCount).toBe(1);
264
+
265
+ await tracingInstrumentation.shutdown();
266
+ expect(otelInstrumentation.disableCalls).toBe(1);
267
+ expect(spanProcessor.shutdown).toHaveBeenCalledTimes(1);
268
+ });
269
+
270
+ it('fails without clearing global registrations owned by another provider', async () => {
271
+ const externalProvider = new BasicTracerProvider();
272
+ const externalContextManager = new StackContextManager().enable();
273
+ const externalPropagator = new W3CTraceContextPropagator();
274
+ expect(trace.setGlobalTracerProvider(externalProvider)).toBe(true);
275
+ expect(context.setGlobalContextManager(externalContextManager)).toBe(true);
276
+ expect(propagation.setGlobalPropagator(externalPropagator)).toBe(true);
277
+
278
+ const traceDisableSpy = jest.spyOn(trace, 'disable');
279
+ const contextDisableSpy = jest.spyOn(context, 'disable');
280
+ const propagationDisableSpy = jest.spyOn(propagation, 'disable');
281
+ const contextManagerDisableSpy = jest.spyOn(externalContextManager, 'disable');
282
+ const previousFaroOtel = { owner: 'existing' };
283
+ const otelInstrumentation = new FetchTestInstrumentation();
284
+ const spanProcessor = createSpanProcessor();
285
+ const faro = initializeFaro(
286
+ mockConfig({
287
+ instrumentations: [],
288
+ transports: [new MockTransport()],
289
+ })
290
+ ) as FaroWithOtel;
291
+ faro.otel = previousFaroOtel;
292
+ getInternalFaroMock.mockReturnValue(faro);
293
+ const tracingInstrumentation = new TracingInstrumentation({
294
+ instrumentations: [otelInstrumentation],
295
+ spanProcessor,
296
+ contextManager: externalContextManager,
297
+ propagator: externalPropagator,
298
+ });
299
+ tracingInstrumentations.push(tracingInstrumentation);
300
+
301
+ expect(() => faro.instrumentations.add(tracingInstrumentation)).toThrow(
302
+ 'Unable to register the Faro OpenTelemetry tracer provider'
303
+ );
304
+ await new Promise((resolve) => setTimeout(resolve, 0));
305
+
306
+ expect(otelInstrumentation.enableCalls).toBe(0);
307
+ expect(spanProcessor.forceFlush).toHaveBeenCalledTimes(1);
308
+ expect(traceDisableSpy).not.toHaveBeenCalled();
309
+ expect(contextDisableSpy).not.toHaveBeenCalled();
310
+ expect(propagationDisableSpy).not.toHaveBeenCalled();
311
+ expect(contextManagerDisableSpy).not.toHaveBeenCalled();
312
+ expect(faro.otel).toBe(previousFaroOtel);
313
+ await externalProvider.shutdown();
314
+ });
315
+
316
+ it('disables an unused default context manager without clearing the active one', async () => {
317
+ const externalContextManager = new StackContextManager().enable();
318
+ expect(context.setGlobalContextManager(externalContextManager)).toBe(true);
319
+ const externalDisableSpy = jest.spyOn(externalContextManager, 'disable');
320
+ const defaultDisableSpy = jest.spyOn(StackContextManager.prototype, 'disable');
321
+ const otelInstrumentation = new FetchTestInstrumentation();
322
+ const spanProcessor = createSpanProcessor();
323
+ const { tracingInstrumentation } = addTracingInstrumentation(otelInstrumentation, spanProcessor);
324
+
325
+ await tracingInstrumentation.shutdown();
326
+
327
+ expect(externalDisableSpy).not.toHaveBeenCalled();
328
+ expect(defaultDisableSpy).toHaveBeenCalledTimes(1);
329
+ });
330
+
331
+ it('detaches synchronously during Faro removal and handles flush rejection', async () => {
332
+ const flushError = new Error('provider flush failed');
333
+ const otelInstrumentation = new FetchTestInstrumentation();
334
+ const spanProcessor = createSpanProcessor(
335
+ async () => {},
336
+ async () => {
337
+ throw flushError;
338
+ }
339
+ );
340
+ const { faro, tracingInstrumentation } = addTracingInstrumentation(otelInstrumentation, spanProcessor);
341
+ const loggerSpy = jest.spyOn(faro.internalLogger, 'error').mockImplementation(() => {});
342
+
343
+ faro.instrumentations.remove(tracingInstrumentation);
344
+ await new Promise((resolve) => setTimeout(resolve, 0));
345
+
346
+ expect(otelInstrumentation.disableCalls).toBe(1);
347
+ expect(faro.otel).toBeUndefined();
348
+ expect(faro.instrumentations.instrumentations).not.toContain(tracingInstrumentation);
349
+ expect(loggerSpy).toHaveBeenCalledWith(
350
+ expect.stringContaining('@grafana/faro-react-native-tracing'),
351
+ expect.stringContaining('Failed to flush the OpenTelemetry tracer provider'),
352
+ expect.arrayContaining([flushError])
353
+ );
354
+ expect(spanProcessor.shutdown).not.toHaveBeenCalled();
355
+
356
+ await tracingInstrumentation.shutdown();
357
+ expect(otelInstrumentation.disableCalls).toBe(1);
358
+ expect(spanProcessor.shutdown).toHaveBeenCalledTimes(1);
359
+ });
360
+
361
+ it('finishes cleanup when an instrumentation throws while disabling', async () => {
362
+ const throwingInstrumentation = new ThrowingDisableInstrumentation();
363
+ const followingInstrumentation = new FetchTestInstrumentation();
364
+ const spanProcessor = createSpanProcessor();
365
+ const traceDisableSpy = jest.spyOn(trace, 'disable');
366
+ const { faro, tracingInstrumentation } = addTracingInstrumentation(
367
+ [throwingInstrumentation, followingInstrumentation],
368
+ spanProcessor
369
+ );
370
+ const loggerSpy = jest.spyOn(faro.internalLogger, 'error').mockImplementation(() => {});
371
+
372
+ await tracingInstrumentation.shutdown();
373
+
374
+ expect(throwingInstrumentation.disableCalls).toBe(2);
375
+ expect(followingInstrumentation.disableCalls).toBe(1);
376
+ expect(spanProcessor.shutdown).toHaveBeenCalledTimes(1);
377
+ expect(traceDisableSpy).toHaveBeenCalledTimes(1);
378
+ expect(faro.otel).toBeUndefined();
379
+ expect(loggerSpy).toHaveBeenCalledWith(
380
+ expect.stringContaining('@grafana/faro-react-native-tracing'),
381
+ expect.stringContaining('Failed to unregister'),
382
+ expect.any(Error)
383
+ );
384
+ });
385
+
386
+ it('detaches before propagating an explicit shutdown failure', async () => {
387
+ const shutdownError = new Error('provider shutdown failed');
388
+ const otelInstrumentation = new FetchTestInstrumentation();
389
+ const spanProcessor = createSpanProcessor(async () => {
390
+ throw shutdownError;
391
+ });
392
+ const { faro, tracingInstrumentation } = addTracingInstrumentation(otelInstrumentation, spanProcessor);
393
+
394
+ await expect(tracingInstrumentation.shutdown()).rejects.toBe(shutdownError);
395
+
396
+ expect(otelInstrumentation.disableCalls).toBe(1);
397
+ expect(faro.otel).toBeUndefined();
398
+ await globalThis.fetch('https://example.com/after-failed-shutdown');
399
+ expect(otelInstrumentation.requestCount).toBe(0);
400
+ });
401
+
402
+ it('releases a supplied processor after removal when its shutdown throws synchronously', async () => {
403
+ const shutdownError = new Error('provider shutdown threw');
404
+ const otelInstrumentation = new FetchTestInstrumentation();
405
+ const spanProcessor = createSpanProcessor(() => {
406
+ throw shutdownError;
407
+ });
408
+ const { faro, tracingInstrumentation } = addTracingInstrumentation(otelInstrumentation, spanProcessor);
409
+ faro.instrumentations.remove(tracingInstrumentation);
410
+
411
+ expect(otelInstrumentation.disableCalls).toBe(1);
412
+ await expect(tracingInstrumentation.shutdown()).rejects.toBe(shutdownError);
413
+ expect(spanProcessor.shutdown).toHaveBeenCalledTimes(1);
414
+ });
415
+
416
+ it('does not replace a newer faro.otel owner during cleanup', async () => {
417
+ const otelInstrumentation = new FetchTestInstrumentation();
418
+ const spanProcessor = createSpanProcessor();
419
+ const { faro, tracingInstrumentation } = addTracingInstrumentation(otelInstrumentation, spanProcessor);
420
+ const replacementOtel = { owner: 'replacement' };
421
+ faro.otel = replacementOtel;
422
+
423
+ await tracingInstrumentation.shutdown();
424
+
425
+ expect(faro.otel).toBe(replacementOtel);
426
+ });
427
+
428
+ it('waits for every provider cleanup before shutting down a supplied processor', async () => {
429
+ let resolveFirstFlush: () => void = () => {};
430
+ let resolveSecondFlush: () => void = () => {};
431
+ const firstFlush = new Promise<void>((resolve) => {
432
+ resolveFirstFlush = resolve;
433
+ });
434
+ const secondFlush = new Promise<void>((resolve) => {
435
+ resolveSecondFlush = resolve;
436
+ });
437
+ const forceFlush = jest.fn().mockReturnValueOnce(firstFlush).mockReturnValueOnce(secondFlush);
438
+ const otelInstrumentation = new FetchTestInstrumentation();
439
+ const spanProcessor = createSpanProcessor(async () => {}, forceFlush);
440
+ const { tracingInstrumentation } = addTracingInstrumentation(otelInstrumentation, spanProcessor);
441
+
442
+ tracingInstrumentation.initialize();
443
+ const finalShutdown = tracingInstrumentation.shutdown();
444
+ let finalShutdownSettled = false;
445
+ void finalShutdown.then(
446
+ () => {
447
+ finalShutdownSettled = true;
448
+ },
449
+ () => {
450
+ finalShutdownSettled = true;
451
+ }
452
+ );
453
+
454
+ resolveFirstFlush();
455
+ await new Promise((resolve) => setTimeout(resolve, 0));
456
+ expect(finalShutdownSettled).toBe(false);
457
+ expect(spanProcessor.shutdown).not.toHaveBeenCalled();
458
+
459
+ resolveSecondFlush();
460
+ await expect(finalShutdown).resolves.toBeUndefined();
461
+ expect(spanProcessor.shutdown).toHaveBeenCalledTimes(1);
462
+ });
463
+
464
+ it('rolls back registrations when initialization fails', async () => {
465
+ const faro = initializeFaro(
466
+ mockConfig({
467
+ instrumentations: [],
468
+ transports: [new MockTransport()],
469
+ })
470
+ ) as FaroWithOtel;
471
+ getInternalFaroMock.mockReturnValue(faro);
472
+ const baseFetch = globalThis.fetch;
473
+ const otelInstrumentation = new ThrowingEnableInstrumentation();
474
+ const spanProcessor = createSpanProcessor();
475
+ const traceDisableSpy = jest.spyOn(trace, 'disable');
476
+ const contextDisableSpy = jest.spyOn(context, 'disable');
477
+ const propagationDisableSpy = jest.spyOn(propagation, 'disable');
478
+ const tracingInstrumentation = new TracingInstrumentation({
479
+ instrumentations: [otelInstrumentation],
480
+ spanProcessor,
481
+ });
482
+ tracingInstrumentations.push(tracingInstrumentation);
483
+
484
+ expect(() => faro.instrumentations.add(tracingInstrumentation)).toThrow('enable failed');
485
+
486
+ expect(globalThis.fetch).toBe(baseFetch);
487
+ expect(otelInstrumentation.disableCalls).toBe(1);
488
+ expect(traceDisableSpy).toHaveBeenCalledTimes(1);
489
+ expect(contextDisableSpy).toHaveBeenCalledTimes(1);
490
+ expect(propagationDisableSpy).toHaveBeenCalledTimes(1);
491
+ expect(faro.otel).toBeUndefined();
492
+ await new Promise((resolve) => setTimeout(resolve, 0));
493
+ expect(spanProcessor.forceFlush).toHaveBeenCalledTimes(1);
494
+ expect(spanProcessor.shutdown).not.toHaveBeenCalled();
495
+
496
+ await tracingInstrumentation.shutdown();
497
+ expect(spanProcessor.shutdown).toHaveBeenCalledTimes(1);
498
+ });
499
+
500
+ it('keeps a supplied processor active across Faro remove and re-add', async () => {
501
+ const exporter = new InMemorySpanExporter();
502
+ const exporterShutdownSpy = jest.spyOn(exporter, 'shutdown');
503
+ const spanProcessor = new SimpleSpanProcessor(exporter);
504
+ const processorShutdownSpy = jest.spyOn(spanProcessor, 'shutdown');
505
+ const { faro, tracingInstrumentation } = addTracingInstrumentation([], spanProcessor);
506
+ faro.api.setSession({ id: 'sampled-session', attributes: { isSampled: 'true' } });
507
+
508
+ trace.getTracer('processor-reuse').startSpan('before-remove').end();
509
+ await spanProcessor.forceFlush();
510
+ faro.instrumentations.remove(tracingInstrumentation);
511
+ await new Promise((resolve) => setTimeout(resolve, 0));
512
+
513
+ expect(processorShutdownSpy).not.toHaveBeenCalled();
514
+ expect(exporterShutdownSpy).not.toHaveBeenCalled();
515
+
516
+ const replacementTracingInstrumentation = new TracingInstrumentation({
517
+ instrumentations: [],
518
+ spanProcessor,
519
+ });
520
+ tracingInstrumentations.push(replacementTracingInstrumentation);
521
+ faro.instrumentations.add(replacementTracingInstrumentation);
522
+ trace.getTracer('processor-reuse').startSpan('after-readd').end();
523
+ await spanProcessor.forceFlush();
524
+
525
+ expect(exporter.getFinishedSpans().map((span) => span.name)).toEqual(['before-remove', 'after-readd']);
526
+
527
+ await replacementTracingInstrumentation.shutdown();
528
+ expect(processorShutdownSpy).toHaveBeenCalledTimes(1);
529
+ expect(exporterShutdownSpy).toHaveBeenCalledTimes(1);
530
+ });
531
+
532
+ it('re-enables a real fetch instrumentation when a new tracing instance reuses it', async () => {
533
+ const fetchInstrumentation = new FetchInstrumentation({
534
+ ignoreNetworkEvents: true,
535
+ propagateTraceHeaderCorsUrls: [/.*/],
536
+ });
537
+ const enableSpy = jest.spyOn(fetchInstrumentation, 'enable');
538
+ const spanProcessor = createSpanProcessor();
539
+ const { faro, tracingInstrumentation } = addTracingInstrumentation(fetchInstrumentation, spanProcessor);
540
+ const patchedFetch = globalThis.fetch;
541
+ faro.api.setSession({ id: 'sampled-session', attributes: { isSampled: 'true' } });
542
+
543
+ await globalThis.fetch('https://example.com/first');
544
+ expect(spanProcessor.onStart).toHaveBeenCalledTimes(1);
545
+ faro.instrumentations.remove(tracingInstrumentation);
546
+
547
+ const replacementTracingInstrumentation = new TracingInstrumentation({
548
+ instrumentations: [fetchInstrumentation],
549
+ spanProcessor,
550
+ });
551
+ tracingInstrumentations.push(replacementTracingInstrumentation);
552
+ faro.instrumentations.add(replacementTracingInstrumentation);
553
+ expect(globalThis.fetch).toBe(patchedFetch);
554
+ expect(enableSpy).toHaveBeenCalledTimes(1);
555
+ await globalThis.fetch('https://example.com/second');
556
+
557
+ expect(spanProcessor.onStart).toHaveBeenCalledTimes(2);
558
+ });
559
+
560
+ it('reuses default request instrumentations and refreshes transport ignore URLs', async () => {
561
+ const firstCollectorUrl = 'https://collector.example.com/first';
562
+ const secondCollectorUrl = 'https://collector.example.com/second';
563
+ const transport = new MockTransport([firstCollectorUrl]);
564
+ const getIgnoreUrlsSpy = jest.spyOn(transport, 'getIgnoreUrls');
565
+ const setTracerProviderSpy = jest.spyOn(FetchInstrumentation.prototype, 'setTracerProvider');
566
+ const setXhrTracerProviderSpy = jest.spyOn(XMLHttpRequestInstrumentation.prototype, 'setTracerProvider');
567
+ const spanProcessor = createSpanProcessor();
568
+ const faro = initializeFaro(
569
+ mockConfig({
570
+ instrumentations: [],
571
+ transports: [transport],
572
+ })
573
+ ) as FaroWithOtel;
574
+ getInternalFaroMock.mockReturnValue(faro);
575
+ const tracingInstrumentation = new TracingInstrumentation({
576
+ instrumentationOptions: { enableXhrInstrumentation: true },
577
+ spanProcessor,
578
+ });
579
+ tracingInstrumentations.push(tracingInstrumentation);
580
+ faro.api.setSession({ id: 'sampled-session', attributes: { isSampled: 'true' } });
581
+
582
+ faro.instrumentations.add(tracingInstrumentation);
583
+ const firstFetchInstrumentation = setTracerProviderSpy.mock.instances[0];
584
+ const firstXhrInstrumentation = setXhrTracerProviderSpy.mock.instances[0];
585
+ expect(firstFetchInstrumentation).toBeInstanceOf(FetchInstrumentation);
586
+ expect(firstXhrInstrumentation).toBeInstanceOf(XMLHttpRequestInstrumentation);
587
+
588
+ await globalThis.fetch(firstCollectorUrl);
589
+ expect(spanProcessor.onStart).not.toHaveBeenCalled();
590
+ faro.instrumentations.remove(tracingInstrumentation);
591
+
592
+ getIgnoreUrlsSpy.mockReturnValue([secondCollectorUrl]);
593
+ faro.instrumentations.add(tracingInstrumentation);
594
+ expect(setTracerProviderSpy.mock.instances).toEqual([firstFetchInstrumentation, firstFetchInstrumentation]);
595
+ expect(setXhrTracerProviderSpy.mock.instances).toEqual([firstXhrInstrumentation, firstXhrInstrumentation]);
596
+
597
+ await globalThis.fetch(secondCollectorUrl);
598
+ expect(spanProcessor.onStart).not.toHaveBeenCalled();
599
+ await globalThis.fetch(firstCollectorUrl);
600
+ expect(spanProcessor.onStart).toHaveBeenCalledTimes(1);
601
+ });
602
+
603
+ it('does not double-enable an instrumentation that the OpenTelemetry registry re-enables', () => {
604
+ const fetchInstrumentation = new FetchInstrumentation({
605
+ enabled: false,
606
+ ignoreNetworkEvents: true,
607
+ propagateTraceHeaderCorsUrls: [/.*/],
608
+ });
609
+ const enableSpy = jest.spyOn(fetchInstrumentation, 'enable');
610
+ const spanProcessor = createSpanProcessor();
611
+ const { faro, tracingInstrumentation } = addTracingInstrumentation(fetchInstrumentation, spanProcessor);
612
+
613
+ expect(enableSpy).toHaveBeenCalledTimes(1);
614
+ faro.instrumentations.remove(tracingInstrumentation);
615
+
616
+ const replacementTracingInstrumentation = new TracingInstrumentation({
617
+ instrumentations: [fetchInstrumentation],
618
+ spanProcessor,
619
+ });
620
+ tracingInstrumentations.push(replacementTracingInstrumentation);
621
+ faro.instrumentations.add(replacementTracingInstrumentation);
622
+
623
+ expect(enableSpy).toHaveBeenCalledTimes(2);
624
+ });
625
+
626
+ it('re-enables a supplied context manager when tracing is re-added', async () => {
627
+ const contextManager = new StatefulContextManager();
628
+ const spanProcessor = createSpanProcessor();
629
+ const { faro, tracingInstrumentation } = addTracingInstrumentation([], spanProcessor, { contextManager });
630
+
631
+ expect(contextManager.enabled).toBe(true);
632
+ expect(contextManager.enableCalls).toBe(1);
633
+
634
+ faro.instrumentations.remove(tracingInstrumentation);
635
+ expect(contextManager.enabled).toBe(false);
636
+ expect(contextManager.disableCalls).toBe(1);
637
+
638
+ const replacementTracingInstrumentation = new TracingInstrumentation({
639
+ contextManager,
640
+ instrumentations: [],
641
+ spanProcessor,
642
+ });
643
+ tracingInstrumentations.push(replacementTracingInstrumentation);
644
+ faro.instrumentations.add(replacementTracingInstrumentation);
645
+ expect(contextManager.enabled).toBe(true);
646
+ expect(contextManager.enableCalls).toBe(2);
647
+ });
648
+
649
+ it('forwards onEnding to a supplied span processor when it is implemented', () => {
650
+ const spanProcessor = createSpanProcessor();
651
+ const { faro } = addTracingInstrumentation([], spanProcessor);
652
+ faro.api.setSession({ id: 'sampled-session', attributes: { isSampled: 'true' } });
653
+
654
+ trace.getTracer('on-ending').startSpan('span').end();
655
+
656
+ expect(spanProcessor.onEnding).toHaveBeenCalledTimes(1);
657
+ });
658
+
659
+ it('supports a supplied span processor without onEnding', () => {
660
+ const spanProcessor = createSpanProcessor();
661
+ delete spanProcessor.onEnding;
662
+ const { faro } = addTracingInstrumentation([], spanProcessor);
663
+ faro.api.setSession({ id: 'sampled-session', attributes: { isSampled: 'true' } });
664
+
665
+ expect(() => trace.getTracer('without-on-ending').startSpan('span').end()).not.toThrow();
666
+ });
667
+
668
+ it('shuts down the SDK-owned processor during Faro removal', async () => {
669
+ const exporterShutdownSpy = jest.spyOn(FaroTraceExporter.prototype, 'shutdown');
670
+ const { faro, tracingInstrumentation } = addDefaultTracingInstrumentation();
671
+
672
+ faro.instrumentations.remove(tracingInstrumentation);
673
+ await new Promise((resolve) => setTimeout(resolve, 0));
674
+
675
+ expect(exporterShutdownSpy).toHaveBeenCalledTimes(1);
676
+ });
677
+
678
+ it('reports an SDK-owned provider failure during explicit shutdown', async () => {
679
+ const shutdownError = new Error('exporter shutdown failed');
680
+ jest.spyOn(FaroTraceExporter.prototype, 'shutdown').mockRejectedValue(shutdownError);
681
+ const { faro, tracingInstrumentation } = addDefaultTracingInstrumentation();
682
+
683
+ await expect(tracingInstrumentation.shutdown()).rejects.toBe(shutdownError);
684
+
685
+ expect(faro.otel).toBeUndefined();
686
+ });
687
+
688
+ it('logs an SDK-owned provider failure during Faro removal', async () => {
689
+ const shutdownError = new Error('exporter shutdown failed');
690
+ jest.spyOn(FaroTraceExporter.prototype, 'shutdown').mockRejectedValue(shutdownError);
691
+ const { faro, tracingInstrumentation } = addDefaultTracingInstrumentation();
692
+ const loggerSpy = jest.spyOn(faro.internalLogger, 'error').mockImplementation(() => {});
693
+
694
+ faro.instrumentations.remove(tracingInstrumentation);
695
+ await new Promise((resolve) => setTimeout(resolve, 0));
696
+
697
+ expect(loggerSpy).toHaveBeenCalledWith(
698
+ expect.stringContaining('@grafana/faro-react-native-tracing'),
699
+ expect.stringContaining('Failed to shut down the OpenTelemetry tracer provider'),
700
+ shutdownError
701
+ );
702
+ });
703
+
704
+ it('reinitializes with one active request span', async () => {
705
+ const baseFetch = globalThis.fetch;
706
+ const firstOtelInstrumentation = new FetchTestInstrumentation();
707
+ const firstSpanProcessor = createSpanProcessor();
708
+ const { faro, tracingInstrumentation: firstTracingInstrumentation } = addTracingInstrumentation(
709
+ firstOtelInstrumentation,
710
+ firstSpanProcessor
711
+ );
712
+ faro.api.setSession({ id: 'sampled-session', attributes: { isSampled: 'true' } });
713
+
714
+ await globalThis.fetch('https://example.com/first');
715
+ expect(firstSpanProcessor.onStart).toHaveBeenCalledTimes(1);
716
+ faro.instrumentations.remove(firstTracingInstrumentation);
717
+
718
+ const secondOtelInstrumentation = new FetchTestInstrumentation();
719
+ const secondSpanProcessor = createSpanProcessor();
720
+ const secondTracingInstrumentation = new TracingInstrumentation({
721
+ instrumentations: [secondOtelInstrumentation],
722
+ spanProcessor: secondSpanProcessor,
723
+ });
724
+ tracingInstrumentations.push(secondTracingInstrumentation);
725
+ faro.instrumentations.add(secondTracingInstrumentation);
726
+ await globalThis.fetch('https://example.com/second');
727
+
728
+ expect(firstSpanProcessor.onStart).toHaveBeenCalledTimes(1);
729
+ expect(secondSpanProcessor.onStart).toHaveBeenCalledTimes(1);
730
+ expect(firstOtelInstrumentation.requestCount).toBe(1);
731
+ expect(secondOtelInstrumentation.requestCount).toBe(1);
732
+
733
+ await secondTracingInstrumentation.shutdown();
734
+ await globalThis.fetch('https://example.com/after-shutdown');
735
+ expect(firstSpanProcessor.onStart).toHaveBeenCalledTimes(1);
736
+ expect(secondSpanProcessor.onStart).toHaveBeenCalledTimes(1);
737
+ expect(globalThis.fetch).toBe(baseFetch);
738
+ expect(baseFetch).toHaveBeenCalledTimes(3);
739
+ });
740
+ });