@grafana/faro-react-native-tracing 1.0.0-alpha.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 (74) hide show
  1. package/LICENSE +201 -0
  2. package/README.md +833 -0
  3. package/dist/cjs/exporters/faroTraceExporter.js +101 -0
  4. package/dist/cjs/exporters/faroTraceExporter.js.map +1 -0
  5. package/dist/cjs/exporters/faroTraceExporter.utils.js +99 -0
  6. package/dist/cjs/exporters/faroTraceExporter.utils.js.map +1 -0
  7. package/dist/cjs/index.js +17 -0
  8. package/dist/cjs/index.js.map +1 -0
  9. package/dist/cjs/instrumentation.js +251 -0
  10. package/dist/cjs/instrumentation.js.map +1 -0
  11. package/dist/cjs/instrumentations/getDefaultOTELInstrumentations.js +77 -0
  12. package/dist/cjs/instrumentations/getDefaultOTELInstrumentations.js.map +1 -0
  13. package/dist/cjs/instrumentations/instrumentationUtils.js +89 -0
  14. package/dist/cjs/instrumentations/instrumentationUtils.js.map +1 -0
  15. package/dist/cjs/processors/faroMetaAttributesSpanProcessor.js +59 -0
  16. package/dist/cjs/processors/faroMetaAttributesSpanProcessor.js.map +1 -0
  17. package/dist/cjs/processors/httpRequestMonitorSpanProcessor.js +98 -0
  18. package/dist/cjs/processors/httpRequestMonitorSpanProcessor.js.map +1 -0
  19. package/dist/cjs/semconv.js +29 -0
  20. package/dist/cjs/semconv.js.map +1 -0
  21. package/dist/cjs/types.js +3 -0
  22. package/dist/cjs/types.js.map +1 -0
  23. package/dist/cjs/utils/sampler.js +21 -0
  24. package/dist/cjs/utils/sampler.js.map +1 -0
  25. package/dist/esm/exporters/faroTraceExporter.js +65 -0
  26. package/dist/esm/exporters/faroTraceExporter.js.map +1 -0
  27. package/dist/esm/exporters/faroTraceExporter.utils.js +88 -0
  28. package/dist/esm/exporters/faroTraceExporter.utils.js.map +1 -0
  29. package/dist/esm/index.js +7 -0
  30. package/dist/esm/index.js.map +1 -0
  31. package/dist/esm/instrumentation.js +183 -0
  32. package/dist/esm/instrumentation.js.map +1 -0
  33. package/dist/esm/instrumentations/getDefaultOTELInstrumentations.js +62 -0
  34. package/dist/esm/instrumentations/getDefaultOTELInstrumentations.js.map +1 -0
  35. package/dist/esm/instrumentations/instrumentationUtils.js +83 -0
  36. package/dist/esm/instrumentations/instrumentationUtils.js.map +1 -0
  37. package/dist/esm/processors/faroMetaAttributesSpanProcessor.js +54 -0
  38. package/dist/esm/processors/faroMetaAttributesSpanProcessor.js.map +1 -0
  39. package/dist/esm/processors/httpRequestMonitorSpanProcessor.js +93 -0
  40. package/dist/esm/processors/httpRequestMonitorSpanProcessor.js.map +1 -0
  41. package/dist/esm/semconv.js +26 -0
  42. package/dist/esm/semconv.js.map +1 -0
  43. package/dist/esm/types.js +2 -0
  44. package/dist/esm/types.js.map +1 -0
  45. package/dist/esm/utils/sampler.js +17 -0
  46. package/dist/esm/utils/sampler.js.map +1 -0
  47. package/dist/types/exporters/faroTraceExporter.d.ts +21 -0
  48. package/dist/types/exporters/faroTraceExporter.utils.d.ts +17 -0
  49. package/dist/types/index.d.ts +7 -0
  50. package/dist/types/instrumentation.d.ts +46 -0
  51. package/dist/types/instrumentations/getDefaultOTELInstrumentations.d.ts +17 -0
  52. package/dist/types/instrumentations/instrumentationUtils.d.ts +26 -0
  53. package/dist/types/processors/faroMetaAttributesSpanProcessor.d.ts +22 -0
  54. package/dist/types/processors/httpRequestMonitorSpanProcessor.d.ts +18 -0
  55. package/dist/types/semconv.d.ts +19 -0
  56. package/dist/types/types.d.ts +31 -0
  57. package/dist/types/utils/sampler.d.ts +12 -0
  58. package/package.json +77 -0
  59. package/src/exporters/faroTraceExporter.test.ts +110 -0
  60. package/src/exporters/faroTraceExporter.ts +64 -0
  61. package/src/exporters/faroTraceExporter.utils.ts +105 -0
  62. package/src/index.ts +16 -0
  63. package/src/instrumentation.ts +241 -0
  64. package/src/instrumentations/getDefaultOTELInstrumentations.test.ts +70 -0
  65. package/src/instrumentations/getDefaultOTELInstrumentations.ts +83 -0
  66. package/src/instrumentations/instrumentationUtils.test.ts +107 -0
  67. package/src/instrumentations/instrumentationUtils.ts +106 -0
  68. package/src/processors/faroMetaAttributesSpanProcessor.test.ts +127 -0
  69. package/src/processors/faroMetaAttributesSpanProcessor.ts +71 -0
  70. package/src/processors/httpRequestMonitorSpanProcessor.ts +106 -0
  71. package/src/semconv.ts +31 -0
  72. package/src/types.ts +39 -0
  73. package/src/utils/sampler.test.ts +51 -0
  74. package/src/utils/sampler.ts +19 -0
