@grafana/faro-react-native-tracing 1.3.1 → 2.0.0

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,4 +1,7 @@
1
+ import { hrTimeToMilliseconds } from '@opentelemetry/core';
1
2
  import { notifyHttpRequestEnd, notifyHttpRequestStart, } from '@grafana/faro-react-native';
3
+ import { captureFetchUserAction } from '../instrumentations/instrumentationUtils';
4
+ // Optional XHR instrumentation still emits these attributes.
2
5
  const ATTR_HTTP_METHOD = 'http.method';
3
6
  const ATTR_HTTP_STATUS_CODE = 'http.status_code';
4
7
  const ATTR_HTTP_URL = 'http.url';
@@ -27,7 +30,16 @@ function getAttr(span, key) {
27
30
  * Check if a span is an HTTP span (from FetchInstrumentation).
28
31
  */
29
32
  function isHttpSpan(span) {
30
- return getAttr(span, ATTR_HTTP_URL) != null || getAttr(span, ATTR_HTTP_METHOD) != null;
33
+ // url.full is shared by non-HTTP spans; stable HTTP spans identify their method at creation.
34
+ return getHttpMethod(span) != null || getAttr(span, ATTR_HTTP_URL) != null;
35
+ }
36
+ function getHttpUrl(span) {
37
+ var _a;
38
+ return (_a = getAttr(span, 'url.full')) !== null && _a !== void 0 ? _a : getAttr(span, ATTR_HTTP_URL);
39
+ }
40
+ function getHttpMethod(span) {
41
+ var _a;
42
+ return (_a = getAttr(span, 'http.request.method')) !== null && _a !== void 0 ? _a : getAttr(span, ATTR_HTTP_METHOD);
31
43
  }
32
44
  /**
33
45
  * SpanProcessor that notifies httpRequestMonitor when HTTP spans start and end.
@@ -44,10 +56,15 @@ export class HttpRequestMonitorSpanProcessor {
44
56
  onStart(span, parentContext) {
45
57
  var _a, _b;
46
58
  if (isHttpSpan(span)) {
47
- const url = (_a = getAttr(span, ATTR_HTTP_URL)) !== null && _a !== void 0 ? _a : '';
48
- const method = (_b = getAttr(span, ATTR_HTTP_METHOD)) !== null && _b !== void 0 ? _b : 'GET';
59
+ // Capture before notifyStart changes Started to Halted. New requests while
60
+ // already Halted are not tracked by the controller and must not inherit it.
61
+ if (span.instrumentationScope.name === '@opentelemetry/instrumentation-fetch') {
62
+ captureFetchUserAction(span);
63
+ }
64
+ const url = (_a = getHttpUrl(span)) !== null && _a !== void 0 ? _a : '';
65
+ const method = (_b = getHttpMethod(span)) !== null && _b !== void 0 ? _b : 'GET';
49
66
  const requestId = span.spanContext().spanId;
50
- const startTimeMs = Date.now();
67
+ const startTimeMs = hrTimeToMilliseconds(span.startTime);
51
68
  const payload = {
52
69
  requestId,
53
70
  url,
@@ -59,18 +76,15 @@ export class HttpRequestMonitorSpanProcessor {
59
76
  this.processor.onStart(span, parentContext);
60
77
  }
61
78
  onEnd(span) {
62
- var _a, _b;
79
+ var _a, _b, _c;
63
80
  if (isHttpSpan(span)) {
64
- const url = (_a = getAttr(span, ATTR_HTTP_URL)) !== null && _a !== void 0 ? _a : '';
65
- const method = (_b = getAttr(span, ATTR_HTTP_METHOD)) !== null && _b !== void 0 ? _b : 'GET';
81
+ const url = (_a = getHttpUrl(span)) !== null && _a !== void 0 ? _a : '';
82
+ const method = (_b = getHttpMethod(span)) !== null && _b !== void 0 ? _b : 'GET';
66
83
  const requestId = span.spanContext().spanId;
67
- const statusAttr = getAttr(span, ATTR_HTTP_STATUS_CODE);
84
+ const statusAttr = (_c = getAttr(span, 'http.response.status_code')) !== null && _c !== void 0 ? _c : getAttr(span, ATTR_HTTP_STATUS_CODE);
68
85
  const status = statusAttr != null ? parseInt(statusAttr, 10) : undefined;
69
- const spanWithTime = span;
70
- const startNs = spanWithTime.startTimeUnixNano;
71
- const endNs = spanWithTime.endTimeUnixNano;
72
- const startTimeMs = startNs != null && !Number.isNaN(Number(startNs)) ? Number(startNs) / 1000000 : Date.now();
73
- const endTimeMs = endNs != null && !Number.isNaN(Number(endNs)) ? Number(endNs) / 1000000 : Date.now();
86
+ const startTimeMs = hrTimeToMilliseconds(span.startTime);
87
+ const endTimeMs = hrTimeToMilliseconds(span.endTime);
74
88
  const payload = {
75
89
  requestId,
76
90
  url,
@@ -1 +1 @@
1
- {"version":3,"file":"httpRequestMonitorSpanProcessor.js","sourceRoot":"","sources":["../../../src/processors/httpRequestMonitorSpanProcessor.ts"],"names":[],"mappings":"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","sourcesContent":["import type { Context } from '@opentelemetry/api';\nimport type { ReadableSpan, Span, SpanProcessor } from '@opentelemetry/sdk-trace-base';\n\nimport {\n type HttpRequestMessagePayload,\n notifyHttpRequestEnd,\n notifyHttpRequestStart,\n} from '@grafana/faro-react-native';\n\nconst ATTR_HTTP_METHOD = 'http.method';\nconst ATTR_HTTP_STATUS_CODE = 'http.status_code';\nconst ATTR_HTTP_URL = 'http.url';\n\n/**\n * Get string attribute from span.\n * Handles both SDK attribute format and OTLP-style attribute values.\n */\nfunction getAttr(span: Span | ReadableSpan, key: string): string | undefined {\n const attrs = span.attributes as Record<string, unknown> | undefined;\n if (attrs == null) return undefined;\n const raw = typeof attrs['get'] === 'function' ? (attrs as { get: (k: string) => unknown })['get'](key) : attrs[key];\n if (raw == null) return undefined;\n if (typeof raw === 'string') return raw.length > 0 ? raw : undefined;\n if (typeof raw === 'object' && raw !== null && 'stringValue' in raw) {\n const s = String((raw as { stringValue?: string }).stringValue ?? '');\n return s.length > 0 ? s : undefined;\n }\n const str = String(raw);\n return str.length > 0 ? str : undefined;\n}\n\n/**\n * Check if a span is an HTTP span (from FetchInstrumentation).\n */\nfunction isHttpSpan(span: Span | ReadableSpan): boolean {\n return getAttr(span, ATTR_HTTP_URL) != null || getAttr(span, ATTR_HTTP_METHOD) != null;\n}\n\n/**\n * SpanProcessor that notifies httpRequestMonitor when HTTP spans start and end.\n *\n * This enables user action correlation (UserActionController halt logic) when\n * TracingInstrumentation is used instead of HttpInstrumentation.\n *\n * IMPORTANT: Must not use console or trigger any instrumentation to avoid loops.\n */\nexport class HttpRequestMonitorSpanProcessor implements SpanProcessor {\n constructor(private readonly processor: SpanProcessor) {}\n\n onStart(span: Span, parentContext: Context): void {\n if (isHttpSpan(span)) {\n const url = getAttr(span, ATTR_HTTP_URL) ?? '';\n const method = getAttr(span, ATTR_HTTP_METHOD) ?? 'GET';\n const requestId = span.spanContext().spanId;\n const startTimeMs = Date.now();\n\n const payload: HttpRequestMessagePayload = {\n requestId,\n url,\n method,\n startTime: startTimeMs,\n };\n notifyHttpRequestStart(payload);\n }\n\n this.processor.onStart(span, parentContext);\n }\n\n onEnd(span: ReadableSpan): void {\n if (isHttpSpan(span)) {\n const url = getAttr(span, ATTR_HTTP_URL) ?? '';\n const method = getAttr(span, ATTR_HTTP_METHOD) ?? 'GET';\n const requestId = span.spanContext().spanId;\n const statusAttr = getAttr(span, ATTR_HTTP_STATUS_CODE);\n const status = statusAttr != null ? parseInt(statusAttr, 10) : undefined;\n const spanWithTime = span as ReadableSpan & {\n startTimeUnixNano?: number | bigint;\n endTimeUnixNano?: number | bigint;\n };\n const startNs = spanWithTime.startTimeUnixNano;\n const endNs = spanWithTime.endTimeUnixNano;\n const startTimeMs = startNs != null && !Number.isNaN(Number(startNs)) ? Number(startNs) / 1_000_000 : Date.now();\n const endTimeMs = endNs != null && !Number.isNaN(Number(endNs)) ? Number(endNs) / 1_000_000 : Date.now();\n\n const payload: HttpRequestMessagePayload = {\n requestId,\n url,\n method,\n startTime: startTimeMs,\n endTime: endTimeMs,\n status: !Number.isNaN(status) ? status : undefined,\n };\n notifyHttpRequestEnd(payload);\n }\n\n this.processor.onEnd(span);\n }\n\n forceFlush(): Promise<void> {\n return this.processor.forceFlush();\n }\n\n shutdown(): Promise<void> {\n return this.processor.shutdown();\n }\n}\n"]}
1
+ {"version":3,"file":"httpRequestMonitorSpanProcessor.js","sourceRoot":"","sources":["../../../src/processors/httpRequestMonitorSpanProcessor.ts"],"names":[],"mappings":"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","sourcesContent":["import type { Context } from '@opentelemetry/api';\nimport { hrTimeToMilliseconds } from '@opentelemetry/core';\nimport type { ReadableSpan, Span, SpanProcessor } from '@opentelemetry/sdk-trace-base';\n\nimport {\n type HttpRequestMessagePayload,\n notifyHttpRequestEnd,\n notifyHttpRequestStart,\n} from '@grafana/faro-react-native';\n\nimport { captureFetchUserAction } from '../instrumentations/instrumentationUtils';\n\n// Optional XHR instrumentation still emits these attributes.\nconst ATTR_HTTP_METHOD = 'http.method';\nconst ATTR_HTTP_STATUS_CODE = 'http.status_code';\nconst ATTR_HTTP_URL = 'http.url';\n\n/**\n * Get string attribute from span.\n * Handles both SDK attribute format and OTLP-style attribute values.\n */\nfunction getAttr(span: Span | ReadableSpan, key: string): string | undefined {\n const attrs = span.attributes as Record<string, unknown> | undefined;\n if (attrs == null) return undefined;\n const raw = typeof attrs['get'] === 'function' ? (attrs as { get: (k: string) => unknown })['get'](key) : attrs[key];\n if (raw == null) return undefined;\n if (typeof raw === 'string') return raw.length > 0 ? raw : undefined;\n if (typeof raw === 'object' && raw !== null && 'stringValue' in raw) {\n const s = String((raw as { stringValue?: string }).stringValue ?? '');\n return s.length > 0 ? s : undefined;\n }\n const str = String(raw);\n return str.length > 0 ? str : undefined;\n}\n\n/**\n * Check if a span is an HTTP span (from FetchInstrumentation).\n */\nfunction isHttpSpan(span: Span | ReadableSpan): boolean {\n // url.full is shared by non-HTTP spans; stable HTTP spans identify their method at creation.\n return getHttpMethod(span) != null || getAttr(span, ATTR_HTTP_URL) != null;\n}\n\nfunction getHttpUrl(span: Span | ReadableSpan): string | undefined {\n return getAttr(span, 'url.full') ?? getAttr(span, ATTR_HTTP_URL);\n}\n\nfunction getHttpMethod(span: Span | ReadableSpan): string | undefined {\n return getAttr(span, 'http.request.method') ?? getAttr(span, ATTR_HTTP_METHOD);\n}\n\n/**\n * SpanProcessor that notifies httpRequestMonitor when HTTP spans start and end.\n *\n * This enables user action correlation (UserActionController halt logic) when\n * TracingInstrumentation is used instead of HttpInstrumentation.\n *\n * IMPORTANT: Must not use console or trigger any instrumentation to avoid loops.\n */\nexport class HttpRequestMonitorSpanProcessor implements SpanProcessor {\n constructor(private readonly processor: SpanProcessor) {}\n\n onStart(span: Span, parentContext: Context): void {\n if (isHttpSpan(span)) {\n // Capture before notifyStart changes Started to Halted. New requests while\n // already Halted are not tracked by the controller and must not inherit it.\n if (span.instrumentationScope.name === '@opentelemetry/instrumentation-fetch') {\n captureFetchUserAction(span);\n }\n const url = getHttpUrl(span) ?? '';\n const method = getHttpMethod(span) ?? 'GET';\n const requestId = span.spanContext().spanId;\n const startTimeMs = hrTimeToMilliseconds(span.startTime);\n\n const payload: HttpRequestMessagePayload = {\n requestId,\n url,\n method,\n startTime: startTimeMs,\n };\n notifyHttpRequestStart(payload);\n }\n\n this.processor.onStart(span, parentContext);\n }\n\n onEnd(span: ReadableSpan): void {\n if (isHttpSpan(span)) {\n const url = getHttpUrl(span) ?? '';\n const method = getHttpMethod(span) ?? 'GET';\n const requestId = span.spanContext().spanId;\n const statusAttr = getAttr(span, 'http.response.status_code') ?? getAttr(span, ATTR_HTTP_STATUS_CODE);\n const status = statusAttr != null ? parseInt(statusAttr, 10) : undefined;\n const startTimeMs = hrTimeToMilliseconds(span.startTime);\n const endTimeMs = hrTimeToMilliseconds(span.endTime);\n\n const payload: HttpRequestMessagePayload = {\n requestId,\n url,\n method,\n startTime: startTimeMs,\n endTime: endTimeMs,\n status: !Number.isNaN(status) ? status : undefined,\n };\n notifyHttpRequestEnd(payload);\n }\n\n this.processor.onEnd(span);\n }\n\n forceFlush(): Promise<void> {\n return this.processor.forceFlush();\n }\n\n shutdown(): Promise<void> {\n return this.processor.shutdown();\n }\n}\n"]}
@@ -1 +1 @@
1
- {"version":3,"file":"types.js","sourceRoot":"","sources":["../../src/types.ts"],"names":[],"mappings":"","sourcesContent":["import type { Attributes, ContextManager, TextMapPropagator } from '@opentelemetry/api';\nimport type { Instrumentation } from '@opentelemetry/instrumentation';\nimport type { FetchCustomAttributeFunction } from '@opentelemetry/instrumentation-fetch';\nimport type { XHRCustomAttributeFunction } from '@opentelemetry/instrumentation-xml-http-request';\nimport type { SpanProcessor } from '@opentelemetry/sdk-trace-base';\n\nimport type { API, Patterns } from '@grafana/faro-core';\n\nexport type InstrumentationOption = Instrumentation | Instrumentation[];\n\nexport interface FaroTraceExporterConfig {\n api: API;\n}\n\nexport interface TracingInstrumentationOptions {\n resourceAttributes?: Attributes;\n propagator?: TextMapPropagator;\n contextManager?: ContextManager;\n instrumentations?: InstrumentationOption[];\n spanProcessor?: SpanProcessor;\n instrumentationOptions?: Omit<DefaultInstrumentationsOptions, 'ignoreUrls'>;\n}\n\nexport type MatchUrlDefinitions = Patterns;\n\nexport type DefaultInstrumentationsOptions = {\n ignoreUrls?: MatchUrlDefinitions;\n propagateTraceHeaderCorsUrls?: MatchUrlDefinitions;\n enableFetchInstrumentation?: boolean;\n enableXhrInstrumentation?: boolean;\n\n fetchInstrumentationOptions?: {\n applyCustomAttributesOnSpan?: FetchCustomAttributeFunction;\n ignoreNetworkEvents?: boolean;\n };\n\n xhrInstrumentationOptions?: {\n applyCustomAttributesOnSpan?: XHRCustomAttributeFunction;\n ignoreNetworkEvents?: boolean;\n };\n};\n"]}
1
+ {"version":3,"file":"types.js","sourceRoot":"","sources":["../../src/types.ts"],"names":[],"mappings":"","sourcesContent":["import type { Attributes, ContextManager, TextMapPropagator } from '@opentelemetry/api';\nimport type { Instrumentation } from '@opentelemetry/instrumentation';\nimport type { FetchCustomAttributeFunction } from '@opentelemetry/instrumentation-fetch';\nimport type { XHRCustomAttributeFunction } from '@opentelemetry/instrumentation-xml-http-request';\nimport type { SpanProcessor } from '@opentelemetry/sdk-trace-base';\n\nimport type { API, Patterns } from '@grafana/faro-core';\n\nexport type InstrumentationOption = Instrumentation | Instrumentation[];\n\nexport interface FaroTraceExporterConfig {\n api: API;\n}\n\nexport interface TracingInstrumentationOptions {\n resourceAttributes?: Attributes;\n propagator?: TextMapPropagator;\n contextManager?: ContextManager;\n instrumentations?: InstrumentationOption[];\n /**\n * A caller-supplied processor. Faro removal flushes it so the tracing instrumentation can be re-added;\n * TracingInstrumentation.shutdown() shuts it down and is terminal for that processor.\n */\n spanProcessor?: SpanProcessor;\n instrumentationOptions?: Omit<DefaultInstrumentationsOptions, 'ignoreUrls'>;\n}\n\nexport type MatchUrlDefinitions = Patterns;\n\nexport type DefaultInstrumentationsOptions = {\n ignoreUrls?: MatchUrlDefinitions;\n propagateTraceHeaderCorsUrls?: MatchUrlDefinitions;\n enableFetchInstrumentation?: boolean;\n enableXhrInstrumentation?: boolean;\n\n fetchInstrumentationOptions?: {\n applyCustomAttributesOnSpan?: FetchCustomAttributeFunction;\n ignoreNetworkEvents?: boolean;\n };\n\n xhrInstrumentationOptions?: {\n applyCustomAttributesOnSpan?: XHRCustomAttributeFunction;\n ignoreNetworkEvents?: boolean;\n };\n};\n"]}
@@ -31,7 +31,10 @@ export declare class TracingInstrumentation extends BaseInstrumentation {
31
31
  name: string;
32
32
  version: string;
33
33
  static SCHEDULED_BATCH_DELAY_MS: number;
34
- private provider?;
34
+ private activeRegistration?;
35
+ private defaultInstrumentations?;
36
+ private pendingShutdowns;
37
+ private suppliedSpanProcessorShutdown?;
35
38
  constructor(options?: TracingInstrumentationOptions);
36
39
  initialize(): void;
37
40
  /**
@@ -39,8 +42,13 @@ export declare class TracingInstrumentation extends BaseInstrumentation {
39
42
  * CRITICAL: This prevents infinite loops where trace exports trigger more traces
40
43
  */
41
44
  private getIgnoreUrls;
42
- /**
43
- * Shutdown the tracer provider
44
- */
45
+ private detachRegistration;
46
+ private runCleanup;
47
+ private logCleanupError;
48
+ private startProviderShutdown;
49
+ private startSuppliedSpanProcessorShutdown;
50
+ /** Detach request instrumentations and wait for the tracer provider to flush. */
45
51
  shutdown(): Promise<void>;
52
+ /** Detach synchronously when Faro removes this instrumentation. */
53
+ destroy(): void;
46
54
  }
@@ -1,3 +1,4 @@
1
+ import type { Instrumentation } from '@opentelemetry/instrumentation';
1
2
  import type { DefaultInstrumentationsOptions, InstrumentationOption } from '../types';
2
3
  /**
3
4
  * Get default OTEL instrumentations for React Native
@@ -15,3 +16,4 @@ import type { DefaultInstrumentationsOptions, InstrumentationOption } from '../t
15
16
  * @returns Array of OTEL instrumentations
16
17
  */
17
18
  export declare function getDefaultOTELInstrumentations(options?: DefaultInstrumentationsOptions): InstrumentationOption[];
19
+ export declare function updateDefaultOTELInstrumentations(instrumentations: Instrumentation[], options?: DefaultInstrumentationsOptions): void;
@@ -1,6 +1,8 @@
1
1
  import type { Span } from '@opentelemetry/api';
2
2
  import type { FetchCustomAttributeFunction } from '@opentelemetry/instrumentation-fetch';
3
3
  import type { XHRCustomAttributeFunction } from '@opentelemetry/instrumentation-xml-http-request';
4
+ /** Capture request ownership at start, before the monitor can halt the action. */
5
+ export declare function captureFetchUserAction(span: Span): void;
4
6
  /**
5
7
  * Set span status to ERROR when fetch fails
6
8
  *
@@ -13,6 +13,10 @@ export interface TracingInstrumentationOptions {
13
13
  propagator?: TextMapPropagator;
14
14
  contextManager?: ContextManager;
15
15
  instrumentations?: InstrumentationOption[];
16
+ /**
17
+ * A caller-supplied processor. Faro removal flushes it so the tracing instrumentation can be re-added;
18
+ * TracingInstrumentation.shutdown() shuts it down and is terminal for that processor.
19
+ */
16
20
  spanProcessor?: SpanProcessor;
17
21
  instrumentationOptions?: Omit<DefaultInstrumentationsOptions, 'ignoreUrls'>;
18
22
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@grafana/faro-react-native-tracing",
3
- "version": "1.3.1",
3
+ "version": "2.0.0",
4
4
  "description": "Faro React Native tracing implementation with OpenTelemetry.",
5
5
  "keywords": [
6
6
  "observability",
@@ -52,7 +52,7 @@
52
52
  },
53
53
  "dependencies": {
54
54
  "@grafana/faro-core": "^2.8.2",
55
- "@grafana/faro-react-native": "^1.3.1",
55
+ "@grafana/faro-react-native": "^1.4.0",
56
56
  "@opentelemetry/api": "^1.9.0",
57
57
  "@opentelemetry/core": "^2.9.0",
58
58
  "@opentelemetry/instrumentation": "^0.220.0",
@@ -74,5 +74,5 @@
74
74
  "publishConfig": {
75
75
  "access": "public"
76
76
  },
77
- "gitHead": "fed3084557bb9c34129fff2b190ad4758fad7daf"
77
+ "gitHead": "38d87a3474585e1177c38d2e9acd04860a4236e6"
78
78
  }
@@ -0,0 +1,90 @@
1
+ import type { IResourceSpans } from '@opentelemetry/otlp-transformer/build/src/trace/internal-types';
2
+
3
+ import { initializeFaro, TransportItemType } from '@grafana/faro-core';
4
+ import type { Config, EventEvent } from '@grafana/faro-core';
5
+ import { mockConfig, MockTransport } from '@grafana/faro-test-utils';
6
+
7
+ import { sendFaroEvents as send } from './faroTraceExporter.utils';
8
+
9
+ function fixture(paths = ['/same']): IResourceSpans[] {
10
+ return [
11
+ {
12
+ scopeSpans: [
13
+ {
14
+ scope: { name: '@opentelemetry/instrumentation-fetch' },
15
+ spans: paths.map((path, i) => ({
16
+ kind: 3,
17
+ traceId: 'trace',
18
+ spanId: String(i),
19
+ startTimeUnixNano: '1000000000',
20
+ endTimeUnixNano: '1001000000',
21
+ attributes: Object.entries({
22
+ 'http.request.method': 'GET',
23
+ 'url.full': 'https://example.com' + path,
24
+ 'http.response.status_code': '200',
25
+ 'server.address': 'example.com',
26
+ }).map(([key, value]) => ({ key, value: { stringValue: value } })),
27
+ })),
28
+ },
29
+ ],
30
+ },
31
+ ] as unknown as IResourceSpans[];
32
+ }
33
+
34
+ describe('span-derived event pipeline', () => {
35
+ function setup(beforeSend?: Config['beforeSend']) {
36
+ const transport = new MockTransport();
37
+ const faro = initializeFaro(
38
+ mockConfig({ transports: [transport], dedupe: true, ...(beforeSend ? { beforeSend } : {}) })
39
+ );
40
+ const events = () =>
41
+ transport.items.filter((item) => item.type === TransportItemType.EVENT).map((item) => item.payload as EventEvent);
42
+ return { transport, faro, events };
43
+ }
44
+ it('keeps payment-failed, fetch, payment-failed', () => {
45
+ const { faro, events } = setup();
46
+ faro.api.pushEvent('payment-failed');
47
+ send(fixture());
48
+ faro.api.pushEvent('payment-failed');
49
+ expect(events().map((e) => e.name)).toEqual(['payment-failed', 'faro.tracing.fetch', 'payment-failed']);
50
+ });
51
+ it('keeps fetch, other event, identical fetch', () => {
52
+ const { faro, events } = setup();
53
+ send(fixture());
54
+ faro.api.pushEvent('other-event');
55
+ send(fixture());
56
+ expect(events().map((e) => e.name)).toEqual(['faro.tracing.fetch', 'other-event', 'faro.tracing.fetch']);
57
+ });
58
+ it('continues after a throwing beforeSend hook', () => {
59
+ const { events } = setup((item) => {
60
+ if (
61
+ item.type === TransportItemType.EVENT &&
62
+ (item.payload as EventEvent).attributes?.['http.url'] === 'https://example.com/first'
63
+ )
64
+ throw new Error('test hook failure');
65
+ return item;
66
+ });
67
+ send(fixture(['/first', '/second', '/third']));
68
+ expect(events().map((e) => e.attributes?.['http.url'])).toEqual([
69
+ 'https://example.com/second',
70
+ 'https://example.com/third',
71
+ ]);
72
+ });
73
+ it('continues after a throwing transport', () => {
74
+ const { transport, events } = setup();
75
+ const originalSend = transport.send.bind(transport);
76
+ let first = true;
77
+ transport.send = (...args: Parameters<typeof transport.send>) => {
78
+ if (first) {
79
+ first = false;
80
+ throw new Error('test transport failure');
81
+ }
82
+ return originalSend(...args);
83
+ };
84
+ send(fixture(['/first', '/second', '/third']));
85
+ expect(events().map((e) => e.attributes?.['http.url'])).toEqual([
86
+ 'https://example.com/second',
87
+ 'https://example.com/third',
88
+ ]);
89
+ });
90
+ });
@@ -7,6 +7,34 @@ import type { EventAttributes as FaroEventAttributes } from '@grafana/faro-core'
7
7
  const internalLogger = createInternalLogger();
8
8
 
9
9
  const DURATION_NS_KEY = 'duration_ns';
10
+ /** Map stable OTel span attributes to Faro's fetch event contract. */
11
+ function projectFetchEventAttributes(attributes: FaroEventAttributes): void {
12
+ const fields = {
13
+ 'http.request.method': 'http.method',
14
+ 'url.full': 'http.url',
15
+ 'http.response.status_code': 'http.status_code',
16
+ };
17
+ for (const [spanField, eventField] of Object.entries(fields)) {
18
+ if (attributes[spanField] != null) {
19
+ attributes[eventField] = attributes[spanField];
20
+ delete attributes[spanField];
21
+ }
22
+ }
23
+
24
+ const url = attributes['http.url'];
25
+ if (url) {
26
+ try {
27
+ const parsed = new URL(url);
28
+ attributes['http.host'] = attributes['http.host'] ?? parsed.host;
29
+ attributes['http.scheme'] = attributes['http.scheme'] ?? parsed.protocol.replace(':', '');
30
+ } catch {
31
+ // Keep the captured URL even if it cannot supply host/scheme metadata.
32
+ }
33
+ }
34
+ delete attributes['server.address'];
35
+ delete attributes['server.port'];
36
+ attributes['component'] = attributes['component'] ?? 'fetch';
37
+ }
10
38
 
11
39
  /**
12
40
  * Send Faro events for CLIENT spans (HTTP requests, navigation, etc.)
@@ -48,6 +76,10 @@ export function sendFaroEvents(resourceSpans: IResourceSpans[] = []) {
48
76
  faroEventAttributes[attribute.key] = String(Object.values(attribute.value)[0]);
49
77
  }
50
78
 
79
+ if (scope?.name === '@opentelemetry/instrumentation-fetch') {
80
+ projectFetchEventAttributes(faroEventAttributes);
81
+ }
82
+
51
83
  // Add span duration in nanoseconds
52
84
  if (!Number.isNaN(span.endTimeUnixNano) && !Number.isNaN(span.startTimeUnixNano)) {
53
85
  faroEventAttributes[DURATION_NS_KEY] = String(
@@ -0,0 +1,300 @@
1
+ import { context, propagation, SpanKind, trace } from '@opentelemetry/api';
2
+
3
+ import { initializeFaro, TransportItemType, UserActionState } from '@grafana/faro-core';
4
+ import type { EventEvent, Faro, TraceEvent, UserActionInternalInterface } from '@grafana/faro-core';
5
+ import { mockConfig, MockTransport } from '@grafana/faro-test-utils';
6
+
7
+ import { UserActionInstrumentation } from '../../react-native/src/instrumentations/userActions';
8
+ import { monitorHttpRequests } from '../../react-native/src/instrumentations/userActions/httpRequestMonitor';
9
+ import type { HttpRequestMessage } from '../../react-native/src/instrumentations/userActions/httpRequestMonitor';
10
+
11
+ import { sendFaroEvents } from './exporters/faroTraceExporter.utils';
12
+ import { TracingInstrumentation } from './instrumentation';
13
+
14
+ // Use the real RN monitor without loading native modules in jsdom.
15
+ jest.mock('@grafana/faro-react-native', () =>
16
+ jest.requireActual('../../react-native/src/instrumentations/userActions/httpRequestMonitor')
17
+ );
18
+
19
+ const url = 'https://api.example.com:8443/orders';
20
+
21
+ describe('stable fetch semantics', () => {
22
+ let faro: Faro;
23
+ let transport: MockTransport;
24
+ let instrumentation: TracingInstrumentation;
25
+ let originalFetch: typeof fetch;
26
+ let resolveResponse: (response: Response) => void;
27
+ let messages: HttpRequestMessage[];
28
+ let unsubscribe: () => void;
29
+
30
+ beforeEach(() => {
31
+ jest.useFakeTimers();
32
+ originalFetch = globalThis.fetch;
33
+ globalThis.fetch = jest.fn(
34
+ () =>
35
+ new Promise<Response>((resolve) => {
36
+ resolveResponse = resolve;
37
+ })
38
+ );
39
+ globalThis.__FARO_HTTP_MONITOR__ = undefined;
40
+ messages = [];
41
+ const subscription = monitorHttpRequests().subscribe((message) => messages.push(message));
42
+ unsubscribe = () => subscription.unsubscribe();
43
+ transport = new MockTransport();
44
+ transport.getIgnoreUrls = () => [/ignored/];
45
+ faro = initializeFaro(mockConfig({ transports: [transport], dedupe: false }))!;
46
+ faro.api.setSession({ id: 'http-session', attributes: { isSampled: 'true' } });
47
+ instrumentation = new TracingInstrumentation({
48
+ instrumentationOptions: {
49
+ propagateTraceHeaderCorsUrls: [/.*/],
50
+ fetchInstrumentationOptions: {
51
+ ignoreNetworkEvents: true,
52
+ },
53
+ },
54
+ });
55
+ faro.instrumentations.add(new UserActionInstrumentation(), instrumentation);
56
+ });
57
+
58
+ afterEach(async () => {
59
+ await jest.advanceTimersByTimeAsync(2000);
60
+ await instrumentation.shutdown();
61
+ faro.instrumentations.remove(...faro.instrumentations.instrumentations);
62
+ unsubscribe();
63
+ globalThis.fetch = originalFetch;
64
+ globalThis.__FARO_HTTP_MONITOR__ = undefined;
65
+ trace.disable();
66
+ context.disable();
67
+ propagation.disable();
68
+ jest.useRealTimers();
69
+ });
70
+
71
+ function respond(status: number) {
72
+ resolveResponse(
73
+ Object.assign(new Response(), { url, status, statusText: status === 200 ? 'OK' : 'Request failed' })
74
+ );
75
+ }
76
+
77
+ function exported() {
78
+ const spans = transport.items
79
+ .filter((item) => item.type === TransportItemType.TRACE)
80
+ .flatMap((item) => (item.payload as TraceEvent).resourceSpans ?? [])
81
+ .flatMap((resource) => resource.scopeSpans)
82
+ .flatMap((scope) => scope.spans ?? []);
83
+ const events = transport.items
84
+ .filter((item) => item.type === TransportItemType.EVENT)
85
+ .map((item) => item.payload as EventEvent)
86
+ .filter((event) => event.name === 'faro.tracing.fetch');
87
+ expect(spans).toHaveLength(1);
88
+ expect(events).toHaveLength(1);
89
+ return { span: spans[0]!, event: events[0]! };
90
+ }
91
+
92
+ it.each([
93
+ ['GET', 200],
94
+ ['POST', 200],
95
+ ['POST', 401],
96
+ ['POST', 500],
97
+ ] as const)('exports compatible spans and events for %s %i', async (method, status) => {
98
+ const pending = globalThis.fetch(url, { method });
99
+ await jest.advanceTimersByTimeAsync(250);
100
+ respond(status);
101
+ await pending;
102
+ await jest.advanceTimersByTimeAsync(2000);
103
+ const { span, event } = exported();
104
+ expect(span.name).toBe(method);
105
+ expect(span.status?.code).toBe(status >= 400 ? 2 : 0);
106
+ const attributes = Object.fromEntries(span.attributes.map(({ key, value }) => [key, value]));
107
+ expect(attributes).toMatchObject({
108
+ 'http.request.method': { stringValue: method },
109
+ 'url.full': { stringValue: url },
110
+ 'server.address': { stringValue: 'api.example.com' },
111
+ 'server.port': { intValue: 8443 },
112
+ 'http.response.status_code': { intValue: status },
113
+ });
114
+ expect(attributes['error.type']).toEqual(status >= 400 ? { stringValue: String(status) } : undefined);
115
+ expect(span.status?.message).toBeUndefined();
116
+ expect(attributes['http.method']).toBeUndefined();
117
+ expect(attributes['http.url']).toBeUndefined();
118
+ expect(attributes['http.status_code']).toBeUndefined();
119
+ expect(event.attributes).toMatchObject({
120
+ 'http.method': method,
121
+ 'http.url': url,
122
+ 'http.status_code': String(status),
123
+ 'http.host': 'api.example.com:8443',
124
+ 'http.scheme': 'https',
125
+ 'session.id': 'http-session',
126
+ });
127
+ expect(event.attributes!['url.full']).toBeUndefined();
128
+ expect(event.attributes!['http.request.method']).toBeUndefined();
129
+ expect(event.attributes!['http.response.status_code']).toBeUndefined();
130
+ expect(event.attributes!['server.address']).toBeUndefined();
131
+ expect(event.attributes!['server.port']).toBeUndefined();
132
+ expect(Object.values(event.attributes!).every((value) => typeof value === 'string')).toBe(true);
133
+ expect(Number(event.attributes!['duration_ns'])).toBeGreaterThan(0);
134
+ expect(event.trace).toEqual({ trace_id: span.traceId, span_id: span.spanId });
135
+ expect(messages).toHaveLength(2);
136
+ expect(messages[0]!.request).toMatchObject({ requestId: span.spanId, url, method });
137
+ expect(messages[1]!.request).toMatchObject({ requestId: span.spanId, url, method, status });
138
+ expect(messages[1]!.request.startTime).toBe(messages[0]!.request.startTime);
139
+ expect(messages[1]!.request.endTime! - messages[1]!.request.startTime).toBeCloseTo(250, 0);
140
+ });
141
+
142
+ it('does not classify a non-HTTP URL span as a request', async () => {
143
+ const span = trace.getTracer('custom').startSpan('read-file', {
144
+ kind: SpanKind.CLIENT,
145
+ attributes: { 'url.full': 'file:///example.txt' },
146
+ });
147
+ span.end();
148
+ await jest.advanceTimersByTimeAsync(2000);
149
+ expect(messages).toEqual([]);
150
+ const events = transport.items
151
+ .filter((item) => item.type === TransportItemType.EVENT)
152
+ .map((item) => item.payload as EventEvent);
153
+ expect(events).toEqual(
154
+ expect.arrayContaining([
155
+ expect.objectContaining({
156
+ name: 'faro.tracing.custom',
157
+ attributes: expect.objectContaining({ 'url.full': 'file:///example.txt' }),
158
+ }),
159
+ ])
160
+ );
161
+ expect(events.some((event) => event.name === 'faro.tracing.fetch')).toBe(false);
162
+ });
163
+
164
+ function pairs() {
165
+ const spans = transport.items
166
+ .filter((item) => item.type === TransportItemType.TRACE)
167
+ .flatMap((item) => (item.payload as TraceEvent).resourceSpans ?? [])
168
+ .flatMap((resource) => resource.scopeSpans)
169
+ .flatMap((scope) => scope.spans ?? []);
170
+ const events = transport.items
171
+ .filter((item) => item.type === TransportItemType.EVENT)
172
+ .map((item) => item.payload as EventEvent)
173
+ .filter((event) => event.name === 'faro.tracing.fetch');
174
+ return spans.map((span) => ({ span, event: events.find((event) => event.trace?.span_id === span.spanId)! }));
175
+ }
176
+
177
+ function expectAssociation(pair: ReturnType<typeof pairs>[number], action?: UserActionInternalInterface) {
178
+ const attributes = Object.fromEntries(pair.span.attributes.map(({ key, value }) => [key, value]));
179
+ expect(attributes['faro.action.user.name']).toEqual(action ? { stringValue: action.name } : undefined);
180
+ expect(attributes['faro.action.user.parentId']).toEqual(action ? { stringValue: action.parentId } : undefined);
181
+ expect(pair.event).toBeDefined();
182
+ expect(pair.event.action).toEqual(action ? { name: action.name, parentId: action.parentId } : undefined);
183
+ }
184
+
185
+ it.each(['none', 'ended', 'cancelled', 'halted'] as const)(
186
+ 'does not associate a request with an action that is %s',
187
+ async (state) => {
188
+ if (state !== 'none') {
189
+ const action = faro.api.startUserAction('previous-action') as UserActionInternalInterface;
190
+ if (state === 'ended') action.end();
191
+ else if (state === 'cancelled') action.cancel();
192
+ else action.halt();
193
+ }
194
+ const pending = globalThis.fetch(url);
195
+ respond(200);
196
+ await pending;
197
+ await jest.advanceTimersByTimeAsync(2000);
198
+ expectAssociation(exported());
199
+ }
200
+ );
201
+
202
+ it('does not track excluded requests or make the action wait for them', async () => {
203
+ const action = faro.api.startUserAction('ignored-request') as UserActionInternalInterface;
204
+ const pending = globalThis.fetch('https://api.example.com/ignored');
205
+ await jest.advanceTimersByTimeAsync(250);
206
+ expect(action.getState()).toBe(UserActionState.Ended);
207
+ expect(messages).toEqual([]);
208
+ respond(200);
209
+ await pending;
210
+ await jest.advanceTimersByTimeAsync(2000);
211
+ expect(pairs()).toEqual([]);
212
+ });
213
+
214
+ it.each(['paused', 'beforeSend'] as const)('respects transport filtering when %s', async (filter) => {
215
+ if (filter === 'paused') faro.transports.pause();
216
+ else faro.transports.addBeforeSendHooks((item) => (item.type === TransportItemType.EVENT ? null : item));
217
+ const pending = globalThis.fetch(url);
218
+ respond(200);
219
+ await pending;
220
+ await jest.advanceTimersByTimeAsync(2000);
221
+ expect(transport.items.some((item) => item.type === TransportItemType.EVENT)).toBe(false);
222
+ });
223
+
224
+ it('respects fetch event deduplication and the configured event domain', async () => {
225
+ faro.config.eventDomain = 'http-tests';
226
+ const pending = globalThis.fetch(url);
227
+ respond(200);
228
+ await pending;
229
+ await jest.advanceTimersByTimeAsync(2000);
230
+ expect(exported().event.domain).toBe('http-tests');
231
+ const resourceSpans = (transport.items.find((item) => item.type === TransportItemType.TRACE)!.payload as TraceEvent)
232
+ .resourceSpans;
233
+ faro.config.dedupe = true;
234
+ sendFaroEvents(resourceSpans);
235
+ expect(transport.items.filter((item) => item.type === TransportItemType.EVENT)).toHaveLength(1);
236
+ faro.config.dedupe = false;
237
+ sendFaroEvents(resourceSpans);
238
+ expect(transport.items.filter((item) => item.type === TransportItemType.EVENT)).toHaveLength(2);
239
+ });
240
+
241
+ it('does not attach a new request to an action already waiting for another request', async () => {
242
+ const action = faro.api.startUserAction('first-request') as UserActionInternalInterface;
243
+ const first = globalThis.fetch(url);
244
+ const resolveFirst = resolveResponse;
245
+ expect(action.getState()).toBe(UserActionState.Halted);
246
+ const second = globalThis.fetch(url);
247
+ const resolveSecond = resolveResponse;
248
+ resolveFirst(Object.assign(new Response(), { url, status: 200 }));
249
+ await first;
250
+ await jest.advanceTimersByTimeAsync(2000);
251
+ expect(action.getState()).toBe(UserActionState.Ended);
252
+ resolveSecond(Object.assign(new Response(), { url, status: 200 }));
253
+ await second;
254
+ await jest.advanceTimersByTimeAsync(2000);
255
+ const results = pairs();
256
+ expect(results).toHaveLength(2);
257
+ expectAssociation(results.find(({ span }) => span.spanId === messages[0]!.request.requestId)!, action);
258
+ expectAssociation(results.find(({ span }) => span.spanId === messages[1]!.request.requestId)!);
259
+ });
260
+
261
+ it('retains request ownership when a later action starts before the earlier response arrives', async () => {
262
+ const firstAction = faro.api.startUserAction('first-action') as UserActionInternalInterface;
263
+ const first = globalThis.fetch(url);
264
+ const resolveFirst = resolveResponse;
265
+ firstAction.end();
266
+ const secondAction = faro.api.startUserAction('second-action') as UserActionInternalInterface;
267
+ const second = globalThis.fetch(url);
268
+ const resolveSecond = resolveResponse;
269
+ resolveFirst(Object.assign(new Response(), { url, status: 200 }));
270
+ await first;
271
+ await jest.advanceTimersByTimeAsync(500);
272
+ expect(secondAction.getState()).toBe(UserActionState.Halted);
273
+ resolveSecond(Object.assign(new Response(), { url, status: 200 }));
274
+ await second;
275
+ await jest.advanceTimersByTimeAsync(2000);
276
+ const results = pairs();
277
+ expect(results).toHaveLength(2);
278
+ expectAssociation(results.find(({ span }) => span.spanId === messages[0]!.request.requestId)!, firstAction);
279
+ expectAssociation(results.find(({ span }) => span.spanId === messages[1]!.request.requestId)!, secondAction);
280
+ });
281
+
282
+ it('keeps a slow request associated with its action until it ends', async () => {
283
+ const action = faro.api.startUserAction('load-orders') as UserActionInternalInterface;
284
+ const pending = globalThis.fetch(url, { method: 'GET' });
285
+ await jest.advanceTimersByTimeAsync(250);
286
+ expect(action.getState()).toBe(UserActionState.Halted);
287
+ respond(200);
288
+ await pending;
289
+ await jest.advanceTimersByTimeAsync(2000);
290
+ expect(action.getState()).toBe(UserActionState.Ended);
291
+ const { span, event } = exported();
292
+ expect(span.attributes).toEqual(
293
+ expect.arrayContaining([
294
+ { key: 'faro.action.user.name', value: { stringValue: 'load-orders' } },
295
+ { key: 'faro.action.user.parentId', value: { stringValue: action.parentId } },
296
+ ])
297
+ );
298
+ expect(event.action).toEqual({ name: 'load-orders', parentId: action.parentId });
299
+ });
300
+ });