From a86e504a57a86f91ab345125edd8bd138f75a1ce Mon Sep 17 00:00:00 2001 From: Ravi Kumar L Date: Tue, 2 Jun 2026 03:11:35 +0200 Subject: [PATCH] =?UTF-8?q?=F0=9F=93=A1=20feat:=20Add=20Authenticated=20Pr?= =?UTF-8?q?oxy=20Mode=20for=20Browser=20RUM=20Telemetry=20(#13464)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .env.example | 6 + api/server/index.js | 1 + .../routes/__tests__/config.rum.spec.js | 33 ++++ api/server/routes/index.js | 2 + api/server/routes/rum.js | 22 +++ api/server/services/Config/rum.js | 38 +++-- client/src/lib/rum/useRum.spec.tsx | 59 +++++++ client/src/lib/rum/useRum.ts | 81 +++++++++- packages/api/src/index.ts | 2 + packages/api/src/rum/proxy.spec.ts | 138 ++++++++++++++++ packages/api/src/rum/proxy.ts | 149 ++++++++++++++++++ packages/data-provider/src/config.ts | 2 +- 12 files changed, 511 insertions(+), 22 deletions(-) create mode 100644 api/server/routes/rum.js create mode 100644 packages/api/src/rum/proxy.spec.ts create mode 100644 packages/api/src/rum/proxy.ts diff --git a/.env.example b/.env.example index 65673c2fcd..71173747c3 100644 --- a/.env.example +++ b/.env.example @@ -152,6 +152,12 @@ NODE_MAX_OLD_SPACE_SIZE=6144 # RUM_AUTH_MODE=publicToken # RUM_PUBLIC_TOKEN= +# Authenticated proxy mode sends browser telemetry to this LibreChat backend first. +# The backend validates the LibreChat session, strips app auth, and forwards to the collector. +# RUM_AUTH_MODE=proxy +# RUM_PROXY_TARGET_URL=http://otel-collector:4318 +# RUM_PROXY_TIMEOUT_MS=10000 + # Optional comma-separated first-party HTTPS origins/URLs that should receive traceparent headers. # Wildcards and non-HTTPS targets are ignored. # RUM_TRACE_PROPAGATION_TARGETS=https://api.example.com diff --git a/api/server/index.js b/api/server/index.js index a36484c87a..57b9e3e3a3 100644 --- a/api/server/index.js +++ b/api/server/index.js @@ -220,6 +220,7 @@ const startServer = async () => { app.use('/api/tags', routes.tags); app.use('/api/mcp', routes.mcp); + app.use('/api/rum', routes.rum); app.use('/metrics', metricsRouter); diff --git a/api/server/routes/__tests__/config.rum.spec.js b/api/server/routes/__tests__/config.rum.spec.js index 867e0a0049..2b0a5b20e2 100644 --- a/api/server/routes/__tests__/config.rum.spec.js +++ b/api/server/routes/__tests__/config.rum.spec.js @@ -58,6 +58,7 @@ afterEach(() => { delete process.env.RUM_ENABLED; delete process.env.RUM_PROVIDER; delete process.env.RUM_URL; + delete process.env.RUM_PROXY_TARGET_URL; delete process.env.RUM_SERVICE_NAME; delete process.env.RUM_AUTH_MODE; delete process.env.RUM_PUBLIC_TOKEN; @@ -111,6 +112,38 @@ describe('GET /api/config RUM config', () => { expect(response.body).not.toHaveProperty('rum'); }); + it('includes proxy RUM config when enabled with valid env', async () => { + mockGetAppConfig.mockResolvedValue(baseAppConfig); + process.env.RUM_ENABLED = 'true'; + process.env.RUM_AUTH_MODE = 'proxy'; + process.env.RUM_PROXY_TARGET_URL = 'http://otel-collector:4318'; + const app = createApp(mockUser); + + const response = await request(app).get('/api/config'); + + expect(response.body.rum).toEqual({ + provider: 'hyperdx', + enabled: true, + url: '/api/rum', + serviceName: 'librechat-web', + authMode: 'proxy', + consoleCapture: false, + disableReplay: true, + advancedNetworkCapture: false, + }); + }); + + it('omits proxy RUM config without a target collector URL', async () => { + mockGetAppConfig.mockResolvedValue(baseAppConfig); + process.env.RUM_ENABLED = 'true'; + process.env.RUM_AUTH_MODE = 'proxy'; + const app = createApp(mockUser); + + const response = await request(app).get('/api/config'); + + expect(response.body).not.toHaveProperty('rum'); + }); + it('omits RUM config when the URL contains credentials', async () => { mockGetAppConfig.mockResolvedValue(baseAppConfig); process.env.RUM_ENABLED = 'true'; diff --git a/api/server/routes/index.js b/api/server/routes/index.js index cab2f92ed1..6694e47f23 100644 --- a/api/server/routes/index.js +++ b/api/server/routes/index.js @@ -32,8 +32,10 @@ const auth = require('./auth'); const keys = require('./keys'); const user = require('./user'); const mcp = require('./mcp'); +const rum = require('./rum'); module.exports = { + rum, mcp, auth, adminAuth, diff --git a/api/server/routes/rum.js b/api/server/routes/rum.js new file mode 100644 index 0000000000..b407992e1e --- /dev/null +++ b/api/server/routes/rum.js @@ -0,0 +1,22 @@ +const express = require('express'); +const { getRumProxyBodyLimit, isRumProxyEnabled, proxyRumRequest } = require('@librechat/api'); +const { requireJwtAuth } = require('~/server/middleware'); + +const router = express.Router(); +const rawOtlpBody = express.raw({ + limit: getRumProxyBodyLimit(), + type: ['application/x-protobuf', 'application/octet-stream'], +}); + +function requireRumProxyEnabled(_req, res, next) { + if (!isRumProxyEnabled()) { + return res.status(404).json({ message: 'RUM proxy is not configured' }); + } + + return next(); +} + +router.post('/v1/traces', requireRumProxyEnabled, requireJwtAuth, rawOtlpBody, proxyRumRequest); +router.post('/v1/logs', requireRumProxyEnabled, requireJwtAuth, rawOtlpBody, proxyRumRequest); + +module.exports = router; diff --git a/api/server/services/Config/rum.js b/api/server/services/Config/rum.js index da7bf5e555..c9f36ea80b 100644 --- a/api/server/services/Config/rum.js +++ b/api/server/services/Config/rum.js @@ -1,4 +1,4 @@ -const { isEnabled } = require('@librechat/api'); +const { getRumProxyClientUrl, isEnabled, isRumProxyEnabled } = require('@librechat/api'); const { logger } = require('@librechat/data-schemas'); const DEFAULT_RUM_SERVICE_NAME = 'librechat-web'; @@ -80,22 +80,34 @@ function getRumConfig() { } const authMode = process.env.RUM_AUTH_MODE || 'publicToken'; - if (authMode !== 'publicToken') { + if (authMode !== 'publicToken' && authMode !== 'proxy') { logger.warn(`[config] Unsupported RUM auth mode "${authMode}", disabling RUM`); return undefined; } - const rumUrl = process.env.RUM_URL; - const parsedUrl = rumUrl ? parseUrl(rumUrl) : undefined; + let rumUrl; + if (authMode === 'proxy') { + rumUrl = getRumProxyClientUrl(); - if (!parsedUrl || !isSafeRumUrl(parsedUrl)) { - logger.warn('[config] Invalid RUM_URL, disabling RUM'); - return undefined; - } + if (!isRumProxyEnabled()) { + logger.warn('[config] RUM proxy mode requires RUM_PROXY_TARGET_URL, disabling RUM'); + return undefined; + } + } else { + rumUrl = process.env.RUM_URL; + const parsedUrl = rumUrl ? parseUrl(rumUrl) : undefined; - if (!process.env.RUM_PUBLIC_TOKEN) { - logger.warn('[config] RUM publicToken mode requires RUM_PUBLIC_TOKEN, disabling RUM'); - return undefined; + if (!parsedUrl || !isSafeRumUrl(parsedUrl)) { + logger.warn('[config] Invalid RUM_URL, disabling RUM'); + return undefined; + } + + if (!process.env.RUM_PUBLIC_TOKEN) { + logger.warn('[config] RUM publicToken mode requires RUM_PUBLIC_TOKEN, disabling RUM'); + return undefined; + } + + rumUrl = parsedUrl.href.replace(/\/$/, ''); } const rawTracePropagationTargets = parseCsvEnv(process.env.RUM_TRACE_PROPAGATION_TARGETS); @@ -123,10 +135,10 @@ function getRumConfig() { return { provider: 'hyperdx', enabled: true, - url: parsedUrl.href.replace(/\/$/, ''), + url: rumUrl, serviceName: process.env.RUM_SERVICE_NAME || DEFAULT_RUM_SERVICE_NAME, authMode, - publicToken: process.env.RUM_PUBLIC_TOKEN, + ...(authMode === 'publicToken' ? { publicToken: process.env.RUM_PUBLIC_TOKEN } : {}), ...(tracePropagationTargets.length > 0 ? { tracePropagationTargets } : {}), consoleCapture, disableReplay: parseBooleanEnv(process.env.RUM_DISABLE_REPLAY, true), diff --git a/client/src/lib/rum/useRum.spec.tsx b/client/src/lib/rum/useRum.spec.tsx index f76362dcaf..6386a55aea 100644 --- a/client/src/lib/rum/useRum.spec.tsx +++ b/client/src/lib/rum/useRum.spec.tsx @@ -103,4 +103,63 @@ describe('useRum', () => { expect(mockInit).not.toHaveBeenCalled(); }); + + it('initializes proxy RUM with the LibreChat bearer token for same-origin ingest', async () => { + const fetchMock = jest.fn(() => Promise.resolve({})); + window.fetch = fetchMock; + mockUseGetStartupConfig.mockReturnValue({ + data: { + rum: { + provider: 'hyperdx', + enabled: true, + url: '/api/rum', + serviceName: 'librechat-web', + authMode: 'proxy', + }, + }, + }); + + renderHook(() => useRum()); + + await waitFor(() => { + expect(mockInit).toHaveBeenCalledWith({ + advancedNetworkCapture: false, + apiKey: 'librechat-rum-proxy', + consoleCapture: false, + disableReplay: true, + service: 'librechat-web', + tracePropagationTargets: undefined, + url: '/api/rum', + }); + }); + + await window.fetch('/api/rum/v1/traces', { method: 'POST' }); + + const headers = fetchMock.mock.calls[0]?.[1]?.headers; + expect(headers).toBeInstanceOf(Headers); + expect((headers as Headers).get('authorization')).toBe('Bearer jwt-token'); + }); + + it('does not initialize proxy RUM without an authenticated token', async () => { + mockUseAuthContext.mockReturnValue({ + isAuthenticated: false, + token: undefined, + user: undefined, + }); + mockUseGetStartupConfig.mockReturnValue({ + data: { + rum: { + provider: 'hyperdx', + enabled: true, + url: '/api/rum', + serviceName: 'librechat-web', + authMode: 'proxy', + }, + }, + }); + + renderHook(() => useRum()); + + expect(mockInit).not.toHaveBeenCalled(); + }); }); diff --git a/client/src/lib/rum/useRum.ts b/client/src/lib/rum/useRum.ts index 76757ab602..d3d0627fc8 100644 --- a/client/src/lib/rum/useRum.ts +++ b/client/src/lib/rum/useRum.ts @@ -5,6 +5,11 @@ import { useGetStartupConfig } from '~/data-provider'; import { useAuthContext } from '~/hooks/AuthContext'; import { normalizeRumPath } from './routes'; +const PROXY_API_KEY = 'librechat-rum-proxy'; + +let rumProxyToken: string | undefined; +let rumProxyFetchPatched = false; + type HyperDXBrowser = { init: (config: { advancedNetworkCapture: boolean; @@ -18,18 +23,70 @@ type HyperDXBrowser = { setGlobalAttributes: (attributes: Record) => void; }; -function shouldInitializeRum(config: TRumConfig | undefined): boolean { +function shouldInitializeRum(config: TRumConfig | undefined, token: string | undefined): boolean { if (!config?.enabled || config.provider !== 'hyperdx' || !config.url || !config.serviceName) { return false; } - return config.authMode === 'publicToken' && !!config.publicToken; + if (config.authMode === 'publicToken') { + return !!config.publicToken; + } + + return config.authMode === 'proxy' && !!token && !config.publicToken; } -function getApiKey(config: TRumConfig): string { +function getApiKey(config: TRumConfig, token: string | undefined): string { + if (config.authMode === 'proxy') { + return token ? PROXY_API_KEY : ''; + } + return config.publicToken ?? ''; } +function isRumProxyRequest(input: RequestInfo | URL, proxyUrl: string): boolean { + const rawUrl = + typeof Request !== 'undefined' && input instanceof Request ? input.url : input.toString(); + const url = new URL(rawUrl, window.location.origin); + const proxy = new URL(proxyUrl, window.location.origin); + + return url.origin === window.location.origin && url.pathname.startsWith(`${proxy.pathname}/`); +} + +function withAuthorization( + input: RequestInfo | URL, + init: RequestInit | undefined, + token: string, +): [RequestInfo | URL, RequestInit | undefined] { + const headers = new Headers( + init?.headers ?? + (typeof Request !== 'undefined' && input instanceof Request ? input.headers : undefined), + ); + headers.set('authorization', `Bearer ${token}`); + + if (typeof Request !== 'undefined' && input instanceof Request) { + return [new Request(input, { ...init, headers }), undefined]; + } + + return [input, { ...init, headers }]; +} + +function ensureRumProxyAuth(proxyUrl: string): void { + if (rumProxyFetchPatched || typeof window === 'undefined' || typeof window.fetch !== 'function') { + return; + } + + const originalFetch = window.fetch.bind(window); + window.fetch = (input: RequestInfo | URL, init?: RequestInit) => { + if (rumProxyToken && isRumProxyRequest(input, proxyUrl)) { + const [authorizedInput, authorizedInit] = withAuthorization(input, init, rumProxyToken); + return originalFetch(authorizedInput, authorizedInit); + } + + return originalFetch(input, init); + }; + rumProxyFetchPatched = true; +} + function buildGlobalAttributes( user: TUser | undefined, config: TRumConfig, @@ -56,7 +113,7 @@ async function loadHyperDX(): Promise { export default function useRum(): void { const { data: startupConfig } = useGetStartupConfig(); - const { user } = useAuthContext(); + const { token, user } = useAuthContext(); const location = useLocation(); const initializedKeyRef = useRef(undefined); const sampledInitKeyRef = useRef(undefined); @@ -75,13 +132,21 @@ export default function useRum(): void { return; } - if (!shouldInitializeRum(rumConfig)) { + if (!shouldInitializeRum(rumConfig, token)) { + if (rumConfig?.authMode === 'proxy') { + rumProxyToken = undefined; + } return; } const config = rumConfig; + const apiKey = getApiKey(config, token); + if (config.authMode === 'proxy') { + rumProxyToken = token; + ensureRumProxyAuth(config.url); + } - const initKey = [config.url, config.serviceName, config.authMode, config.publicToken].join(':'); + const initKey = [config.url, config.serviceName, config.authMode, apiKey].join(':'); if (initializedKeyRef.current === initKey) { return; @@ -107,7 +172,7 @@ export default function useRum(): void { HyperDX.init({ advancedNetworkCapture: config.advancedNetworkCapture ?? false, - apiKey: getApiKey(config), + apiKey, consoleCapture: config.consoleCapture ?? false, disableReplay: config.disableReplay ?? true, service: config.serviceName, @@ -124,7 +189,7 @@ export default function useRum(): void { return () => { cancelled = true; }; - }, [rumConfig, user]); + }, [rumConfig, token, user]); useEffect(() => { hyperDxRef.current?.setGlobalAttributes( diff --git a/packages/api/src/index.ts b/packages/api/src/index.ts index 11459f07dc..8be6526c7a 100644 --- a/packages/api/src/index.ts +++ b/packages/api/src/index.ts @@ -59,6 +59,8 @@ export * from './cache'; export * from './stream'; /* Diagnostics */ export { memoryDiagnostics } from './utils/memory'; +/* RUM */ +export * from './rum/proxy'; /* types */ export type * from './mcp/types'; export type * from './flow/types'; diff --git a/packages/api/src/rum/proxy.spec.ts b/packages/api/src/rum/proxy.spec.ts new file mode 100644 index 0000000000..74225eb417 --- /dev/null +++ b/packages/api/src/rum/proxy.spec.ts @@ -0,0 +1,138 @@ +import { + getRumProxyBodyLimit, + getRumProxyClientUrl, + getRumProxyTimeoutMs, + getRumProxyTargetBaseUrl, + isRumProxyEnabled, + proxyRumRequest, + resolveRumProxyTarget, +} from './proxy'; + +const makeResponse = () => { + const res = { + set: jest.fn(), + send: jest.fn(), + status: jest.fn(), + json: jest.fn(), + }; + res.status.mockReturnValue(res); + return res; +}; + +describe('RUM proxy configuration', () => { + const originalEnv = process.env; + + beforeEach(() => { + process.env = { ...originalEnv }; + }); + + afterEach(() => { + process.env = originalEnv; + }); + + it('uses the fixed LibreChat RUM proxy URL and default body limit', () => { + delete process.env.RUM_PROXY_BODY_LIMIT; + delete process.env.RUM_PROXY_TIMEOUT_MS; + + expect(getRumProxyClientUrl()).toBe('/api/rum'); + expect(getRumProxyBodyLimit()).toBe('3mb'); + expect(getRumProxyTimeoutMs()).toBe(10000); + }); + + it('uses a positive custom collector timeout', () => { + process.env.RUM_PROXY_TIMEOUT_MS = '2500'; + expect(getRumProxyTimeoutMs()).toBe(2500); + + process.env.RUM_PROXY_TIMEOUT_MS = '-1'; + expect(getRumProxyTimeoutMs()).toBe(10000); + }); + + it('resolves OTLP paths against the configured collector base URL', () => { + process.env.RUM_AUTH_MODE = 'proxy'; + process.env.RUM_PROXY_TARGET_URL = 'http://otel-collector:4318'; + + expect(isRumProxyEnabled()).toBe(true); + expect(resolveRumProxyTarget('/v1/traces')).toBe('http://otel-collector:4318/v1/traces'); + expect(resolveRumProxyTarget('/v1/logs')).toBe('http://otel-collector:4318/v1/logs'); + expect(resolveRumProxyTarget('/v1/metrics')).toBeUndefined(); + }); + + it('rejects unsafe collector target URLs', () => { + process.env.RUM_PROXY_TARGET_URL = 'https://user:pass@collector.example.com'; + expect(getRumProxyTargetBaseUrl()).toBeUndefined(); + + process.env.RUM_PROXY_TARGET_URL = 'file:///tmp/collector'; + expect(getRumProxyTargetBaseUrl()).toBeUndefined(); + }); + + it('forwards OTLP requests without forwarding app authorization', async () => { + process.env.RUM_AUTH_MODE = 'proxy'; + process.env.RUM_PROXY_TARGET_URL = 'http://otel-collector:4318'; + const fetchMock = jest.spyOn(global, 'fetch').mockResolvedValue( + new Response('ok', { + status: 202, + headers: { 'content-type': 'application/json' }, + }), + ); + const res = makeResponse(); + + await proxyRumRequest( + { + path: '/v1/traces', + body: { resourceSpans: [] }, + headers: { + accept: 'application/json', + authorization: 'Bearer app-token', + 'content-type': 'application/json', + }, + } as never, + res as never, + ); + + expect(fetchMock).toHaveBeenCalledWith('http://otel-collector:4318/v1/traces', { + method: 'POST', + headers: { + accept: 'application/json', + 'content-type': 'application/json', + }, + body: JSON.stringify({ resourceSpans: [] }), + signal: expect.any(AbortSignal), + }); + expect(res.set).toHaveBeenCalledWith('content-type', 'application/json'); + expect(res.status).toHaveBeenCalledWith(202); + expect(res.send).toHaveBeenCalledWith(Buffer.from('ok')); + + fetchMock.mockRestore(); + }); + + it('returns 400 for missing payloads and 404 for unsupported OTLP paths', async () => { + process.env.RUM_AUTH_MODE = 'proxy'; + process.env.RUM_PROXY_TARGET_URL = 'http://otel-collector:4318'; + const missingBodyRes = makeResponse(); + const unsupportedPathRes = makeResponse(); + + await proxyRumRequest({ path: '/v1/traces', headers: {} } as never, missingBodyRes as never); + await proxyRumRequest( + { path: '/v1/metrics', body: Buffer.from('payload'), headers: {} } as never, + unsupportedPathRes as never, + ); + + expect(missingBodyRes.status).toHaveBeenCalledWith(400); + expect(unsupportedPathRes.status).toHaveBeenCalledWith(404); + }); + + it('returns 502 when the collector request fails', async () => { + process.env.RUM_AUTH_MODE = 'proxy'; + process.env.RUM_PROXY_TARGET_URL = 'http://otel-collector:4318'; + const fetchMock = jest.spyOn(global, 'fetch').mockRejectedValue(new Error('collector down')); + const res = makeResponse(); + + await proxyRumRequest( + { path: '/v1/traces', body: Buffer.from('payload'), headers: {} } as never, + res as never, + ); + + expect(res.status).toHaveBeenCalledWith(502); + fetchMock.mockRestore(); + }); +}); diff --git a/packages/api/src/rum/proxy.ts b/packages/api/src/rum/proxy.ts new file mode 100644 index 0000000000..051f74f5af --- /dev/null +++ b/packages/api/src/rum/proxy.ts @@ -0,0 +1,149 @@ +import type { Request, Response } from 'express'; +import { logger } from '@librechat/data-schemas'; + +const DEFAULT_PROXY_PATH = '/api/rum'; +const DEFAULT_BODY_LIMIT = '3mb'; +const DEFAULT_TIMEOUT_MS = 10_000; +const OTLP_PATHS = new Set(['/v1/traces', '/v1/logs']); + +function normalizeBasePath(pathname: string): string { + if (pathname === '/') { + return ''; + } + + return pathname.endsWith('/') ? pathname.slice(0, -1) : pathname; +} + +export function getRumProxyClientUrl(): string { + return DEFAULT_PROXY_PATH; +} + +export function getRumProxyBodyLimit(): string { + return process.env.RUM_PROXY_BODY_LIMIT?.trim() || DEFAULT_BODY_LIMIT; +} + +export function getRumProxyTimeoutMs(): number { + const parsed = Number(process.env.RUM_PROXY_TIMEOUT_MS); + return Number.isFinite(parsed) && parsed > 0 ? parsed : DEFAULT_TIMEOUT_MS; +} + +export function getRumProxyTargetBaseUrl(): URL | undefined { + const value = process.env.RUM_PROXY_TARGET_URL?.trim(); + if (!value) { + return undefined; + } + + let url: URL; + try { + url = new URL(value); + } catch { + return undefined; + } + + if (url.username || url.password || url.search || url.hash) { + return undefined; + } + + if (url.protocol !== 'https:' && url.protocol !== 'http:') { + return undefined; + } + + return url; +} + +export function isRumProxyEnabled(): boolean { + return process.env.RUM_AUTH_MODE === 'proxy' && getRumProxyTargetBaseUrl() != null; +} + +export function resolveRumProxyTarget(path: string): string | undefined { + if (!OTLP_PATHS.has(path)) { + return undefined; + } + + const baseUrl = getRumProxyTargetBaseUrl(); + if (!baseUrl) { + return undefined; + } + + const targetUrl = new URL(baseUrl.href); + targetUrl.pathname = `${normalizeBasePath(targetUrl.pathname)}${path}`; + return targetUrl.href; +} + +function getRequestBody(req: Request): Buffer | string | undefined { + const body = req.body as unknown; + + if (Buffer.isBuffer(body) || typeof body === 'string') { + return body; + } + + if (body && typeof body === 'object') { + return JSON.stringify(body); + } + + return undefined; +} + +function getHeader(req: Request, name: string): string | undefined { + const value = req.headers[name.toLowerCase()]; + if (Array.isArray(value)) { + return value[0]; + } + + return typeof value === 'string' ? value : undefined; +} + +function getProxyHeaders(req: Request, body: Buffer | string): Record { + const contentType = + getHeader(req, 'content-type') || (typeof body === 'string' ? 'application/json' : undefined); + const accept = getHeader(req, 'accept'); + return { + ...(contentType ? { 'content-type': contentType } : {}), + ...(accept ? { accept } : {}), + }; +} + +export async function proxyRumRequest(req: Request, res: Response): Promise { + const target = resolveRumProxyTarget(req.path); + if (!target) { + res.status(404).json({ message: 'RUM proxy is not configured' }); + return; + } + + const body = getRequestBody(req); + if (!body) { + res.status(400).json({ message: 'RUM payload is required' }); + return; + } + + const controller = new AbortController(); + const timeout = setTimeout(() => controller.abort(), getRumProxyTimeoutMs()); + try { + const response = await fetch(target, { + method: 'POST', + headers: getProxyHeaders(req, body), + body, + signal: controller.signal, + }); + + const contentType = response.headers.get('content-type'); + if (contentType) { + res.set('content-type', contentType); + } + + const responseBody = Buffer.from(await response.arrayBuffer()); + res.status(response.status).send(responseBody); + } catch (error) { + logger.warn('[rumProxy] Failed to proxy RUM telemetry', { + error: error instanceof Error ? error.message : String(error), + target, + }); + res.status(controller.signal.aborted ? 504 : 502).json({ + message: controller.signal.aborted + ? 'RUM telemetry proxy timed out' + : 'Failed to proxy RUM telemetry', + }); + } finally { + clearTimeout(timeout); + } +} diff --git a/packages/data-provider/src/config.ts b/packages/data-provider/src/config.ts index b9e569ad08..b027fd266b 100644 --- a/packages/data-provider/src/config.ts +++ b/packages/data-provider/src/config.ts @@ -1079,7 +1079,7 @@ export type TRumConfig = { enabled: boolean; url: string; serviceName: string; - authMode: 'publicToken'; + authMode: 'publicToken' | 'proxy'; publicToken?: string; tracePropagationTargets?: string[]; consoleCapture?: boolean;