Skip to content
Open
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
12 changes: 11 additions & 1 deletion .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -426,8 +426,18 @@ NODE_MAX_OLD_SPACE_SIZE=6144
# RUM_TRACE_PROPAGATION_TARGETS=https://api.example.com

# Privacy defaults: replay, console capture, and full network body capture stay off.
# Opt in separately with RUM_SESSION_REPLAY_ENABLED=true (requires RUM_ENABLED=true).
# The client masks
# all text and input values and stops recording outside its authenticated layout.
# URLs, element attributes and assets may still contain sensitive information;
# validate with synthetic data before enabling recording for real users.
# Console/network capture may collect sensitive browser logs, prompts, responses, or payloads.
# RUM_DISABLE_REPLAY=true
# RUM_SESSION_REPLAY_ENABLED=false
# Deprecated fallback: RUM_DISABLE_REPLAY=false opts in only when the positive flag is unset.
# YAML telemetry.rum.enabled / telemetry.rum.sessionReplay.enabled take precedence
# over environment variables. Explicit false always wins over lower-precedence opt-ins.
# Proxy mode strips replay records even when /v1/logs remains open for other log sources.
# Direct public-token ingestion bypasses this server and must enforce its own collector policy.
# RUM_CONSOLE_CAPTURE=false
# RUM_ADVANCED_NETWORK_CAPTURE=false
# RUM_SAMPLE_RATE=1
Expand Down
46 changes: 46 additions & 0 deletions api/server/routes/__tests__/config.rum.spec.js
Original file line number Diff line number Diff line change
Expand Up @@ -66,13 +66,57 @@ afterEach(() => {
delete process.env.RUM_TRACE_PROPAGATION_TARGETS;
delete process.env.RUM_CONSOLE_CAPTURE;
delete process.env.RUM_DISABLE_REPLAY;
delete process.env.RUM_SESSION_REPLAY_ENABLED;
delete process.env.RUM_ADVANCED_NETWORK_CAPTURE;
delete process.env.RUM_SAMPLE_RATE;
delete process.env.RUM_ENVIRONMENT;
delete process.env.RUM_CLIENT_LOGS;
});

