📡 feat: add rum browser page-load diagnostics (#14106)

* feat(rum): add browser navigation diagnostics

* Potential fix for pull request finding

Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>

* feat(rum): add browser navigation diagnostics

* refactor(rum): extract bootstrap diagnostics

* test(rum): fix bootstrap spec typings

* fix(rum): keep stale asset recovery inline

* fix(rum): simplify bootstrap recovery split

* fix(rum): discard early queue when unsampled

* fix(rum): restore emitter after re-enable

* fix(rum): ignore optional bootstrap failures

* fix(rum): preserve proxy queue until token

---------

Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
This commit is contained in:
Ravi Kumar L 2026-07-05 17:32:53 +02:00 committed by GitHub
parent 446b73bb6b
commit e8d76542b6
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
12 changed files with 1030 additions and 10 deletions

View file

@ -1,4 +1,4 @@
<!DOCTYPE html>
<!doctype html>
<html lang="en-US">
<head>
<meta charset="utf-8" />
@ -7,12 +7,18 @@
<meta name="mobile-web-app-capable" content="yes" />
<meta name="apple-mobile-web-app-capable" content="yes" />
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent" />
<meta name="description" content="LibreChat - An open source chat application with support for multiple AI models" />
<meta
name="description"
content="LibreChat - An open source chat application with support for multiple AI models"
/>
<title>LibreChat</title>
<link rel="icon" type="image/png" sizes="32x32" href="assets/favicon-32x32.png" />
<link rel="icon" type="image/png" sizes="16x16" href="assets/favicon-16x16.png" />
<link rel="apple-touch-icon" href="assets/apple-touch-icon-180x180.png" />
<meta name="viewport" content="width=device-width, initial-scale=1, interactive-widget=resizes-content" />
<meta
name="viewport"
content="width=device-width, initial-scale=1, interactive-widget=resizes-content"
/>
<style>
html,
body {
@ -51,6 +57,46 @@
<script>
(function () {
var KEY = 'lc-asset-recovery-at';
var RUM_QUEUE_KEY = 'lc-rum-queue';
var MAX_RUM_QUEUE = 20;
window.__lcRumRecoveryGuardInstalled = true;
window.__lcRumQueue = Array.isArray(window.__lcRumQueue) ? window.__lcRumQueue : [];
try {
var persistedQueue = JSON.parse(sessionStorage.getItem(RUM_QUEUE_KEY) || '[]');
if (Array.isArray(persistedQueue)) {
window.__lcRumQueue = persistedQueue.concat(window.__lcRumQueue).slice(-MAX_RUM_QUEUE);
}
} catch (e) {
/* Diagnostics should never affect application startup. */
}
window.__lcRumPush = function (type, attributes) {
try {
var safeAttributes = {};
Object.keys(attributes || {}).forEach(function (key) {
var value = attributes[key];
if (
typeof value !== 'string' &&
typeof value !== 'number' &&
typeof value !== 'boolean'
) {
return;
}
safeAttributes[key] = value;
});
if (window.__lcRumQueue.length >= MAX_RUM_QUEUE) {
window.__lcRumQueue.shift();
}
window.__lcRumQueue.push({
type: type,
at: Math.round(performance.now()),
visibilityState: document.visibilityState,
attributes: safeAttributes,
});
sessionStorage.setItem(RUM_QUEUE_KEY, JSON.stringify(window.__lcRumQueue));
} catch (e) {
/* Diagnostics should never affect application startup. */
}
};
function shouldRecover() {
try {
var last = Number(sessionStorage.getItem(KEY)) || 0;
@ -63,14 +109,13 @@
return false;
}
}
/** Recovers from stale builds (e.g. an outdated service worker serving old
* hashed chunks after a deploy) by unregistering workers and reloading once.
* Returns true when a recovery reload was initiated. */
window.__lcRecoverStaleAssets = function () {
if (!shouldRecover()) {
return false;
}
window.__lcRumPush('stale-asset-recovery-start');
var reload = function () {
window.__lcRumPush('stale-asset-recovery-reload');
window.location.reload();
};
if (navigator.serviceWorker) {
@ -116,6 +161,15 @@
var failedPreload =
el.tagName === 'LINK' && /preload/.test(el.rel || '') && /\.js$/.test(el.href || '');
if (failedScript || failedPreload) {
var isRumBootstrap = el.getAttribute('data-lc-rum-bootstrap') === 'true';
window.__lcRumPush('asset-load-error', {
tagName: el.tagName,
assetUrl: el.src || el.href,
optional: isRumBootstrap,
});
if (isRumBootstrap) {
return;
}
window.__lcRecoverStaleAssets();
}
},
@ -128,11 +182,17 @@
(message.indexOf('dynamically imported module') !== -1 ||
message.indexOf('Importing a module script failed') !== -1)
) {
window.__lcRumPush('dynamic-import-error');
window.__lcRecoverStaleAssets();
}
});
})();
</script>
<script
data-lc-rum-bootstrap="true"
type="module"
src="/src/lib/rum/bootstrap-entry.js"
></script>
<script defer type="module" src="/src/main.jsx"></script>
</head>
<body>