@@ -0,0 +1,241 @@
1
+ import { context, trace } from '@opentelemetry/api';
2
+ import type { Attributes } from '@opentelemetry/api';
3
+ import { registerInstrumentations } from '@opentelemetry/instrumentation';
4
+ import { defaultResource, resourceFromAttributes } from '@opentelemetry/resources';
5
+ import { BatchSpanProcessor, BasicTracerProvider as ReactNativeTracerProvider } from '@opentelemetry/sdk-trace-base';
6
+ import type { BasicTracerProvider } from '@opentelemetry/sdk-trace-base';
7
+ import {
8
+ ATTR_SERVICE_NAME,
9
+ ATTR_SERVICE_VERSION,
10
+ SEMRESATTRS_DEPLOYMENT_ENVIRONMENT,
11
+ } from '@opentelemetry/semantic-conventions';
12
+
13
+ import { BaseInstrumentation, getInternalFaroFromGlobalObject, VERSION } from '@grafana/faro-core';
14
+ import type { Transport } from '@grafana/faro-core';
15
+
16
+ import { FaroTraceExporter } from './exporters/faroTraceExporter';
17
+ import { getDefaultOTELInstrumentations } from './instrumentations/getDefaultOTELInstrumentations';
18
+ import { FaroMetaAttributesSpanProcessor } from './processors/faroMetaAttributesSpanProcessor';
19
+ import { HttpRequestMonitorSpanProcessor } from './processors/httpRequestMonitorSpanProcessor';
20
+ import {
21
+ ATTR_APP_VERSION,
22
+ ATTR_DEPLOYMENT_ENVIRONMENT_NAME,
23
+ ATTR_DEVICE_BRAND,
24
+ ATTR_DEVICE_LOCALE,
25
+ ATTR_DEVICE_MODEL,
26
+ ATTR_DEVICE_OS_VERSION,
27
+ ATTR_DEVICE_PLATFORM,
28
+ ATTR_PROCESS_RUNTIME_NAME,
29
+ ATTR_PROCESS_RUNTIME_VERSION,
30
+ ATTR_SERVICE_NAMESPACE,
31
+ ATTR_TELEMETRY_DISTRO_NAME,
32
+ ATTR_TELEMETRY_DISTRO_VERSION,
33
+ } from './semconv';
34
+ import type { TracingInstrumentationOptions } from './types';
35
+ import { getSamplingDecision } from './utils/sampler';
36
+
37
+ // Import React Native TracerProvider
38
+ // Note: We use the base provider since React Native doesn't have a specific one
39
+
40
+ /**
41
+ * TracingInstrumentation for React Native
42
+ *
43
+ * Enables distributed tracing with OpenTelemetry for React Native applications.
44
+ *
45
+ * IMPORTANT: Infinite loop prevention
46
+ * - Uses internalLogger for debugging instead of console
47
+ * - Collector URLs are added to ignoreUrls in HTTP instrumentation
48
+ * - BatchSpanProcessor delays span export to avoid blocking
49
+ * - No console logging during trace export
50
+ *
51
+ * Example usage:
52
+ * ```ts
53
+ * import { initializeFaro } from '@grafana/faro-react-native';
54
+ * import { TracingInstrumentation } from '@grafana/faro-react-native-tracing';
55
+ *
56
+ * initializeFaro({
57
+ * // ... other config
58
+ * instrumentations: [
59
+ * new TracingInstrumentation({
60
+ * propagateTraceHeaderCorsUrls: [/https:\\/\\/my-api\\.com/],
61
+ * }),
62
+ * ],
63
+ * });
64
+ * ```
65
+ */
66
+ export class TracingInstrumentation extends BaseInstrumentation {
67
+ name = '@grafana/faro-react-native-tracing';
68
+ version = VERSION;
69
+
70
+ static SCHEDULED_BATCH_DELAY_MS = 1000;
71
+
72
+ private provider?: BasicTracerProvider;
73
+
74
+ constructor(private options: TracingInstrumentationOptions = {}) {
75
+ super();
76
+ }
77
+
78
+ initialize(): void {
79
+ const options = this.options;
80
+ const attributes: Attributes = {};
81
+
82
+ // App attributes
83
+ if (this.config.app.name) {
84
+ attributes[ATTR_SERVICE_NAME] = this.config.app.name;
85
+ }
86
+
87
+ if (this.config.app.namespace) {
88
+ attributes[ATTR_SERVICE_NAMESPACE] = this.config.app.namespace;
89
+ }
90
+
91
+ if (this.config.app.version) {
92
+ attributes[ATTR_SERVICE_VERSION] = this.config.app.version;
93
+ attributes[ATTR_APP_VERSION] = this.config.app.version;
94
+ }
95
+
96
+ if (this.config.app.environment) {
97
+ attributes[ATTR_DEPLOYMENT_ENVIRONMENT_NAME] = this.config.app.environment;
98
+
99
+ /**
100
+ * @deprecated will be removed in the future and has been replaced by ATTR_DEPLOYMENT_ENVIRONMENT_NAME (deployment.environment.name)
101
+ * We need to keep this for compatibility with some internal services for now.
102
+ */
103
+ attributes[SEMRESATTRS_DEPLOYMENT_ENVIRONMENT] = this.config.app.environment;
104
+ }
105
+
106
+ // Device/Platform attributes from React Native
107
+ // Note: metas.value contains all meta providers, we need to check if device meta exists
108
+ const allMetas = this.metas.value as Record<string, unknown>;
109
+ const deviceMeta = allMetas['device'] as
110
+ | {
111
+ model?: string;
112
+ brand?: string;
113
+ osName?: string;
114
+ osVersion?: string;
115
+ locale?: string;
116
+ }
117
+ | undefined;
118
+
119
+ if (deviceMeta?.model) {
120
+ attributes[ATTR_DEVICE_MODEL] = deviceMeta.model;
121
+ }
122
+
123
+ if (deviceMeta?.brand) {
124
+ attributes[ATTR_DEVICE_BRAND] = deviceMeta.brand;
125
+ }
126
+
127
+ if (deviceMeta?.osName) {
128
+ attributes[ATTR_DEVICE_PLATFORM] = deviceMeta.osName;
129
+ }
130
+
131
+ if (deviceMeta?.osVersion) {
132
+ attributes[ATTR_DEVICE_OS_VERSION] = deviceMeta.osVersion;
133
+ }
134
+
135
+ if (deviceMeta?.locale) {
136
+ attributes[ATTR_DEVICE_LOCALE] = deviceMeta.locale;
137
+ }
138
+
139
+ attributes[ATTR_PROCESS_RUNTIME_NAME] = 'react-native';
140
+ attributes[ATTR_PROCESS_RUNTIME_VERSION] = deviceMeta?.osVersion ?? 'unknown';
141
+
142
+ attributes[ATTR_TELEMETRY_DISTRO_NAME] = 'faro-react-native-sdk';
143
+ attributes[ATTR_TELEMETRY_DISTRO_VERSION] = VERSION;
144
+
145
+ // Merge with user-provided attributes
146
+ Object.assign(attributes, options.resourceAttributes);
147
+
148
+ const resource = defaultResource().merge(resourceFromAttributes(attributes));
149
+
150
+ // Create tracer provider with span processors
151
+ this.provider = new ReactNativeTracerProvider({
152
+ resource,
153
+ sampler: {
154
+ shouldSample: () => {
155
+ return {
156
+ decision: getSamplingDecision(this.api.getSession()),
157
+ };
158
+ },
159
+ },
160
+ spanProcessors: [
161
+ options.spanProcessor ??
162
+ new HttpRequestMonitorSpanProcessor(
163
+ new FaroMetaAttributesSpanProcessor(
164
+ new BatchSpanProcessor(new FaroTraceExporter({ api: this.api }), {
165
+ scheduledDelayMillis: TracingInstrumentation.SCHEDULED_BATCH_DELAY_MS,
166
+ maxExportBatchSize: 30,
167
+ }),
168
+ this.metas
169
+ )
170
+ ),
171
+ ],
172
+ });
173
+
174
+ // Register the provider as the global tracer provider
175
+ // This is CRITICAL for the tracer to generate real trace IDs instead of all zeros
176
+ trace.setGlobalTracerProvider(this.provider);
177
+
178
+ const { propagateTraceHeaderCorsUrls, fetchInstrumentationOptions, xhrInstrumentationOptions } =
179
+ this.options.instrumentationOptions ?? {};
180
+
181
+ // Get ignore URLs from transports to prevent infinite loops
182
+ const ignoreUrls = this.getIgnoreUrls();
183
+
184
+ // Register instrumentations
185
+ registerInstrumentations({
186
+ instrumentations:
187
+ options.instrumentations ??
188
+ getDefaultOTELInstrumentations({
189
+ ignoreUrls,
190
+ propagateTraceHeaderCorsUrls,
191
+ fetchInstrumentationOptions,
192
+ xhrInstrumentationOptions,
193
+ }),
194
+ });
195
+
196
+ // Expose OTEL API on the global Faro instance for manual span creation
197
+ // This allows users to access trace and context APIs via faro.otel
198
+ const globalFaroInstance = getInternalFaroFromGlobalObject();
199
+ if (globalFaroInstance) {
200
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any -- Extending Faro instance with OTEL API
201
+ (globalFaroInstance as any).otel = {
202
+ trace,
203
+ context,
204
+ };
205
+ }
206
+ }
207
+
208
+ /**
209
+ * Get ignore URLs from all transports to avoid tracing collector requests
210
+ * CRITICAL: This prevents infinite loops where trace exports trigger more traces
211
+ */
212
+ private getIgnoreUrls(): Array<string | RegExp> {
213
+ // Get URLs from transports' getIgnoreUrls() method
214
+ const transportUrls = this.transports.transports.flatMap((transport: Transport) => {
215
+ return transport.getIgnoreUrls();
216
+ });
217
+
218
+ // Create regex patterns that match both with and without trailing slashes
219
+ // This is critical because fetch() might add trailing slashes
220
+ const regexPatterns = transportUrls.map((url) => {
221
+ if (typeof url === 'string') {
222
+ // Escape special regex characters and make trailing slash optional
223
+ const escapedUrl = url.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
224
+ return new RegExp(`^${escapedUrl}/?$`);
225
+ }
226
+ return url;
227
+ });
228
+
229
+ // Return both original URLs and regex patterns for maximum coverage
230
+ return [...transportUrls, ...regexPatterns];
231
+ }
232
+
233
+ /**
234
+ * Shutdown the tracer provider
235
+ */
236
+ async shutdown(): Promise<void> {
237
+ if (this.provider) {
238
+ await this.provider.shutdown();
239
+ }
240
+ }
241
+ }
@@ -0,0 +1,70 @@
1
+ import { FetchInstrumentation } from '@opentelemetry/instrumentation-fetch';
2
+ import { XMLHttpRequestInstrumentation } from '@opentelemetry/instrumentation-xml-http-request';
3
+
4
+ import { getDefaultOTELInstrumentations } from './getDefaultOTELInstrumentations';
5
+
6
+ describe('getDefaultOTELInstrumentations', () => {
7
+ it('should return an array with FetchInstrumentation and XMLHttpRequestInstrumentation', () => {
8
+ const instrumentations = getDefaultOTELInstrumentations();
9
+
10
+ expect(instrumentations).toHaveLength(2);
11
+ expect(instrumentations[0]).toBeInstanceOf(FetchInstrumentation);
12
+ expect(instrumentations[1]).toBeInstanceOf(XMLHttpRequestInstrumentation);
13
+ });
14
+
15
+ it('should configure ignoreUrls', () => {
16
+ const ignoreUrls = [/test-url/];
17
+ const instrumentations = getDefaultOTELInstrumentations({ ignoreUrls });
18
+
19
+ expect(instrumentations).toHaveLength(2);
20
+ // FetchInstrumentation should have ignoreUrls configured
21
+ expect((instrumentations[0] as any)._config.ignoreUrls).toEqual(ignoreUrls);
22
+ // XMLHttpRequestInstrumentation should have ignoreUrls configured
23
+ expect((instrumentations[1] as any)._config.ignoreUrls).toEqual(ignoreUrls);
24
+ });
25
+
26
+ it('should set ignoreNetworkEvents to true by default', () => {
27
+ const instrumentations = getDefaultOTELInstrumentations();
28
+
29
+ expect((instrumentations[0] as any)._config.ignoreNetworkEvents).toBe(true);
30
+ });
31
+
32
+ it('should allow overriding ignoreNetworkEvents', () => {
33
+ const instrumentations = getDefaultOTELInstrumentations({
34
+ fetchInstrumentationOptions: {
35
+ ignoreNetworkEvents: false,
36
+ },
37
+ });
38
+
39
+ expect((instrumentations[0] as any)._config.ignoreNetworkEvents).toBe(false);
40
+ });
41
+
42
+ it('should configure propagateTraceHeaderCorsUrls', () => {
43
+ const propagateUrls = [/api\.example\.com/];
44
+ const instrumentations = getDefaultOTELInstrumentations({
45
+ propagateTraceHeaderCorsUrls: propagateUrls,
46
+ });
47
+
48
+ expect((instrumentations[0] as any)._config.propagateTraceHeaderCorsUrls).toEqual(propagateUrls);
49
+ });
50
+
51
+ it('should apply custom attribute function', () => {
52
+ const customFn = jest.fn();
53
+ const instrumentations = getDefaultOTELInstrumentations({
54
+ fetchInstrumentationOptions: {
55
+ applyCustomAttributesOnSpan: customFn,
56
+ },
57
+ });
58
+
59
+ expect(instrumentations).toHaveLength(2);
60
+ // Custom function should be wrapped by our defaults
61
+ expect((instrumentations[0] as any)._config.applyCustomAttributesOnSpan).toBeDefined();
62
+ });
63
+
64
+ it('should have a requestHook defined', () => {
65
+ const instrumentations = getDefaultOTELInstrumentations();
66
+
67
+ expect((instrumentations[0] as any)._config.requestHook).toBeDefined();
68
+ expect(typeof (instrumentations[0] as any)._config.requestHook).toBe('function');
69
+ });
70
+ });
@@ -0,0 +1,83 @@
1
+ import type { Span } from '@opentelemetry/api';
2
+ import { FetchInstrumentation } from '@opentelemetry/instrumentation-fetch';
3
+ import { XMLHttpRequestInstrumentation } from '@opentelemetry/instrumentation-xml-http-request';
4
+
5
+ import { faro, type UserActionInternalInterface, UserActionState } from '@grafana/faro-core';
6
+
7
+ import type { DefaultInstrumentationsOptions, InstrumentationOption } from '../types';
8
+
9
+ import {
10
+ fetchCustomAttributeFunctionWithDefaults,
11
+ xhrCustomAttributeFunctionWithDefaults,
12
+ } from './instrumentationUtils';
13
+
14
+ /**
15
+ * Get default OTEL instrumentations for React Native
16
+ *
17
+ * This function creates the default OpenTelemetry instrumentations for React Native:
18
+ * - FetchInstrumentation: Traces fetch() API calls
19
+ * - XMLHttpRequestInstrumentation: Traces XMLHttpRequest and axios (which uses XHR)
20
+ *
21
+ * IMPORTANT: Infinite loop prevention
22
+ * - ignoreUrls is used to exclude Faro collector URLs
23
+ * - ignoreNetworkEvents is true to avoid duplicate events
24
+ * - No console logging during instrumentation
25
+ *
26
+ * @param options - Configuration options
27
+ * @returns Array of OTEL instrumentations
28
+ */
29
+ export function getDefaultOTELInstrumentations(options: DefaultInstrumentationsOptions = {}): InstrumentationOption[] {
30
+ const { fetchInstrumentationOptions, xhrInstrumentationOptions, ...sharedOptions } = options;
31
+
32
+ const fetchOpts = createFetchInstrumentationOptions(fetchInstrumentationOptions, sharedOptions);
33
+ const xhrOpts = createXhrInstrumentationOptions(xhrInstrumentationOptions, sharedOptions);
34
+
35
+ return [new FetchInstrumentation(fetchOpts), new XMLHttpRequestInstrumentation(xhrOpts)];
36
+ }
37
+
38
+ function createFetchInstrumentationOptions(
39
+ fetchInstrumentationOptions: DefaultInstrumentationsOptions['fetchInstrumentationOptions'],
40
+ sharedOptions: Record<string, unknown>
41
+ ) {
42
+ return {
43
+ ...sharedOptions,
44
+ // Ignore network performance events to avoid duplicates
45
+ ignoreNetworkEvents: true,
46
+ // Keep this here to overwrite the defaults above if provided by the users
47
+ ...fetchInstrumentationOptions,
48
+ // Always keep this function
49
+ applyCustomAttributesOnSpan: fetchCustomAttributeFunctionWithDefaults(
50
+ fetchInstrumentationOptions?.applyCustomAttributesOnSpan
51
+ ),
52
+ // Request hook to add user action context
53
+ requestHook: (span: Span, _: Request | RequestInit) => {
54
+ try {
55
+ const currentAction = faro.api.getActiveUserAction();
56
+ if (
57
+ currentAction &&
58
+ (currentAction as unknown as UserActionInternalInterface)?.getState() === UserActionState.Started
59
+ ) {
60
+ span.setAttribute('faro.action.user.name', currentAction.name);
61
+ span.setAttribute('faro.action.user.parentId', currentAction.parentId);
62
+ }
63
+ } catch (_error) {
64
+ // Silently fail - don't log to avoid infinite loops
65
+ // The span will just not have user action context
66
+ }
67
+ },
68
+ };
69
+ }
70
+
71
+ function createXhrInstrumentationOptions(
72
+ xhrInstrumentationOptions: DefaultInstrumentationsOptions['xhrInstrumentationOptions'],
73
+ sharedOptions: Record<string, unknown>
74
+ ) {
75
+ return {
76
+ ...sharedOptions,
77
+ ignoreNetworkEvents: true,
78
+ ...xhrInstrumentationOptions,
79
+ applyCustomAttributesOnSpan: xhrCustomAttributeFunctionWithDefaults(
80
+ xhrInstrumentationOptions?.applyCustomAttributesOnSpan
81
+ ),
82
+ };
83
+ }
@@ -0,0 +1,107 @@
1
+ import { SpanStatusCode } from '@opentelemetry/api';
2
+ import { BasicTracerProvider } from '@opentelemetry/sdk-trace-base';
3
+
4
+ import { fetchCustomAttributeFunctionWithDefaults, setSpanStatusOnFetchError } from './instrumentationUtils';
5
+
6
+ describe('instrumentationUtils', () => {
7
+ let provider: BasicTracerProvider;
8
+
9
+ beforeEach(() => {
10
+ provider = new BasicTracerProvider();
11
+ });
12
+
13
+ describe('setSpanStatusOnFetchError', () => {
14
+ it('should set span status to ERROR with error message', () => {
15
+ const tracer = provider.getTracer('test');
16
+ const span = tracer.startSpan('test-span');
17
+ const setStatusSpy = jest.spyOn(span, 'setStatus');
18
+ const error = new Error('Test error');
19
+
20
+ setSpanStatusOnFetchError(span, error);
21
+
22
+ expect(setStatusSpy).toHaveBeenCalledWith({
23
+ code: SpanStatusCode.ERROR,
24
+ message: 'Test error',
25
+ });
26
+ });
27
+
28
+ it('should set span status to ERROR with string message', () => {
29
+ const tracer = provider.getTracer('test');
30
+ const span = tracer.startSpan('test-span');
31
+ const setStatusSpy = jest.spyOn(span, 'setStatus');
32
+
33
+ setSpanStatusOnFetchError(span, 'String error message');
34
+
35
+ expect(setStatusSpy).toHaveBeenCalledWith({
36
+ code: SpanStatusCode.ERROR,
37
+ message: 'String error message',
38
+ });
39
+ });
40
+ });
41
+
42
+ describe('fetchCustomAttributeFunctionWithDefaults', () => {
43
+ it('should call user function if provided', () => {
44
+ const userFunction = jest.fn();
45
+ const fn = fetchCustomAttributeFunctionWithDefaults(userFunction);
46
+
47
+ const tracer = provider.getTracer('test');
48
+ const span = tracer.startSpan('test-span');
49
+ const request = new Request('https://example.com');
50
+ const response = new Response();
51
+
52
+ fn(span, request, response);
53
+
54
+ expect(userFunction).toHaveBeenCalledWith(span, request, response);
55
+ });
56
+
57
+ it('should set error status when result is an Error', () => {
58
+ const fn = fetchCustomAttributeFunctionWithDefaults();
59
+
60
+ const tracer = provider.getTracer('test');
61
+ const span = tracer.startSpan('test-span');
62
+ const setStatusSpy = jest.spyOn(span, 'setStatus');
63
+ const request = new Request('https://example.com');
64
+ const error = new Error('Fetch failed');
65
+
66
+ fn(span, request, error as any);
67
+
68
+ expect(setStatusSpy).toHaveBeenCalledWith({
69
+ code: SpanStatusCode.ERROR,
70
+ message: 'Fetch failed',
71
+ });
72
+ });
73
+
74
+ it('should not set error status for successful Response', () => {
75
+ const fn = fetchCustomAttributeFunctionWithDefaults();
76
+
77
+ const tracer = provider.getTracer('test');
78
+ const span = tracer.startSpan('test-span');
79
+ const setStatusSpy = jest.spyOn(span, 'setStatus');
80
+ const request = new Request('https://example.com');
81
+ const response = new Response();
82
+
83
+ fn(span, request, response);
84
+
85
+ expect(setStatusSpy).not.toHaveBeenCalled();
86
+ });
87
+
88
+ it('should work with both user function and default error handling', () => {
89
+ const userFunction = jest.fn();
90
+ const fn = fetchCustomAttributeFunctionWithDefaults(userFunction);
91
+
92
+ const tracer = provider.getTracer('test');
93
+ const span = tracer.startSpan('test-span');
94
+ const setStatusSpy = jest.spyOn(span, 'setStatus');
95
+ const request = new Request('https://example.com');
96
+ const error = new Error('Combined test error');
97
+
98
+ fn(span, request, error as any);
99
+
100
+ expect(userFunction).toHaveBeenCalledWith(span, request, error);
101
+ expect(setStatusSpy).toHaveBeenCalledWith({
102
+ code: SpanStatusCode.ERROR,
103
+ message: 'Combined test error',
104
+ });
105
+ });
106
+ });
107
+ });
@@ -0,0 +1,106 @@
1
+ import { SpanStatusCode } from '@opentelemetry/api';
2
+ import type { Span } from '@opentelemetry/api';
3
+ import type { FetchCustomAttributeFunction } from '@opentelemetry/instrumentation-fetch';
4
+ import type { XHRCustomAttributeFunction } from '@opentelemetry/instrumentation-xml-http-request';
5
+
6
+ import { faro, type UserActionInternalInterface, UserActionState } from '@grafana/faro-core';
7
+
8
+ /**
9
+ * FetchError interface matching OpenTelemetry's internal type
10
+ * Note: FetchError is not exported from @opentelemetry/instrumentation-fetch
11
+ */
12
+ interface FetchError {
13
+ status?: number;
14
+ message: string;
15
+ }
16
+
17
+ /**
18
+ * Set span status to ERROR when fetch fails
19
+ *
20
+ * This ensures that failed HTTP requests are marked as errors in traces.
21
+ */
22
+ export function setSpanStatusOnFetchError(span: Span, error: Error | string): void {
23
+ const message = typeof error === 'string' ? error : error.message;
24
+ span.setStatus({
25
+ code: SpanStatusCode.ERROR,
26
+ message,
27
+ });
28
+ }
29
+
30
+ /**
31
+ * Type guard to check if result is a FetchError
32
+ */
33
+ function isFetchError(result: Response | FetchError): result is FetchError {
34
+ return 'message' in result && typeof (result as FetchError).message === 'string';
35
+ }
36
+
37
+ /**
38
+ * Custom attribute function for fetch instrumentation with defaults
39
+ *
40
+ * Combines user-provided custom attributes with default handling.
41
+ *
42
+ * @param userFunction - Optional user-provided custom attribute function
43
+ * @returns Combined custom attribute function
44
+ */
45
+ export function fetchCustomAttributeFunctionWithDefaults(
46
+ userFunction?: FetchCustomAttributeFunction
47
+ ): FetchCustomAttributeFunction {
48
+ const fn: FetchCustomAttributeFunction = (
49
+ span: Span,
50
+ request: Request | RequestInit,
51
+ result: Response | FetchError
52
+ ) => {
53
+ // Call user function first if provided
54
+ if (userFunction) {
55
+ userFunction(span, request, result);
56
+ }
57
+
58
+ // Add default error handling
59
+ if (isFetchError(result)) {
60
+ setSpanStatusOnFetchError(span, result.message);
61
+ } else if (result instanceof Response && result.status >= 400 && result.status < 600) {
62
+ setSpanStatusOnFetchError(span, `HTTP ${result.status}: ${result.statusText}`);
63
+ }
64
+ };
65
+ return fn;
66
+ }
67
+
68
+ /**
69
+ * Set span status to ERROR for XHR failures (status 0 or 4xx/5xx).
70
+ */
71
+ export function setSpanStatusOnXMLHttpRequestError(span: Span, xhr: XMLHttpRequest): void {
72
+ const status = xhr.status;
73
+ if (status == null) return;
74
+ if (status === 0 || (status >= 400 && status < 600)) {
75
+ span.setStatus({ code: SpanStatusCode.ERROR });
76
+ }
77
+ }
78
+
79
+ /**
80
+ * Add user action context to span when active (for HTTP Errors column in user action table).
81
+ */
82
+ function addUserActionContextToSpan(span: Span): void {
83
+ try {
84
+ const currentAction = faro.api?.getActiveUserAction?.();
85
+ const state = (currentAction as unknown as UserActionInternalInterface)?.getState?.();
86
+ if (currentAction && (state === UserActionState.Started || state === UserActionState.Halted)) {
87
+ span.setAttribute('faro.action.user.name', currentAction.name);
88
+ span.setAttribute('faro.action.user.parentId', currentAction.parentId);
89
+ }
90
+ } catch (_) {
91
+ // Silently fail - don't log to avoid instrumentation loops
92
+ }
93
+ }
94
+
95
+ /**
96
+ * Custom attribute function for XHR instrumentation with defaults.
97
+ */
98
+ export function xhrCustomAttributeFunctionWithDefaults(
99
+ userFunction?: XHRCustomAttributeFunction
100
+ ): XHRCustomAttributeFunction {
101
+ return (span: Span, xhr: XMLHttpRequest) => {
102
+ setSpanStatusOnXMLHttpRequestError(span, xhr);
103
+ addUserActionContextToSpan(span);
104
+ userFunction?.(span, xhr);
105
+ };
106
+ }