Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions experimental/instrumentation-otel-axios/.browserslistrc
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
supports es6-module
52 changes: 52 additions & 0 deletions experimental/instrumentation-otel-axios/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
# @grafana/instrumentation-fetch

Faro instrumentation of the JavaScript [Fetch](https://developer.mozilla.org/en-US/docs/Web/API/Fetch_API) API.

❗️*Warning*: this package is experimental and may be subject to frequent and breaking changes.
Use at your own risk.❗️

## Installation and Usage

❗️*Warning*: This package is not interoperable with `@opentelemetry/instrumentation-fetch`.
Use one or the other❗️

Add the instrumentation as outlined below.
The instrumentation send the following events alongside respective request/response data like HTTP
headers and other response properties like status codes the requests url and more.

Event names are:

- `faro.fetch.resolved` for resolved requests.
- `faro.fetch.rejected` for rejected requests.

```ts
// index.ts
import { FetchInstrumentation } from '@grafana/faro-instrumentation-fetch';
import { getWebInstrumentations, initializeFaro } from '@grafana/faro-react';

initializeFaro({
// ...
instrumentations: [
// Load the default Web instrumentations
...getWebInstrumentations(),
// Add fetch instrumentation
new FetchInstrumentation(),
],
});


// myApi.ts
fetch(...) // Use fetch as normal - telemetry data is sent to your Faro endpoint
```

## Backend correlation

In order to prepare backend correlation, this instrumentation adds the following headers to each
request that server-side instrumentation can use as context:

- `x-faro-session` - the client-side session id

## Planned Development

- Additional functionality to correlate frontend requests with backend actions
- Event attributes with end-to-end timing details
16 changes: 16 additions & 0 deletions experimental/instrumentation-otel-axios/jest.config.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
const { jestBaseConfig } = require('../../jest.config.base.js');

module.exports = {
...jestBaseConfig,
roots: ['experimental/instrumentation-fetch/src'],
testEnvironment: 'jsdom',
setupFiles: ['<rootDir>/experimental/instrumentation-fetch/src/setupTests.ts'],
moduleNameMapper: {
'^@remix-run/router$': '<rootDir>/index.ts',
'^@remix-run/web-blob$': require.resolve('@remix-run/web-blob'),
'^@remix-run/web-fetch$': require.resolve('@remix-run/web-fetch'),
'^@remix-run/web-form-data$': require.resolve('@remix-run/web-form-data'),
'^@remix-run/web-stream$': require.resolve('@remix-run/web-stream'),
'^@web3-storage/multipart-parser$': require.resolve('@web3-storage/multipart-parser'),
},
};
66 changes: 66 additions & 0 deletions experimental/instrumentation-otel-axios/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
{
"name": "@grafana/faro-instrumentation-otel-axios",
"version": "1.12.2",
"description": "Faro axios open telemetry auto-instrumentation package",
"keywords": [
"observability",
"apm",
"rum",
"logs",
"traces",
"metrics",
"fetch"
],
"main": "./dist/cjs/index.js",
"module": "./dist/esm/index.js",
"types": "./dist/types/index.d.ts",
"scripts": {
"test": "jest",
"start": "yarn watch",
"build": "run-s 'build:*'",
"build:compile": "run-p 'build:compile:*'",
"build:compile:cjs": "tsc --build tsconfig.cjs.json",
"build:compile:esm": "tsc --build tsconfig.esm.json",
"build:compile:bundle": "run-s 'build:compile:bundle:*'",
"build:compile:bundle:create": "rollup -c ./rollup.config.js",
"build:compile:bundle:remove-extras": "rimraf dist/bundle/dist",
"watch": "run-s watch:compile",
"watch:compile": "yarn build:compile:cjs -w",
"clean": "rimraf dist/ yarn-error.log",
"quality": "run-s 'quality:*'",
"quality:test": "jest",
"quality:format": "prettier --cache --cache-location=../../.cache/prettier/instrumentationOtelAxios --ignore-path ../../.prettierignore -w \"./**/*.{js,jsx,ts,tsx,css,scss,md,yaml,yml,json}\"",
"quality:lint": "run-s 'quality:lint:*'",
"quality:lint:eslint": "eslint --cache --cache-location ../../.cache/eslint/instrumentationOtelAxios --ignore-path ../../.eslintignore \"./**/*.{js,jsx,ts,tsx}\"",
"quality:lint:prettier": "prettier --cache --cache-location=../../.cache/prettier/instrumentationOtelAxios --ignore-path ../../.prettierignore -c \"./**/*.{js,jsx,ts,tsx,css,scss,md,yaml,yml,json}\"",
"quality:lint:md": "markdownlint README.md",
"quality:circular-deps": "madge --circular ."
},
"repository": {
"type": "git",
"url": "git+https://github.com/grafana/faro-web-sdk.git",
"directory": "experimental/instrumentation-fetch"
},
"author": "Grafana Labs",
"license": "Apache-2.0",
"bugs": {
"url": "https://github.com/grafana/faro-web-sdk/issues"
},
"homepage": "https://github.com/grafana/faro-web-sdk",
"dependencies": {
"@grafana/faro-core": "^1.12.2",
"@opentelemetry/api": "^1.9.0"
},
"peerDependencies": {
"axios": "*"
},
"publishConfig": {
"access": "public"
},
"files": [
".browserslistrc",
"dist",
"README.md",
"LICENSE"
]
}
3 changes: 3 additions & 0 deletions experimental/instrumentation-otel-axios/rollup.config.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
const { getRollupConfigBase } = require('../../rollup.config.base.js');

module.exports = getRollupConfigBase('instrumentationOtelAxios');
5 changes: 5 additions & 0 deletions experimental/instrumentation-otel-axios/src/constants.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
export enum AttributeNames {
COMPONENT = 'component',
HTTP_ERROR_NAME = 'http.error_name',
HTTP_STATUS_TEXT = 'http.status_text',
}
1 change: 1 addition & 0 deletions experimental/instrumentation-otel-axios/src/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export { AxiosInstrumentation } from './instrumentation';
180 changes: 180 additions & 0 deletions experimental/instrumentation-otel-axios/src/instrumentation.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,180 @@
import * as api from '@opentelemetry/api';
import * as core from '@opentelemetry/core';
import { InstrumentationBase, isWrapped } from '@opentelemetry/instrumentation';
import * as web from '@opentelemetry/sdk-trace-web';
import {
SEMATTRS_HTTP_HOST,
SEMATTRS_HTTP_METHOD,
SEMATTRS_HTTP_SCHEME,
SEMATTRS_HTTP_STATUS_CODE,
SEMATTRS_HTTP_URL,
SEMATTRS_HTTP_USER_AGENT,
} from '@opentelemetry/semantic-conventions';
import { Axios, type AxiosInstance, type AxiosRequestConfig, type AxiosResponse } from 'axios';

import { AttributeNames } from './constants';
import type { AxiosInstrumentationOptions } from './types';

/**
* Additional custom attribute names
*/

export const VERSION = '1.12.3';

/**
* Configuration interface for the AxiosInstrumentation
*/

export class AxiosInstrumentation extends InstrumentationBase<AxiosInstrumentationOptions> {
readonly component: string = 'axios';

readonly version: string = VERSION;

moduleName = this.component;

constructor(config: AxiosInstrumentationOptions = {}) {
super('@grafana/instrumentation-axios', VERSION, config);
this.enable = this.enable.bind(this);
this.disable = this.disable.bind(this);
}

init(): void {
// no-op
}

private _addFinalSpanAttributes(span: api.Span, response: AxiosResponse) {
const responseUrl = `${response.config.baseURL ?? ''}/${response.config.url}`;
const parsedUrl = web.parseUrl(responseUrl);

span.updateName(`${response.config.method?.toUpperCase()}`);
span.setAttribute(SEMATTRS_HTTP_STATUS_CODE, response.status);
if (response.statusText != null) {
span.setAttribute(AttributeNames.HTTP_STATUS_TEXT, response.statusText);
}
span.setAttribute(SEMATTRS_HTTP_HOST, parsedUrl.host);
span.setAttribute(SEMATTRS_HTTP_SCHEME, parsedUrl.protocol.replace(':', ''));
if (typeof navigator !== 'undefined') {
span.setAttribute(SEMATTRS_HTTP_USER_AGENT, navigator.userAgent);
}

try {
this._config.applyCustomAttributesOnSpan?.(span as web.Span, response);
} catch (e) {
this._diag.error('Error applying custom attributes', e);
}
}

private _addAxiosHeaders(config: AxiosRequestConfig): void {
const headers: Record<string, unknown> = config.headers || {};
api.propagation.inject(api.context.active(), headers);
// @ts-ignore
config.headers = headers;
}

private createSpan(url: string, options: Partial<Request | RequestInit> = {}): api.Span | undefined {
if (core.isUrlIgnored(url, this.getConfig().ignoreUrls)) {
this._diag.debug('ignoring span as url matches ignored url');
return;
}

const method = (options.method || 'GET').toUpperCase();
const spanName = `HTTP ${method}`;

return this.tracer.startSpan(spanName, {
kind: api.SpanKind.CLIENT,
attributes: {
[AttributeNames.COMPONENT]: this.moduleName,
[SEMATTRS_HTTP_METHOD]: method,
[SEMATTRS_HTTP_URL]: url,
},
});
}

private _endSpan<T = any>(span: api.Span, response?: AxiosResponse<T>, error?: Error) {
if (response) {
// It's an AxiosResponse
this._addFinalSpanAttributes(span, response);

if (response.status >= 400) {
span.setStatus({ code: api.SpanStatusCode.ERROR });
} else {
span.setStatus({ code: api.SpanStatusCode.OK });
}
} else if (error) {
// It's an error object
span.setStatus({ code: api.SpanStatusCode.ERROR, message: error.message });
if (error.name) {
span.setAttribute(AttributeNames.HTTP_ERROR_NAME, error.name);
}
}

span.end();
}

private _patchAxios() {
const plugin = this;

// Wrap axios.request
if (isWrapped(Axios.prototype.request)) {
plugin._unwrap(Axios.prototype, 'request');
}

plugin._wrap(
Axios.prototype,
'request',
(original: <T = any, R = AxiosResponse<T>, D = any>(config: AxiosRequestConfig<D>) => Promise<R>) => {
return function patchRequest<T = any, R = AxiosResponse<T>, D = any>(
this: AxiosInstance,
requestConfig: AxiosRequestConfig<D>
): Promise<R> {
const url = requestConfig.url ?? '';

// Create a new span if the URL is not ignored
const span = plugin.createSpan(url, { method: requestConfig.method });
if (!span) {
return original.apply(this, [requestConfig]) as Promise<R>;
}

return api.context.with(api.trace.setSpan(api.context.active(), span), () => {
plugin._addAxiosHeaders(requestConfig);

return (original.apply(this, [requestConfig]) as Promise<R>)
.then((response: R) => {
try {
plugin._endSpan<T>(span, response as AxiosResponse<T>);
} catch (e) {
plugin._diag.error('Error ending span', e);
}
return response;
})
.catch((error: any) => {
try {
plugin._endSpan(
span,
error?.response,
error?.response || {
message: error?.message || 'Unknown error',
name: error?.name || 'Error',
}
);
} catch (e) {
plugin._diag.error('Error ending span', e);
}
throw error;
});
});
};
}
);
}

override enable(): void {
// Patch the instance passed in via this._config
this._patchAxios();
}

override disable(): void {
// Unwrap the instance
this._unwrap(Axios.prototype, 'request');
}
}
12 changes: 12 additions & 0 deletions experimental/instrumentation-otel-axios/src/setupTests.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
import { fetch, Request, Response } from '@remix-run/web-fetch';

if (!globalThis.fetch) {
// @ts-expect-error
globalThis.fetch = fetch;

// @ts-expect-error
globalThis.Request = Request;

// @ts-expect-error
globalThis.Response = Response;
}
10 changes: 10 additions & 0 deletions experimental/instrumentation-otel-axios/src/types.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
import type { Span } from '@opentelemetry/api';
import type { InstrumentationConfig } from '@opentelemetry/instrumentation';
import type { AxiosResponse } from 'axios';

export interface AxiosInstrumentationOptions extends InstrumentationConfig {
clearTimingResources?: boolean;
ignoreUrls?: Array<string | RegExp>;
measureRequestSize?: boolean;
applyCustomAttributesOnSpan?: (span: Span, request: AxiosResponse) => void;
}
11 changes: 11 additions & 0 deletions experimental/instrumentation-otel-axios/tsconfig.bundle.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
{
"extends": "../../tsconfig.base.esm.json",
"compilerOptions": {
"declarationDir": "./dist/bundle/types",
"outDir": "./dist/bundle",
"rootDir": "./src",
"tsBuildInfoFile": "../../.cache/tsc/core.esm.tsbuildinfo"
},
"include": ["./src"],
"exclude": ["./**/*.test.ts"]
}
12 changes: 12 additions & 0 deletions experimental/instrumentation-otel-axios/tsconfig.cjs.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
{
"extends": "../../tsconfig.base.cjs.json",
"compilerOptions": {
"declarationDir": "./dist/types",
"outDir": "./dist/cjs",
"rootDir": "./src",
"tsBuildInfoFile": "../../.cache/tsc/instrumentationOtelAxios.cjs.tsbuildinfo"
},
"include": ["./src"],
"exclude": ["**/*.test.ts"],
"references": [{ "path": "../../packages/core/tsconfig.spec.json" }]
}
12 changes: 12 additions & 0 deletions experimental/instrumentation-otel-axios/tsconfig.esm.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
{
"extends": "../../tsconfig.base.esm.json",
"compilerOptions": {
"declarationDir": "./dist/types",
"outDir": "./dist/esm",
"rootDir": "./src",
"tsBuildInfoFile": "../../.cache/tsc/instrumentationOtelAxios.esm.tsbuildinfo"
},
"include": ["./src"],
"exclude": ["**/*.test.ts"],
"references": [{ "path": "../../packages/core/tsconfig.spec.json" }]
}
Loading
Loading