mirror of
https://github.com/langbot-app/LangBot.git
synced 2026-08-09 12:40:59 +00:00
fix(cloud): bound tenant maintenance and monitoring work
This commit is contained in:
@@ -905,6 +905,32 @@ class TestMaintenanceServiceExpiredLocalUploadCandidates:
|
||||
# Verify - path included
|
||||
assert 'path' in result[0]
|
||||
|
||||
def test_expired_local_upload_candidates_respects_run_limit(self):
|
||||
ap = SimpleNamespace(
|
||||
logger=SimpleNamespace(warning=Mock()),
|
||||
storage_mgr=_scoped_storage_manager(),
|
||||
instance_config=SimpleNamespace(data={'storage': {'cleanup': {'max_files_per_run': 2}}}),
|
||||
)
|
||||
service = MaintenanceService(ap)
|
||||
entries = []
|
||||
for index in range(3):
|
||||
entry = Mock(spec=Path)
|
||||
entry.is_file = Mock(return_value=True)
|
||||
entry.stat = Mock(return_value=SimpleNamespace(st_size=100, st_mtime=0))
|
||||
entry.relative_to = Mock(return_value=Path(f'scoped/old-{index}.txt'))
|
||||
entries.append(entry)
|
||||
|
||||
with patch.object(Path, 'exists', return_value=True):
|
||||
with patch.object(Path, 'rglob', return_value=entries):
|
||||
result = service._expired_local_upload_candidates(TEST_CONTEXT, 7)
|
||||
|
||||
assert [item['key'] for item in result] == [
|
||||
'scoped/old-0.txt',
|
||||
'scoped/old-1.txt',
|
||||
]
|
||||
ap.instance_config.data['storage']['cleanup']['max_files_per_run'] = 999999
|
||||
assert service._max_files_per_run() == 10000
|
||||
|
||||
|
||||
ISOLATION_WORKSPACE_A = '00000000-0000-0000-0000-00000000000a'
|
||||
ISOLATION_WORKSPACE_B = '00000000-0000-0000-0000-00000000000b'
|
||||
|
||||
@@ -11,7 +11,7 @@ from langbot.pkg.api.http.authz import WorkspaceRequiredError
|
||||
from langbot.pkg.api.http.context import ExecutionContext
|
||||
from langbot.pkg.api.http.service.monitoring import MonitoringService
|
||||
from langbot.pkg.entity.persistence.base import Base
|
||||
from langbot.pkg.entity.persistence.monitoring import MonitoringMessage
|
||||
from langbot.pkg.entity.persistence.monitoring import MonitoringLLMCall, MonitoringMessage
|
||||
from langbot.pkg.entity.persistence.workspace import Workspace
|
||||
from langbot.pkg.persistence.mgr import PersistenceManager
|
||||
|
||||
@@ -176,6 +176,161 @@ async def test_feedback_upsert_and_cancel_are_workspace_scoped(service):
|
||||
assert (await service.get_feedback_stats(context_b))['total_feedback'] == 1
|
||||
|
||||
|
||||
async def test_monitoring_queries_and_detail_views_are_strictly_bounded(service):
|
||||
context = _context(WORKSPACE_A)
|
||||
service.ap.instance_config.data['monitoring'] = {
|
||||
'query_limits': {
|
||||
'page_rows': 2,
|
||||
'export_rows': 2,
|
||||
'detail_rows': 2,
|
||||
'timeseries_buckets': 2,
|
||||
'max_offset': 10,
|
||||
}
|
||||
}
|
||||
await service.record_session_start(
|
||||
context,
|
||||
session_id='same-session',
|
||||
bot_id='same-bot',
|
||||
bot_name='Same Bot',
|
||||
pipeline_id='same-pipeline',
|
||||
pipeline_name='Same Pipeline',
|
||||
)
|
||||
message_ids = [await _record_message(service, context, f'message-{index}') for index in range(4)]
|
||||
for index in range(3):
|
||||
await service.record_llm_call(
|
||||
context,
|
||||
bot_id='same-bot',
|
||||
bot_name='Same Bot',
|
||||
pipeline_id='same-pipeline',
|
||||
pipeline_name='Same Pipeline',
|
||||
session_id='same-session',
|
||||
model_name='model',
|
||||
input_tokens=1,
|
||||
output_tokens=2,
|
||||
duration=10,
|
||||
message_id=message_ids[0],
|
||||
)
|
||||
await service.record_tool_call(
|
||||
context,
|
||||
tool_name=f'tool-{index}',
|
||||
tool_source='native',
|
||||
duration=5,
|
||||
session_id='same-session',
|
||||
message_id=message_ids[0],
|
||||
)
|
||||
await service.record_error(
|
||||
context,
|
||||
bot_id='same-bot',
|
||||
bot_name='Same Bot',
|
||||
pipeline_id='same-pipeline',
|
||||
pipeline_name='Same Pipeline',
|
||||
error_type='Failure',
|
||||
error_message=f'error-{index}',
|
||||
session_id='same-session',
|
||||
message_id=message_ids[0],
|
||||
)
|
||||
|
||||
page, total = await service.get_messages(context, limit=100000, offset=-5)
|
||||
exported = await service.export_messages(context, limit=100000)
|
||||
session_detail = await service.get_session_analysis(context, 'same-session')
|
||||
message_detail = await service.get_message_details(context, message_ids[0])
|
||||
|
||||
assert total == 4
|
||||
assert len(page) == 2
|
||||
assert len(exported) == 2
|
||||
assert session_detail['message_stats']['total'] == 4
|
||||
assert session_detail['llm_stats']['total_calls'] == 3
|
||||
assert session_detail['tool_stats']['total_calls'] == 3
|
||||
assert len(session_detail['tool_calls']) == 2
|
||||
assert len(session_detail['errors']) == 2
|
||||
assert session_detail['detail_truncated'] == {
|
||||
'tool_calls': True,
|
||||
'errors': True,
|
||||
}
|
||||
assert message_detail['llm_stats']['total_calls'] == 3
|
||||
assert len(message_detail['llm_calls']) == 2
|
||||
assert len(message_detail['errors']) == 2
|
||||
assert message_detail['detail_truncated'] == {
|
||||
'llm_calls': True,
|
||||
'errors': True,
|
||||
}
|
||||
|
||||
service.ap.instance_config.data['monitoring']['query_limits'] = {
|
||||
'page_rows': 999999,
|
||||
'export_rows': 999999,
|
||||
'detail_rows': 999999,
|
||||
'timeseries_buckets': 999999,
|
||||
'max_offset': 99999999,
|
||||
}
|
||||
assert service.normalize_page_window(999999, 99999999) == (5000, 10000000)
|
||||
assert service.normalize_export_limit(999999) == 50000
|
||||
assert service._detail_limit() == 10000
|
||||
assert service._timeseries_bucket_limit() == 10000
|
||||
|
||||
|
||||
async def test_token_statistics_aggregate_and_limit_groups_in_database(service):
|
||||
context = _context(WORKSPACE_A)
|
||||
service.ap.instance_config.data['monitoring'] = {
|
||||
'query_limits': {
|
||||
'page_rows': 1,
|
||||
'timeseries_buckets': 2,
|
||||
}
|
||||
}
|
||||
first_hour = datetime.datetime(2026, 7, 28, 10, 0)
|
||||
rows = [
|
||||
{
|
||||
'id': f'llm-{index}',
|
||||
'workspace_uuid': WORKSPACE_A,
|
||||
'timestamp': first_hour + datetime.timedelta(hours=hour, minutes=index),
|
||||
'model_name': model,
|
||||
'input_tokens': input_tokens,
|
||||
'output_tokens': output_tokens,
|
||||
'total_tokens': input_tokens + output_tokens,
|
||||
'duration': 100,
|
||||
'cost': 0.01,
|
||||
'status': 'success',
|
||||
'bot_id': 'same-bot',
|
||||
'bot_name': 'Same Bot',
|
||||
'pipeline_id': 'same-pipeline',
|
||||
'pipeline_name': 'Same Pipeline',
|
||||
'session_id': 'same-session',
|
||||
}
|
||||
for index, (hour, model, input_tokens, output_tokens) in enumerate(
|
||||
[
|
||||
(0, 'small-model', 1, 2),
|
||||
(1, 'large-model', 3, 4),
|
||||
(2, 'large-model', 5, 6),
|
||||
(2, 'large-model', 7, 8),
|
||||
]
|
||||
)
|
||||
]
|
||||
await service.ap.persistence_mgr.execute_async(sqlalchemy.insert(MonitoringLLMCall), rows)
|
||||
|
||||
stats = await service.get_token_statistics(context, bucket='hour')
|
||||
|
||||
assert stats['summary']['total_calls'] == 4
|
||||
assert stats['summary']['total_tokens'] == 36
|
||||
assert stats['by_model_truncated'] is True
|
||||
assert [model['model_name'] for model in stats['by_model']] == ['large-model']
|
||||
assert stats['timeseries_truncated'] is True
|
||||
assert stats['timeseries'] == [
|
||||
{
|
||||
'bucket': '2026-07-28 11:00',
|
||||
'input_tokens': 3,
|
||||
'output_tokens': 4,
|
||||
'total_tokens': 7,
|
||||
'calls': 1,
|
||||
},
|
||||
{
|
||||
'bucket': '2026-07-28 12:00',
|
||||
'input_tokens': 12,
|
||||
'output_tokens': 14,
|
||||
'total_tokens': 26,
|
||||
'calls': 2,
|
||||
},
|
||||
]
|
||||
|
||||
|
||||
async def test_cleanup_commits_sqlite_delete_before_vacuum(tmp_path):
|
||||
engine = create_async_engine(
|
||||
f'sqlite+aiosqlite:///{tmp_path / "monitoring-cleanup.db"}',
|
||||
@@ -200,32 +355,38 @@ async def test_cleanup_commits_sqlite_delete_before_vacuum(tmp_path):
|
||||
)
|
||||
)
|
||||
await connection.execute(
|
||||
sqlalchemy.insert(MonitoringMessage).values(
|
||||
id='expired-message',
|
||||
workspace_uuid=WORKSPACE_A,
|
||||
timestamp=datetime.datetime.now(datetime.timezone.utc).replace(tzinfo=None)
|
||||
- datetime.timedelta(days=30),
|
||||
bot_id='bot',
|
||||
bot_name='Bot',
|
||||
pipeline_id='pipeline',
|
||||
pipeline_name='Pipeline',
|
||||
message_content='expired',
|
||||
session_id='session',
|
||||
status='success',
|
||||
level='info',
|
||||
)
|
||||
sqlalchemy.insert(MonitoringMessage),
|
||||
[
|
||||
{
|
||||
'id': f'expired-message-{index}',
|
||||
'workspace_uuid': WORKSPACE_A,
|
||||
'timestamp': datetime.datetime.now(datetime.timezone.utc).replace(tzinfo=None)
|
||||
- datetime.timedelta(days=30),
|
||||
'bot_id': 'bot',
|
||||
'bot_name': 'Bot',
|
||||
'pipeline_id': 'pipeline',
|
||||
'pipeline_name': 'Pipeline',
|
||||
'message_content': 'expired',
|
||||
'session_id': 'session',
|
||||
'status': 'success',
|
||||
'level': 'info',
|
||||
}
|
||||
for index in range(5)
|
||||
],
|
||||
)
|
||||
|
||||
deleted = await MonitoringService(application).cleanup_expired_records(
|
||||
_context(WORKSPACE_A),
|
||||
retention_days=1,
|
||||
batch_size=2,
|
||||
max_batches_per_table=1,
|
||||
)
|
||||
|
||||
assert deleted['monitoring_messages'] == 1
|
||||
assert deleted['monitoring_messages'] == 2
|
||||
async with engine.connect() as connection:
|
||||
remaining = await connection.scalar(
|
||||
sqlalchemy.select(sqlalchemy.func.count()).select_from(MonitoringMessage)
|
||||
)
|
||||
assert remaining == 0
|
||||
assert remaining == 3
|
||||
finally:
|
||||
await engine.dispose()
|
||||
|
||||
Reference in New Issue
Block a user