View file

@ -124,6 +124,7 @@
"tailwindcss-animate": "^1.0.5",
"tailwindcss-radix": "^2.8.0",
"ts-md5": "^1.3.1",
"web-vitals": "^3.5.2",
"zod": "^3.22.4"
},
"devDependencies": {

7
client/src/lib/rum/bootstrap-entry.js vendored Normal file
View file

@ -0,0 +1,7 @@
import { installRumBootstrap } from './bootstrap';
try {
installRumBootstrap(window);
} catch {
/* Diagnostics should never affect application startup. */
}

142
client/src/lib/rum/bootstrap.js vendored Normal file
View file

@ -0,0 +1,142 @@
const RUM_QUEUE_KEY = 'lc-rum-queue';
const MAX_RUM_QUEUE = 20;
function safeNow(targetWindow) {
return Math.round(targetWindow.performance?.now?.() ?? 0);
}
function safeAttributes(attributes) {
const output = {};
Object.keys(attributes || {}).forEach((key) => {
const value = attributes[key];
if (typeof value !== 'string' && typeof value !== 'number' && typeof value !== 'boolean') {
return;
}
output[key] = value;
});
return output;
}
export function installRumBootstrap(targetWindow) {
const targetDocument = targetWindow.document;
const targetNavigator = targetWindow.navigator;
let targetSessionStorage;
try {
targetSessionStorage = targetWindow.sessionStorage;
} catch {
targetSessionStorage = undefined;
}
targetWindow.__lcRumQueue = Array.isArray(targetWindow.__lcRumQueue)
? targetWindow.__lcRumQueue
: [];
function enqueueRumEvent(event, shouldPersist) {
if (targetWindow.__lcRumQueue.length >= MAX_RUM_QUEUE) {
const replaceIndex = targetWindow.__lcRumQueue.findIndex(
(queuedEvent) => queuedEvent && queuedEvent.type === 'visibility-change',
);
targetWindow.__lcRumQueue.splice(replaceIndex === -1 ? 0 : replaceIndex, 1);
}
targetWindow.__lcRumQueue.push(event);
if (shouldPersist !== false) {
persistRumQueue();
}
}
function recordRumQueueStorageError(operation) {
try {
enqueueRumEvent(
{
type: 'rum-queue-storage-error',
at: safeNow(targetWindow),
visibilityState: targetDocument.visibilityState,
attributes: { operation },
},
false,
);
} catch {
/* Diagnostics should never affect application startup. */
}
}
function persistRumQueue() {
try {
if (!targetSessionStorage) {
throw new Error('sessionStorage unavailable');
}
targetSessionStorage.setItem(RUM_QUEUE_KEY, JSON.stringify(targetWindow.__lcRumQueue));
} catch {
recordRumQueueStorageError('persist');
}
}
targetWindow.__lcRumPush = function (type, attributes) {
try {
enqueueRumEvent({
type,
at: safeNow(targetWindow),
visibilityState: targetDocument.visibilityState,
attributes: safeAttributes(attributes),
});
} catch {
/* Diagnostics should never affect application startup. */
}
};
targetWindow.__lcRumPush('inline-start', {
prerendering: targetDocument.prerendering === true,
currentPath: targetWindow.location.pathname,
});
targetDocument.addEventListener(
'visibilitychange',
() => {
targetWindow.__lcRumPush('visibility-change', { state: targetDocument.visibilityState });
},
true,
);
targetWindow.addEventListener(
'pageshow',
(event) => {
targetWindow.__lcRumPush('pageshow', { persisted: event.persisted === true });
},
true,
);
if (targetNavigator.serviceWorker) {
const controller = targetNavigator.serviceWorker.controller;
targetWindow.__lcRumPush('sw-controller', {
controlled: !!controller,
state: controller && controller.state,
scriptUrl: controller && controller.scriptURL,
});
targetNavigator.serviceWorker.getRegistrations().then(
(registrations) => {
targetWindow.__lcRumPush('sw-registrations', {
count: registrations.length,
firstScopeUrl: registrations[0] && registrations[0].scope,
});
},
() => {
targetWindow.__lcRumPush('sw-registrations-error');
},
);
targetNavigator.serviceWorker.addEventListener('controllerchange', () => {
const nextController = targetNavigator.serviceWorker.controller;
targetWindow.__lcRumPush('sw-controller-change', {
state: nextController && nextController.state,
scriptUrl: nextController && nextController.scriptURL,
});
});
targetNavigator.serviceWorker.addEventListener('message', (event) => {
if (!event.data || event.data.type !== 'LC_SW_PING') {
return;
}
targetWindow.__lcRumPush('sw-ping');
targetWindow.__lcRumPush('sw-pong');
});
}
}

View file

@ -0,0 +1,97 @@
import { installRumBootstrap } from './bootstrap';
type BootstrapTestWindow = {
__lcRumPush?: unknown;
__lcRumQueue?: unknown;
};
function bootstrapWindow(): BootstrapTestWindow {
return window as unknown as BootstrapTestWindow;
}
describe('rum bootstrap', () => {
beforeEach(() => {
sessionStorage.clear();
bootstrapWindow().__lcRumQueue = undefined;
bootstrapWindow().__lcRumPush = undefined;
Object.defineProperty(navigator, 'serviceWorker', {
configurable: true,
value: undefined,
});
jest.spyOn(performance, 'now').mockReturnValue(42.4);
});
afterEach(() => {
jest.restoreAllMocks();
});
it('forces the early RUM queue to an array and persists sanitized events', () => {
bootstrapWindow().__lcRumQueue = 'bad';
installRumBootstrap(window);
window.__lcRumPush?.('asset-load-error', {
assetUrl: '/assets/app.js?token=secret',
nested: { dropped: true },
tagName: 'SCRIPT',
});
const persistedQueue = JSON.parse(sessionStorage.getItem('lc-rum-queue') || '[]');
expect(Array.isArray(window.__lcRumQueue)).toBe(true);
expect(window.__lcRumQueue?.[0]).toEqual(
expect.objectContaining({
type: 'inline-start',
attributes: expect.objectContaining({ currentPath: '/' }),
}),
);
expect(persistedQueue.at(-1)).toEqual({
type: 'asset-load-error',
at: 42,
visibilityState: 'visible',
attributes: {
assetUrl: '/assets/app.js?token=secret',
tagName: 'SCRIPT',
},
});
});
it('preserves inline guard events already in the queue', () => {
bootstrapWindow().__lcRumQueue = [
{ type: 'asset-load-error', attributes: { tagName: 'SCRIPT' } },
];
installRumBootstrap(window);
expect(window.__lcRumQueue?.map((event) => event.type)).toEqual([
'asset-load-error',
'inline-start',
]);
});
it('records service worker pings without sending duplicate pongs', () => {
const postMessage = jest.fn();
let messageHandler: ((event: MessageEvent) => void) | undefined;
Object.defineProperty(navigator, 'serviceWorker', {
configurable: true,
value: {
addEventListener: jest.fn((eventName, handler) => {
if (eventName === 'message') {
messageHandler = handler;
}
}),
controller: undefined,
getRegistrations: jest.fn(() => Promise.resolve([])),
},
});
installRumBootstrap(window);
messageHandler?.({
data: { type: 'LC_SW_PING' },
source: { postMessage },
} as unknown as MessageEvent);
expect(postMessage).not.toHaveBeenCalled();
expect(window.__lcRumQueue?.map((event) => event.type)).toEqual(
expect.arrayContaining(['sw-ping', 'sw-pong']),
);
});
});

View file

@ -0,0 +1,239 @@
import type { FCPMetricWithAttribution } from 'web-vitals/attribution';
import {
discardEarlyRumQueue,
flushEarlyRumQueue,
queueSpaRouteChange,
registerFcpAttribution,
restoreRumEmitter,
testExports,
} from './diagnostics';
const mockOnFCP = jest.fn();
jest.mock('web-vitals/attribution', () => ({
onFCP: (...args: unknown[]) => mockOnFCP(...args),
}));
describe('rum diagnostics', () => {
const addAction = jest.fn();
beforeEach(() => {
jest.clearAllMocks();
testExports.resetDiagnosticsState();
window.history.replaceState({}, '', '/c/65a5e0a7d1c2b3a4f5e6d789?token=secret#hash');
window.__lcRumQueue = undefined;
window.__lcRumPush = undefined;
sessionStorage.clear();
jest.spyOn(performance, 'now').mockReturnValue(1234.4);
});
afterEach(() => {
jest.restoreAllMocks();
});
it('flushes early queued lifecycle events once', () => {
window.__lcRumQueue = [
{
type: 'sw-controller',
at: 2.2,
visibilityState: 'hidden',
attributes: {
scriptPath: '/service-worker.js',
fullUrl: 'https://example.com/c/secret',
ignored: { nested: true },
},
},
];
flushEarlyRumQueue({ addAction });
flushEarlyRumQueue({ addAction });
expect(addAction).toHaveBeenCalledTimes(1);
expect(addAction).toHaveBeenCalledWith('early-sw-controller', {
at: 2,
visibilityState: 'hidden',
scriptPath: '/service-worker.js',
fullPath: '/c/:conversationId',
});
expect(window.__lcRumQueue).toEqual([]);
});
it('routes SPA changes through the early RUM queue', () => {
window.__lcRumPush = jest.fn();
queueSpaRouteChange('/login', '/c/65a5e0a7d1c2b3a4f5e6d789');
expect(window.__lcRumPush).toHaveBeenCalledWith('spa-route-change', {
fromPath: '/login',
toPath: '/c/:conversationId',
pageElapsedMs: 1234,
});
});
it('emits queued SPA route changes without an early prefix', () => {
window.__lcRumQueue = [
{
type: 'spa-route-change',
at: 1234.4,
visibilityState: 'visible',
attributes: {
fromPath: '/login?token=secret',
toPath: '/c/65a5e0a7d1c2b3a4f5e6d789',
},
},
];
flushEarlyRumQueue({ addAction });
expect(addAction).toHaveBeenCalledWith('spa-route-change', {
fromPath: '/login',
toPath: '/c/:conversationId',
at: 1234,
visibilityState: 'visible',
});
});
it('keeps post-flush queue pushes non-throwing when HyperDX rejects an action', () => {
const throwingAddAction = jest.fn(() => {
throw new Error('sdk failure');
});
flushEarlyRumQueue({ addAction: throwingAddAction });
expect(() => window.__lcRumPush?.('stale-asset-recovery-start')).not.toThrow();
expect(throwingAddAction).toHaveBeenCalledWith(
'early-stale-asset-recovery-start',
expect.any(Object),
);
});
it('discards persisted early RUM when the page is not sampled', () => {
window.__lcRumQueue = [
{
type: 'asset-load-error',
attributes: { tagName: 'SCRIPT' },
},
];
window.__lcRumPush = jest.fn();
sessionStorage.setItem('lc-rum-queue', JSON.stringify(window.__lcRumQueue));
discardEarlyRumQueue();
window.__lcRumPush?.('spa-route-change', { fromPath: '/login', toPath: '/c/new' });
expect(window.__lcRumQueue).toEqual([]);
expect(sessionStorage.getItem('lc-rum-queue')).toBeNull();
});
it('restores the HyperDX-backed emitter after the early queue was discarded', () => {
window.__lcRumQueue = [];
discardEarlyRumQueue();
restoreRumEmitter({ addAction });
window.__lcRumPush?.('spa-route-change', { fromPath: '/login', toPath: '/c/new' });
expect(addAction).toHaveBeenCalledWith(
'spa-route-change',
expect.objectContaining({
fromPath: '/login',
toPath: '/c/new',
}),
);
});
it('builds FCP attribution from web-vitals attribution metrics', () => {
const metric = {
name: 'FCP',
value: 11568.4,
rating: 'poor',
delta: 11568.4,
id: 'v1-123',
navigationType: 'navigate',
entries: [],
attribution: {
timeToFirstByte: 10955.4,
firstByteToFCP: 613,
loadState: 'complete',
fcpEntry: { startTime: 11568.4 },
navigationEntry: {
name: 'https://example.com/c/new?orgId=secret',
type: 'navigate',
redirectCount: 0,
workerStart: 300,
fetchStart: 10866,
requestStart: 10870,
responseStart: 10955,
responseEnd: 10956,
activationStart: 0,
},
},
} as unknown as FCPMetricWithAttribution;
expect(testExports.fcpAttributes(metric, '/c/:conversationId')).toEqual(
expect.objectContaining({
currentPath: '/c/:conversationId',
currentRoute: '/c/:conversationId',
fcp: 11568,
fcpEntryStart: 11568,
timeToFirstByte: 10955,
firstByteToFCP: 613,
loadState: 'complete',
navigationType: 'navigate',
initialPath: '/c/new',
workerStart: 300,
fetchStart: 10866,
responseStart: 10955,
}),
);
});
it('emits one page-load diagnostic action from FCP attribution', async () => {
const metric = {
name: 'FCP',
value: 12000.2,
rating: 'poor',
delta: 12000.2,
id: 'v1-123',
navigationType: 'navigate',
entries: [],
attribution: {
timeToFirstByte: 11000.2,
firstByteToFCP: 1000,
loadState: 'complete',
fcpEntry: { startTime: 12000.2 },
navigationEntry: {
name: 'https://example.com/c/new',
type: 'navigate',
fetchStart: 10866,
responseStart: 11000,
},
},
} as unknown as FCPMetricWithAttribution;
await registerFcpAttribution({ addAction }, () => '/c/new');
mockOnFCP.mock.calls[0][0](metric);
expect(addAction).toHaveBeenCalledTimes(1);
expect(addAction).toHaveBeenCalledWith(
'page-load-diagnostics',
expect.objectContaining({
currentRoute: '/c/new',
fcp: 12000,
firstByteToFCP: 1000,
fetchStart: 10866,
initialPath: '/c/new',
responseStart: 11000,
}),
);
});
it('allows FCP attribution registration to retry after registration failures', async () => {
mockOnFCP.mockImplementationOnce(() => {
throw new Error('registration failed');
});
await registerFcpAttribution({ addAction }, () => '/c/new');
await registerFcpAttribution({ addAction }, () => '/c/new');
expect(mockOnFCP).toHaveBeenCalledTimes(2);
});
});

View file

@ -0,0 +1,282 @@
import type { FCPMetricWithAttribution } from 'web-vitals/attribution';
import { normalizeRumPath } from './routes';
export type RumActionAttributes = Record<string, string | number | boolean>;
export type HyperDXActionClient = {
addAction: (name: string, attributes?: RumActionAttributes) => void;
};
type RumQueuedEvent = {
type?: unknown;
at?: unknown;
visibilityState?: unknown;
attributes?: Record<string, unknown>;
};
type NavigationTimingLike = {
activationStart?: number;
connectStart?: number;
decodedBodySize?: number;
domComplete?: number;
domContentLoadedEventStart?: number;
domInteractive?: number;
domainLookupStart?: number;
encodedBodySize?: number;
fetchStart?: number;
loadEventEnd?: number;
name?: string;
nextHopProtocol?: string;
redirectCount?: number;
redirectEnd?: number;
redirectStart?: number;
requestStart?: number;
responseEnd?: number;
responseStart?: number;
transferSize?: number;
type?: string;
unloadEventEnd?: number;
unloadEventStart?: number;
workerStart?: number;
};
const URL_ATTRIBUTE_KEYS: Record<string, string> = {
assetUrl: 'assetPath',
currentPath: 'currentPath',
currentUrl: 'currentPath',
firstScopeUrl: 'firstScopePath',
fromPath: 'fromPath',
fullUrl: 'fullPath',
scriptUrl: 'scriptPath',
toPath: 'toPath',
};
const EARLY_RUM_QUEUE_STORAGE_KEY = 'lc-rum-queue';
declare global {
interface Window {
__lcRumQueue?: RumQueuedEvent[];
__lcRumPush?: (type: string, attributes?: Record<string, unknown>) => void;
}
}
let fcpAttributionRegistered = false;
let earlyQueueFlushed = false;
function round(value: unknown): number | undefined {
return typeof value === 'number' && Number.isFinite(value) ? Math.round(value) : undefined;
}
function nonEmptyString(value: unknown): string | undefined {
return typeof value === 'string' && value !== '' ? value : undefined;
}
function compact(attributes: Record<string, unknown>): RumActionAttributes {
return Object.fromEntries(
Object.entries(attributes).filter(
(entry): entry is [string, string | number | boolean] =>
typeof entry[1] === 'string' ||
typeof entry[1] === 'number' ||
typeof entry[1] === 'boolean',
),
);
}
function sanitizeQueuedAttributes(
attributes: Record<string, unknown> | undefined,
): Record<string, unknown> {
if (!attributes) {
return {};
}
return Object.fromEntries(
Object.entries(attributes).map(([key, value]) => {
const sanitizedKey = URL_ATTRIBUTE_KEYS[key];
if (sanitizedKey) {
return [sanitizedKey, pathFromUrl(value)];
}
return [key, value];
}),
);
}
function pathFromUrl(rawUrl: unknown): string | undefined {
if (typeof rawUrl !== 'string' || rawUrl === '') {
return undefined;
}
try {
return normalizeRumPath(new URL(rawUrl, window.location.origin).pathname);
} catch {
return normalizeRumPath(rawUrl.split('?')[0]?.split('#')[0] ?? rawUrl);
}
}
function sharedNavigationAttributes(nav: NavigationTimingLike | undefined): RumActionAttributes {
return compact({
initialPath: pathFromUrl(nav?.name),
navType: nav?.type,
redirectCount: round(nav?.redirectCount),
redirectStart: round(nav?.redirectStart),
redirectEnd: round(nav?.redirectEnd),
workerStart: round(nav?.workerStart),
fetchStart: round(nav?.fetchStart),
domainLookupStart: round(nav?.domainLookupStart),
connectStart: round(nav?.connectStart),
requestStart: round(nav?.requestStart),
responseStart: round(nav?.responseStart),
responseEnd: round(nav?.responseEnd),
domInteractive: round(nav?.domInteractive),
domContentLoadedEventStart: round(nav?.domContentLoadedEventStart),
domComplete: round(nav?.domComplete),
loadEventEnd: round(nav?.loadEventEnd),
unloadEventStart: round(nav?.unloadEventStart),
unloadEventEnd: round(nav?.unloadEventEnd),
activationStart: round(nav?.activationStart),
transferSize: round(nav?.transferSize),
encodedBodySize: round(nav?.encodedBodySize),
decodedBodySize: round(nav?.decodedBodySize),
nextHopProtocol: nonEmptyString(nav?.nextHopProtocol),
});
}
function fcpAttributes(
metric: FCPMetricWithAttribution,
currentRoute: string,
): RumActionAttributes {
const nav = metric.attribution.navigationEntry as NavigationTimingLike | undefined;
return compact({
currentPath: normalizeRumPath(window.location.pathname),
currentRoute,
fcp: round(metric.value),
fcpEntryStart: round(metric.attribution.fcpEntry?.startTime),
timeToFirstByte: round(metric.attribution.timeToFirstByte),
firstByteToFCP: round(metric.attribution.firstByteToFCP),
loadState: metric.attribution.loadState,
navigationType: metric.navigationType,
...sharedNavigationAttributes(nav),
visibilityState: document.visibilityState,
});
}
export function flushEarlyRumQueue(HyperDX: HyperDXActionClient): void {
if (earlyQueueFlushed) {
installRumEmitter(HyperDX);
return;
}
earlyQueueFlushed = true;
const queuedEvents = window.__lcRumQueue?.splice(0) ?? [];
try {
sessionStorage.removeItem(EARLY_RUM_QUEUE_STORAGE_KEY);
} catch {
HyperDX.addAction('early-rum-queue-storage-error', { operation: 'clear' });
}
queuedEvents.forEach((event) => {
emitEarlyRumEvent(HyperDX, event);
});
installRumEmitter(HyperDX);
}
export function restoreRumEmitter(HyperDX: HyperDXActionClient): void {
installRumEmitter(HyperDX);
}
function installRumEmitter(HyperDX: HyperDXActionClient): void {
window.__lcRumPush = (type, attributes) => {
emitEarlyRumEvent(HyperDX, {
type,
at: performance.now(),
visibilityState: document.visibilityState,
attributes,
});
};
}
export function discardEarlyRumQueue(): void {
window.__lcRumQueue?.splice(0);
try {
sessionStorage.removeItem(EARLY_RUM_QUEUE_STORAGE_KEY);
} catch {
/* Diagnostics should never affect app behavior. */
}
window.__lcRumPush = () => undefined;
}
function emitEarlyRumEvent(HyperDX: HyperDXActionClient, event: RumQueuedEvent): void {
if (typeof event.type !== 'string' || event.type === '') {
return;
}
const actionName = event.type === 'spa-route-change' ? event.type : `early-${event.type}`;
try {
HyperDX.addAction(
actionName,
compact({
at: round(event.at),
visibilityState: nonEmptyString(event.visibilityState),
...sanitizeQueuedAttributes(event.attributes),
}),
);
} catch {
/* Diagnostics should never affect app behavior or stale-asset recovery. */
}
}
export function queueSpaRouteChange(
fromPath: string,
toPath: string,
pageElapsedMs = performance.now(),
): void {
const normalizedFromPath = normalizeRumPath(fromPath);
const normalizedToPath = normalizeRumPath(toPath);
if (normalizedFromPath === normalizedToPath) {
return;
}
window.__lcRumPush?.('spa-route-change', {
fromPath: normalizedFromPath,
toPath: normalizedToPath,
pageElapsedMs: round(pageElapsedMs),
});
}
export async function registerFcpAttribution(
HyperDX: HyperDXActionClient,
getCurrentRoute: () => string,
): Promise<void> {
if (fcpAttributionRegistered) {
return;
}
try {
const { onFCP } = await import('web-vitals/attribution');
onFCP((metric) => {
HyperDX.addAction('page-load-diagnostics', fcpAttributes(metric, getCurrentRoute()));
});
fcpAttributionRegistered = true;
} catch {
/* Diagnostics must never trigger stale-asset recovery or app reloads. */
}
}
export function startRumDiagnostics(
HyperDX: HyperDXActionClient,
getCurrentRoute: () => string,
): void {
flushEarlyRumQueue(HyperDX);
void registerFcpAttribution(HyperDX, getCurrentRoute);
}
export const testExports = {
compact,
fcpAttributes,
pathFromUrl,
resetDiagnosticsState: () => {
fcpAttributionRegistered = false;
earlyQueueFlushed = false;
},
};

View file

@ -3,6 +3,7 @@ import { normalizeRumPath } from './routes';
describe('normalizeRumPath', () => {
it('normalizes dynamic LibreChat route identifiers', () => {
expect(normalizeRumPath('/c/65a5e0a7d1c2b3a4f5e6d789')).toBe('/c/:conversationId');
expect(normalizeRumPath('/c/new')).toBe('/c/new');
expect(normalizeRumPath('/share/65a5e0a7d1c2b3a4f5e6d789')).toBe('/share/:shareId');
expect(normalizeRumPath('/assistants/asst_123')).toBe('/assistants/:assistantId');
});

View file

@ -2,6 +2,10 @@ 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' && segment === 'new') {
return segment;
}
if (previous === 'c') {
return ':conversationId';
}

View file

@ -2,6 +2,7 @@ import { renderHook, waitFor } from '@testing-library/react';
import useRum from './useRum';
const mockInit = jest.fn();
const mockAddAction = jest.fn();
const mockSetGlobalAttributes = jest.fn();
const mockUseGetStartupConfig = jest.fn();
const mockUseAuthContext = jest.fn();
@ -10,11 +11,22 @@ const mockUseLocation = jest.fn();
jest.mock('@hyperdx/browser', () => ({
__esModule: true,
default: {
addAction: (...args: unknown[]) => mockAddAction(...args),
init: (...args: unknown[]) => mockInit(...args),
setGlobalAttributes: (...args: unknown[]) => mockSetGlobalAttributes(...args),
},
}));
jest.mock('./diagnostics', () => ({
discardEarlyRumQueue: jest.fn(),
queueSpaRouteChange: jest.fn(),
restoreRumEmitter: jest.fn(),
startRumDiagnostics: jest.fn(),
}));
const { discardEarlyRumQueue, queueSpaRouteChange, restoreRumEmitter, startRumDiagnostics } =
jest.requireMock('./diagnostics');
jest.mock('~/data-provider', () => ({
useGetStartupConfig: () => mockUseGetStartupConfig(),
}));
@ -31,6 +43,7 @@ jest.mock('react-router-dom', () => ({
describe('useRum', () => {
beforeEach(() => {
jest.clearAllMocks();
mockUseGetStartupConfig.mockReturnValue({ data: undefined, isFetched: false });
mockUseLocation.mockReturnValue({ pathname: '/c/conversation-123' });
mockUseAuthContext.mockReturnValue({
isAuthenticated: true,
@ -83,10 +96,15 @@ describe('useRum', () => {
expect(mockSetGlobalAttributes).not.toHaveBeenCalledWith(
expect.objectContaining({ email: 'user@example.com' }),
);
expect(startRumDiagnostics).toHaveBeenCalledWith(
expect.objectContaining({ init: expect.any(Function) }),
expect.any(Function),
);
});
it('does not initialize RUM for unsupported auth modes', async () => {
mockUseGetStartupConfig.mockReturnValue({
isFetched: true,
data: {
rum: {
provider: 'hyperdx',
@ -102,6 +120,106 @@ describe('useRum', () => {
renderHook(() => useRum());
expect(mockInit).not.toHaveBeenCalled();
expect(discardEarlyRumQueue).toHaveBeenCalled();
});
it('discards the early RUM queue when sampling excludes the page', async () => {
mockUseGetStartupConfig.mockReturnValue({
isFetched: true,
data: {
rum: {
provider: 'hyperdx',
enabled: true,
url: 'https://rum.example.com',
serviceName: 'librechat-web',
authMode: 'publicToken',
publicToken: 'public-token',
sampleRate: 0,
},
},
});
renderHook(() => useRum());
expect(mockInit).not.toHaveBeenCalled();
expect(discardEarlyRumQueue).toHaveBeenCalled();
});
it('discards and stops route buffering when startup config has no RUM config', async () => {
mockUseGetStartupConfig.mockReturnValue({
data: {},
isFetched: true,
});
const { rerender } = renderHook(() => useRum());
expect(discardEarlyRumQueue).toHaveBeenCalled();
mockUseLocation.mockReturnValue({ pathname: '/login' });
rerender();
expect(queueSpaRouteChange).not.toHaveBeenCalled();
});
it('preserves the early RUM queue while proxy mode waits for an auth token', async () => {
mockUseAuthContext.mockReturnValue({
isAuthenticated: false,
token: undefined,
user: undefined,
});
mockUseGetStartupConfig.mockReturnValue({
isFetched: true,
data: {
rum: {
provider: 'hyperdx',
enabled: true,
url: '/api/rum',
serviceName: 'librechat-web',
authMode: 'proxy',
},
},
});
renderHook(() => useRum());
expect(mockInit).not.toHaveBeenCalled();
expect(discardEarlyRumQueue).not.toHaveBeenCalled();
});
it('restores the RUM emitter when an initialized config becomes valid again', async () => {
const validRumConfig = {
provider: 'hyperdx',
enabled: true,
url: 'https://rum.example.com',
serviceName: 'librechat-web',
authMode: 'publicToken',
publicToken: 'public-token',
};
let rumConfig = validRumConfig;
mockUseGetStartupConfig.mockImplementation(() => ({
isFetched: true,
data: {
rum: rumConfig,
},
}));
const { rerender } = renderHook(() => useRum());
await waitFor(() => {
expect(mockInit).toHaveBeenCalled();
});
rumConfig = { ...validRumConfig, enabled: false };
rerender();
expect(discardEarlyRumQueue).toHaveBeenCalled();
rumConfig = { ...validRumConfig };
rerender();
expect(restoreRumEmitter).toHaveBeenCalledWith(
expect.objectContaining({ init: expect.any(Function) }),
);
});
it('initializes proxy RUM with the LibreChat bearer token for same-origin ingest', async () => {
@ -111,6 +229,7 @@ describe('useRum', () => {
);
window.fetch = Object.assign(fetchMock, { preconnect: () => undefined });
mockUseGetStartupConfig.mockReturnValue({
isFetched: true,
data: {
rum: {
provider: 'hyperdx',
@ -150,6 +269,7 @@ describe('useRum', () => {
user: undefined,
});
mockUseGetStartupConfig.mockReturnValue({
isFetched: true,
data: {
rum: {
provider: 'hyperdx',
@ -164,5 +284,32 @@ describe('useRum', () => {
renderHook(() => useRum());
expect(mockInit).not.toHaveBeenCalled();
expect(discardEarlyRumQueue).not.toHaveBeenCalled();
});
it('queues SPA route changes through the shared early RUM channel', async () => {
mockUseGetStartupConfig.mockReturnValue({
data: {
rum: {
provider: 'hyperdx',
enabled: true,
url: 'https://rum.example.com',
serviceName: 'librechat-web',
authMode: 'publicToken',
publicToken: 'public-token',
},
},
});
const { rerender } = renderHook(() => useRum());
await waitFor(() => {
expect(mockInit).toHaveBeenCalled();
});
mockUseLocation.mockReturnValue({ pathname: '/login' });
rerender();
expect(queueSpaRouteChange).toHaveBeenCalledWith('/c/:conversationId', '/login');
});
});

View file

@ -1,6 +1,13 @@
import { useEffect, useMemo, useRef } from 'react';
import { useLocation } from 'react-router-dom';
import type { TRumConfig, TUser } from 'librechat-data-provider';
import type { HyperDXActionClient } from './diagnostics';
import {
discardEarlyRumQueue,
queueSpaRouteChange,
restoreRumEmitter,
startRumDiagnostics,
} from './diagnostics';
import { useGetStartupConfig } from '~/data-provider';
import { useAuthContext } from '~/hooks/AuthContext';
import { normalizeRumPath } from './routes';
@ -10,7 +17,7 @@ const PROXY_API_KEY = 'librechat-rum-proxy';
let rumProxyToken: string | undefined;
let rumProxyFetchPatched = false;
type HyperDXBrowser = {
type HyperDXBrowser = HyperDXActionClient & {
init: (config: {
advancedNetworkCapture: boolean;
apiKey: string;
@ -35,6 +42,21 @@ function shouldInitializeRum(config: TRumConfig | undefined, token: string | und
return config.authMode === 'proxy' && !!token && !config.publicToken;
}
function isProxyRumWaitingForToken(
config: TRumConfig | undefined,
token: string | undefined,
): boolean {
return (
!!config?.enabled &&
config.provider === 'hyperdx' &&
!!config.url &&
!!config.serviceName &&
config.authMode === 'proxy' &&
!token &&
!config.publicToken
);
}
function getApiKey(config: TRumConfig, token: string | undefined): string {
if (config.authMode === 'proxy') {
return token ? PROXY_API_KEY : '';
@ -113,7 +135,7 @@ async function loadHyperDX(): Promise<HyperDXBrowser> {
}
export default function useRum(): void {
const { data: startupConfig } = useGetStartupConfig();
const { data: startupConfig, isFetched: startupConfigFetched } = useGetStartupConfig();
const { token, user } = useAuthContext();
const location = useLocation();
const initializedKeyRef = useRef<string | undefined>(undefined);
@ -121,15 +143,24 @@ export default function useRum(): void {
const sampledInRef = useRef<boolean>(true);
const hyperDxRef = useRef<HyperDXBrowser | undefined>(undefined);
const rumConfig = startupConfig?.rum;
const shouldBufferRoutes = !startupConfigFetched || !!rumConfig;
const route = useMemo(() => normalizeRumPath(location.pathname), [location.pathname]);
const routeRef = useRef<string>(route);
useEffect(() => {
const previousRoute = routeRef.current;
if (previousRoute && previousRoute !== route && shouldBufferRoutes) {
queueSpaRouteChange(previousRoute, route);
}
routeRef.current = route;
}, [route]);
}, [route, shouldBufferRoutes]);
useEffect(() => {
if (!rumConfig) {
if (startupConfigFetched) {
discardEarlyRumQueue();
}
return;
}
@ -137,6 +168,9 @@ export default function useRum(): void {
if (rumConfig?.authMode === 'proxy') {
rumProxyToken = undefined;
}
if (!isProxyRumWaitingForToken(rumConfig, token)) {
discardEarlyRumQueue();
}
return;
}
@ -150,6 +184,9 @@ export default function useRum(): void {
const initKey = [config.url, config.serviceName, config.authMode, apiKey].join(':');
if (initializedKeyRef.current === initKey) {
if (hyperDxRef.current) {
restoreRumEmitter(hyperDxRef.current);
}
return;
}
@ -160,6 +197,7 @@ export default function useRum(): void {
}
if (!sampledInRef.current) {
discardEarlyRumQueue();
return;
}
@ -184,13 +222,14 @@ export default function useRum(): void {
hyperDxRef.current = HyperDX;
initializedKeyRef.current = initKey;
HyperDX.setGlobalAttributes(buildGlobalAttributes(user, config, routeRef.current));
startRumDiagnostics(HyperDX, () => routeRef.current);
})
.catch(() => undefined);
return () => {
cancelled = true;
};
}, [rumConfig, token, user]);
}, [rumConfig, startupConfigFetched, token, user]);
useEffect(() => {
hyperDxRef.current?.setGlobalAttributes(

1
package-lock.json generated
View file

@ -508,6 +508,7 @@
"tailwindcss-animate": "^1.0.5",
"tailwindcss-radix": "^2.8.0",
"ts-md5": "^1.3.1",
"web-vitals": "^3.5.2",
"zod": "^3.22.4"
},
"devDependencies": {