@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
@@ -1,10 +1,11 @@
1
1
  import { context, propagation, trace } from '@opentelemetry/api';
2
- import type { Attributes } from '@opentelemetry/api';
2
+ import type { Attributes, Context, ContextManager } from '@opentelemetry/api';
3
3
  import { CompositePropagator, W3CBaggagePropagator, W3CTraceContextPropagator } from '@opentelemetry/core';
4
4
  import { registerInstrumentations } from '@opentelemetry/instrumentation';
5
+ import type { Instrumentation } from '@opentelemetry/instrumentation';
5
6
  import { defaultResource, resourceFromAttributes } from '@opentelemetry/resources';
6
7
  import { BatchSpanProcessor, BasicTracerProvider as ReactNativeTracerProvider } from '@opentelemetry/sdk-trace-base';
7
- import type { BasicTracerProvider } from '@opentelemetry/sdk-trace-base';
8
+ import type { BasicTracerProvider, ReadableSpan, Span, SpanProcessor } from '@opentelemetry/sdk-trace-base';
8
9
  import { StackContextManager } from '@opentelemetry/sdk-trace-web';
9
10
  import {
10
11
  ATTR_SERVICE_NAME,
@@ -13,11 +14,14 @@ import {
13
14
  } from '@opentelemetry/semantic-conventions';
14
15
 
15
16
  import { BaseInstrumentation, getInternalFaroFromGlobalObject, VERSION } from '@grafana/faro-core';
16
- import type { Transport } from '@grafana/faro-core';
17
+ import type { Faro, OTELApi, Transport } from '@grafana/faro-core';
17
18
 
18
19
  import { FaroTraceExporter } from './exporters/faroTraceExporter';
19
20
  import { getReactNativeDevServerIgnoreUrls } from './instrumentations/devServerIgnoreUrls';
20
- import { getDefaultOTELInstrumentations } from './instrumentations/getDefaultOTELInstrumentations';
21
+ import {
22
+ getDefaultOTELInstrumentations,
23
+ updateDefaultOTELInstrumentations,
24
+ } from './instrumentations/getDefaultOTELInstrumentations';
21
25
  import { FaroMetaAttributesSpanProcessor } from './processors/faroMetaAttributesSpanProcessor';
22
26
  import { HttpRequestMonitorSpanProcessor } from './processors/httpRequestMonitorSpanProcessor';
23
27
  import {
@@ -40,6 +44,53 @@ import { getSamplingDecision } from './utils/sampler';
40
44
  // Import React Native TracerProvider
41
45
  // Note: We use the base provider since React Native doesn't have a specific one
42
46
 
47
+ type FaroWithOtel = Faro & { otel?: unknown };
48
+ const detachedInstrumentations = new WeakSet<Instrumentation>();
49
+
50
+ /**
51
+ * Keeps a caller-supplied processor reusable across Faro remove/add cycles.
52
+ * Explicit TracingInstrumentation.shutdown() remains responsible for the
53
+ * processor's terminal shutdown.
54
+ */
55
+ class BorrowedSpanProcessor implements SpanProcessor {
56
+ constructor(private readonly processor: SpanProcessor) {}
57
+
58
+ forceFlush(): Promise<void> {
59
+ return this.processor.forceFlush();
60
+ }
61
+
62
+ onStart(span: Span, parentContext: Context): void {
63
+ this.processor.onStart(span, parentContext);
64
+ }
65
+
66
+ onEnding(span: Span): void {
67
+ this.processor.onEnding?.(span);
68
+ }
69
+
70
+ onEnd(span: ReadableSpan): void {
71
+ this.processor.onEnd(span);
72
+ }
73
+
74
+ shutdown(): Promise<void> {
75
+ return Promise.resolve();
76
+ }
77
+ }
78
+
79
+ interface TracingRegistration {
80
+ contextManager?: ContextManager;
81
+ faro?: FaroWithOtel;
82
+ faroOtel?: OTELApi;
83
+ instrumentations: Instrumentation[];
84
+ ownsContextManager: boolean;
85
+ ownsContextManagerInstance: boolean;
86
+ ownsPropagator: boolean;
87
+ ownsTracerProvider: boolean;
88
+ previousFaroOtel?: unknown;
89
+ provider: BasicTracerProvider;
90
+ shutdownPromise?: Promise<void>;
91
+ unregisterInstrumentations?: VoidFunction;
92
+ }
93
+
43
94
  /**
44
95
  * TracingInstrumentation for React Native
45
96
  *
@@ -72,13 +123,18 @@ export class TracingInstrumentation extends BaseInstrumentation {
72
123
 
73
124
  static SCHEDULED_BATCH_DELAY_MS = 1000;
74
125
 
75
- private provider?: BasicTracerProvider;
126
+ private activeRegistration?: TracingRegistration;
127
+ private defaultInstrumentations?: Instrumentation[];
128
+ private pendingShutdowns = new Set<Promise<void>>();
129
+ private suppliedSpanProcessorShutdown?: Promise<void>;
76
130
 
77
131
  constructor(private options: TracingInstrumentationOptions = {}) {
78
132
  super();
79
133
  }
80
134
 
81
135
  initialize(): void {
136
+ this.destroy();
137
+
82
138
  const options = this.options;
83
139
  const attributes: Attributes = {};
84
140
 
@@ -151,7 +207,7 @@ export class TracingInstrumentation extends BaseInstrumentation {
151
207
  const resource = defaultResource().merge(resourceFromAttributes(attributes));
152
208
 
153
209
  // Create tracer provider with span processors
154
- this.provider = new ReactNativeTracerProvider({
210
+ const provider = new ReactNativeTracerProvider({
155
211
  resource,
156
212
  sampler: {
157
213
  shouldSample: () => {
@@ -161,7 +217,7 @@ export class TracingInstrumentation extends BaseInstrumentation {
161
217
  },
162
218
  },
163
219
  spanProcessors: [
164
- options.spanProcessor ??
220
+ (options.spanProcessor && new BorrowedSpanProcessor(options.spanProcessor)) ??
165
221
  new HttpRequestMonitorSpanProcessor(
166
222
  new FaroMetaAttributesSpanProcessor(
167
223
  new BatchSpanProcessor(new FaroTraceExporter({ api: this.api }), {
@@ -174,62 +230,102 @@ export class TracingInstrumentation extends BaseInstrumentation {
174
230
  ],
175
231
  });
176
232
 
177
- // Register the provider as the global tracer provider
178
- // This is CRITICAL for the tracer to generate real trace IDs instead of all zeros
179
- trace.setGlobalTracerProvider(this.provider);
180
-
181
- // Register a global ContextManager. Without one, OTel falls back to the NoopContextManager,
182
- // which always returns ROOT_CONTEXT — so when `@opentelemetry/instrumentation-fetch` does
183
- // `context.with(setSpan(active(), createdSpan), () => _addHeaders(...))` the span set on the
184
- // wrapped context is invisible inside `_addHeaders` and `propagation.inject` writes nothing.
185
- // `StackContextManager` is pure JS (no DOM/Zone deps) and works in React Native.
186
- context.setGlobalContextManager(options.contextManager ?? new StackContextManager().enable());
187
-
188
- // Register the global text-map propagator. Without this, OTel falls back to a
189
- // NoopTextMapPropagator and `propagation.inject(...)` becomes a no-op, meaning
190
- // `traceparent` / `tracestate` (and `baggage`) headers are never written on the
191
- // outbound fetch/XHR — so the backend receives no context and starts a new trace.
192
- propagation.setGlobalPropagator(
193
- options.propagator ??
194
- new CompositePropagator({
195
- propagators: [new W3CTraceContextPropagator(), new W3CBaggagePropagator()],
196
- })
197
- );
198
-
199
- const {
200
- enableFetchInstrumentation,
201
- enableXhrInstrumentation,
202
- propagateTraceHeaderCorsUrls,
203
- fetchInstrumentationOptions,
204
- xhrInstrumentationOptions,
205
- } = this.options.instrumentationOptions ?? {};
206
-
207
- // Get ignore URLs from transports to prevent infinite loops
208
- const ignoreUrls = this.getIgnoreUrls();
209
-
210
- // Register instrumentations
211
- registerInstrumentations({
212
- instrumentations:
213
- options.instrumentations ??
214
- getDefaultOTELInstrumentations({
215
- ignoreUrls,
216
- enableFetchInstrumentation,
217
- enableXhrInstrumentation,
218
- propagateTraceHeaderCorsUrls,
219
- fetchInstrumentationOptions,
220
- xhrInstrumentationOptions,
221
- }),
222
- });
223
-
224
- // Expose OTEL API on the global Faro instance for manual span creation
225
- // This allows users to access trace and context APIs via faro.otel
226
- const globalFaroInstance = getInternalFaroFromGlobalObject();
227
- if (globalFaroInstance) {
228
- // eslint-disable-next-line @typescript-eslint/no-explicit-any -- Extending Faro instance with OTEL API
229
- (globalFaroInstance as any).otel = {
230
- trace,
231
- context,
233
+ const registration: TracingRegistration = {
234
+ instrumentations: [],
235
+ ownsContextManager: false,
236
+ ownsContextManagerInstance: false,
237
+ ownsPropagator: false,
238
+ ownsTracerProvider: false,
239
+ provider,
240
+ };
241
+ this.activeRegistration = registration;
242
+
243
+ try {
244
+ // Register the provider as the global tracer provider
245
+ // This is CRITICAL for the tracer to generate real trace IDs instead of all zeros
246
+ if (!trace.setGlobalTracerProvider(provider)) {
247
+ throw new Error(
248
+ 'Unable to register the Faro OpenTelemetry tracer provider. Remove the existing global tracer provider before initializing Faro tracing.'
249
+ );
250
+ }
251
+ registration.ownsTracerProvider = true;
252
+
253
+ // Register a global ContextManager. Without one, OTel falls back to the NoopContextManager,
254
+ // which always returns ROOT_CONTEXT — so when `@opentelemetry/instrumentation-fetch` does
255
+ // `context.with(setSpan(active(), createdSpan), () => _addHeaders(...))` the span set on the
256
+ // wrapped context is invisible inside `_addHeaders` and `propagation.inject` writes nothing.
257
+ // `StackContextManager` is pure JS (no DOM/Zone deps) and works in React Native.
258
+ const contextManager = (options.contextManager ?? new StackContextManager()).enable();
259
+ registration.contextManager = contextManager;
260
+ registration.ownsContextManagerInstance = options.contextManager == null;
261
+ registration.ownsContextManager = context.setGlobalContextManager(contextManager);
262
+
263
+ // Register the global text-map propagator. Without this, OTel falls back to a
264
+ // NoopTextMapPropagator and `propagation.inject(...)` becomes a no-op, meaning
265
+ // `traceparent` / `tracestate` (and `baggage`) headers are never written on the
266
+ // outbound fetch/XHR — so the backend receives no context and starts a new trace.
267
+ registration.ownsPropagator = propagation.setGlobalPropagator(
268
+ options.propagator ??
269
+ new CompositePropagator({
270
+ propagators: [new W3CTraceContextPropagator(), new W3CBaggagePropagator()],
271
+ })
272
+ );
273
+
274
+ const {
275
+ enableFetchInstrumentation,
276
+ enableXhrInstrumentation,
277
+ propagateTraceHeaderCorsUrls,
278
+ fetchInstrumentationOptions,
279
+ xhrInstrumentationOptions,
280
+ } = this.options.instrumentationOptions ?? {};
281
+
282
+ // Get ignore URLs from transports to prevent infinite loops
283
+ const ignoreUrls = this.getIgnoreUrls();
284
+
285
+ const defaultInstrumentationOptions = {
286
+ ignoreUrls,
287
+ enableFetchInstrumentation,
288
+ enableXhrInstrumentation,
289
+ propagateTraceHeaderCorsUrls,
290
+ fetchInstrumentationOptions,
291
+ xhrInstrumentationOptions,
232
292
  };
293
+
294
+ if (options.instrumentations) {
295
+ registration.instrumentations = options.instrumentations.flat();
296
+ } else if (this.defaultInstrumentations) {
297
+ updateDefaultOTELInstrumentations(this.defaultInstrumentations, defaultInstrumentationOptions);
298
+ registration.instrumentations = this.defaultInstrumentations;
299
+ } else {
300
+ this.defaultInstrumentations = getDefaultOTELInstrumentations(defaultInstrumentationOptions).flat();
301
+ registration.instrumentations = this.defaultInstrumentations;
302
+ }
303
+
304
+ const instrumentationsNeedingExplicitEnable = registration.instrumentations.filter(
305
+ (instrumentation) =>
306
+ detachedInstrumentations.has(instrumentation) && instrumentation.getConfig().enabled === true
307
+ );
308
+ registration.unregisterInstrumentations = registerInstrumentations({
309
+ instrumentations: registration.instrumentations,
310
+ });
311
+ registration.instrumentations.forEach((instrumentation) => {
312
+ detachedInstrumentations.delete(instrumentation);
313
+ });
314
+ instrumentationsNeedingExplicitEnable.forEach((instrumentation) => instrumentation.enable());
315
+
316
+ // Expose OTEL API on the global Faro instance for manual span creation
317
+ // This allows users to access trace and context APIs via faro.otel
318
+ const globalFaroInstance = getInternalFaroFromGlobalObject() as FaroWithOtel | undefined;
319
+ if (globalFaroInstance) {
320
+ const faroOtel: OTELApi = { trace, context };
321
+ registration.faro = globalFaroInstance;
322
+ registration.faroOtel = faroOtel;
323
+ registration.previousFaroOtel = globalFaroInstance.otel;
324
+ globalFaroInstance.otel = faroOtel;
325
+ }
326
+ } catch (error) {
327
+ this.destroy();
328
+ throw error;
233
329
  }
234
330
  }
235
331
 
@@ -258,12 +354,148 @@ export class TracingInstrumentation extends BaseInstrumentation {
258
354
  return [...getReactNativeDevServerIgnoreUrls(), ...transportUrls, ...regexPatterns];
259
355
  }
260
356
 
261
- /**
262
- * Shutdown the tracer provider
263
- */
357
+ private detachRegistration(): TracingRegistration | undefined {
358
+ const registration = this.activeRegistration;
359
+ if (!registration) {
360
+ return undefined;
361
+ }
362
+ this.activeRegistration = undefined;
363
+ registration.instrumentations.forEach((instrumentation) => detachedInstrumentations.add(instrumentation));
364
+
365
+ let unregistered = false;
366
+ if (registration.unregisterInstrumentations) {
367
+ try {
368
+ registration.unregisterInstrumentations();
369
+ unregistered = true;
370
+ } catch (error) {
371
+ this.logCleanupError('unregister OpenTelemetry instrumentations', error);
372
+ }
373
+ }
374
+ if (!unregistered) {
375
+ registration.instrumentations.forEach((instrumentation) => {
376
+ this.runCleanup(`disable ${instrumentation.instrumentationName}`, () => instrumentation.disable());
377
+ });
378
+ }
379
+
380
+ const { faro, faroOtel } = registration;
381
+ if (faro && faroOtel && faro.otel === faroOtel) {
382
+ this.runCleanup('restore faro.otel', () => {
383
+ if (registration.previousFaroOtel === undefined) {
384
+ delete faro.otel;
385
+ } else {
386
+ faro.otel = registration.previousFaroOtel;
387
+ }
388
+ });
389
+ }
390
+
391
+ if (registration.ownsPropagator) {
392
+ this.runCleanup('clear the OpenTelemetry propagator', () => propagation.disable());
393
+ }
394
+ if (registration.ownsContextManager) {
395
+ this.runCleanup('clear the OpenTelemetry context manager', () => context.disable());
396
+ } else if (registration.ownsContextManagerInstance && registration.contextManager) {
397
+ this.runCleanup('disable the unused OpenTelemetry context manager', () => registration.contextManager?.disable());
398
+ }
399
+ if (registration.ownsTracerProvider) {
400
+ this.runCleanup('clear the OpenTelemetry tracer provider', () => trace.disable());
401
+ }
402
+
403
+ return registration;
404
+ }
405
+
406
+ private runCleanup(action: string, cleanup: VoidFunction): void {
407
+ try {
408
+ cleanup();
409
+ } catch (error) {
410
+ this.logCleanupError(action, error);
411
+ }
412
+ }
413
+
414
+ private logCleanupError(action: string, error: unknown): void {
415
+ try {
416
+ this.logError(`Failed to ${action}:`, error);
417
+ } catch (_loggingError) {
418
+ // Cleanup must not fail because a custom logger threw.
419
+ }
420
+ }
421
+
422
+ private startProviderShutdown(registration: TracingRegistration): Promise<void> {
423
+ if (!registration.shutdownPromise) {
424
+ const shutdownPromise = Promise.resolve()
425
+ .then(() => registration.provider.forceFlush())
426
+ .catch((error) => {
427
+ this.logCleanupError('flush the OpenTelemetry tracer provider', error);
428
+ })
429
+ .then(() => registration.provider.shutdown());
430
+ registration.shutdownPromise = shutdownPromise;
431
+ this.pendingShutdowns.add(shutdownPromise);
432
+ void shutdownPromise.then(
433
+ () => this.pendingShutdowns.delete(shutdownPromise),
434
+ () => this.pendingShutdowns.delete(shutdownPromise)
435
+ );
436
+ }
437
+ return registration.shutdownPromise;
438
+ }
439
+
440
+ private startSuppliedSpanProcessorShutdown(): Promise<void> | undefined {
441
+ const spanProcessor = this.options.spanProcessor;
442
+ if (!spanProcessor) {
443
+ return undefined;
444
+ }
445
+ if (!this.suppliedSpanProcessorShutdown) {
446
+ try {
447
+ this.suppliedSpanProcessorShutdown = spanProcessor.shutdown();
448
+ } catch (error) {
449
+ this.suppliedSpanProcessorShutdown = Promise.reject(error);
450
+ }
451
+ }
452
+ return this.suppliedSpanProcessorShutdown;
453
+ }
454
+
455
+ /** Detach request instrumentations and wait for the tracer provider to flush. */
264
456
  async shutdown(): Promise<void> {
265
- if (this.provider) {
266
- await this.provider.shutdown();
457
+ const pendingShutdowns = Array.from(this.pendingShutdowns);
458
+ const registration = this.detachRegistration();
459
+ if (registration) {
460
+ pendingShutdowns.push(this.startProviderShutdown(registration));
461
+ }
462
+
463
+ let firstError: unknown;
464
+ let shutdownFailed = false;
465
+ for (const shutdownPromise of pendingShutdowns) {
466
+ try {
467
+ await shutdownPromise;
468
+ } catch (error) {
469
+ if (!shutdownFailed) {
470
+ firstError = error;
471
+ shutdownFailed = true;
472
+ }
473
+ }
267
474
  }
475
+ const spanProcessorShutdown = this.startSuppliedSpanProcessorShutdown();
476
+ if (spanProcessorShutdown) {
477
+ try {
478
+ await spanProcessorShutdown;
479
+ } catch (error) {
480
+ if (!shutdownFailed) {
481
+ firstError = error;
482
+ shutdownFailed = true;
483
+ }
484
+ }
485
+ }
486
+ if (shutdownFailed) {
487
+ throw firstError;
488
+ }
489
+ }
490
+
491
+ /** Detach synchronously when Faro removes this instrumentation. */
492
+ destroy(): void {
493
+ const registration = this.detachRegistration();
494
+ if (!registration) {
495
+ return;
496
+ }
497
+ void this.startProviderShutdown(registration).then(undefined, (error) => {
498
+ this.logCleanupError('shut down the OpenTelemetry tracer provider', error);
499
+ });
268
500
  }
269
501
  }
@@ -1,7 +1,7 @@
1
1
  import { FetchInstrumentation } from '@opentelemetry/instrumentation-fetch';
2
2
  import { XMLHttpRequestInstrumentation } from '@opentelemetry/instrumentation-xml-http-request';
3
3
 
4
- import { getDefaultOTELInstrumentations } from './getDefaultOTELInstrumentations';
4
+ import { getDefaultOTELInstrumentations, updateDefaultOTELInstrumentations } from './getDefaultOTELInstrumentations';
5
5
 
6
6
  describe('getDefaultOTELInstrumentations', () => {
7
7
  it('should return FetchInstrumentation by default', () => {
@@ -9,6 +9,18 @@ describe('getDefaultOTELInstrumentations', () => {
9
9
 
10
10
  expect(instrumentations).toHaveLength(1);
11
11
  expect(instrumentations[0]).toBeInstanceOf(FetchInstrumentation);
12
+ expect((instrumentations[0] as FetchInstrumentation).getConfig().semconvStabilityOptIn).toBe('http');
13
+ });
14
+
15
+ it.each(['', 'http/dup'])('ignores unsupported fetch convention overrides (%s)', (mode) => {
16
+ // JavaScript callers can still pass properties absent from the public type.
17
+ const options = { fetchInstrumentationOptions: { ignoreNetworkEvents: true, semconvStabilityOptIn: mode } };
18
+ const instrumentations = getDefaultOTELInstrumentations(options).flat();
19
+ const fetch = instrumentations[0] as FetchInstrumentation;
20
+ expect(fetch.getConfig().semconvStabilityOptIn).toBe('http');
21
+
22
+ updateDefaultOTELInstrumentations(instrumentations, options);
23
+ expect(fetch.getConfig().semconvStabilityOptIn).toBe('http');
12
24
  });
13
25
 
14
26
  it('should allow enabling XMLHttpRequestInstrumentation', () => {
@@ -49,6 +61,21 @@ describe('getDefaultOTELInstrumentations', () => {
49
61
  expect((instrumentations[1] as any)._config.ignoreUrls).toEqual(ignoreUrls);
50
62
  });
51
63
 
64
+ it('updates existing Fetch and XMLHttpRequest instrumentation configs', () => {
65
+ const instrumentations = getDefaultOTELInstrumentations({
66
+ enableXhrInstrumentation: true,
67
+ ignoreUrls: [/first-url/],
68
+ }).flat();
69
+
70
+ updateDefaultOTELInstrumentations(instrumentations, {
71
+ enableXhrInstrumentation: true,
72
+ ignoreUrls: [/second-url/],
73
+ });
74
+
75
+ expect((instrumentations[0] as FetchInstrumentation).getConfig().ignoreUrls).toEqual([/second-url/]);
76
+ expect((instrumentations[1] as XMLHttpRequestInstrumentation).getConfig().ignoreUrls).toEqual([/second-url/]);
77
+ });
78
+
52
79
  it('should set ignoreNetworkEvents to true by default', () => {
53
80
  const instrumentations = getDefaultOTELInstrumentations();
54
81
 
@@ -1,12 +1,11 @@
1
- import type { Span } from '@opentelemetry/api';
1
+ import type { Instrumentation } from '@opentelemetry/instrumentation';
2
2
  import { FetchInstrumentation } from '@opentelemetry/instrumentation-fetch';
3
3
  import { XMLHttpRequestInstrumentation } from '@opentelemetry/instrumentation-xml-http-request';
4
4
 
5
- import { faro, type UserActionInternalInterface, UserActionState } from '@grafana/faro-core';
6
-
7
5
  import type { DefaultInstrumentationsOptions, InstrumentationOption } from '../types';
8
6
 
9
7
  import {
8
+ captureFetchUserAction,
10
9
  fetchCustomAttributeFunctionWithDefaults,
11
10
  xhrCustomAttributeFunctionWithDefaults,
12
11
  } from './instrumentationUtils';
@@ -27,31 +26,54 @@ import {
27
26
  * @returns Array of OTEL instrumentations
28
27
  */
29
28
  export function getDefaultOTELInstrumentations(options: DefaultInstrumentationsOptions = {}): InstrumentationOption[] {
30
- const {
31
- enableFetchInstrumentation = true,
32
- enableXhrInstrumentation = false,
33
- fetchInstrumentationOptions,
34
- xhrInstrumentationOptions,
35
- ...sharedOptions
36
- } = options;
29
+ const { enableFetchInstrumentation, enableXhrInstrumentation, fetchConfig, xhrConfig } =
30
+ resolveDefaultInstrumentationOptions(options);
37
31
 
38
32
  const instrumentations: InstrumentationOption[] = [];
39
33
 
40
34
  if (enableFetchInstrumentation) {
41
- instrumentations.push(
42
- new FetchInstrumentation(createFetchInstrumentationOptions(fetchInstrumentationOptions, sharedOptions))
43
- );
35
+ instrumentations.push(new FetchInstrumentation(fetchConfig));
44
36
  }
45
37
 
46
38
  if (enableXhrInstrumentation) {
47
- instrumentations.push(
48
- new XMLHttpRequestInstrumentation(createXhrInstrumentationOptions(xhrInstrumentationOptions, sharedOptions))
49
- );
39
+ instrumentations.push(new XMLHttpRequestInstrumentation(xhrConfig));
50
40
  }
51
41
 
52
42
  return instrumentations;
53
43
  }
54
44
 
45
+ export function updateDefaultOTELInstrumentations(
46
+ instrumentations: Instrumentation[],
47
+ options: DefaultInstrumentationsOptions = {}
48
+ ): void {
49
+ const { fetchConfig, xhrConfig } = resolveDefaultInstrumentationOptions(options);
50
+
51
+ instrumentations.forEach((instrumentation) => {
52
+ if (instrumentation instanceof FetchInstrumentation) {
53
+ instrumentation.setConfig(fetchConfig);
54
+ } else if (instrumentation instanceof XMLHttpRequestInstrumentation) {
55
+ instrumentation.setConfig(xhrConfig);
56
+ }
57
+ });
58
+ }
59
+
60
+ function resolveDefaultInstrumentationOptions(options: DefaultInstrumentationsOptions) {
61
+ const {
62
+ enableFetchInstrumentation = true,
63
+ enableXhrInstrumentation = false,
64
+ fetchInstrumentationOptions,
65
+ xhrInstrumentationOptions,
66
+ ...sharedOptions
67
+ } = options;
68
+
69
+ return {
70
+ enableFetchInstrumentation,
71
+ enableXhrInstrumentation,
72
+ fetchConfig: createFetchInstrumentationOptions(fetchInstrumentationOptions, sharedOptions),
73
+ xhrConfig: createXhrInstrumentationOptions(xhrInstrumentationOptions, sharedOptions),
74
+ };
75
+ }
76
+
55
77
  function createFetchInstrumentationOptions(
56
78
  fetchInstrumentationOptions: DefaultInstrumentationsOptions['fetchInstrumentationOptions'],
57
79
  sharedOptions: Record<string, unknown>
@@ -62,26 +84,13 @@ function createFetchInstrumentationOptions(
62
84
  ignoreNetworkEvents: true,
63
85
  // Keep this here to overwrite the defaults above if provided by the users
64
86
  ...fetchInstrumentationOptions,
87
+ semconvStabilityOptIn: 'http',
65
88
  // Always keep this function
66
89
  applyCustomAttributesOnSpan: fetchCustomAttributeFunctionWithDefaults(
67
90
  fetchInstrumentationOptions?.applyCustomAttributesOnSpan
68
91
  ),
69
- // Request hook to add user action context
70
- requestHook: (span: Span, _: Request | RequestInit) => {
71
- try {
72
- const currentAction = faro.api.getActiveUserAction();
73
- if (
74
- currentAction &&
75
- (currentAction as unknown as UserActionInternalInterface)?.getState() === UserActionState.Started
76
- ) {
77
- span.setAttribute('faro.action.user.name', currentAction.name);
78
- span.setAttribute('faro.action.user.parentId', currentAction.parentId);
79
- }
80
- } catch (_error) {
81
- // Silently fail - don't log to avoid infinite loops
82
- // The span will just not have user action context
83
- }
84
- },
92
+ // Also supports callers using a custom processor without the RN request monitor.
93
+ requestHook: captureFetchUserAction,
85
94
  };
86
95
  }
87
96
 
@@ -5,6 +5,19 @@ import type { XHRCustomAttributeFunction } from '@opentelemetry/instrumentation-
5
5
 
6
6
  import { faro, type UserActionInternalInterface, UserActionState } from '@grafana/faro-core';
7
7
 
8
+ /** Capture request ownership at start, before the monitor can halt the action. */
9
+ export function captureFetchUserAction(span: Span): void {
10
+ try {
11
+ const action = faro.api?.getActiveUserAction?.();
12
+ if (action && (action as UserActionInternalInterface).getState() === UserActionState.Started) {
13
+ span.setAttribute('faro.action.user.name', action.name);
14
+ span.setAttribute('faro.action.user.parentId', action.parentId);
15
+ }
16
+ } catch {
17
+ // Instrumentation must not interrupt the request or log recursively.
18
+ }
19
+ }
20
+
8
21
  /**
9
22
  * FetchError interface matching OpenTelemetry's internal type
10
23
  * Note: FetchError is not exported from @opentelemetry/instrumentation-fetch