Skip to content
Merged
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
33 changes: 33 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -135,6 +135,39 @@ NODE_MAX_OLD_SPACE_SIZE=6144
# OTEL_LOG_LEVEL=INFO
# OTEL_SDK_DISABLED=false

#===============================#
# Real User Monitoring (Browser) #
#===============================#

# Enables browser Real User Monitoring. Disabled by default.
# Currently supports HyperDX via the browser SDK.
# RUM_ENABLED=false
# RUM_PROVIDER=hyperdx
# RUM_URL=http://localhost:4318
# RUM_SERVICE_NAME=librechat-web
# RUM_ENVIRONMENT=development

# Public browser-token mode is suitable for OSS/self-hosted deployments.
# Treat the token as public and restrict/rate-limit ingestion in your RUM backend.
# RUM_AUTH_MODE=publicToken
# RUM_PUBLIC_TOKEN=

# Authenticated mode sends the active LibreChat user JWT only to the configured RUM URL.
# Use only with a trusted HTTPS ingest URL that will not log or expose authorization headers.
# RUM_AUTH_MODE=userJwt
# RUM_AUTH_HEADER_SCHEME=Bearer

# 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

# Privacy defaults: replay, console capture, and full network body capture stay off.
# Console/network capture may collect sensitive browser logs, prompts, responses, or payloads.
# RUM_DISABLE_REPLAY=true
# RUM_CONSOLE_CAPTURE=false
# RUM_ADVANCED_NETWORK_CAPTURE=false
# RUM_SAMPLE_RATE=1

#===================================================#
# Endpoints #
#===================================================#
Expand Down
173 changes: 173 additions & 0 deletions api/server/routes/__tests__/config.rum.spec.js
Original file line number Diff line number Diff line change
@@ -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');
});
});
4 changes: 4 additions & 0 deletions api/server/routes/config.js
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down Expand Up @@ -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();
Expand All @@ -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;
Expand Down Expand Up @@ -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);
Expand Down
Loading
Loading