describe('GET /api/config RUM config', () => {
it.each([null, mockUser])('serves separate YAML RUM/replay opt-ins for %p', async (user) => {
mockGetAppConfig.mockResolvedValue({
...baseAppConfig,
config: {
telemetry: {
rum: { enabled: true, sessionReplay: { enabled: true } },
},
},
});
process.env.RUM_ENABLED = 'false';
process.env.RUM_AUTH_MODE = 'proxy';
process.env.RUM_PROXY_TARGET_URL = 'http://otel-collector:4318';
const response = await request(createApp(user)).get('/api/config');
expect(response.body.rum).toMatchObject({
enabled: true,
sessionReplay: { enabled: true },
disableReplay: false,
});
});

it('lets YAML disable replay while RUM and client logs stay enabled', async () => {
mockGetAppConfig.mockResolvedValue({
...baseAppConfig,
config: {
telemetry: {
rum: { sessionReplay: { enabled: false } },
},
},
});
process.env.RUM_ENABLED = 'true';
process.env.RUM_SESSION_REPLAY_ENABLED = 'true';
process.env.RUM_CLIENT_LOGS = 'true';
process.env.RUM_AUTH_MODE = 'proxy';
process.env.RUM_PROXY_TARGET_URL = 'http://otel-collector:4318';
const response = await request(createApp(mockUser)).get('/api/config');
expect(response.body.rum).toMatchObject({
enabled: true,
sessionReplay: { enabled: false },
disableReplay: true,
clientLogs: true,
});
});

it('includes public-token RUM config when enabled with valid env', async () => {
mockGetAppConfig.mockResolvedValue(baseAppConfig);
process.env.RUM_ENABLED = 'true';
Expand All @@ -96,6 +140,7 @@ describe('GET /api/config RUM config', () => {
tracePropagationTargets: ['https://app.example.com', 'https://api.openai.com'],
consoleCapture: false,
disableReplay: true,
sessionReplay: { enabled: false },
advancedNetworkCapture: false,
sampleRate: 0.25,
environment: 'test',
Expand Down Expand Up @@ -137,6 +182,7 @@ describe('GET /api/config RUM config', () => {
authMode: 'proxy',
consoleCapture: false,
disableReplay: true,
sessionReplay: { enabled: false },
advancedNetworkCapture: false,
clientLogs: false,
});
Expand Down
3 changes: 3 additions & 0 deletions api/server/routes/__tests__/rum.spec.js
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,10 @@ jest.mock('~/server/middleware', () => ({
requireRumProxyAuth: (...args) => mockRequireRumProxyAuth(...args),
}));

jest.mock('~/server/services/Config/app', () => ({ getAppConfig: jest.fn() }));

jest.mock('@librechat/api', () => ({
loadRumConfig: () => (_req, _res, next) => next(),
getRumProxyBodyLimit: jest.fn(() => '3mb'),
limiterCache: (...args) => mockLimiterCache(...args),
createRumProxyLimiter: (...args) => mockCreateRumProxyLimiter(...args),
Expand Down
5 changes: 2 additions & 3 deletions api/server/routes/config.js
Original file line number Diff line number Diff line change
Expand Up @@ -229,7 +229,6 @@ router.get('/', async function (req, res) {
try {
const preLoginPayload = buildPreLoginPayload();
const publicSharePayload = buildPublicSharePayload();
const rum = getRumConfig();

if (!req.user) {
const tenantId = getTenantId();
Expand All @@ -240,7 +239,7 @@ router.get('/', async function (req, res) {
...preLoginPayload,
socialLogins: baseConfig?.registration?.socialLogins ?? defaultSocialLogins,
turnstile: baseConfig?.turnstileConfig,
...(rum ? { rum } : {}),
rum: getRumConfig(baseConfig?.config?.telemetry),
};

const interfaceConfig = baseConfig?.interfaceConfig;
Expand Down Expand Up @@ -349,7 +348,7 @@ router.get('/', async function (req, res) {
...resolveCodeWorkspaceInheritanceCapability(process.env.CODE_ENVIRONMENT_DECISION_VERSION),
...(codeEnvironmentTransitionVersion != null ? { codeEnvironmentTransitionVersion } : {}),
...(cloudFront ? { cloudFront } : {}),
...(rum ? { rum } : {}),
rum: getRumConfig(appConfig?.config?.telemetry),
fileUploadSseEnabled: isEnabled(process.env.FILE_UPLOAD_SSE_ENABLED),
endpointsDropParamsMap: endpointsDropParamsMap,
};
Expand Down
3 changes: 3 additions & 0 deletions api/server/routes/rum.js
Original file line number Diff line number Diff line change
Expand Up @@ -7,10 +7,13 @@ const {
requireRumLogsEnabled,
handleJsonParseError,
createRumProxyLimiter,
loadRumConfig,
} = require('@librechat/api');
const { requireRumProxyAuth } = require('~/server/middleware');
const { getAppConfig } = require('~/server/services/Config/app');

const router = express.Router();
router.use(loadRumConfig(getAppConfig));
const rawOtlpBody = express.raw({
limit: getRumProxyBodyLimit(),
type: ['application/x-protobuf', 'application/octet-stream'],
Expand Down
158 changes: 1 addition & 157 deletions api/server/services/Config/rum.js
Original file line number Diff line number Diff line change
@@ -1,157 +1 @@
const {
isEnabled,
isRumProxyEnabled,
getRumProxyClientUrl,
isRumClientLogsEnabled,
} = require('@librechat/api');
const { logger } = require('@librechat/data-schemas');

const DEFAULT_RUM_SERVICE_NAME = 'librechat-web';

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) {
if (url.username || url.password) {
return false;
}

if (url.protocol === 'https:') {
return true;
}

return 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 getRumConfig() {
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 || 'publicToken';
if (authMode !== 'publicToken' && authMode !== 'proxy') {
logger.warn(`[config] Unsupported RUM auth mode "${authMode}", disabling RUM`);
return undefined;
}

let rumUrl;
if (authMode === 'proxy') {
rumUrl = getRumProxyClientUrl();

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 (!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);
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 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: rumUrl,
serviceName: process.env.RUM_SERVICE_NAME || DEFAULT_RUM_SERVICE_NAME,
authMode,
...(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 } : {}),
clientLogs: isRumClientLogsEnabled(),
};
}

module.exports = { getRumConfig };
module.exports = { getRumConfig: require('@librechat/api').getRumConfig };
Loading
Loading