mirror of
https://github.com/langbot-app/LangBot.git
synced 2026-10-24 01:15:24 +08:00
fix(monitoring): persist session summaries to avoid history scans
- Maintain counts and display snapshots in source transactions with replay-safe deduplication. - Read session lists exclusively from summaries and remove execution-only filters. - Automatically initialize historical summaries in bounded, resumable batches during OSS startup. - Add behavioral contracts covering accounting, retention, query cost and PostgreSQL concurrency. Verified with the required session_summary suite and real container upgrade startup.
This commit is contained in:
@@ -300,6 +300,17 @@ class TestMonitoringSessionsEndpoint:
|
||||
assert kwargs['limit'] == 20
|
||||
assert kwargs['offset'] == 40
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.session_summary
|
||||
@pytest.mark.parametrize('query', ['mode=real', 'mode=debug', 'status=failed'])
|
||||
async def test_session_list_rejects_execution_filters(self, quart_test_client, fake_monitoring_app, query):
|
||||
response = await quart_test_client.get(
|
||||
'/api/v1/monitoring/sessions?' + query,
|
||||
headers={'Authorization': 'Bearer test_token'},
|
||||
)
|
||||
assert response.status_code == 400
|
||||
fake_monitoring_app.monitoring_service.get_sessions.assert_not_awaited()
|
||||
|
||||
|
||||
@pytest.mark.usefixtures('mock_circular_import_chain')
|
||||
class TestMonitoringErrorsEndpoint:
|
||||
@@ -364,6 +375,98 @@ class TestMonitoringAllDataEndpoint:
|
||||
'errors': 2,
|
||||
}
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.session_summary
|
||||
@pytest.mark.contract('reader_http')
|
||||
@pytest.mark.parametrize(
|
||||
'query,mode,statuses',
|
||||
[
|
||||
('/data?mode=all', 'all', None),
|
||||
('/data?mode=real&status=failed', 'real', ['failed']),
|
||||
('/data?mode=debug&status=running', 'debug', ['running']),
|
||||
('/data?status=completed&status=failed', 'all', ['completed', 'failed']),
|
||||
('/sessions?mode=all&userQuery=Alice', None, None),
|
||||
],
|
||||
)
|
||||
async def test_http_session_queries_only_read_summaries(
|
||||
self,
|
||||
quart_test_client,
|
||||
fake_monitoring_app,
|
||||
monkeypatch,
|
||||
query,
|
||||
mode,
|
||||
statuses,
|
||||
):
|
||||
"""Other /data records may read history; its session query may not."""
|
||||
import datetime
|
||||
import sqlite3
|
||||
from contextlib import asynccontextmanager
|
||||
import sqlalchemy as sa
|
||||
from sqlalchemy.ext.asyncio import AsyncSession, create_async_engine
|
||||
from langbot.pkg.api.http.service.monitoring import MonitoringService
|
||||
from langbot.pkg.entity.persistence.monitoring import MonitoringSession
|
||||
from langbot.pkg.persistence.session_summary_reader import SqlSessionSummaryReader
|
||||
|
||||
engine = create_async_engine('sqlite+aiosqlite:///:memory:')
|
||||
try:
|
||||
async with engine.begin() as connection:
|
||||
await connection.run_sync(MonitoringSession.__table__.create)
|
||||
await connection.execute(
|
||||
sa.insert(MonitoringSession).values(
|
||||
workspace_uuid='00000000-0000-0000-0000-00000000000a',
|
||||
bot_id='bot-a',
|
||||
session_id='summary',
|
||||
bot_name='Bot',
|
||||
pipeline_id='pipeline-a',
|
||||
pipeline_name='Pipeline',
|
||||
message_count=7,
|
||||
start_time=datetime.datetime(2026, 1, 2),
|
||||
last_activity=datetime.datetime(2026, 1, 2),
|
||||
user_name='Alice',
|
||||
)
|
||||
)
|
||||
|
||||
@asynccontextmanager
|
||||
async def scope(workspace):
|
||||
assert workspace == '00000000-0000-0000-0000-00000000000a'
|
||||
async with AsyncSession(engine) as session:
|
||||
yield session
|
||||
|
||||
service = MonitoringService(fake_monitoring_app)
|
||||
service._session_list = SqlSessionSummaryReader(scope)
|
||||
monkeypatch.setattr(fake_monitoring_app.monitoring_service, 'get_sessions', service.get_sessions)
|
||||
|
||||
def authorize(action, table, column, database, trigger):
|
||||
if action == sqlite3.SQLITE_READ and table != 'monitoring_sessions':
|
||||
return sqlite3.SQLITE_DENY
|
||||
return sqlite3.SQLITE_OK
|
||||
|
||||
async with engine.connect() as connection:
|
||||
await connection.run_sync(
|
||||
lambda sync: sync.connection.dbapi_connection.run_async(
|
||||
lambda raw: raw._execute(raw._conn.set_authorizer, authorize)
|
||||
)
|
||||
)
|
||||
with patch(
|
||||
'langbot.pkg.api.http.controller.groups.monitoring.get_traffic_series',
|
||||
new=AsyncMock(return_value={}),
|
||||
):
|
||||
response = await quart_test_client.get(
|
||||
'/api/v1/monitoring' + query + '&botId=bot-a&pipelineId=pipeline-a'
|
||||
'&startTime=2026-01-01T00:00:00&endTime=2026-01-03T00:00:00',
|
||||
headers={'Authorization': 'Bearer test_token'},
|
||||
)
|
||||
assert response.status_code == 200
|
||||
result = (await response.get_json())['data']
|
||||
assert [row['session_id'] for row in result['sessions']] == ['summary']
|
||||
assert result['sessions'][0]['message_count'] == 7
|
||||
assert (result['totalCount']['sessions'] if mode is not None else result['total']) == 1
|
||||
if mode is not None:
|
||||
kwargs = fake_monitoring_app.monitoring_service.get_messages.await_args.kwargs
|
||||
assert kwargs['mode'] == mode and kwargs['execution_statuses'] == statuses
|
||||
finally:
|
||||
await engine.dispose()
|
||||
|
||||
|
||||
@pytest.mark.usefixtures('mock_circular_import_chain')
|
||||
class TestMonitoringDetailsEndpoints:
|
||||
|
||||
@@ -224,7 +224,7 @@ def _query(context, sender_id):
|
||||
|
||||
|
||||
@pytest.mark.parametrize('user_id', [123456789, -100123456789, 0, None, '', '00123', ' opaque用户 '])
|
||||
@pytest.mark.parametrize('record_type', ['message', 'session', 'feedback'])
|
||||
@pytest.mark.parametrize('record_type', ['message', 'feedback'])
|
||||
async def test_optional_user_ids_round_trip_through_asyncpg(service, user_id, record_type):
|
||||
context = _context(WORKSPACE_A)
|
||||
expected = str(user_id) if isinstance(user_id, int) else user_id
|
||||
@@ -238,8 +238,6 @@ async def test_optional_user_ids_round_trip_through_asyncpg(service, user_id, re
|
||||
)
|
||||
details = await _read(service, 'get_message_details', context, record_id)
|
||||
assert details['message']['user_id'] == expected
|
||||
elif record_type == 'session':
|
||||
await service.record_session_start(context, **RESOURCE, session_id='same-session', user_id=user_id)
|
||||
rows, total = await _read(service, 'get_sessions', context)
|
||||
assert total == 1
|
||||
assert rows[0]['user_id'] == expected
|
||||
@@ -302,7 +300,7 @@ async def test_query_error_persists_error_message_and_linked_log(service, user_i
|
||||
|
||||
|
||||
@pytest.mark.parametrize('user_id', [True, 1.5, b'123', ['123']])
|
||||
@pytest.mark.parametrize('record_type', ['message', 'session', 'feedback'])
|
||||
@pytest.mark.parametrize('record_type', ['message', 'feedback'])
|
||||
async def test_unsupported_user_ids_fail_at_the_write_boundary(service, user_id, record_type):
|
||||
context = _context(WORKSPACE_A)
|
||||
with pytest.raises(TypeError, match='user_id must be a string, integer, or None'):
|
||||
@@ -314,8 +312,6 @@ async def test_unsupported_user_ids_fail_at_the_write_boundary(service, user_id,
|
||||
session_id='session',
|
||||
user_id=user_id,
|
||||
)
|
||||
elif record_type == 'session':
|
||||
await service.record_session_start(context, **RESOURCE, session_id='session', user_id=user_id)
|
||||
else:
|
||||
await service.record_feedback(context, feedback_id='feedback', feedback_type=1, user_id=user_id)
|
||||
async with service.ap.persistence_mgr.tenant_scope(WORKSPACE_A):
|
||||
@@ -326,7 +322,6 @@ async def test_unsupported_user_ids_fail_at_the_write_boundary(service, user_id,
|
||||
|
||||
async def test_session_analysis_aggregates_under_cloud_sql_guard(service):
|
||||
context = _context(WORKSPACE_A)
|
||||
await service.record_session_start(context, **RESOURCE, session_id='same-session')
|
||||
await service.record_message(context, **RESOURCE, session_id='same-session', message_content='hello')
|
||||
result = await _read(service, 'get_session_analysis', context, 'same-session')
|
||||
assert result['found'] is True
|
||||
|
||||
@@ -107,7 +107,10 @@ async def test_legacy_sqlite_resources_are_backfilled_and_contracted(tmp_path):
|
||||
)
|
||||
assert (
|
||||
await conn.scalar(
|
||||
sa.text('SELECT COUNT(*) FROM workspace_metadata WHERE workspace_uuid = :workspace_uuid'),
|
||||
sa.text(
|
||||
'SELECT COUNT(*) FROM workspace_metadata WHERE workspace_uuid = :workspace_uuid '
|
||||
"AND key IN ('wizard_status', 'wizard_progress', 'rag_plugin_migration_needed')"
|
||||
),
|
||||
{'workspace_uuid': workspace_uuid},
|
||||
)
|
||||
== 3
|
||||
@@ -296,7 +299,15 @@ async def test_fresh_sqlite_schema_matches_resource_tenancy_contract(tmp_path):
|
||||
engine = create_async_engine(f'sqlite+aiosqlite:///{tmp_path / "fresh-resources.db"}')
|
||||
try:
|
||||
async with engine.begin() as conn:
|
||||
await conn.run_sync(Base.metadata.create_all)
|
||||
# Match PersistenceManager: 0040 creates the receipt FK after older PK migrations.
|
||||
await conn.run_sync(
|
||||
lambda sync: Base.metadata.create_all(
|
||||
sync,
|
||||
tables=[
|
||||
table for table in Base.metadata.sorted_tables if table.name != 'monitoring_session_receipts'
|
||||
],
|
||||
)
|
||||
)
|
||||
await run_alembic_stamp(engine, '0001_baseline')
|
||||
await run_alembic_upgrade(engine, 'head')
|
||||
|
||||
@@ -312,3 +323,76 @@ async def test_fresh_sqlite_schema_matches_resource_tenancy_contract(tmp_path):
|
||||
assert columns['legacy_vector_collection']['nullable'] is False
|
||||
finally:
|
||||
await engine.dispose()
|
||||
|
||||
|
||||
@pytest.mark.session_summary
|
||||
@pytest.mark.contract('summary_schema')
|
||||
@pytest.mark.parametrize('has_history', [False, True])
|
||||
async def test_legacy_sqlite_without_monitoring_tables_initializes_summaries(tmp_path, monkeypatch, has_history):
|
||||
import datetime
|
||||
import logging
|
||||
from types import SimpleNamespace
|
||||
|
||||
from langbot.pkg.api.http.context import ExecutionContext
|
||||
from langbot.pkg.api.http.service.monitoring import MonitoringService
|
||||
from langbot.pkg.entity.persistence.event_log import EventLog
|
||||
from langbot.pkg.persistence.mgr import PersistenceManager
|
||||
from langbot.pkg.persistence.session_summary_backfill import SessionSummaryBackfill, require_initialized
|
||||
from langbot.pkg.utils import constants
|
||||
|
||||
instance = str(uuid.uuid4())
|
||||
monkeypatch.setattr(constants, 'instance_id', instance)
|
||||
engine = create_async_engine(f'sqlite+aiosqlite:///{tmp_path / "legacy-without-monitoring.db"}')
|
||||
try:
|
||||
await create_legacy_resource_schema(engine, instance_uuid=instance)
|
||||
async with engine.begin() as conn:
|
||||
for name in TENANT_TABLES:
|
||||
if name.startswith('monitoring_'):
|
||||
await conn.exec_driver_sql(f'DROP TABLE {name}')
|
||||
if has_history:
|
||||
await conn.run_sync(EventLog.__table__.create)
|
||||
await conn.execute(
|
||||
sa.insert(EventLog).values(
|
||||
event_id='legacy-event',
|
||||
event_type='message.received',
|
||||
source='platform',
|
||||
workspace_id=instance,
|
||||
bot_id='bot-1',
|
||||
conversation_id='group_legacy',
|
||||
created_at=datetime.datetime(2026, 1, 1),
|
||||
)
|
||||
)
|
||||
await run_alembic_stamp(engine, '0008_mcp_resource_prefs')
|
||||
ap = SimpleNamespace(logger=logging.getLogger(__name__))
|
||||
ap.persistence_mgr = PersistenceManager(ap)
|
||||
ap.persistence_mgr.db = SimpleNamespace(get_engine=lambda: engine)
|
||||
await ap.persistence_mgr._initialize_managed_schema()
|
||||
await ap.persistence_mgr._enable_sqlite_foreign_keys_after_migration()
|
||||
tables = await _inspect(engine, lambda conn: set(sa.inspect(conn).get_table_names()))
|
||||
assert {'monitoring_sessions', 'monitoring_session_receipts'} <= tables
|
||||
service = MonitoringService(ap)
|
||||
if has_history:
|
||||
with pytest.raises(RuntimeError, match='offline initialization'):
|
||||
async with service._summary_session(instance) as session:
|
||||
await require_initialized(session, instance)
|
||||
worker = SessionSummaryBackfill(service._summary_session, 'sqlite')
|
||||
while not await worker.run_batch(instance):
|
||||
pass
|
||||
ctx = ExecutionContext(instance_uuid=instance, workspace_uuid=instance, placement_generation=1)
|
||||
await service.record_message(
|
||||
ctx,
|
||||
bot_id='bot-1',
|
||||
bot_name='Bot',
|
||||
pipeline_id='pipeline-1',
|
||||
pipeline_name='Pipeline',
|
||||
session_id='group_live',
|
||||
message_content='hello',
|
||||
)
|
||||
await ap.persistence_mgr._initialize_managed_schema()
|
||||
rows, total = await service.get_sessions(ctx)
|
||||
assert total == 1 + int(has_history)
|
||||
assert all(row['message_count'] == 1 for row in rows)
|
||||
async with engine.connect() as conn:
|
||||
assert (await conn.exec_driver_sql('PRAGMA foreign_key_check')).all() == []
|
||||
finally:
|
||||
await engine.dispose()
|
||||
|
||||
Reference in New Issue
Block a user