Skip to content

Commit 2336ee4

Browse files
ilia-kurganskiilucasbentomarcocaldera
authored andcommitted
feat: add React Native support (#1035)
Co-authored-by: Lucas Bento <lucas@bitvavo.com> Co-authored-by: Marco Caldera <marco.caldera93@icloud.com> Co-authored-by: Lucas Bento <6207220+lucasbento@users.noreply.github.com>
1 parent 2bc52a0 commit 2336ee4

File tree

173 files changed

+22766
-15
lines changed

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

173 files changed

+22766
-15
lines changed
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
supports es6-module
Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,52 @@
1+
# @grafana/instrumentation-fetch
2+
3+
Faro instrumentation of the JavaScript [Fetch](https://developer.mozilla.org/en-US/docs/Web/API/Fetch_API) API.
4+
5+
❗️*Warning*: this package is experimental and may be subject to frequent and breaking changes.
6+
Use at your own risk.❗️
7+
8+
## Installation and Usage
9+
10+
❗️*Warning*: This package is not interoperable with `@opentelemetry/instrumentation-fetch`.
11+
Use one or the other❗️
12+
13+
Add the instrumentation as outlined below.
14+
The instrumentation send the following events alongside respective request/response data like HTTP
15+
headers and other response properties like status codes the requests url and more.
16+
17+
Event names are:
18+
19+
- `faro.fetch.resolved` for resolved requests.
20+
- `faro.fetch.rejected` for rejected requests.
21+
22+
```ts
23+
// index.ts
24+
import { FetchInstrumentation } from '@grafana/faro-instrumentation-fetch';
25+
import { getWebInstrumentations, initializeFaro } from '@grafana/faro-react';
26+
27+
initializeFaro({
28+
// ...
29+
instrumentations: [
30+
// Load the default Web instrumentations
31+
...getWebInstrumentations(),
32+
// Add fetch instrumentation
33+
new FetchInstrumentation(),
34+
],
35+
});
36+
37+
38+
// myApi.ts
39+
fetch(...) // Use fetch as normal - telemetry data is sent to your Faro endpoint
40+
```
41+
42+
## Backend correlation
43+
44+
In order to prepare backend correlation, this instrumentation adds the following headers to each
45+
request that server-side instrumentation can use as context:
46+
47+
- `x-faro-session` - the client-side session id
48+
49+
## Planned Development
50+
51+
- Additional functionality to correlate frontend requests with backend actions
52+
- Event attributes with end-to-end timing details
Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
const { jestBaseConfig } = require('../../jest.config.base.js');
2+
3+
module.exports = {
4+
...jestBaseConfig,
5+
roots: ['experimental/instrumentation-fetch/src'],
6+
testEnvironment: 'jsdom',
7+
setupFiles: ['<rootDir>/experimental/instrumentation-fetch/src/setupTests.ts'],
8+
moduleNameMapper: {
9+
'^@remix-run/router$': '<rootDir>/index.ts',
10+
'^@remix-run/web-blob$': require.resolve('@remix-run/web-blob'),
11+
'^@remix-run/web-fetch$': require.resolve('@remix-run/web-fetch'),
12+
'^@remix-run/web-form-data$': require.resolve('@remix-run/web-form-data'),
13+
'^@remix-run/web-stream$': require.resolve('@remix-run/web-stream'),
14+
'^@web3-storage/multipart-parser$': require.resolve('@web3-storage/multipart-parser'),
15+
},
16+
};
Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,66 @@
1+
{
2+
"name": "@grafana/faro-instrumentation-otel-axios",
3+
"version": "1.12.2",
4+
"description": "Faro axios open telemetry auto-instrumentation package",
5+
"keywords": [
6+
"observability",
7+
"apm",
8+
"rum",
9+
"logs",
10+
"traces",
11+
"metrics",
12+
"fetch"
13+
],
14+
"main": "./dist/cjs/index.js",
15+
"module": "./dist/esm/index.js",
16+
"types": "./dist/types/index.d.ts",
17+
"scripts": {
18+
"test": "jest",
19+
"start": "yarn watch",
20+
"build": "run-s build:*",
21+
"build:compile": "run-p build:compile:*",
22+
"build:compile:cjs": "tsc --build tsconfig.cjs.json",
23+
"build:compile:esm": "tsc --build tsconfig.esm.json",
24+
"build:compile:bundle": "run-s build:compile:bundle:*",
25+
"build:compile:bundle:create": "rollup -c ./rollup.config.js",
26+
"build:compile:bundle:remove-extras": "rimraf dist/bundle/dist",
27+
"watch": "run-s watch:compile",
28+
"watch:compile": "yarn build:compile:cjs -w",
29+
"clean": "rimraf dist/ yarn-error.log",
30+
"quality": "run-s quality:*",
31+
"quality:test": "jest",
32+
"quality:format": "prettier --cache --cache-location=../../.cache/prettier/instrumentationOtelAxios --ignore-path ../../.prettierignore -w \"./**/*.{js,jsx,ts,tsx,css,scss,md,yaml,yml,json}\"",
33+
"quality:lint": "run-s quality:lint:*",
34+
"quality:lint:eslint": "eslint --cache --cache-location ../../.cache/eslint/instrumentationOtelAxios --ignore-path ../../.eslintignore \"./**/*.{js,jsx,ts,tsx}\"",
35+
"quality:lint:prettier": "prettier --cache --cache-location=../../.cache/prettier/instrumentationOtelAxios --ignore-path ../../.prettierignore -c \"./**/*.{js,jsx,ts,tsx,css,scss,md,yaml,yml,json}\"",
36+
"quality:lint:md": "markdownlint README.md",
37+
"quality:circular-deps": "madge --circular ."
38+
},
39+
"repository": {
40+
"type": "git",
41+
"url": "git+https://github.com/grafana/faro-web-sdk.git",
42+
"directory": "experimental/instrumentation-fetch"
43+
},
44+
"author": "Grafana Labs",
45+
"license": "Apache-2.0",
46+
"bugs": {
47+
"url": "https://github.com/grafana/faro-web-sdk/issues"
48+
},
49+
"homepage": "https://github.com/grafana/faro-web-sdk",
50+
"dependencies": {
51+
"@grafana/faro-core": "^1.12.2",
52+
"@opentelemetry/api": "^1.9.0"
53+
},
54+
"peerDependencies": {
55+
"axios": "*"
56+
},
57+
"publishConfig": {
58+
"access": "public"
59+
},
60+
"files": [
61+
".browserslistrc",
62+
"dist",
63+
"README.md",
64+
"LICENSE"
65+
]
66+
}
Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
const { getRollupConfigBase } = require('../../rollup.config.base.js');
2+
3+
module.exports = getRollupConfigBase('instrumentationOtelAxios');
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
export enum AttributeNames {
2+
COMPONENT = 'component',
3+
HTTP_ERROR_NAME = 'http.error_name',
4+
HTTP_STATUS_TEXT = 'http.status_text'
5+
}
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
export { AxiosInstrumentation } from './instrumentation';
Lines changed: 180 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,180 @@
1+
import * as api from '@opentelemetry/api';
2+
import * as core from '@opentelemetry/core';
3+
import { InstrumentationBase, isWrapped } from '@opentelemetry/instrumentation';
4+
import * as web from '@opentelemetry/sdk-trace-web';
5+
import {
6+
SEMATTRS_HTTP_HOST,
7+
SEMATTRS_HTTP_METHOD,
8+
SEMATTRS_HTTP_SCHEME,
9+
SEMATTRS_HTTP_STATUS_CODE,
10+
SEMATTRS_HTTP_URL,
11+
SEMATTRS_HTTP_USER_AGENT,
12+
} from '@opentelemetry/semantic-conventions';
13+
import { Axios, AxiosInstance, AxiosRequestConfig, AxiosResponse } from 'axios';
14+
15+
import { AttributeNames } from './constants';
16+
import type { AxiosInstrumentationOptions } from './types';
17+
18+
/**
19+
* Additional custom attribute names
20+
*/
21+
22+
export const VERSION = '1.12.3';
23+
24+
/**
25+
* Configuration interface for the AxiosInstrumentation
26+
*/
27+
28+
export class AxiosInstrumentation extends InstrumentationBase<AxiosInstrumentationOptions> {
29+
readonly component: string = 'axios';
30+
31+
readonly version: string = VERSION;
32+
33+
moduleName = this.component;
34+
35+
constructor(config: AxiosInstrumentationOptions = {}) {
36+
super('@grafana/instrumentation-axios', VERSION, config);
37+
this.enable = this.enable.bind(this);
38+
this.disable = this.disable.bind(this);
39+
}
40+
41+
init(): void {
42+
// no-op
43+
}
44+
45+
private _addFinalSpanAttributes(span: api.Span, response: AxiosResponse) {
46+
const responseUrl = `${response.config.baseURL ?? ''}/${response.config.url}`;
47+
const parsedUrl = web.parseUrl(responseUrl);
48+
49+
span.updateName(`${response.config.method?.toUpperCase()}`);
50+
span.setAttribute(SEMATTRS_HTTP_STATUS_CODE, response.status);
51+
if (response.statusText != null) {
52+
span.setAttribute(AttributeNames.HTTP_STATUS_TEXT, response.statusText);
53+
}
54+
span.setAttribute(SEMATTRS_HTTP_HOST, parsedUrl.host);
55+
span.setAttribute(SEMATTRS_HTTP_SCHEME, parsedUrl.protocol.replace(':', ''));
56+
if (typeof navigator !== 'undefined') {
57+
span.setAttribute(SEMATTRS_HTTP_USER_AGENT, navigator.userAgent);
58+
}
59+
60+
try {
61+
this._config.applyCustomAttributesOnSpan?.(span as web.Span, response);
62+
} catch (e) {
63+
this._diag.error('Error applying custom attributes', e);
64+
}
65+
}
66+
67+
private _addAxiosHeaders(config: AxiosRequestConfig): void {
68+
const headers: Record<string, unknown> = config.headers || {};
69+
api.propagation.inject(api.context.active(), headers);
70+
// @ts-ignore
71+
config.headers = headers;
72+
}
73+
74+
private createSpan(url: string, options: Partial<Request | RequestInit> = {}): api.Span | undefined {
75+
if (core.isUrlIgnored(url, this.getConfig().ignoreUrls)) {
76+
this._diag.debug('ignoring span as url matches ignored url');
77+
return;
78+
}
79+
80+
const method = (options.method || 'GET').toUpperCase();
81+
const spanName = `HTTP ${method}`;
82+
83+
return this.tracer.startSpan(spanName, {
84+
kind: api.SpanKind.CLIENT,
85+
attributes: {
86+
[AttributeNames.COMPONENT]: this.moduleName,
87+
[SEMATTRS_HTTP_METHOD]: method,
88+
[SEMATTRS_HTTP_URL]: url,
89+
},
90+
});
91+
}
92+
93+
private _endSpan<T = any>(span: api.Span, response?: AxiosResponse<T>, error?: Error) {
94+
if (response) {
95+
// It's an AxiosResponse
96+
this._addFinalSpanAttributes(span, response);
97+
98+
if (response.status >= 400) {
99+
span.setStatus({ code: api.SpanStatusCode.ERROR });
100+
} else {
101+
span.setStatus({ code: api.SpanStatusCode.OK });
102+
}
103+
} else if (error) {
104+
// It's an error object
105+
span.setStatus({ code: api.SpanStatusCode.ERROR, message: error.message });
106+
if (error.name) {
107+
span.setAttribute(AttributeNames.HTTP_ERROR_NAME, error.name);
108+
}
109+
}
110+
111+
span.end();
112+
}
113+
114+
private _patchAxios() {
115+
const plugin = this;
116+
117+
// Wrap axios.request
118+
if (isWrapped(Axios.prototype.request)) {
119+
plugin._unwrap(Axios.prototype, 'request');
120+
}
121+
122+
plugin._wrap(
123+
Axios.prototype,
124+
'request',
125+
(original: <T = any, R = AxiosResponse<T>, D = any>(config: AxiosRequestConfig<D>) => Promise<R>) => {
126+
return function patchRequest<T = any, R = AxiosResponse<T>, D = any>(
127+
this: AxiosInstance,
128+
requestConfig: AxiosRequestConfig<D>
129+
): Promise<R> {
130+
const url = requestConfig.url ?? '';
131+
132+
// Create a new span if the URL is not ignored
133+
const span = plugin.createSpan(url, { method: requestConfig.method });
134+
if (!span) {
135+
return original.apply(this, [requestConfig]) as Promise<R>;
136+
}
137+
138+
return api.context.with(api.trace.setSpan(api.context.active(), span), () => {
139+
plugin._addAxiosHeaders(requestConfig);
140+
141+
return (original.apply(this, [requestConfig]) as Promise<R>)
142+
.then((response: R) => {
143+
try {
144+
plugin._endSpan<T>(span, response as AxiosResponse<T>);
145+
} catch (e) {
146+
plugin._diag.error('Error ending span', e);
147+
}
148+
return response;
149+
})
150+
.catch((error: any) => {
151+
try {
152+
plugin._endSpan(
153+
span,
154+
error?.response,
155+
error?.response || {
156+
message: error?.message || 'Unknown error',
157+
name: error?.name || 'Error',
158+
}
159+
);
160+
} catch (e) {
161+
plugin._diag.error('Error ending span', e);
162+
}
163+
throw error;
164+
});
165+
});
166+
};
167+
}
168+
);
169+
}
170+
171+
override enable(): void {
172+
// Patch the instance passed in via this._config
173+
this._patchAxios();
174+
}
175+
176+
override disable(): void {
177+
// Unwrap the instance
178+
this._unwrap(Axios.prototype, 'request');
179+
}
180+
}
Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
import { fetch, Request, Response } from '@remix-run/web-fetch';
2+
3+
if (!globalThis.fetch) {
4+
// @ts-expect-error
5+
globalThis.fetch = fetch;
6+
7+
// @ts-expect-error
8+
globalThis.Request = Request;
9+
10+
// @ts-expect-error
11+
globalThis.Response = Response;
12+
}
Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
import type {Span} from "@opentelemetry/api";
2+
import type {InstrumentationConfig} from "@opentelemetry/instrumentation";
3+
import type {AxiosResponse} from "axios";
4+
5+
export interface AxiosInstrumentationOptions extends InstrumentationConfig {
6+
clearTimingResources?: boolean;
7+
ignoreUrls?: Array<string | RegExp>;
8+
measureRequestSize?: boolean;
9+
applyCustomAttributesOnSpan?: (span: Span, request: AxiosResponse) => void;
10+
}

0 commit comments

Comments
 (0)