mirror of
https://github.com/danny-avila/LibreChat.git
synced 2026-06-09 17:31:19 +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
33
.env.example
33
.env.example
|
|
@ -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 #
|
||||
#===================================================#
|
||||
|
|
|
|||
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 };
|
||||
|
|
@ -35,6 +35,7 @@
|
|||
"@dicebear/collection": "^9.4.1",
|
||||
"@dicebear/core": "^9.4.1",
|
||||
"@headlessui/react": "^2.1.2",
|
||||
"@hyperdx/browser": "^0.22.1",
|
||||
"@librechat/client": "*",
|
||||
"@marsidev/react-turnstile": "^1.1.0",
|
||||
"@mcp-ui/client": "^5.7.0",
|
||||
|
|
|
|||
8
client/src/lib/rum/WithRum.tsx
Normal file
8
client/src/lib/rum/WithRum.tsx
Normal file
|
|
@ -0,0 +1,8 @@
|
|||
import type { ReactNode } from 'react';
|
||||
import useRum from './useRum';
|
||||
|
||||
export default function WithRum({ children }: { children: ReactNode }) {
|
||||
useRum();
|
||||
|
||||
return <>{children}</>;
|
||||
}
|
||||
152
client/src/lib/rum/early.spec.ts
Normal file
152
client/src/lib/rum/early.spec.ts
Normal file
|
|
@ -0,0 +1,152 @@
|
|||
describe('RUM early interceptor', () => {
|
||||
const originalFetch = window.fetch;
|
||||
const OriginalXMLHttpRequest = window.XMLHttpRequest;
|
||||
|
||||
beforeEach(() => {
|
||||
jest.resetModules();
|
||||
window.fetch = jest.fn(() => Promise.resolve({} as Response)) as unknown as typeof fetch;
|
||||
window.XMLHttpRequest = OriginalXMLHttpRequest;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
window.fetch = originalFetch;
|
||||
window.XMLHttpRequest = OriginalXMLHttpRequest;
|
||||
window.__libreChatRumInterceptor?.clear();
|
||||
delete window.__libreChatRumInterceptor;
|
||||
});
|
||||
|
||||
it('adds auth only to the configured RUM origin and path', async () => {
|
||||
await import('./early');
|
||||
|
||||
window.__libreChatRumInterceptor?.configure({
|
||||
url: 'https://rum.example.com/ingest',
|
||||
authHeaderScheme: 'Bearer',
|
||||
tokenProvider: () => 'token-123',
|
||||
});
|
||||
|
||||
await fetch('https://rum.example.com/ingest/v1/traces');
|
||||
await fetch('https://rum.example.com.attacker.com/ingest/v1/traces');
|
||||
await fetch('https://rum.example.com/other');
|
||||
|
||||
const calls = (window.fetch as jest.MockedFunction<typeof fetch>).mock.calls;
|
||||
expect(new Headers(calls[0][1]?.headers).get('authorization')).toBe('Bearer token-123');
|
||||
expect(calls[1][1]?.headers).toBeUndefined();
|
||||
expect(calls[2][1]?.headers).toBeUndefined();
|
||||
});
|
||||
|
||||
it('supports Basic auth when configured', async () => {
|
||||
await import('./early');
|
||||
|
||||
window.__libreChatRumInterceptor?.configure({
|
||||
url: 'https://rum.example.com',
|
||||
authHeaderScheme: 'Basic',
|
||||
tokenProvider: () => 'token-123',
|
||||
});
|
||||
|
||||
await fetch('https://rum.example.com/v1/traces');
|
||||
|
||||
const calls = (window.fetch as jest.MockedFunction<typeof fetch>).mock.calls;
|
||||
expect(new Headers(calls[0][1]?.headers).get('authorization')).toBe('Basic token-123');
|
||||
});
|
||||
|
||||
it('leaves requests unchanged when token is unavailable', async () => {
|
||||
await import('./early');
|
||||
|
||||
window.__libreChatRumInterceptor?.configure({
|
||||
url: 'https://rum.example.com',
|
||||
tokenProvider: () => undefined,
|
||||
});
|
||||
|
||||
await fetch('https://rum.example.com/v1/traces');
|
||||
|
||||
const calls = (window.fetch as jest.MockedFunction<typeof fetch>).mock.calls;
|
||||
expect(calls[0][1]?.headers).toBeUndefined();
|
||||
});
|
||||
|
||||
it('preserves XMLHttpRequest constructor constants', async () => {
|
||||
await import('./early');
|
||||
|
||||
expect(window.XMLHttpRequest.DONE).toBe(OriginalXMLHttpRequest.DONE);
|
||||
expect(window.XMLHttpRequest.prototype).toBe(OriginalXMLHttpRequest.prototype);
|
||||
});
|
||||
|
||||
it('falls back to SDK-set XHR auth when the token provider is empty', async () => {
|
||||
const instances: Array<{ headers: Map<string, string> }> = [];
|
||||
|
||||
class FakeXMLHttpRequest {
|
||||
static DONE = 4;
|
||||
headers = new Map<string, string>();
|
||||
|
||||
constructor() {
|
||||
instances.push(this);
|
||||
}
|
||||
|
||||
open() {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
setRequestHeader(name: string, value: string) {
|
||||
this.headers.set(name.toLowerCase(), value);
|
||||
}
|
||||
|
||||
send() {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
window.XMLHttpRequest = FakeXMLHttpRequest as unknown as typeof XMLHttpRequest;
|
||||
await import('./early');
|
||||
|
||||
window.__libreChatRumInterceptor?.configure({
|
||||
url: 'https://rum.example.com/ingest',
|
||||
tokenProvider: () => undefined,
|
||||
});
|
||||
|
||||
const xhr = new XMLHttpRequest();
|
||||
xhr.open('POST', 'https://rum.example.com/ingest/v1/traces');
|
||||
xhr.setRequestHeader('authorization', 'Bearer sdk-key');
|
||||
xhr.send();
|
||||
|
||||
expect(instances[0].headers.get('authorization')).toBe('Bearer sdk-key');
|
||||
});
|
||||
|
||||
it('preserves XHR credentials when async is omitted', async () => {
|
||||
const openCalls: unknown[][] = [];
|
||||
|
||||
class FakeXMLHttpRequest {
|
||||
static DONE = 4;
|
||||
|
||||
open(...args: unknown[]) {
|
||||
openCalls.push(args);
|
||||
}
|
||||
|
||||
setRequestHeader() {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
send() {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
window.XMLHttpRequest = FakeXMLHttpRequest as unknown as typeof XMLHttpRequest;
|
||||
await import('./early');
|
||||
|
||||
const xhr = new XMLHttpRequest();
|
||||
(xhr.open as unknown as (...args: unknown[]) => void)(
|
||||
'GET',
|
||||
'https://api.example.com/resource',
|
||||
undefined,
|
||||
'user',
|
||||
'password',
|
||||
);
|
||||
|
||||
expect(openCalls[0]).toEqual([
|
||||
'GET',
|
||||
'https://api.example.com/resource',
|
||||
true,
|
||||
'user',
|
||||
'password',
|
||||
]);
|
||||
});
|
||||
});
|
||||
163
client/src/lib/rum/early.ts
Normal file
163
client/src/lib/rum/early.ts
Normal file
|
|
@ -0,0 +1,163 @@
|
|||
type TokenProvider = () => string | undefined;
|
||||
|
||||
type RumInterceptorConfig = {
|
||||
url?: string;
|
||||
tokenProvider?: TokenProvider;
|
||||
authHeaderScheme?: 'Bearer' | 'Basic';
|
||||
};
|
||||
|
||||
declare global {
|
||||
interface Window {
|
||||
__libreChatRumInterceptor?: {
|
||||
configure: (config: RumInterceptorConfig) => void;
|
||||
clear: () => void;
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
let rumUrl: URL | undefined;
|
||||
let tokenProvider: TokenProvider | undefined;
|
||||
let authHeaderScheme: 'Bearer' | 'Basic' = 'Bearer';
|
||||
|
||||
const originalFetch = window.fetch.bind(window);
|
||||
const OriginalXMLHttpRequest = window.XMLHttpRequest;
|
||||
|
||||
function isRequest(input: RequestInfo | URL): input is Request {
|
||||
return typeof Request !== 'undefined' && input instanceof Request;
|
||||
}
|
||||
|
||||
function parseUrl(value: string | URL): URL | undefined {
|
||||
try {
|
||||
return value instanceof URL ? value : new URL(value, window.location.origin);
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
function matchesRumUrl(value: string | URL): boolean {
|
||||
if (!rumUrl) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const requestUrl = parseUrl(value);
|
||||
if (!requestUrl || requestUrl.origin !== rumUrl.origin) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const basePath = rumUrl.pathname.endsWith('/') ? rumUrl.pathname : `${rumUrl.pathname}/`;
|
||||
return requestUrl.pathname === rumUrl.pathname || requestUrl.pathname.startsWith(basePath);
|
||||
}
|
||||
|
||||
function getAuthorization(): string | undefined {
|
||||
const token = tokenProvider?.();
|
||||
return token ? `${authHeaderScheme} ${token}` : undefined;
|
||||
}
|
||||
|
||||
const interceptedFetch = function interceptedFetch(
|
||||
input: RequestInfo | URL,
|
||||
init?: RequestInit,
|
||||
): Promise<Response> {
|
||||
const requestUrl = isRequest(input) ? input.url : input;
|
||||
|
||||
if (!matchesRumUrl(requestUrl)) {
|
||||
return originalFetch(input, init);
|
||||
}
|
||||
|
||||
const authorization = getAuthorization();
|
||||
if (!authorization) {
|
||||
return originalFetch(input, init);
|
||||
}
|
||||
|
||||
const headers = new Headers(isRequest(input) ? input.headers : init?.headers);
|
||||
headers.set('authorization', authorization);
|
||||
|
||||
return originalFetch(input, {
|
||||
...init,
|
||||
headers,
|
||||
});
|
||||
};
|
||||
|
||||
window.fetch = Object.assign(interceptedFetch, window.fetch);
|
||||
|
||||
function InterceptedXMLHttpRequest(): XMLHttpRequest {
|
||||
const xhr = new OriginalXMLHttpRequest();
|
||||
const originalOpen = xhr.open;
|
||||
const originalSend = xhr.send;
|
||||
const originalSetRequestHeader = xhr.setRequestHeader;
|
||||
let isRumRequest = false;
|
||||
let rumSdkAuthorization: string | undefined;
|
||||
|
||||
xhr.open = function open(
|
||||
method: string,
|
||||
url: string | URL,
|
||||
async?: boolean,
|
||||
username?: string | null,
|
||||
password?: string | null,
|
||||
): void {
|
||||
isRumRequest = matchesRumUrl(url);
|
||||
|
||||
if (arguments.length >= 5) {
|
||||
originalOpen.call(
|
||||
this,
|
||||
method,
|
||||
url,
|
||||
async ?? true,
|
||||
username ?? undefined,
|
||||
password ?? undefined,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
if (arguments.length === 4) {
|
||||
originalOpen.call(this, method, url, async ?? true, username ?? undefined);
|
||||
return;
|
||||
}
|
||||
|
||||
if (arguments.length === 3) {
|
||||
originalOpen.call(this, method, url, async ?? true);
|
||||
return;
|
||||
}
|
||||
|
||||
originalOpen.call(this, method, url);
|
||||
};
|
||||
|
||||
xhr.setRequestHeader = function setRequestHeader(name: string, value: string): void {
|
||||
if (isRumRequest && name.toLowerCase() === 'authorization') {
|
||||
// HyperDX sets its own API key header; userJwt mode replaces it with the LibreChat auth token.
|
||||
rumSdkAuthorization = value;
|
||||
return;
|
||||
}
|
||||
|
||||
originalSetRequestHeader.call(this, name, value);
|
||||
};
|
||||
|
||||
xhr.send = function send(body?: Document | XMLHttpRequestBodyInit | null): void {
|
||||
const authorization = isRumRequest ? (getAuthorization() ?? rumSdkAuthorization) : undefined;
|
||||
if (authorization) {
|
||||
originalSetRequestHeader.call(this, 'authorization', authorization);
|
||||
}
|
||||
|
||||
originalSend.call(this, body);
|
||||
};
|
||||
|
||||
return xhr;
|
||||
}
|
||||
|
||||
Object.setPrototypeOf(InterceptedXMLHttpRequest, OriginalXMLHttpRequest);
|
||||
InterceptedXMLHttpRequest.prototype = OriginalXMLHttpRequest.prototype;
|
||||
window.XMLHttpRequest = InterceptedXMLHttpRequest as unknown as typeof XMLHttpRequest;
|
||||
|
||||
window.__libreChatRumInterceptor = {
|
||||
configure(config: RumInterceptorConfig) {
|
||||
rumUrl = config.url ? parseUrl(config.url) : undefined;
|
||||
tokenProvider = config.tokenProvider;
|
||||
authHeaderScheme = config.authHeaderScheme ?? 'Bearer';
|
||||
},
|
||||
clear() {
|
||||
rumUrl = undefined;
|
||||
tokenProvider = undefined;
|
||||
authHeaderScheme = 'Bearer';
|
||||
},
|
||||
};
|
||||
|
||||
export {};
|
||||
19
client/src/lib/rum/routes.spec.ts
Normal file
19
client/src/lib/rum/routes.spec.ts
Normal file
|
|
@ -0,0 +1,19 @@
|
|||
import { normalizeRumPath } from './routes';
|
||||
|
||||
describe('normalizeRumPath', () => {
|
||||
it('normalizes dynamic LibreChat route identifiers', () => {
|
||||
expect(normalizeRumPath('/c/65a5e0a7d1c2b3a4f5e6d789')).toBe('/c/:conversationId');
|
||||
expect(normalizeRumPath('/share/65a5e0a7d1c2b3a4f5e6d789')).toBe('/share/:shareId');
|
||||
expect(normalizeRumPath('/assistants/asst_123')).toBe('/assistants/:assistantId');
|
||||
});
|
||||
|
||||
it('normalizes generic UUID and ObjectId path segments', () => {
|
||||
expect(normalizeRumPath('/files/550e8400-e29b-41d4-a716-446655440000')).toBe('/files/:id');
|
||||
expect(normalizeRumPath('/files/65a5e0a7d1c2b3a4f5e6d789/preview')).toBe('/files/:id/preview');
|
||||
});
|
||||
|
||||
it('preserves static routes', () => {
|
||||
expect(normalizeRumPath('/search')).toBe('/search');
|
||||
expect(normalizeRumPath('/')).toBe('/');
|
||||
});
|
||||
});
|
||||
31
client/src/lib/rum/routes.ts
Normal file
31
client/src/lib/rum/routes.ts
Normal file
|
|
@ -0,0 +1,31 @@
|
|||
const OBJECT_ID = /^[0-9a-f]{24}$/i;
|
||||
const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
|
||||
|
||||
function normalizeSegment(segment: string, previous: string | undefined): string {
|
||||
if (previous === 'c') {
|
||||
return ':conversationId';
|
||||
}
|
||||
|
||||
if (previous === 'share') {
|
||||
return ':shareId';
|
||||
}
|
||||
|
||||
if (previous === 'assistants') {
|
||||
return ':assistantId';
|
||||
}
|
||||
|
||||
if (UUID.test(segment) || OBJECT_ID.test(segment)) {
|
||||
return ':id';
|
||||
}
|
||||
|
||||
return segment;
|
||||
}
|
||||
|
||||
export function normalizeRumPath(pathname: string): string {
|
||||
const segments = pathname.split('/').filter(Boolean);
|
||||
if (segments.length === 0) {
|
||||
return '/';
|
||||
}
|
||||
|
||||
return `/${segments.map((segment, index) => normalizeSegment(segment, segments[index - 1])).join('/')}`;
|
||||
}
|
||||
123
client/src/lib/rum/useRum.spec.tsx
Normal file
123
client/src/lib/rum/useRum.spec.tsx
Normal file
|
|
@ -0,0 +1,123 @@
|
|||
import { renderHook, waitFor } from '@testing-library/react';
|
||||
import useRum from './useRum';
|
||||
|
||||
const mockInit = jest.fn();
|
||||
const mockSetGlobalAttributes = jest.fn();
|
||||
const mockUseGetStartupConfig = jest.fn();
|
||||
const mockUseAuthContext = jest.fn();
|
||||
const mockUseLocation = jest.fn();
|
||||
|
||||
jest.mock('@hyperdx/browser', () => ({
|
||||
__esModule: true,
|
||||
default: {
|
||||
init: (...args: unknown[]) => mockInit(...args),
|
||||
setGlobalAttributes: (...args: unknown[]) => mockSetGlobalAttributes(...args),
|
||||
},
|
||||
}));
|
||||
|
||||
jest.mock('~/data-provider', () => ({
|
||||
useGetStartupConfig: () => mockUseGetStartupConfig(),
|
||||
}));
|
||||
|
||||
jest.mock('~/hooks/AuthContext', () => ({
|
||||
useAuthContext: () => mockUseAuthContext(),
|
||||
}));
|
||||
|
||||
jest.mock('react-router-dom', () => ({
|
||||
...jest.requireActual('react-router-dom'),
|
||||
useLocation: () => mockUseLocation(),
|
||||
}));
|
||||
|
||||
describe('useRum', () => {
|
||||
beforeEach(() => {
|
||||
mockUseLocation.mockReturnValue({ pathname: '/c/conversation-123' });
|
||||
mockUseAuthContext.mockReturnValue({
|
||||
isAuthenticated: true,
|
||||
token: 'jwt-token',
|
||||
user: {
|
||||
id: 'user-123',
|
||||
role: 'USER',
|
||||
tenantId: 'org-123',
|
||||
email: 'user@example.com',
|
||||
},
|
||||
});
|
||||
delete window.__libreChatRumInterceptor;
|
||||
});
|
||||
|
||||
it('initializes HyperDX public-token RUM with privacy defaults and safe attributes', async () => {
|
||||
mockUseGetStartupConfig.mockReturnValue({
|
||||
data: {
|
||||
rum: {
|
||||
provider: 'hyperdx',
|
||||
enabled: true,
|
||||
url: 'https://rum.example.com',
|
||||
serviceName: 'librechat-web',
|
||||
authMode: 'publicToken',
|
||||
publicToken: 'public-token',
|
||||
tracePropagationTargets: ['https://librechat.example.com'],
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
renderHook(() => useRum());
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockInit).toHaveBeenCalledWith({
|
||||
advancedNetworkCapture: false,
|
||||
apiKey: 'public-token',
|
||||
consoleCapture: false,
|
||||
disableReplay: true,
|
||||
service: 'librechat-web',
|
||||
tracePropagationTargets: ['https://librechat.example.com'],
|
||||
url: 'https://rum.example.com',
|
||||
});
|
||||
});
|
||||
|
||||
expect(mockSetGlobalAttributes).toHaveBeenCalledWith({
|
||||
route: '/c/:conversationId',
|
||||
role: 'USER',
|
||||
userId: 'user-123',
|
||||
orgId: 'org-123',
|
||||
serviceName: 'librechat-web',
|
||||
});
|
||||
expect(mockSetGlobalAttributes).not.toHaveBeenCalledWith(
|
||||
expect.objectContaining({ email: 'user@example.com' }),
|
||||
);
|
||||
});
|
||||
|
||||
it('configures the early interceptor and uses a placeholder api key for userJwt mode', async () => {
|
||||
const configure = jest.fn();
|
||||
const clear = jest.fn();
|
||||
window.__libreChatRumInterceptor = { configure, clear };
|
||||
mockUseGetStartupConfig.mockReturnValue({
|
||||
data: {
|
||||
rum: {
|
||||
provider: 'hyperdx',
|
||||
enabled: true,
|
||||
url: 'https://rum.example.com/ingest',
|
||||
serviceName: 'librechat-web',
|
||||
authMode: 'userJwt',
|
||||
authHeaderScheme: 'Basic',
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
renderHook(() => useRum());
|
||||
|
||||
expect(configure).toHaveBeenCalledWith({
|
||||
url: 'https://rum.example.com/ingest',
|
||||
authHeaderScheme: 'Basic',
|
||||
tokenProvider: expect.any(Function),
|
||||
});
|
||||
expect(configure.mock.calls[0][0].tokenProvider()).toBe('jwt-token');
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockInit).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
apiKey: 'placeholder',
|
||||
url: 'https://rum.example.com/ingest',
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
163
client/src/lib/rum/useRum.ts
Normal file
163
client/src/lib/rum/useRum.ts
Normal file
|
|
@ -0,0 +1,163 @@
|
|||
import { useEffect, useMemo, useRef } from 'react';
|
||||
import { useLocation } from 'react-router-dom';
|
||||
import type { TRumConfig, TUser } from 'librechat-data-provider';
|
||||
import { useGetStartupConfig } from '~/data-provider';
|
||||
import { useAuthContext } from '~/hooks/AuthContext';
|
||||
import { normalizeRumPath } from './routes';
|
||||
|
||||
type HyperDXBrowser = {
|
||||
init: (config: {
|
||||
advancedNetworkCapture: boolean;
|
||||
apiKey: string;
|
||||
consoleCapture: boolean;
|
||||
disableReplay: boolean;
|
||||
service: string;
|
||||
tracePropagationTargets?: string[];
|
||||
url: string;
|
||||
}) => void;
|
||||
setGlobalAttributes: (attributes: Record<string, string>) => void;
|
||||
};
|
||||
|
||||
function shouldInitializeRum(config: TRumConfig | undefined, token: string | undefined): boolean {
|
||||
if (!config?.enabled || config.provider !== 'hyperdx' || !config.url || !config.serviceName) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (config.authMode === 'publicToken') {
|
||||
return !!config.publicToken;
|
||||
}
|
||||
|
||||
return !!token;
|
||||
}
|
||||
|
||||
function getApiKey(config: TRumConfig): string {
|
||||
return config.authMode === 'publicToken' ? (config.publicToken ?? '') : 'placeholder';
|
||||
}
|
||||
|
||||
function buildGlobalAttributes(
|
||||
user: TUser | undefined,
|
||||
config: TRumConfig,
|
||||
route: string,
|
||||
): Record<string, string> {
|
||||
return Object.fromEntries(
|
||||
Object.entries({
|
||||
route,
|
||||
role: user?.role,
|
||||
userId: user?.id,
|
||||
orgId: user?.tenantId,
|
||||
serviceName: config.serviceName,
|
||||
environment: config.environment,
|
||||
}).filter(
|
||||
(entry): entry is [string, string] => typeof entry[1] === 'string' && entry[1] !== '',
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
async function loadHyperDX(): Promise<HyperDXBrowser> {
|
||||
const module = await import('@hyperdx/browser');
|
||||
return module.default;
|
||||
}
|
||||
|
||||
export default function useRum(): void {
|
||||
const { data: startupConfig } = useGetStartupConfig();
|
||||
const { isAuthenticated, token, user } = useAuthContext();
|
||||
const location = useLocation();
|
||||
const initializedKeyRef = useRef<string | undefined>(undefined);
|
||||
const sampledInitKeyRef = useRef<string | undefined>(undefined);
|
||||
const sampledInRef = useRef<boolean>(true);
|
||||
const hyperDxRef = useRef<HyperDXBrowser | undefined>(undefined);
|
||||
const tokenRef = useRef<string | undefined>(token);
|
||||
const rumConfig = startupConfig?.rum;
|
||||
const route = useMemo(() => normalizeRumPath(location.pathname), [location.pathname]);
|
||||
const routeRef = useRef<string>(route);
|
||||
|
||||
useEffect(() => {
|
||||
tokenRef.current = token;
|
||||
}, [token]);
|
||||
|
||||
useEffect(() => {
|
||||
routeRef.current = route;
|
||||
}, [route]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!rumConfig || rumConfig.authMode !== 'userJwt') {
|
||||
window.__libreChatRumInterceptor?.clear();
|
||||
return;
|
||||
}
|
||||
|
||||
window.__libreChatRumInterceptor?.configure({
|
||||
url: rumConfig.url,
|
||||
authHeaderScheme: rumConfig.authHeaderScheme,
|
||||
tokenProvider: () => tokenRef.current,
|
||||
});
|
||||
|
||||
return () => {
|
||||
window.__libreChatRumInterceptor?.clear();
|
||||
};
|
||||
}, [rumConfig]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!rumConfig) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (
|
||||
!shouldInitializeRum(rumConfig, token) ||
|
||||
(rumConfig.authMode === 'userJwt' && !isAuthenticated)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
const config = rumConfig;
|
||||
|
||||
const initKey = [config.url, config.serviceName, config.authMode, config.publicToken].join(':');
|
||||
|
||||
if (initializedKeyRef.current === initKey) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (sampledInitKeyRef.current !== initKey) {
|
||||
sampledInitKeyRef.current = initKey;
|
||||
sampledInRef.current =
|
||||
typeof config.sampleRate === 'number' ? Math.random() < config.sampleRate : true;
|
||||
}
|
||||
|
||||
if (!sampledInRef.current) {
|
||||
return;
|
||||
}
|
||||
|
||||
let cancelled = false;
|
||||
|
||||
loadHyperDX()
|
||||
.then((HyperDX) => {
|
||||
if (cancelled || initializedKeyRef.current === initKey) {
|
||||
return;
|
||||
}
|
||||
|
||||
HyperDX.init({
|
||||
advancedNetworkCapture: config.advancedNetworkCapture ?? false,
|
||||
apiKey: getApiKey(config),
|
||||
consoleCapture: config.consoleCapture ?? false,
|
||||
disableReplay: config.disableReplay ?? true,
|
||||
service: config.serviceName,
|
||||
tracePropagationTargets: config.tracePropagationTargets,
|
||||
url: config.url,
|
||||
});
|
||||
|
||||
hyperDxRef.current = HyperDX;
|
||||
initializedKeyRef.current = initKey;
|
||||
HyperDX.setGlobalAttributes(buildGlobalAttributes(user, config, routeRef.current));
|
||||
})
|
||||
.catch(() => undefined);
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [isAuthenticated, rumConfig, token, user]);
|
||||
|
||||
useEffect(() => {
|
||||
hyperDxRef.current?.setGlobalAttributes(
|
||||
rumConfig ? buildGlobalAttributes(user, rumConfig, route) : { route },
|
||||
);
|
||||
}, [route, rumConfig, user]);
|
||||
}
|
||||
|
|
@ -1,3 +1,4 @@
|
|||
import './lib/rum/early';
|
||||
import 'regenerator-runtime/runtime';
|
||||
import { createRoot } from 'react-dom/client';
|
||||
import './locales/i18n';
|
||||
|
|
|
|||
|
|
@ -12,6 +12,7 @@ import { MarketplaceProvider } from '~/components/Agents/MarketplaceContext';
|
|||
import AgentMarketplace from '~/components/Agents/Marketplace';
|
||||
import { OAuthSuccess, OAuthError } from '~/components/OAuth';
|
||||
import { AuthContextProvider } from '~/hooks/AuthContext';
|
||||
import WithRum from '~/lib/rum/WithRum';
|
||||
import RouteErrorBoundary from './RouteErrorBoundary';
|
||||
import StartupLayout from './Layouts/Startup';
|
||||
import LoginLayout from './Layouts/Login';
|
||||
|
|
@ -23,7 +24,9 @@ import Root from './Root';
|
|||
|
||||
const AuthLayout = () => (
|
||||
<AuthContextProvider>
|
||||
<Outlet />
|
||||
<WithRum>
|
||||
<Outlet />
|
||||
</WithRum>
|
||||
<ApiErrorWatcher />
|
||||
</AuthContextProvider>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -82,7 +82,7 @@ export default defineConfig(({ command }) => ({
|
|||
'assets/maskable-icon.png',
|
||||
'manifest.webmanifest',
|
||||
],
|
||||
globIgnores: ['images/**/*', '**/*.map', 'index.html'],
|
||||
globIgnores: ['images/**/*', '**/*.map', 'index.html', 'assets/rum.*.js'],
|
||||
maximumFileSizeToCacheInBytes: 4 * 1024 * 1024,
|
||||
navigateFallbackDenylist: [/^\/oauth/, /^\/api/],
|
||||
},
|
||||
|
|
@ -139,7 +139,9 @@ export default defineConfig(({ command }) => ({
|
|||
manualChunks(id: string) {
|
||||
const normalizedId = id.replace(/\\/g, '/');
|
||||
if (normalizedId.includes('node_modules')) {
|
||||
// High-impact chunking for large libraries
|
||||
if (normalizedId.includes('@hyperdx/')) {
|
||||
return 'rum';
|
||||
}
|
||||
|
||||
// IMPORTANT: mermaid and ALL its dependencies must be in the same chunk
|
||||
// to avoid initialization order issues. This includes chevrotain, langium,
|
||||
|
|
|
|||
|
|
@ -25,7 +25,6 @@ const compat = new FlatCompat({
|
|||
export default [
|
||||
{
|
||||
ignores: [
|
||||
'client/vite.config.ts',
|
||||
'client/dist/**/*',
|
||||
'client/public/**/*',
|
||||
'client/coverage/**/*',
|
||||
|
|
@ -170,12 +169,15 @@ export default [
|
|||
},
|
||||
},
|
||||
{
|
||||
files: ['**/rollup.config.js', '**/.eslintrc.js', '**/jest.config.js'],
|
||||
files: ['**/rollup.config.js', '**/.eslintrc.js', '**/jest.config.js', 'client/vite.config.ts'],
|
||||
languageOptions: {
|
||||
globals: {
|
||||
...globals.node,
|
||||
},
|
||||
},
|
||||
rules: {
|
||||
'import/no-cycle': 'off',
|
||||
},
|
||||
},
|
||||
{
|
||||
files: [
|
||||
|
|
@ -217,7 +219,7 @@ export default [
|
|||
})),
|
||||
{
|
||||
files: ['**/*.ts', '**/*.tsx'],
|
||||
ignores: ['packages/**/*'],
|
||||
ignores: ['packages/**/*', 'client/vite.config.ts'],
|
||||
plugins: {
|
||||
'@typescript-eslint': typescriptEslintEslintPlugin,
|
||||
jest: fixupPluginRules(jest),
|
||||
|
|
|
|||
1621
package-lock.json
generated
1621
package-lock.json
generated
File diff suppressed because it is too large
Load diff
|
|
@ -1066,6 +1066,22 @@ export const turnstileSchema = z.object({
|
|||
|
||||
export type TTurnstileConfig = z.infer<typeof turnstileSchema>;
|
||||
|
||||
export type TRumConfig = {
|
||||
provider: 'hyperdx';
|
||||
enabled: boolean;
|
||||
url: string;
|
||||
serviceName: string;
|
||||
authMode: 'publicToken' | 'userJwt';
|
||||
authHeaderScheme?: 'Bearer' | 'Basic';
|
||||
publicToken?: string;
|
||||
tracePropagationTargets?: string[];
|
||||
consoleCapture?: boolean;
|
||||
disableReplay?: boolean;
|
||||
advancedNetworkCapture?: boolean;
|
||||
sampleRate?: number;
|
||||
environment?: string;
|
||||
};
|
||||
|
||||
export type TStartupConfig = {
|
||||
appTitle: string;
|
||||
socialLogins?: string[];
|
||||
|
|
@ -1106,6 +1122,7 @@ export type TStartupConfig = {
|
|||
sharedLinksEnabled: boolean;
|
||||
publicSharedLinksEnabled: boolean;
|
||||
analyticsGtmId?: string;
|
||||
rum?: TRumConfig;
|
||||
bundlerURL?: string;
|
||||
staticBundlerURL?: string;
|
||||
sharePointFilePickerEnabled?: boolean;
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue