mirror of
https://github.com/danny-avila/LibreChat.git
synced 2026-08-27 04:07:05 +00:00
📡 feat: Add Configurable HyperDX Browser Real User Monitoring (#13287)
This commit is contained in:
parent
cfee8c72cb
commit
71a7c9ce7b
18 changed files with 2676 additions and 8 deletions
173
api/server/routes/__tests__/config.rum.spec.js
Normal file
173
api/server/routes/__tests__/config.rum.spec.js
Normal file
|
|
@ -0,0 +1,173 @@
|
|||
jest.mock('~/cache/getLogStores');
|
||||
|
||||
const mockGetAppConfig = jest.fn();
|
||||
jest.mock('~/server/services/Config/app', () => ({
|
||||
getAppConfig: (...args) => mockGetAppConfig(...args),
|
||||
}));
|
||||
|
||||
jest.mock('~/server/services/Config/ldap', () => ({
|
||||
getLdapConfig: jest.fn(() => null),
|
||||
}));
|
||||
|
||||
jest.mock('~/server/middleware/roles/capabilities', () => ({
|
||||
hasCapability: jest.fn(),
|
||||
}));
|
||||
|
||||
jest.mock('@librechat/data-schemas', () => ({
|
||||
...jest.requireActual('@librechat/data-schemas'),
|
||||
getTenantId: jest.fn(() => undefined),
|
||||
}));
|
||||
|
||||
jest.mock('@librechat/api', () => ({
|
||||
...jest.requireActual('@librechat/api'),
|
||||
getCloudFrontConfig: jest.fn(() => null),
|
||||
}));
|
||||
|
||||
const request = require('supertest');
|
||||
const express = require('express');
|
||||
const configRoute = require('../config');
|
||||
|
||||
function createApp(user) {
|
||||
const app = express();
|
||||
app.disable('x-powered-by');
|
||||
if (user) {
|
||||
app.use((req, _res, next) => {
|
||||
req.user = user;
|
||||
next();
|
||||
});
|
||||
}
|
||||
app.use('/api/config', configRoute);
|
||||
return app;
|
||||
}
|
||||
|
||||
const baseAppConfig = {
|
||||
registration: { socialLogins: ['google', 'github'] },
|
||||
interfaceConfig: { modelSelect: true },
|
||||
turnstileConfig: { siteKey: 'test-key' },
|
||||
modelSpecs: { list: [{ name: 'test-spec' }] },
|
||||
};
|
||||
|
||||
const mockUser = {
|
||||
id: 'user123',
|
||||
role: 'USER',
|
||||
tenantId: undefined,
|
||||
};
|
||||
|
||||
afterEach(() => {
|
||||
jest.resetAllMocks();
|
||||
delete process.env.RUM_ENABLED;
|
||||
delete process.env.RUM_PROVIDER;
|
||||
delete process.env.RUM_URL;
|
||||
delete process.env.RUM_SERVICE_NAME;
|
||||
delete process.env.RUM_AUTH_MODE;
|
||||
delete process.env.RUM_AUTH_HEADER_SCHEME;
|
||||
delete process.env.RUM_PUBLIC_TOKEN;
|
||||
delete process.env.RUM_TRACE_PROPAGATION_TARGETS;
|
||||
delete process.env.RUM_CONSOLE_CAPTURE;
|
||||
delete process.env.RUM_DISABLE_REPLAY;
|
||||
delete process.env.RUM_ADVANCED_NETWORK_CAPTURE;
|
||||
delete process.env.RUM_SAMPLE_RATE;
|
||||
delete process.env.RUM_ENVIRONMENT;
|
||||
});
|
||||
|
||||
describe('GET /api/config RUM config', () => {
|
||||
it('includes public-token RUM config when enabled with valid env', async () => {
|
||||
mockGetAppConfig.mockResolvedValue(baseAppConfig);
|
||||
process.env.RUM_ENABLED = 'true';
|
||||
process.env.RUM_URL = 'https://rum.example.com';
|
||||
process.env.RUM_PUBLIC_TOKEN = 'public-token';
|
||||
process.env.RUM_TRACE_PROPAGATION_TARGETS =
|
||||
'https://app.example.com,https://api.openai.com,*,http://api.example.com';
|
||||
process.env.RUM_SAMPLE_RATE = '0.25';
|
||||
process.env.RUM_ENVIRONMENT = 'test';
|
||||
const app = createApp(null);
|
||||
|
||||
const response = await request(app).get('/api/config');
|
||||
|
||||
expect(response.body.rum).toEqual({
|
||||
provider: 'hyperdx',
|
||||
enabled: true,
|
||||
url: 'https://rum.example.com',
|
||||
serviceName: 'librechat-web',
|
||||
authMode: 'publicToken',
|
||||
publicToken: 'public-token',
|
||||
tracePropagationTargets: ['https://app.example.com', 'https://api.openai.com'],
|
||||
consoleCapture: false,
|
||||
disableReplay: true,
|
||||
advancedNetworkCapture: false,
|
||||
sampleRate: 0.25,
|
||||
environment: 'test',
|
||||
});
|
||||
});
|
||||
|
||||
it('omits malformed RUM config', async () => {
|
||||
mockGetAppConfig.mockResolvedValue(baseAppConfig);
|
||||
process.env.RUM_ENABLED = 'true';
|
||||
process.env.RUM_URL = 'not a url';
|
||||
process.env.RUM_PUBLIC_TOKEN = 'public-token';
|
||||
const app = createApp(null);
|
||||
|
||||
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';
|
||||
process.env.RUM_URL = 'https://user:password@rum.example.com';
|
||||
process.env.RUM_PUBLIC_TOKEN = 'public-token';
|
||||
const app = createApp(null);
|
||||
|
||||
const response = await request(app).get('/api/config');
|
||||
|
||||
expect(response.body).not.toHaveProperty('rum');
|
||||
});
|
||||
|
||||
it('allows IPv6 localhost HTTP RUM URLs in public-token mode', async () => {
|
||||
mockGetAppConfig.mockResolvedValue(baseAppConfig);
|
||||
process.env.RUM_ENABLED = 'true';
|
||||
process.env.RUM_URL = 'http://[::1]:4318';
|
||||
process.env.RUM_PUBLIC_TOKEN = 'public-token';
|
||||
const app = createApp(null);
|
||||
|
||||
const response = await request(app).get('/api/config');
|
||||
|
||||
expect(response.body.rum?.url).toBe('http://[::1]:4318');
|
||||
});
|
||||
|
||||
it('includes userJwt RUM config for authenticated users', async () => {
|
||||
mockGetAppConfig.mockResolvedValue(baseAppConfig);
|
||||
process.env.RUM_ENABLED = 'true';
|
||||
process.env.RUM_URL = 'https://rum.example.com';
|
||||
process.env.RUM_AUTH_MODE = 'userJwt';
|
||||
process.env.RUM_AUTH_HEADER_SCHEME = 'Basic';
|
||||
const app = createApp(mockUser);
|
||||
|
||||
const response = await request(app).get('/api/config');
|
||||
|
||||
expect(response.body.rum).toEqual({
|
||||
provider: 'hyperdx',
|
||||
enabled: true,
|
||||
url: 'https://rum.example.com',
|
||||
serviceName: 'librechat-web',
|
||||
authMode: 'userJwt',
|
||||
authHeaderScheme: 'Basic',
|
||||
consoleCapture: false,
|
||||
disableReplay: true,
|
||||
advancedNetworkCapture: false,
|
||||
});
|
||||
});
|
||||
|
||||
it('omits userJwt RUM config for unauthenticated users', async () => {
|
||||
mockGetAppConfig.mockResolvedValue(baseAppConfig);
|
||||
process.env.RUM_ENABLED = 'true';
|
||||
process.env.RUM_URL = 'https://rum.example.com';
|
||||
process.env.RUM_AUTH_MODE = 'userJwt';
|
||||
const app = createApp(null);
|
||||
|
||||
const response = await request(app).get('/api/config');
|
||||
|
||||
expect(response.body).not.toHaveProperty('rum');
|
||||
});
|
||||
});
|
||||
|
|
@ -10,6 +10,7 @@ const { defaultSocialLogins } = require('librechat-data-provider');
|
|||
const { logger, getTenantId, SystemCapabilities } = require('@librechat/data-schemas');
|
||||
const { hasCapability } = require('~/server/middleware/roles/capabilities');
|
||||
const { getLdapConfig } = require('~/server/services/Config/ldap');
|
||||
const { getRumConfig } = require('~/server/services/Config/rum');
|
||||
const { getAppConfig } = require('~/server/services/Config/app');
|
||||
|
||||
const router = express.Router();
|
||||
|
|
@ -169,6 +170,7 @@ router.get('/', async function (req, res) {
|
|||
try {
|
||||
const sharedPayload = buildSharedPayload();
|
||||
const cloudFront = buildCloudFrontStartupConfig();
|
||||
const rum = getRumConfig(req.user);
|
||||
|
||||
if (!req.user) {
|
||||
const tenantId = getTenantId();
|
||||
|
|
@ -180,6 +182,7 @@ router.get('/', async function (req, res) {
|
|||
socialLogins: baseConfig?.registration?.socialLogins ?? defaultSocialLogins,
|
||||
turnstile: baseConfig?.turnstileConfig,
|
||||
...(cloudFront ? { cloudFront } : {}),
|
||||
...(rum ? { rum } : {}),
|
||||
};
|
||||
|
||||
const interfaceConfig = baseConfig?.interfaceConfig;
|
||||
|
|
@ -231,6 +234,7 @@ router.get('/', async function (req, res) {
|
|||
? parseInt(process.env.CONVERSATION_IMPORT_MAX_FILE_SIZE_BYTES, 10)
|
||||
: 0,
|
||||
...(cloudFront ? { cloudFront } : {}),
|
||||
...(rum ? { rum } : {}),
|
||||
};
|
||||
|
||||
const webSearch = buildWebSearchConfig(appConfig);
|
||||
|
|
|
|||
156
api/server/services/Config/rum.js
Normal file
156
api/server/services/Config/rum.js
Normal file
|
|
@ -0,0 +1,156 @@
|
|||
const { isEnabled } = require('@librechat/api');
|
||||
const { logger } = require('@librechat/data-schemas');
|
||||
|
||||
const DEFAULT_RUM_SERVICE_NAME = 'librechat-web';
|
||||
let hasWarnedUserJwtAuth = false;
|
||||
|
||||
function parseBooleanEnv(value, defaultValue = false) {
|
||||
if (value == null || value === '') {
|
||||
return defaultValue;
|
||||
}
|
||||
|
||||
return isEnabled(value);
|
||||
}
|
||||
|
||||
function parseNumberEnv(value) {
|
||||
if (value == null || value === '') {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const parsed = Number(value);
|
||||
return Number.isFinite(parsed) ? parsed : undefined;
|
||||
}
|
||||
|
||||
function parseCsvEnv(value) {
|
||||
if (!value) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return value
|
||||
.split(',')
|
||||
.map((item) => item.trim())
|
||||
.filter(Boolean);
|
||||
}
|
||||
|
||||
function parseUrl(value) {
|
||||
try {
|
||||
return new URL(value);
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
function isLocalhost(url) {
|
||||
return url.hostname === 'localhost' || url.hostname === '127.0.0.1' || url.hostname === '[::1]';
|
||||
}
|
||||
|
||||
function isSafeRumUrl(url, authMode) {
|
||||
if (url.username || url.password) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (url.protocol === 'https:') {
|
||||
return true;
|
||||
}
|
||||
|
||||
return authMode === 'publicToken' && url.protocol === 'http:' && isLocalhost(url);
|
||||
}
|
||||
|
||||
function isSafeTraceTarget(target) {
|
||||
if (target.includes('*')) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const url = parseUrl(target);
|
||||
if (!url || url.protocol !== 'https:') {
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
function warnOnceForUserJwtAuth() {
|
||||
if (hasWarnedUserJwtAuth) {
|
||||
return;
|
||||
}
|
||||
|
||||
hasWarnedUserJwtAuth = true;
|
||||
logger.warn(
|
||||
'[config] RUM userJwt mode sends the active LibreChat user JWT to RUM_URL; use only with a trusted HTTPS collector that will not log authorization headers',
|
||||
);
|
||||
}
|
||||
|
||||
function getRumConfig(user) {
|
||||
if (!parseBooleanEnv(process.env.RUM_ENABLED)) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const provider = process.env.RUM_PROVIDER || 'hyperdx';
|
||||
if (provider !== 'hyperdx') {
|
||||
logger.warn(`[config] Unsupported RUM provider "${provider}", disabling RUM`);
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const authMode = process.env.RUM_AUTH_MODE === 'userJwt' ? 'userJwt' : 'publicToken';
|
||||
const rumUrl = process.env.RUM_URL;
|
||||
const parsedUrl = rumUrl ? parseUrl(rumUrl) : undefined;
|
||||
|
||||
if (!parsedUrl || !isSafeRumUrl(parsedUrl, authMode)) {
|
||||
logger.warn('[config] Invalid RUM_URL, disabling RUM');
|
||||
return undefined;
|
||||
}
|
||||
|
||||
if (authMode === 'userJwt' && !user) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
if (authMode === 'userJwt') {
|
||||
warnOnceForUserJwtAuth();
|
||||
}
|
||||
|
||||
if (authMode === 'publicToken' && !process.env.RUM_PUBLIC_TOKEN) {
|
||||
logger.warn('[config] RUM publicToken mode requires RUM_PUBLIC_TOKEN, disabling RUM');
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const rawTracePropagationTargets = parseCsvEnv(process.env.RUM_TRACE_PROPAGATION_TARGETS);
|
||||
const tracePropagationTargets = rawTracePropagationTargets.filter(isSafeTraceTarget);
|
||||
if (rawTracePropagationTargets.length !== tracePropagationTargets.length) {
|
||||
logger.info('[config] Ignored unsafe RUM trace propagation targets');
|
||||
}
|
||||
|
||||
const configuredSampleRate = parseNumberEnv(process.env.RUM_SAMPLE_RATE);
|
||||
const sampleRate =
|
||||
configuredSampleRate != null && configuredSampleRate >= 0 && configuredSampleRate <= 1
|
||||
? configuredSampleRate
|
||||
: undefined;
|
||||
const authHeaderScheme = process.env.RUM_AUTH_HEADER_SCHEME === 'Basic' ? 'Basic' : 'Bearer';
|
||||
const consoleCapture = parseBooleanEnv(process.env.RUM_CONSOLE_CAPTURE);
|
||||
const advancedNetworkCapture = parseBooleanEnv(process.env.RUM_ADVANCED_NETWORK_CAPTURE);
|
||||
|
||||
if (consoleCapture) {
|
||||
logger.warn('[config] RUM console capture is enabled and may collect sensitive browser logs');
|
||||
}
|
||||
|
||||
if (advancedNetworkCapture) {
|
||||
logger.warn('[config] RUM advanced network capture is enabled and may collect payload data');
|
||||
}
|
||||
|
||||
return {
|
||||
provider: 'hyperdx',
|
||||
enabled: true,
|
||||
url: parsedUrl.href.replace(/\/$/, ''),
|
||||
serviceName: process.env.RUM_SERVICE_NAME || DEFAULT_RUM_SERVICE_NAME,
|
||||
authMode,
|
||||
...(authMode === 'userJwt' ? { authHeaderScheme } : {}),
|
||||
...(authMode === 'publicToken' ? { publicToken: process.env.RUM_PUBLIC_TOKEN } : {}),
|
||||
...(tracePropagationTargets.length > 0 ? { tracePropagationTargets } : {}),
|
||||
consoleCapture,
|
||||
disableReplay: parseBooleanEnv(process.env.RUM_DISABLE_REPLAY, true),
|
||||
advancedNetworkCapture,
|
||||
...(sampleRate != null ? { sampleRate } : {}),
|
||||
...(process.env.RUM_ENVIRONMENT ? { environment: process.env.RUM_ENVIRONMENT } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
module.exports = { getRumConfig };
|
||||
Loading…
Add table
Add a link
Reference in a new issue