@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,21 @@
1
+ import type { ExportResult } from '@opentelemetry/core';
2
+ import type { ReadableSpan, SpanExporter } from '@opentelemetry/sdk-trace-base';
3
+ import type { FaroTraceExporterConfig } from '../types';
4
+ /**
5
+ * FaroTraceExporter for React Native
6
+ *
7
+ * Exports OpenTelemetry spans to Faro backend using pushTraces API.
8
+ *
9
+ * IMPORTANT: To avoid infinite loops:
10
+ * - Uses internalLogger instead of console
11
+ * - Does NOT log during export (except errors)
12
+ * - Relies on Faro's internal deduplication
13
+ */
14
+ export declare class FaroTraceExporter implements SpanExporter {
15
+ private config;
16
+ private _isShutdown;
17
+ constructor(config: FaroTraceExporterConfig);
18
+ export(spans: ReadableSpan[], resultCallback: (result: ExportResult) => void): void;
19
+ shutdown(): Promise<void>;
20
+ forceFlush(): Promise<void>;
21
+ }
@@ -0,0 +1,17 @@
1
+ import { type IResourceSpans } from '@opentelemetry/otlp-transformer/build/src/trace/internal-types';
2
+ /**
3
+ * Send Faro events for CLIENT spans (HTTP requests, navigation, etc.)
4
+ *
5
+ * IMPORTANT: This function is called during trace export and must be careful to avoid infinite loops.
6
+ *
7
+ * Infinite loop prevention strategy:
8
+ * 1. Only process SPAN_KIND_CLIENT spans (HTTP requests, etc.)
9
+ * 2. Use faro.api.pushEvent which:
10
+ * - Does NOT trigger console logs if ConsoleInstrumentation is configured properly
11
+ * - Does NOT trigger HTTP instrumentation for collector URLs (they're ignored)
12
+ * 3. Use internalLogger for debugging instead of console
13
+ * 4. Never call console.log/warn/error in this function
14
+ *
15
+ * @param resourceSpans - OTLP resource spans from trace exporter
16
+ */
17
+ export declare function sendFaroEvents(resourceSpans?: IResourceSpans[]): void;
@@ -0,0 +1,7 @@
1
+ export { FaroTraceExporter } from './exporters/faroTraceExporter';
2
+ export { getDefaultOTELInstrumentations } from './instrumentations/getDefaultOTELInstrumentations';
3
+ export { TracingInstrumentation } from './instrumentation';
4
+ export { getSamplingDecision } from './utils/sampler';
5
+ export type { FaroTraceExporterConfig, TracingInstrumentationOptions } from './types';
6
+ export { setSpanStatusOnFetchError, fetchCustomAttributeFunctionWithDefaults, } from './instrumentations/instrumentationUtils';
7
+ export { FaroMetaAttributesSpanProcessor } from './processors/faroMetaAttributesSpanProcessor';
@@ -0,0 +1,46 @@
1
+ import { BaseInstrumentation } from '@grafana/faro-core';
2
+ import type { TracingInstrumentationOptions } from './types';
3
+ /**
4
+ * TracingInstrumentation for React Native
5
+ *
6
+ * Enables distributed tracing with OpenTelemetry for React Native applications.
7
+ *
8
+ * IMPORTANT: Infinite loop prevention
9
+ * - Uses internalLogger for debugging instead of console
10
+ * - Collector URLs are added to ignoreUrls in HTTP instrumentation
11
+ * - BatchSpanProcessor delays span export to avoid blocking
12
+ * - No console logging during trace export
13
+ *
14
+ * Example usage:
15
+ * ```ts
16
+ * import { initializeFaro } from '@grafana/faro-react-native';
17
+ * import { TracingInstrumentation } from '@grafana/faro-react-native-tracing';
18
+ *
19
+ * initializeFaro({
20
+ * // ... other config
21
+ * instrumentations: [
22
+ * new TracingInstrumentation({
23
+ * propagateTraceHeaderCorsUrls: [/https:\\/\\/my-api\\.com/],
24
+ * }),
25
+ * ],
26
+ * });
27
+ * ```
28
+ */
29
+ export declare class TracingInstrumentation extends BaseInstrumentation {
30
+ private options;
31
+ name: string;
32
+ version: string;
33
+ static SCHEDULED_BATCH_DELAY_MS: number;
34
+ private provider?;
35
+ constructor(options?: TracingInstrumentationOptions);
36
+ initialize(): void;
37
+ /**
38
+ * Get ignore URLs from all transports to avoid tracing collector requests
39
+ * CRITICAL: This prevents infinite loops where trace exports trigger more traces
40
+ */
41
+ private getIgnoreUrls;
42
+ /**
43
+ * Shutdown the tracer provider
44
+ */
45
+ shutdown(): Promise<void>;
46
+ }
@@ -0,0 +1,17 @@
1
+ import type { DefaultInstrumentationsOptions, InstrumentationOption } from '../types';
2
+ /**
3
+ * Get default OTEL instrumentations for React Native
4
+ *
5
+ * This function creates the default OpenTelemetry instrumentations for React Native:
6
+ * - FetchInstrumentation: Traces fetch() API calls
7
+ * - XMLHttpRequestInstrumentation: Traces XMLHttpRequest and axios (which uses XHR)
8
+ *
9
+ * IMPORTANT: Infinite loop prevention
10
+ * - ignoreUrls is used to exclude Faro collector URLs
11
+ * - ignoreNetworkEvents is true to avoid duplicate events
12
+ * - No console logging during instrumentation
13
+ *
14
+ * @param options - Configuration options
15
+ * @returns Array of OTEL instrumentations
16
+ */
17
+ export declare function getDefaultOTELInstrumentations(options?: DefaultInstrumentationsOptions): InstrumentationOption[];
@@ -0,0 +1,26 @@
1
+ import type { Span } from '@opentelemetry/api';
2
+ import type { FetchCustomAttributeFunction } from '@opentelemetry/instrumentation-fetch';
3
+ import type { XHRCustomAttributeFunction } from '@opentelemetry/instrumentation-xml-http-request';
4
+ /**
5
+ * Set span status to ERROR when fetch fails
6
+ *
7
+ * This ensures that failed HTTP requests are marked as errors in traces.
8
+ */
9
+ export declare function setSpanStatusOnFetchError(span: Span, error: Error | string): void;
10
+ /**
11
+ * Custom attribute function for fetch instrumentation with defaults
12
+ *
13
+ * Combines user-provided custom attributes with default handling.
14
+ *
15
+ * @param userFunction - Optional user-provided custom attribute function
16
+ * @returns Combined custom attribute function
17
+ */
18
+ export declare function fetchCustomAttributeFunctionWithDefaults(userFunction?: FetchCustomAttributeFunction): FetchCustomAttributeFunction;
19
+ /**
20
+ * Set span status to ERROR for XHR failures (status 0 or 4xx/5xx).
21
+ */
22
+ export declare function setSpanStatusOnXMLHttpRequestError(span: Span, xhr: XMLHttpRequest): void;
23
+ /**
24
+ * Custom attribute function for XHR instrumentation with defaults.
25
+ */
26
+ export declare function xhrCustomAttributeFunctionWithDefaults(userFunction?: XHRCustomAttributeFunction): XHRCustomAttributeFunction;
@@ -0,0 +1,22 @@
1
+ import type { Context } from '@opentelemetry/api';
2
+ import type { ReadableSpan, Span, SpanProcessor } from '@opentelemetry/sdk-trace-base';
3
+ import type { Metas } from '@grafana/faro-core';
4
+ /**
5
+ * Span processor that adds Faro meta attributes to spans
6
+ *
7
+ * This processor enriches spans with:
8
+ * - Session ID
9
+ * - User information (email, id, username, etc.)
10
+ *
11
+ * IMPORTANT: This processor delegates to a wrapped processor and does NOT log
12
+ * to avoid infinite loops.
13
+ */
14
+ export declare class FaroMetaAttributesSpanProcessor implements SpanProcessor {
15
+ private processor;
16
+ private metas;
17
+ constructor(processor: SpanProcessor, metas: Metas);
18
+ forceFlush(): Promise<void>;
19
+ onStart(span: Span, parentContext: Context): void;
20
+ onEnd(span: ReadableSpan): void;
21
+ shutdown(): Promise<void>;
22
+ }
@@ -0,0 +1,18 @@
1
+ import type { Context } from '@opentelemetry/api';
2
+ import type { ReadableSpan, Span, SpanProcessor } from '@opentelemetry/sdk-trace-base';
3
+ /**
4
+ * SpanProcessor that notifies httpRequestMonitor when HTTP spans start and end.
5
+ *
6
+ * This enables user action correlation (UserActionController halt logic) when
7
+ * TracingInstrumentation is used instead of HttpInstrumentation.
8
+ *
9
+ * IMPORTANT: Must not use console or trigger any instrumentation to avoid loops.
10
+ */
11
+ export declare class HttpRequestMonitorSpanProcessor implements SpanProcessor {
12
+ private readonly processor;
13
+ constructor(processor: SpanProcessor);
14
+ onStart(span: Span, parentContext: Context): void;
15
+ onEnd(span: ReadableSpan): void;
16
+ forceFlush(): Promise<void>;
17
+ shutdown(): Promise<void>;
18
+ }
@@ -0,0 +1,19 @@
1
+ /**
2
+ * Semantic convention attributes for React Native tracing
3
+ * Based on OpenTelemetry semantic conventions with React Native-specific additions
4
+ */
5
+ export declare const ATTR_SESSION_ID = "session.id";
6
+ export declare const ATTR_DEVICE_MODEL = "device.model";
7
+ export declare const ATTR_DEVICE_BRAND = "device.brand";
8
+ export declare const ATTR_DEVICE_PLATFORM = "device.platform";
9
+ export declare const ATTR_DEVICE_OS_VERSION = "device.os.version";
10
+ export declare const ATTR_DEVICE_TYPE = "device.type";
11
+ export declare const ATTR_DEVICE_LOCALE = "device.locale";
12
+ export declare const ATTR_APP_VERSION = "app.version";
13
+ export declare const ATTR_APP_BUILD = "app.build";
14
+ export declare const ATTR_SERVICE_NAMESPACE = "service.namespace";
15
+ export declare const ATTR_DEPLOYMENT_ENVIRONMENT_NAME = "deployment.environment.name";
16
+ export declare const ATTR_TELEMETRY_DISTRO_NAME = "telemetry.distro.name";
17
+ export declare const ATTR_TELEMETRY_DISTRO_VERSION = "telemetry.distro.version";
18
+ export declare const ATTR_PROCESS_RUNTIME_NAME = "process.runtime.name";
19
+ export declare const ATTR_PROCESS_RUNTIME_VERSION = "process.runtime.version";
@@ -0,0 +1,31 @@
1
+ import type { Attributes, ContextManager, TextMapPropagator } from '@opentelemetry/api';
2
+ import type { Instrumentation } from '@opentelemetry/instrumentation';
3
+ import type { FetchCustomAttributeFunction } from '@opentelemetry/instrumentation-fetch';
4
+ import type { XHRCustomAttributeFunction } from '@opentelemetry/instrumentation-xml-http-request';
5
+ import type { SpanProcessor } from '@opentelemetry/sdk-trace-base';
6
+ import type { API, Patterns } from '@grafana/faro-core';
7
+ export type InstrumentationOption = Instrumentation | Instrumentation[];
8
+ export interface FaroTraceExporterConfig {
9
+ api: API;
10
+ }
11
+ export interface TracingInstrumentationOptions {
12
+ resourceAttributes?: Attributes;
13
+ propagator?: TextMapPropagator;
14
+ contextManager?: ContextManager;
15
+ instrumentations?: InstrumentationOption[];
16
+ spanProcessor?: SpanProcessor;
17
+ instrumentationOptions?: Omit<DefaultInstrumentationsOptions, 'ignoreUrls'>;
18
+ }
19
+ export type MatchUrlDefinitions = Patterns;
20
+ export type DefaultInstrumentationsOptions = {
21
+ ignoreUrls?: MatchUrlDefinitions;
22
+ propagateTraceHeaderCorsUrls?: MatchUrlDefinitions;
23
+ fetchInstrumentationOptions?: {
24
+ applyCustomAttributesOnSpan?: FetchCustomAttributeFunction;
25
+ ignoreNetworkEvents?: boolean;
26
+ };
27
+ xhrInstrumentationOptions?: {
28
+ applyCustomAttributesOnSpan?: XHRCustomAttributeFunction;
29
+ ignoreNetworkEvents?: boolean;
30
+ };
31
+ };
@@ -0,0 +1,12 @@
1
+ import { SamplingDecision } from '@opentelemetry/sdk-trace-base';
2
+ import type { MetaSession } from '@grafana/faro-core';
3
+ /**
4
+ * Get sampling decision based on session configuration
5
+ *
6
+ * If the session is sampled, traces will be collected.
7
+ * If the session is not sampled, traces will be dropped.
8
+ *
9
+ * @param sessionMeta - Current Faro session meta
10
+ * @returns OTEL sampling decision
11
+ */
12
+ export declare function getSamplingDecision(sessionMeta?: MetaSession): SamplingDecision;
package/package.json ADDED
@@ -0,0 +1,77 @@
1
+ {
2
+ "name": "@grafana/faro-react-native-tracing",
3
+ "version": "1.0.0-alpha.1",
4
+ "description": "Faro React Native tracing implementation with OpenTelemetry.",
5
+ "keywords": [
6
+ "observability",
7
+ "apm",
8
+ "rum",
9
+ "logs",
10
+ "traces",
11
+ "metrics",
12
+ "react-native",
13
+ "mobile",
14
+ "opentelemetry"
15
+ ],
16
+ "license": "Apache-2.0",
17
+ "author": "Grafana Labs",
18
+ "homepage": "https://github.com/grafana/faro-react-native-sdk",
19
+ "sideEffects": false,
20
+ "repository": {
21
+ "type": "git",
22
+ "url": "https://github.com/grafana/faro-react-native-sdk.git",
23
+ "directory": "packages/react-native-tracing"
24
+ },
25
+ "main": "./dist/cjs/index.js",
26
+ "module": "./dist/esm/index.js",
27
+ "types": "./dist/types/index.d.ts",
28
+ "react-native": "./src/index.ts",
29
+ "files": [
30
+ "dist",
31
+ "src",
32
+ "README.md",
33
+ "LICENSE"
34
+ ],
35
+ "scripts": {
36
+ "start": "yarn watch",
37
+ "build": "run-s 'build:*'",
38
+ "build:compile": "run-p 'build:compile:*'",
39
+ "build:compile:cjs": "tsc --build tsconfig.cjs.json",
40
+ "build:compile:esm": "tsc --build tsconfig.esm.json",
41
+ "watch": "run-p 'watch:compile:*'",
42
+ "watch:compile:cjs": "yarn build:compile:cjs -w",
43
+ "watch:compile:esm": "yarn build:compile:esm -w",
44
+ "clean": "rimraf dist/ yarn-error.log",
45
+ "quality": "run-s 'quality:*'",
46
+ "quality:test": "../../node_modules/.bin/jest",
47
+ "quality:format": "prettier --cache --cache-location=../../.cache/prettier/reactNativeTracing --ignore-path ../../.prettierignore -w \"./**/*.{js,jsx,ts,tsx,css,scss,md,yaml,yml,json}\"",
48
+ "quality:lint": "run-p 'quality:lint:eslint' 'quality:lint:prettier'",
49
+ "quality:lint:eslint": "eslint --cache --cache-location ../../.cache/eslint/reactNativeTracing \"./**/*.{js,jsx,ts,tsx}\"",
50
+ "quality:lint:prettier": "prettier --cache --cache-location=../../.cache/prettier/reactNativeTracing --ignore-path ../../.prettierignore -c \"./**/*.{js,jsx,ts,tsx,css,scss,md,yaml,yml,json}\"",
51
+ "quality:circular-deps": "madge --circular ."
52
+ },
53
+ "dependencies": {
54
+ "@grafana/faro-core": "^2.2.3",
55
+ "@grafana/faro-react-native": "^1.0.0-alpha.1",
56
+ "@opentelemetry/api": "^1.9.0",
57
+ "@opentelemetry/core": "^2.0.0",
58
+ "@opentelemetry/instrumentation": "^0.208.0",
59
+ "@opentelemetry/instrumentation-fetch": "^0.208.0",
60
+ "@opentelemetry/instrumentation-xml-http-request": "^0.208.0",
61
+ "@opentelemetry/otlp-transformer": "^0.208.0",
62
+ "@opentelemetry/resources": "^2.0.0",
63
+ "@opentelemetry/sdk-trace-base": "^2.0.0",
64
+ "@opentelemetry/semantic-conventions": "^1.32.0"
65
+ },
66
+ "peerDependencies": {
67
+ "react-native": ">=0.70.0"
68
+ },
69
+ "devDependencies": {
70
+ "@types/node": "24.10.1",
71
+ "@types/react-native": "^0.73.0"
72
+ },
73
+ "publishConfig": {
74
+ "access": "public"
75
+ },
76
+ "gitHead": "8735c51051a17ebaabe49d5d65d729ebda3fa733"
77
+ }
@@ -0,0 +1,110 @@
1
+ import { ExportResultCode } from '@opentelemetry/core';
2
+ import { BasicTracerProvider, ReadableSpan } from '@opentelemetry/sdk-trace-base';
3
+
4
+ import { initializeFaro } from '@grafana/faro-core';
5
+ import { mockConfig, MockTransport } from '@grafana/faro-test-utils';
6
+
7
+ import { FaroTraceExporter } from './faroTraceExporter';
8
+
9
+ describe('FaroTraceExporter', () => {
10
+ let transport: MockTransport;
11
+ let provider: BasicTracerProvider;
12
+ let faro: any;
13
+ let consoleErrorSpy: jest.SpiedFunction<typeof console.error>;
14
+
15
+ beforeEach(() => {
16
+ // internalLogger.error forwards here; expected-error tests should not spam CI output
17
+ consoleErrorSpy = jest.spyOn(console, 'error').mockImplementation(() => {});
18
+ transport = new MockTransport();
19
+ faro = initializeFaro(
20
+ mockConfig({
21
+ transports: [transport],
22
+ })
23
+ );
24
+ provider = new BasicTracerProvider();
25
+ });
26
+
27
+ afterEach(() => {
28
+ consoleErrorSpy.mockRestore();
29
+ transport.items = [];
30
+ });
31
+
32
+ it('should export spans successfully', (done) => {
33
+ const exporter = new FaroTraceExporter({ api: faro.api });
34
+
35
+ const tracer = provider.getTracer('test');
36
+ const span = tracer.startSpan('test-span');
37
+ span.end();
38
+
39
+ const readableSpan = span as any as ReadableSpan;
40
+
41
+ exporter.export([readableSpan], (result) => {
42
+ expect(result.code).toBe(ExportResultCode.SUCCESS);
43
+ done();
44
+ });
45
+ });
46
+
47
+ it('should call api.pushTraces', (done) => {
48
+ const pushTracesSpy = jest.spyOn(faro.api, 'pushTraces');
49
+ const exporter = new FaroTraceExporter({ api: faro.api });
50
+
51
+ const tracer = provider.getTracer('test');
52
+ const span = tracer.startSpan('test-span');
53
+ span.end();
54
+
55
+ const readableSpan = span as any as ReadableSpan;
56
+
57
+ exporter.export([readableSpan], (result) => {
58
+ expect(pushTracesSpy).toHaveBeenCalled();
59
+ expect(result.code).toBe(ExportResultCode.SUCCESS);
60
+ done();
61
+ });
62
+ });
63
+
64
+ it('should return FAILED when exporter is shutdown', (done) => {
65
+ const exporter = new FaroTraceExporter({ api: faro.api });
66
+
67
+ exporter.shutdown();
68
+
69
+ const tracer = provider.getTracer('test');
70
+ const span = tracer.startSpan('test-span');
71
+ span.end();
72
+
73
+ const readableSpan = span as any as ReadableSpan;
74
+
75
+ exporter.export([readableSpan], (result) => {
76
+ expect(result.code).toBe(ExportResultCode.FAILED);
77
+ done();
78
+ });
79
+ });
80
+
81
+ it('should handle export errors gracefully', (done) => {
82
+ jest.spyOn(faro.api, 'pushTraces').mockImplementation(() => {
83
+ throw new Error('Export failed');
84
+ });
85
+ const exporter = new FaroTraceExporter({ api: faro.api });
86
+
87
+ const tracer = provider.getTracer('test');
88
+ const span = tracer.startSpan('test-span');
89
+ span.end();
90
+
91
+ const readableSpan = span as any as ReadableSpan;
92
+
93
+ exporter.export([readableSpan], (result) => {
94
+ expect(result.code).toBe(ExportResultCode.FAILED);
95
+ done();
96
+ });
97
+ });
98
+
99
+ it('should resolve shutdown promise', async () => {
100
+ const exporter = new FaroTraceExporter({ api: faro.api });
101
+
102
+ await expect(exporter.shutdown()).resolves.toBeUndefined();
103
+ });
104
+
105
+ it('should resolve forceFlush promise', async () => {
106
+ const exporter = new FaroTraceExporter({ api: faro.api });
107
+
108
+ await expect(exporter.forceFlush()).resolves.toBeUndefined();
109
+ });
110
+ });
@@ -0,0 +1,64 @@
1
+ import { ExportResultCode } from '@opentelemetry/core';
2
+ import type { ExportResult } from '@opentelemetry/core';
3
+ import { createExportTraceServiceRequest } from '@opentelemetry/otlp-transformer/build/src/trace/internal';
4
+ import type { ReadableSpan, SpanExporter } from '@opentelemetry/sdk-trace-base';
5
+
6
+ import { createInternalLogger } from '@grafana/faro-core';
7
+
8
+ const internalLogger = createInternalLogger();
9
+
10
+ import type { FaroTraceExporterConfig } from '../types';
11
+
12
+ import { sendFaroEvents } from './faroTraceExporter.utils';
13
+
14
+ /**
15
+ * FaroTraceExporter for React Native
16
+ *
17
+ * Exports OpenTelemetry spans to Faro backend using pushTraces API.
18
+ *
19
+ * IMPORTANT: To avoid infinite loops:
20
+ * - Uses internalLogger instead of console
21
+ * - Does NOT log during export (except errors)
22
+ * - Relies on Faro's internal deduplication
23
+ */
24
+ export class FaroTraceExporter implements SpanExporter {
25
+ private _isShutdown = false;
26
+
27
+ constructor(private config: FaroTraceExporterConfig) {}
28
+
29
+ export(spans: ReadableSpan[], resultCallback: (result: ExportResult) => void): void {
30
+ if (this._isShutdown) {
31
+ internalLogger.error('FaroTraceExporter: Cannot export spans, exporter is shut down');
32
+ resultCallback({ code: ExportResultCode.FAILED });
33
+ return;
34
+ }
35
+
36
+ try {
37
+ // Convert spans to OTLP format
38
+ const traceEvent = createExportTraceServiceRequest(spans, { useHex: true, useLongBits: false });
39
+
40
+ // Send traces to Faro
41
+ this.config.api.pushTraces(traceEvent);
42
+
43
+ // Send Faro events for CLIENT spans (HTTP requests, etc.)
44
+ // This is done WITHOUT logging to avoid infinite loops
45
+ sendFaroEvents(traceEvent.resourceSpans);
46
+
47
+ resultCallback({ code: ExportResultCode.SUCCESS });
48
+ } catch (error) {
49
+ // Only log critical errors
50
+ internalLogger.error('FaroTraceExporter: Failed to export spans', error);
51
+ resultCallback({ code: ExportResultCode.FAILED });
52
+ }
53
+ }
54
+
55
+ async shutdown(): Promise<void> {
56
+ this._isShutdown = true;
57
+ return Promise.resolve(undefined);
58
+ }
59
+
60
+ async forceFlush(): Promise<void> {
61
+ // No-op for now - spans are sent immediately via pushTraces
62
+ return Promise.resolve(undefined);
63
+ }
64
+ }
@@ -0,0 +1,105 @@
1
+ import type { SpanContext } from '@opentelemetry/api';
2
+ import { ESpanKind, type IResourceSpans } from '@opentelemetry/otlp-transformer/build/src/trace/internal-types';
3
+
4
+ import { createInternalLogger, faro, unknownString } from '@grafana/faro-core';
5
+ import type { EventAttributes as FaroEventAttributes } from '@grafana/faro-core';
6
+
7
+ const internalLogger = createInternalLogger();
8
+
9
+ const DURATION_NS_KEY = 'duration_ns';
10
+
11
+ /**
12
+ * Send Faro events for CLIENT spans (HTTP requests, navigation, etc.)
13
+ *
14
+ * IMPORTANT: This function is called during trace export and must be careful to avoid infinite loops.
15
+ *
16
+ * Infinite loop prevention strategy:
17
+ * 1. Only process SPAN_KIND_CLIENT spans (HTTP requests, etc.)
18
+ * 2. Use faro.api.pushEvent which:
19
+ * - Does NOT trigger console logs if ConsoleInstrumentation is configured properly
20
+ * - Does NOT trigger HTTP instrumentation for collector URLs (they're ignored)
21
+ * 3. Use internalLogger for debugging instead of console
22
+ * 4. Never call console.log/warn/error in this function
23
+ *
24
+ * @param resourceSpans - OTLP resource spans from trace exporter
25
+ */
26
+ export function sendFaroEvents(resourceSpans: IResourceSpans[] = []) {
27
+ try {
28
+ for (const resourceSpan of resourceSpans) {
29
+ const { scopeSpans } = resourceSpan;
30
+
31
+ for (const scopeSpan of scopeSpans) {
32
+ const { scope, spans = [] } = scopeSpan;
33
+
34
+ for (const span of spans) {
35
+ // Only process CLIENT spans (HTTP requests, external calls)
36
+ // This avoids processing internal spans which could cause loops
37
+ if (span.kind !== ESpanKind.SPAN_KIND_CLIENT) {
38
+ continue;
39
+ }
40
+
41
+ const spanContext: Pick<SpanContext, 'traceId' | 'spanId'> = {
42
+ traceId: span.traceId.toString(),
43
+ spanId: span.spanId.toString(),
44
+ };
45
+
46
+ const faroEventAttributes: FaroEventAttributes = {};
47
+ for (const attribute of span.attributes) {
48
+ faroEventAttributes[attribute.key] = String(Object.values(attribute.value)[0]);
49
+ }
50
+
51
+ // Add span duration in nanoseconds
52
+ if (!Number.isNaN(span.endTimeUnixNano) && !Number.isNaN(span.startTimeUnixNano)) {
53
+ faroEventAttributes[DURATION_NS_KEY] = String(
54
+ Number(span.endTimeUnixNano) - Number(span.startTimeUnixNano)
55
+ );
56
+ }
57
+
58
+ const index = (scope?.name ?? '').indexOf('-');
59
+ let eventName = unknownString;
60
+
61
+ if (scope?.name) {
62
+ if (index === -1) {
63
+ eventName = scope.name.split('/')[1] ?? scope.name;
64
+ }
65
+
66
+ if (index > -1) {
67
+ eventName = scope?.name.substring(index + 1);
68
+ }
69
+ }
70
+
71
+ // Push event to Faro
72
+ // This should NOT cause infinite loops because:
73
+ // 1. The collector URL is in ignoreUrls for HttpInstrumentation
74
+ // 2. ConsoleInstrumentation won't log this event
75
+ // 3. No console.log calls are made here
76
+ faro.api.pushEvent(`faro.tracing.${eventName}`, faroEventAttributes, undefined, {
77
+ spanContext,
78
+ // Convert nanoseconds to milliseconds
79
+ timestampOverwriteMs: Number(span.endTimeUnixNano) / 1_000_000,
80
+ customPayloadTransformer: (payload) => {
81
+ if (
82
+ faroEventAttributes['faro.action.user.name'] != null &&
83
+ faroEventAttributes['faro.action.user.parentId'] != null
84
+ ) {
85
+ payload.action = {
86
+ name: faroEventAttributes['faro.action.user.name'],
87
+ parentId: faroEventAttributes['faro.action.user.parentId'],
88
+ };
89
+
90
+ delete payload.attributes?.['faro.action.user.name'];
91
+ delete payload.attributes?.['faro.action.user.parentId'];
92
+ }
93
+
94
+ return payload;
95
+ },
96
+ });
97
+ }
98
+ }
99
+ }
100
+ } catch (error) {
101
+ // Only log critical errors using internal logger
102
+ // Do NOT use console.log here as it could cause infinite loops
103
+ internalLogger.error('sendFaroEvents: Failed to process spans', error);
104
+ }
105
+ }
package/src/index.ts ADDED
@@ -0,0 +1,16 @@
1
+ export { FaroTraceExporter } from './exporters/faroTraceExporter';
2
+
3
+ export { getDefaultOTELInstrumentations } from './instrumentations/getDefaultOTELInstrumentations';
4
+
5
+ export { TracingInstrumentation } from './instrumentation';
6
+
7
+ export { getSamplingDecision } from './utils/sampler';
8
+
9
+ export type { FaroTraceExporterConfig, TracingInstrumentationOptions } from './types';
10
+
11
+ export {
12
+ setSpanStatusOnFetchError,
13
+ fetchCustomAttributeFunctionWithDefaults,
14
+ } from './instrumentations/instrumentationUtils';
15
+
16
+ export { FaroMetaAttributesSpanProcessor } from './processors/faroMetaAttributesSpanProcessor';