mirror of
https://github.com/danny-avila/LibreChat.git
synced 2026-08-04 14:57:42 +00:00
📡 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:
parent
446b73bb6b
commit
e8d76542b6
12 changed files with 1030 additions and 10 deletions
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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
7
client/src/lib/rum/bootstrap-entry.js
vendored
Normal 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
142
client/src/lib/rum/bootstrap.js
vendored
Normal 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');
|
||||
});
|
||||
}
|
||||
}
|
||||
97
client/src/lib/rum/bootstrap.spec.ts
Normal file
97
client/src/lib/rum/bootstrap.spec.ts
Normal 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']),
|
||||
);
|
||||
});
|
||||
});
|
||||
239
client/src/lib/rum/diagnostics.spec.ts
Normal file
239
client/src/lib/rum/diagnostics.spec.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
282
client/src/lib/rum/diagnostics.ts
Normal file
282
client/src/lib/rum/diagnostics.ts
Normal 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;
|
||||
},
|
||||
};
|
||||
|
|
@ -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');
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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
1
package-lock.json
generated
|
|
@ -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": {
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue