diff --git a/src/langbot/pkg/api/http/controller/groups/monitoring.py b/src/langbot/pkg/api/http/controller/groups/monitoring.py index d3aa03c2e..9a468ab3f 100644 --- a/src/langbot/pkg/api/http/controller/groups/monitoring.py +++ b/src/langbot/pkg/api/http/controller/groups/monitoring.py @@ -218,6 +218,7 @@ class MonitoringRouterGroup(group.RouterGroup): pipeline_ids = quart.request.args.getlist('pipelineId') start_time_str = quart.request.args.get('startTime') end_time_str = quart.request.args.get('endTime') + user_query = quart.request.args.get('userQuery') is_active_str = quart.request.args.get('isActive') limit = int(quart.request.args.get('limit', 100)) offset = int(quart.request.args.get('offset', 0)) @@ -237,6 +238,7 @@ class MonitoringRouterGroup(group.RouterGroup): pipeline_ids=pipeline_ids if pipeline_ids else None, start_time=start_time, end_time=end_time, + user_query=user_query, is_active=is_active, limit=limit, offset=offset, @@ -396,7 +398,14 @@ class MonitoringRouterGroup(group.RouterGroup): @self.route('/sessions//analysis', methods=['GET'], permission=Permission.RESOURCE_VIEW) async def get_session_analysis(session_id: str, request_context: RequestContext) -> str: """Get detailed analysis for a specific session""" - analysis = await self.ap.monitoring_service.get_session_analysis(request_context, session_id) + start_time = parse_iso_datetime(quart.request.args.get('startTime')) + end_time = parse_iso_datetime(quart.request.args.get('endTime')) + analysis = await self.ap.monitoring_service.get_session_analysis( + request_context, + session_id, + start_time=start_time, + end_time=end_time, + ) # Always return success with the analysis data # The frontend will handle the 'found: false' case diff --git a/src/langbot/pkg/api/http/service/monitoring.py b/src/langbot/pkg/api/http/service/monitoring.py index b0363cda4..474a5c1d5 100644 --- a/src/langbot/pkg/api/http/service/monitoring.py +++ b/src/langbot/pkg/api/http/service/monitoring.py @@ -1257,6 +1257,7 @@ class MonitoringService: pipeline_ids: list[str] | None = None, start_time: datetime.datetime | None = None, end_time: datetime.datetime | None = None, + user_query: str | None = None, is_active: bool | None = None, limit: int = 100, offset: int = 0, @@ -1274,6 +1275,14 @@ class MonitoringService: conditions.append(persistence_monitoring.MonitoringSession.start_time >= start_time) if end_time: conditions.append(persistence_monitoring.MonitoringSession.start_time <= end_time) + if user_query and user_query.strip(): + user_pattern = f'%{user_query.strip()}%' + conditions.append( + sqlalchemy.or_( + persistence_monitoring.MonitoringSession.user_id.ilike(user_pattern), + persistence_monitoring.MonitoringSession.user_name.ilike(user_pattern), + ) + ) if is_active is not None: conditions.append(persistence_monitoring.MonitoringSession.is_active == is_active) @@ -1365,6 +1374,8 @@ class MonitoringService: self, context: TenantContext, session_id: str, + start_time: datetime.datetime | None = None, + end_time: datetime.datetime | None = None, ) -> dict: """Get bounded session details with full statistics computed in SQL.""" workspace_uuid = require_workspace_uuid(context) @@ -1478,12 +1489,17 @@ class MonitoringService: ) ) tool_stats = tool_stats_result.one() + tool_conditions = [ + persistence_monitoring.MonitoringToolCall.workspace_uuid == workspace_uuid, + persistence_monitoring.MonitoringToolCall.session_id == session_id, + ] + if start_time is not None: + tool_conditions.append(persistence_monitoring.MonitoringToolCall.timestamp >= start_time) + if end_time is not None: + tool_conditions.append(persistence_monitoring.MonitoringToolCall.timestamp <= end_time) tool_query = ( sqlalchemy.select(persistence_monitoring.MonitoringToolCall) - .where( - persistence_monitoring.MonitoringToolCall.workspace_uuid == workspace_uuid, - persistence_monitoring.MonitoringToolCall.session_id == session_id, - ) + .where(*tool_conditions) .order_by(persistence_monitoring.MonitoringToolCall.timestamp.asc()) .limit(detail_limit + 1) ) diff --git a/tests/integration/api/test_monitoring.py b/tests/integration/api/test_monitoring.py index 9a10ea61a..cf4608e65 100644 --- a/tests/integration/api/test_monitoring.py +++ b/tests/integration/api/test_monitoring.py @@ -242,6 +242,22 @@ class TestMonitoringSessionsEndpoint: assert response.status_code == 200 + @pytest.mark.asyncio + async def test_get_sessions_forwards_user_search_and_page_window(self, quart_test_client, fake_monitoring_app): + fake_monitoring_app.monitoring_service.get_sessions.reset_mock() + + response = await quart_test_client.get( + '/api/v1/monitoring/sessions?botId=bot-1&userQuery=alice&limit=20&offset=40', + headers={'Authorization': 'Bearer test_token'}, + ) + + assert response.status_code == 200 + kwargs = fake_monitoring_app.monitoring_service.get_sessions.await_args.kwargs + assert kwargs['bot_ids'] == ['bot-1'] + assert kwargs['user_query'] == 'alice' + assert kwargs['limit'] == 20 + assert kwargs['offset'] == 40 + @pytest.mark.usefixtures('mock_circular_import_chain') class TestMonitoringErrorsEndpoint: @@ -278,13 +294,19 @@ class TestMonitoringDetailsEndpoints: """Tests for detail endpoints.""" @pytest.mark.asyncio - async def test_get_session_analysis(self, quart_test_client): + async def test_get_session_analysis(self, quart_test_client, fake_monitoring_app): """GET /api/v1/monitoring/sessions/{id}/analysis.""" response = await quart_test_client.get( - '/api/v1/monitoring/sessions/sess-1/analysis', headers={'Authorization': 'Bearer test_token'} + '/api/v1/monitoring/sessions/sess-1/analysis' + '?startTime=2026-08-31T16%3A00%3A00.000Z' + '&endTime=2026-09-01T15%3A59%3A59.999Z', + headers={'Authorization': 'Bearer test_token'}, ) assert response.status_code == 200 + kwargs = fake_monitoring_app.monitoring_service.get_session_analysis.await_args.kwargs + assert kwargs['start_time'].isoformat() == '2026-08-31T16:00:00' + assert kwargs['end_time'].isoformat() == '2026-09-01T15:59:59.999000' @pytest.mark.asyncio async def test_get_message_details(self, quart_test_client): diff --git a/tests/unit_tests/api/service/test_monitoring_tenancy.py b/tests/unit_tests/api/service/test_monitoring_tenancy.py index a24d937ff..2e2b4216f 100644 --- a/tests/unit_tests/api/service/test_monitoring_tenancy.py +++ b/tests/unit_tests/api/service/test_monitoring_tenancy.py @@ -138,6 +138,39 @@ async def test_same_session_and_resource_ids_do_not_collide(service): assert (await service.get_message_details(context_a, message_b))['found'] is False +async def test_session_search_matches_user_id_or_name_within_workspace(service): + context_a = _context(WORKSPACE_A) + context_b = _context(WORKSPACE_B) + fixtures = [ + (context_a, 'session-id-match', 'customer-42', 'Alice'), + (context_a, 'session-name-match', 'customer-99', 'Bob Alice Cooper'), + (context_a, 'session-no-match', 'customer-7', 'Bob'), + (context_b, 'session-other-workspace', 'customer-42', 'Alice'), + ] + for context, session_id, user_id, user_name in fixtures: + await service.record_session_start( + context, + session_id=session_id, + bot_id='same-bot', + bot_name='Same Bot', + pipeline_id='same-pipeline', + pipeline_name='Same Pipeline', + user_id=user_id, + user_name=user_name, + ) + + by_id, id_total = await service.get_sessions(context_a, user_query='customer-42') + by_name, name_total = await service.get_sessions(context_a, user_query='alice') + + assert id_total == 1 + assert [session['session_id'] for session in by_id] == ['session-id-match'] + assert name_total == 2 + assert {session['session_id'] for session in by_name} == { + 'session-id-match', + 'session-name-match', + } + + async def test_tool_call_inherits_context_from_connection_message_row(service): context = _context(WORKSPACE_A) message_id = await _record_message(service, context, 'tool context') diff --git a/web/src/app/home/bots/components/bot-session/BotSessionMonitor.tsx b/web/src/app/home/bots/components/bot-session/BotSessionMonitor.tsx index 3a181134a..5b31e2e9b 100644 --- a/web/src/app/home/bots/components/bot-session/BotSessionMonitor.tsx +++ b/web/src/app/home/bots/components/bot-session/BotSessionMonitor.tsx @@ -17,6 +17,7 @@ import { Copy, Check, ChevronDown, + ChevronLeft, ChevronRight, Workflow, ThumbsUp, @@ -117,16 +118,43 @@ interface BotSessionMonitorProps { botId: string; } +const SESSION_PAGE_SIZE = 20; +const MESSAGE_PAGE_SIZE = 50; + +const localDateBoundaryToISOString = ( + dateValue: string, + endOfDay: boolean, +): string => { + const [year, month, day] = dateValue.split('-').map(Number); + return new Date( + year, + month - 1, + day, + endOfDay ? 23 : 0, + endOfDay ? 59 : 0, + endOfDay ? 59 : 0, + endOfDay ? 999 : 0, + ).toISOString(); +}; + const BotSessionMonitor = forwardRef< BotSessionMonitorHandle, BotSessionMonitorProps >(function BotSessionMonitor({ botId }, ref) { const { t } = useTranslation(); const [sessions, setSessions] = useState([]); + const [sessionTotal, setSessionTotal] = useState(0); + const [sessionPage, setSessionPage] = useState(0); + const [startDate, setStartDate] = useState(''); + const [endDate, setEndDate] = useState(''); + const [userQuery, setUserQuery] = useState(''); + const [appliedUserQuery, setAppliedUserQuery] = useState(''); const [selectedSessionId, setSelectedSessionId] = useState( null, ); const [messages, setMessages] = useState([]); + const [messageTotal, setMessageTotal] = useState(0); + const [messagePage, setMessagePage] = useState(0); const [loadingSessions, setLoadingSessions] = useState(false); const [loadingMessages, setLoadingMessages] = useState(false); const [copiedUserId, setCopiedUserId] = useState(false); @@ -138,6 +166,8 @@ const BotSessionMonitor = forwardRef< Record >({}); const messagesContainerRef = useRef(null); + const sessionRequestIdRef = useRef(0); + const messageRequestIdRef = useRef(0); const { admins, reload: reloadAdmins } = useBotAdmins(botId); const [adminsDialogOpen, setAdminsDialogOpen] = useState(false); const [togglingAdmin, setTogglingAdmin] = useState(null); @@ -204,16 +234,33 @@ const BotSessionMonitor = forwardRef< }; const loadSessions = useCallback(async () => { + const requestId = ++sessionRequestIdRef.current; setLoadingSessions(true); try { - const response = await httpClient.getBotSessions(botId); + const response = await httpClient.getBotSessions(botId, { + limit: SESSION_PAGE_SIZE, + offset: sessionPage * SESSION_PAGE_SIZE, + startTime: startDate + ? localDateBoundaryToISOString(startDate, false) + : undefined, + endTime: endDate + ? localDateBoundaryToISOString(endDate, true) + : undefined, + userQuery: appliedUserQuery || undefined, + }); + if (requestId !== sessionRequestIdRef.current) return; setSessions(response.sessions ?? []); + setSessionTotal(response.total ?? 0); } catch (error) { - console.error('Failed to load sessions:', error); + if (requestId === sessionRequestIdRef.current) { + console.error('Failed to load sessions:', error); + } } finally { - setLoadingSessions(false); + if (requestId === sessionRequestIdRef.current) { + setLoadingSessions(false); + } } - }, [botId]); + }, [appliedUserQuery, botId, endDate, sessionPage, startDate]); useImperativeHandle( ref, @@ -224,25 +271,39 @@ const BotSessionMonitor = forwardRef< ); const loadMessages = useCallback( - async (sessionId: string) => { + async (sessionId: string, page: number) => { + const requestId = ++messageRequestIdRef.current; setLoadingMessages(true); setExpandedToolCallIds({}); try { - const messagesRes = await httpClient.getSessionMessages(sessionId); + const messagesRes = await httpClient.getSessionMessages( + sessionId, + MESSAGE_PAGE_SIZE, + page * MESSAGE_PAGE_SIZE, + ); + if (requestId !== messageRequestIdRef.current) return; const sorted = (messagesRes.messages ?? []).sort( (a, b) => new Date(a.timestamp).getTime() - new Date(b.timestamp).getTime(), ); setMessages(sorted); + setMessageTotal(messagesRes.total ?? 0); try { + const analysisParams = new URLSearchParams(); + if (sorted.length > 0) { + analysisParams.set('startTime', sorted[0].timestamp); + analysisParams.set('endTime', sorted[sorted.length - 1].timestamp); + } const analysisRes = await httpClient.get<{ tool_calls?: SessionToolCall[]; }>( - `/api/v1/monitoring/sessions/${encodeURIComponent(sessionId)}/analysis`, + `/api/v1/monitoring/sessions/${encodeURIComponent(sessionId)}/analysis?${analysisParams.toString()}`, ); + if (requestId !== messageRequestIdRef.current) return; setToolCalls(analysisRes?.tool_calls ?? []); } catch (analysisError) { + if (requestId !== messageRequestIdRef.current) return; console.error('Failed to load session tool calls:', analysisError); setToolCalls([]); } @@ -259,6 +320,7 @@ const BotSessionMonitor = forwardRef< }>( `/api/v1/monitoring/feedback?botId=${encodeURIComponent(botId)}&limit=200`, ); + if (requestId !== messageRequestIdRef.current) return; const map: Record = {}; if (feedbackRes?.feedback) { @@ -273,9 +335,13 @@ const BotSessionMonitor = forwardRef< setFeedbackMap({}); } } catch (error) { - console.error('Failed to load session messages:', error); + if (requestId === messageRequestIdRef.current) { + console.error('Failed to load session messages:', error); + } } finally { - setLoadingMessages(false); + if (requestId === messageRequestIdRef.current) { + setLoadingMessages(false); + } } }, [botId], @@ -285,16 +351,24 @@ const BotSessionMonitor = forwardRef< loadSessions(); }, [loadSessions]); + useEffect(() => { + setSelectedSessionId(null); + setMessagePage(0); + }, [appliedUserQuery, botId, endDate, sessionPage, startDate]); + useEffect(() => { if (selectedSessionId) { - loadMessages(selectedSessionId); + loadMessages(selectedSessionId, messagePage); } else { + messageRequestIdRef.current += 1; + setLoadingMessages(false); setMessages([]); + setMessageTotal(0); setToolCalls([]); setExpandedToolCallIds({}); setFeedbackMap({}); } - }, [selectedSessionId, loadMessages]); + }, [selectedSessionId, messagePage, loadMessages]); useEffect(() => { if (messages.length === 0 && toolCalls.length === 0) return; @@ -552,6 +626,19 @@ const BotSessionMonitor = forwardRef< const selectedSession = sessions.find( (s) => s.session_id === selectedSessionId, ); + const sessionPageCount = Math.max( + 1, + Math.ceil(sessionTotal / SESSION_PAGE_SIZE), + ); + const messagePageCount = Math.max( + 1, + Math.ceil(messageTotal / MESSAGE_PAGE_SIZE), + ); + + const applyUserSearch = () => { + setSessionPage(0); + setAppliedUserQuery(userQuery.trim()); + }; return ( <> @@ -575,6 +662,65 @@ const BotSessionMonitor = forwardRef< )} + + {t('bots.sessionMonitor.totalSessions', { + defaultValue: '{{count}} sessions', + count: sessionTotal, + })} + + +
+
+ setUserQuery(event.target.value)} + onKeyDown={(event) => + event.key === 'Enter' && applyUserSearch() + } + aria-label={t('bots.sessionMonitor.userSearch', { + defaultValue: 'User ID or name', + })} + placeholder={t('bots.sessionMonitor.userSearch', { + defaultValue: 'User ID or name', + })} + className="h-7 min-w-0 flex-1 rounded border bg-background px-2 text-xs" + /> + +
+
+ { + setSessionPage(0); + setStartDate(event.target.value); + }} + aria-label={t('bots.sessionMonitor.startDate', { + defaultValue: 'Start date', + })} + className="h-7 min-w-0 rounded border bg-background px-1 text-[10px]" + /> + { + setSessionPage(0); + setEndDate(event.target.value); + }} + aria-label={t('bots.sessionMonitor.endDate', { + defaultValue: 'End date', + })} + className="h-7 min-w-0 rounded border bg-background px-1 text-[10px]" + /> +
{/* Session List */} @@ -601,7 +747,10 @@ const BotSessionMonitor = forwardRef< 'w-full text-left px-2.5 py-2 rounded-md transition-colors cursor-pointer', isSelected ? 'bg-accent' : 'hover:bg-accent/50', )} - onClick={() => setSelectedSessionId(session.session_id)} + onClick={() => { + setSelectedSessionId(session.session_id); + setMessagePage(0); + }} >
@@ -637,6 +786,29 @@ const BotSessionMonitor = forwardRef<
)}
+
+ + + {sessionPage + 1} / {sessionPageCount} + + +
{/* Right Panel: Messages */} @@ -975,6 +1147,33 @@ const BotSessionMonitor = forwardRef< )} +
+ + + {messagePage + 1} / {messagePageCount} ยท {messageTotal} + + +
)} diff --git a/web/src/app/infra/http/BackendClient.ts b/web/src/app/infra/http/BackendClient.ts index 4a57956ea..32f6c9b66 100644 --- a/web/src/app/infra/http/BackendClient.ts +++ b/web/src/app/infra/http/BackendClient.ts @@ -474,8 +474,13 @@ export class BackendClient extends BaseHttpClient { public getBotSessions( botId: string, - limit: number = 100, - offset: number = 0, + options: { + limit: number; + offset: number; + startTime?: string; + endTime?: string; + userQuery?: string; + }, ): Promise<{ sessions: Array<{ session_id: string; @@ -495,8 +500,17 @@ export class BackendClient extends BaseHttpClient { }> { const queryParams = new URLSearchParams(); queryParams.append('botId', botId); - queryParams.append('limit', limit.toString()); - queryParams.append('offset', offset.toString()); + queryParams.append('limit', options.limit.toString()); + queryParams.append('offset', options.offset.toString()); + if (options.startTime) { + queryParams.append('startTime', options.startTime); + } + if (options.endTime) { + queryParams.append('endTime', options.endTime); + } + if (options.userQuery) { + queryParams.append('userQuery', options.userQuery); + } return this.get(`/api/v1/monitoring/sessions?${queryParams.toString()}`); } diff --git a/web/tests/unit/session-monitor-pagination.test.mjs b/web/tests/unit/session-monitor-pagination.test.mjs new file mode 100644 index 000000000..f855237f4 --- /dev/null +++ b/web/tests/unit/session-monitor-pagination.test.mjs @@ -0,0 +1,139 @@ +import assert from 'node:assert/strict'; +import fs from 'node:fs'; +import path from 'node:path'; +import test from 'node:test'; +import { fileURLToPath } from 'node:url'; + +const root = path.resolve( + path.dirname(fileURLToPath(import.meta.url)), + '../..', +); +const repoRoot = path.resolve(root, '..'); +const read = (file) => fs.readFileSync(path.join(root, file), 'utf8'); +const readRepo = (file) => fs.readFileSync(path.join(repoRoot, file), 'utf8'); +const includes = (source, token, message) => + assert.ok(source.includes(token), message); + +test('session list request supports a server-side page and operator filters', () => { + const client = read('src/app/infra/http/BackendClient.ts'); + includes(client, 'startTime?: string', 'client accepts a start date'); + includes(client, 'endTime?: string', 'client accepts an end date'); + includes(client, 'userQuery?: string', 'client accepts a user query'); + includes( + client, + "queryParams.append('offset', options.offset.toString())", + 'client sends the requested session offset', + ); + includes( + client, + "queryParams.append('userQuery', options.userQuery)", + 'client sends the user query', + ); + + const monitor = read( + 'src/app/home/bots/components/bot-session/BotSessionMonitor.tsx', + ); + for (const token of [ + 'SESSION_PAGE_SIZE', + 'sessionTotal', + 'sessionPage', + 'startDate', + 'endDate', + 'userQuery', + ]) { + includes(monitor, token, `monitor includes ${token}`); + } +}); + +test('session detail requests and renders a bounded message page', () => { + const monitor = read( + 'src/app/home/bots/components/bot-session/BotSessionMonitor.tsx', + ); + for (const token of [ + 'MESSAGE_PAGE_SIZE', + 'messageTotal', + 'messagePage', + 'page * MESSAGE_PAGE_SIZE', + ]) { + includes(monitor, token, `message pagination includes ${token}`); + } +}); + +test('backend filters sessions by user id or user name in the existing endpoint', () => { + const controller = readRepo( + 'src/langbot/pkg/api/http/controller/groups/monitoring.py', + ); + const service = readRepo('src/langbot/pkg/api/http/service/monitoring.py'); + includes( + controller, + "quart.request.args.get('userQuery')", + 'route accepts userQuery', + ); + includes(controller, 'user_query=user_query', 'route forwards userQuery'); + includes( + service, + 'user_query: str | None = None', + 'service accepts userQuery', + ); + includes( + service, + 'MonitoringSession.user_id.ilike', + 'service searches user ids', + ); + includes( + service, + 'MonitoringSession.user_name.ilike', + 'service searches user names', + ); +}); + +test('stale session and message page responses cannot overwrite the latest page', () => { + const monitor = read( + 'src/app/home/bots/components/bot-session/BotSessionMonitor.tsx', + ); + for (const token of [ + 'sessionRequestIdRef', + 'messageRequestIdRef', + 'requestId !== sessionRequestIdRef.current', + 'requestId !== messageRequestIdRef.current', + 'messageRequestIdRef.current += 1', + ]) { + includes(monitor, token, `stale response guard includes ${token}`); + } +}); + +test('changing the session page or filters clears the selected detail', () => { + const monitor = read( + 'src/app/home/bots/components/bot-session/BotSessionMonitor.tsx', + ); + includes(monitor, 'setSelectedSessionId(null)', 'selection is cleared'); + includes( + monitor, + '[appliedUserQuery, botId, endDate, sessionPage, startDate]', + 'page and filters invalidate the selected session', + ); +}); + +test('date filters use the operator local calendar day', () => { + const monitor = read( + 'src/app/home/bots/components/bot-session/BotSessionMonitor.tsx', + ); + includes( + monitor, + 'localDateBoundaryToISOString(startDate, false)', + 'local start-of-day conversion', + ); + includes( + monitor, + 'localDateBoundaryToISOString(endDate, true)', + 'local end-of-day conversion', + ); +}); + +test('session tool calls are bounded to the visible message page', () => { + const monitor = read( + 'src/app/home/bots/components/bot-session/BotSessionMonitor.tsx', + ); + includes(monitor, "analysisParams.set('startTime'", 'analysis page start'); + includes(monitor, "analysisParams.set('endTime'", 'analysis page end'); +});