diff --git a/package.json b/package.json index ee78aa1c..2fe6f0c4 100644 --- a/package.json +++ b/package.json @@ -73,6 +73,9 @@ "@mui/icons-material": "^5.11.11", "@mui/material": "^6.0.0", "@mui/x-date-pickers-pro": "^6.18.2", + "@opentelemetry/api": "^1.9.0", + "@opentelemetry/core": "^2.5.1", + "@opentelemetry/sdk-trace-web": "^2.5.1", "@radix-ui/react-alert-dialog": "^1.1.15", "@radix-ui/react-avatar": "^1.1.3", "@radix-ui/react-dialog": "^1.1.15", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 34e562ff..8743c962 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -83,6 +83,15 @@ importers: '@mui/x-date-pickers-pro': specifier: ^6.18.2 version: 6.20.2(@emotion/react@11.14.0(@types/react@18.3.21)(react@18.3.1))(@emotion/styled@11.14.0(@emotion/react@11.14.0(@types/react@18.3.21)(react@18.3.1))(@types/react@18.3.21)(react@18.3.1))(@mui/material@6.4.11(@emotion/react@11.14.0(@types/react@18.3.21)(react@18.3.1))(@emotion/styled@11.14.0(@emotion/react@11.14.0(@types/react@18.3.21)(react@18.3.1))(@types/react@18.3.21)(react@18.3.1))(@types/react@18.3.21)(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(@mui/system@6.4.11(@emotion/react@11.14.0(@types/react@18.3.21)(react@18.3.1))(@emotion/styled@11.14.0(@emotion/react@11.14.0(@types/react@18.3.21)(react@18.3.1))(@types/react@18.3.21)(react@18.3.1))(@types/react@18.3.21)(react@18.3.1))(@types/react@18.3.21)(date-fns@4.1.0)(dayjs@1.11.13)(luxon@3.6.1)(moment@2.30.1)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@opentelemetry/api': + specifier: ^1.9.0 + version: 1.9.0 + '@opentelemetry/core': + specifier: ^2.5.1 + version: 2.5.1(@opentelemetry/api@1.9.0) + '@opentelemetry/sdk-trace-web': + specifier: ^2.5.1 + version: 2.5.1(@opentelemetry/api@1.9.0) '@radix-ui/react-alert-dialog': specifier: ^1.1.15 version: 1.1.15(@types/react-dom@18.3.7(@types/react@18.3.21))(@types/react@18.3.21)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) @@ -2486,6 +2495,38 @@ packages: resolution: {integrity: sha512-nn5ozdjYQpUCZlWGuxcJY/KpxkWQs4DcbMCmKojjyrYDEAGy4Ce19NN4v5MduafTwJlbKc99UA8YhSVqq9yPZA==} engines: {node: '>=12.4.0'} + '@opentelemetry/api@1.9.0': + resolution: {integrity: sha512-3giAOQvZiH5F9bMlMiv8+GSPMeqg0dbaeo58/0SlA9sxSqZhnUtxzX9/2FzyhS9sWQf5S0GJE0AKBrFqjpeYcg==} + engines: {node: '>=8.0.0'} + + '@opentelemetry/core@2.5.1': + resolution: {integrity: sha512-Dwlc+3HAZqpgTYq0MUyZABjFkcrKTePwuiFVLjahGD8cx3enqihmpAmdgNFO1R4m/sIe5afjJrA25Prqy4NXlA==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': '>=1.0.0 <1.10.0' + + '@opentelemetry/resources@2.5.1': + resolution: {integrity: sha512-BViBCdE/GuXRlp9k7nS1w6wJvY5fnFX5XvuEtWsTAOQFIO89Eru7lGW3WbfbxtCuZ/GbrJfAziXG0w0dpxL7eQ==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': '>=1.3.0 <1.10.0' + + '@opentelemetry/sdk-trace-base@2.5.1': + resolution: {integrity: sha512-iZH3Gw8cxQn0gjpOjJMmKLd9GIaNh/E3v3ST67vyzLSxHBs14HsG4dy7jMYyC5WXGdBVEcM7U/XTF5hCQxjDMw==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': '>=1.3.0 <1.10.0' + + '@opentelemetry/sdk-trace-web@2.5.1': + resolution: {integrity: sha512-4PWFtMJ5nqWMP2YqgKjcMlQlUeN1imUYSXdhy6Xl/3bnO0/Ryo5Y3/kWG8T66uMHo2RpTQLloZjoQACKdbHbxg==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': '>=1.0.0 <1.10.0' + + '@opentelemetry/semantic-conventions@1.39.0': + resolution: {integrity: sha512-R5R9tb2AXs2IRLNKLBJDynhkfmx7mX0vi8NkhZb3gUkPWHn6HXk5J8iQ/dql0U3ApfWym4kXXmBDRGO+oeOfjg==} + engines: {node: '>=14'} + '@parcel/watcher-android-arm64@2.5.1': resolution: {integrity: sha512-KF8+j9nNbUN8vzOFDpRMsaKBHZ/mcjEjMToVMJOhTozkDonQFFrRcfdLWn6yWKCmJKmdVxSgHiYvTCef4/qcBA==} engines: {node: '>= 10.0.0'} @@ -11944,6 +11985,34 @@ snapshots: '@nolyfill/is-core-module@1.0.39': {} + '@opentelemetry/api@1.9.0': {} + + '@opentelemetry/core@2.5.1(@opentelemetry/api@1.9.0)': + dependencies: + '@opentelemetry/api': 1.9.0 + '@opentelemetry/semantic-conventions': 1.39.0 + + '@opentelemetry/resources@2.5.1(@opentelemetry/api@1.9.0)': + dependencies: + '@opentelemetry/api': 1.9.0 + '@opentelemetry/core': 2.5.1(@opentelemetry/api@1.9.0) + '@opentelemetry/semantic-conventions': 1.39.0 + + '@opentelemetry/sdk-trace-base@2.5.1(@opentelemetry/api@1.9.0)': + dependencies: + '@opentelemetry/api': 1.9.0 + '@opentelemetry/core': 2.5.1(@opentelemetry/api@1.9.0) + '@opentelemetry/resources': 2.5.1(@opentelemetry/api@1.9.0) + '@opentelemetry/semantic-conventions': 1.39.0 + + '@opentelemetry/sdk-trace-web@2.5.1(@opentelemetry/api@1.9.0)': + dependencies: + '@opentelemetry/api': 1.9.0 + '@opentelemetry/core': 2.5.1(@opentelemetry/api@1.9.0) + '@opentelemetry/sdk-trace-base': 2.5.1(@opentelemetry/api@1.9.0) + + '@opentelemetry/semantic-conventions@1.39.0': {} + '@parcel/watcher-android-arm64@2.5.1': optional: true diff --git a/src/application/services/js-services/http/http_api.ts b/src/application/services/js-services/http/http_api.ts index a0e0defc..a52e8cc3 100644 --- a/src/application/services/js-services/http/http_api.ts +++ b/src/application/services/js-services/http/http_api.ts @@ -1,4 +1,5 @@ -import axios, { AxiosError, AxiosInstance, AxiosResponse } from 'axios'; +import { Span } from '@opentelemetry/api'; +import axios, { AxiosError, AxiosInstance, AxiosResponse, InternalAxiosRequestConfig } from 'axios'; import dayjs from 'dayjs'; import { omit } from 'lodash-es'; import { nanoid } from 'nanoid'; @@ -76,6 +77,7 @@ import { database_blob } from '@/proto/database_blob'; import { getAppFlowyFileUploadUrl, getAppFlowyFileUrl } from '@/utils/file-storage-url'; import { Log } from '@/utils/log'; import { hasProAccessFromPlans } from '@/utils/subscription'; +import { endHttpSpan, startHttpSpan } from '@/utils/telemetry'; export * from './gotrue'; @@ -266,6 +268,39 @@ export function initAPIService(config: AFCloudConfig) { initGrantService(config.gotrueURL); + // OpenTelemetry: inject W3C Trace Context headers into outgoing requests + axiosInstance.interceptors.request.use((config) => { + const method = config.method?.toUpperCase() || 'UNKNOWN'; + const url = config.url || ''; + const headers = config.headers as unknown as Record; + const span = startHttpSpan(method, url, headers); + + (config as InternalAxiosRequestConfig & { _otelSpan?: Span })._otelSpan = span; + + return config; + }); + + axiosInstance.interceptors.response.use( + (response) => { + const span = (response.config as InternalAxiosRequestConfig & { _otelSpan?: Span })._otelSpan; + + if (span) { + endHttpSpan(span); + } + + return response; + }, + (error) => { + const span = (error?.config as InternalAxiosRequestConfig & { _otelSpan?: Span })?._otelSpan; + + if (span) { + endHttpSpan(span, true); + } + + return Promise.reject(error); + } + ); + axiosInstance.interceptors.request.use( async (config) => { const token = getTokenParsed(); diff --git a/src/utils/telemetry.ts b/src/utils/telemetry.ts new file mode 100644 index 00000000..f0e7f6b8 --- /dev/null +++ b/src/utils/telemetry.ts @@ -0,0 +1,52 @@ +import { context, propagation, Span, SpanStatusCode, trace } from '@opentelemetry/api'; +import { W3CTraceContextPropagator } from '@opentelemetry/core'; +import { WebTracerProvider } from '@opentelemetry/sdk-trace-web'; + +const TRACER_NAME = 'appflowy-web'; + +let initialized = false; + +function ensureInitialized() { + if (initialized) return; + initialized = true; + + const provider = new WebTracerProvider(); + + provider.register({ + propagator: new W3CTraceContextPropagator(), + }); +} + +function getTracer() { + ensureInitialized(); + return trace.getTracer(TRACER_NAME); +} + +/** + * Starts a span for an HTTP request and injects W3C Trace Context headers + * (`traceparent`) into the provided headers record. + * + * Returns the span so the caller can end it after the response arrives. + */ +export function startHttpSpan(method: string, url: string, headers: Record): Span { + const tracer = getTracer(); + const span = tracer.startSpan(`HTTP ${method.toUpperCase()} ${url}`); + + // Inject traceparent header into the carrier (headers object) + const ctx = trace.setSpan(context.active(), span); + + propagation.inject(ctx, headers); + + return span; +} + +/** + * Ends an HTTP span, optionally marking it as an error. + */ +export function endHttpSpan(span: Span, error?: boolean) { + if (error) { + span.setStatus({ code: SpanStatusCode.ERROR }); + } + + span.end(); +}