mirror of
https://github.com/langbot-app/LangBot.git
synced 2026-10-23 17:05:28 +08:00
Merge pull request #2725 from langbot-app/fix/session-summary-persistence
fix(monitoring): persist session summaries to avoid history scans
This commit is contained in:
@@ -8,6 +8,7 @@ import typing
|
||||
import uuid
|
||||
|
||||
import sqlalchemy
|
||||
from sqlalchemy.dialects import postgresql, sqlite
|
||||
from sqlalchemy.ext.asyncio import AsyncEngine, AsyncSession
|
||||
from sqlalchemy.orm import sessionmaker
|
||||
|
||||
@@ -70,6 +71,7 @@ class EventLogStore:
|
||||
runner_id: str | None = None,
|
||||
event_time: datetime.datetime | None = None,
|
||||
metadata: dict[str, typing.Any] | None = None,
|
||||
session: AsyncSession | None = None,
|
||||
) -> str:
|
||||
"""Append an event to the event log.
|
||||
|
||||
@@ -93,6 +95,7 @@ class EventLogStore:
|
||||
runner_id: Runner ID processing this event
|
||||
event_time: When the event occurred
|
||||
metadata: Additional metadata
|
||||
session: Optional owning transaction; the caller commits or rolls back
|
||||
|
||||
Returns:
|
||||
The event_id
|
||||
@@ -104,60 +107,70 @@ class EventLogStore:
|
||||
if input_summary and len(input_summary) > self.MAX_INPUT_SUMMARY_LENGTH:
|
||||
input_summary = input_summary[: self.MAX_INPUT_SUMMARY_LENGTH - 3] + '...'
|
||||
|
||||
async with self._session_factory() as session:
|
||||
event = EventLog(
|
||||
event_id=event_id,
|
||||
event_type=event_type,
|
||||
event_time=as_naive_utc(event_time),
|
||||
source=source,
|
||||
bot_id=bot_id,
|
||||
workspace_id=workspace_id,
|
||||
conversation_id=conversation_id,
|
||||
thread_id=thread_id,
|
||||
actor_type=actor_type,
|
||||
actor_id=actor_id,
|
||||
actor_name=actor_name,
|
||||
subject_type=subject_type,
|
||||
subject_id=subject_id,
|
||||
input_summary=input_summary,
|
||||
input_json=json.dumps(input_json) if input_json else None,
|
||||
raw_ref=raw_ref,
|
||||
run_id=run_id,
|
||||
runner_id=runner_id,
|
||||
metadata_json=json.dumps(metadata) if metadata else None,
|
||||
created_at=as_naive_utc(_utc_now()),
|
||||
)
|
||||
session.add(event)
|
||||
try:
|
||||
await session.commit()
|
||||
except sqlalchemy.exc.IntegrityError:
|
||||
await session.rollback()
|
||||
existing = (
|
||||
(await session.execute(sqlalchemy.select(EventLog).where(EventLog.event_id == event_id)))
|
||||
.scalars()
|
||||
.first()
|
||||
)
|
||||
if existing is None or existing.workspace_id != workspace_id or existing.bot_id != bot_id:
|
||||
raise
|
||||
# Complete the ingress snapshot once a runner claims it. A
|
||||
# second subscriber must not replace another run's identity.
|
||||
for field, value in {
|
||||
'run_id': run_id,
|
||||
'runner_id': runner_id,
|
||||
'conversation_id': conversation_id,
|
||||
'thread_id': thread_id,
|
||||
'actor_id': actor_id,
|
||||
'actor_name': actor_name,
|
||||
}.items():
|
||||
if getattr(existing, field) is None and value is not None:
|
||||
setattr(existing, field, value)
|
||||
if input_json and ('contents' in input_json or 'attachments' in input_json):
|
||||
existing.input_json = json.dumps(input_json)
|
||||
existing.input_summary = input_summary
|
||||
await session.commit()
|
||||
|
||||
values = dict(
|
||||
event_id=event_id,
|
||||
event_type=event_type,
|
||||
event_time=as_naive_utc(event_time),
|
||||
source=source,
|
||||
bot_id=bot_id,
|
||||
workspace_id=workspace_id,
|
||||
conversation_id=conversation_id,
|
||||
thread_id=thread_id,
|
||||
actor_type=actor_type,
|
||||
actor_id=actor_id,
|
||||
actor_name=actor_name,
|
||||
subject_type=subject_type,
|
||||
subject_id=subject_id,
|
||||
input_summary=input_summary,
|
||||
input_json=json.dumps(input_json) if input_json else None,
|
||||
raw_ref=raw_ref,
|
||||
run_id=run_id,
|
||||
runner_id=runner_id,
|
||||
metadata_json=json.dumps(metadata) if metadata else None,
|
||||
created_at=as_naive_utc(_utc_now()),
|
||||
)
|
||||
if session is not None:
|
||||
await self._append_in_transaction(session, values, input_json)
|
||||
else:
|
||||
async with self._session_factory() as owned:
|
||||
await self._append_in_transaction(owned, values, input_json)
|
||||
await owned.commit()
|
||||
return event_id
|
||||
|
||||
async def _append_in_transaction(self, session, values, input_json):
|
||||
insert = {'postgresql': postgresql.insert, 'sqlite': sqlite.insert}[self.engine.dialect.name]
|
||||
result = await session.execute(
|
||||
insert(EventLog).values(**values).on_conflict_do_nothing(index_elements=['event_id'])
|
||||
)
|
||||
if result.rowcount:
|
||||
return
|
||||
existing = (
|
||||
await session.execute(
|
||||
sqlalchemy.select(EventLog).where(
|
||||
EventLog.event_id == values['event_id'],
|
||||
)
|
||||
)
|
||||
).scalar_one_or_none()
|
||||
if existing is None or existing.workspace_id != values['workspace_id'] or existing.bot_id != values['bot_id']:
|
||||
raise ValueError('An event ID cannot be reused across Workspaces or bots')
|
||||
# Only fill missing identities; another subscriber cannot replace a claimed run.
|
||||
updates = {
|
||||
field: sqlalchemy.func.coalesce(getattr(EventLog, field), values[field])
|
||||
for field in ('run_id', 'runner_id', 'conversation_id', 'thread_id', 'actor_id', 'actor_name')
|
||||
if values[field] is not None
|
||||
}
|
||||
if input_json and ('contents' in input_json or 'attachments' in input_json):
|
||||
updates.update(input_json=values['input_json'], input_summary=values['input_summary'])
|
||||
if updates:
|
||||
await session.execute(
|
||||
sqlalchemy.update(EventLog)
|
||||
.where(
|
||||
EventLog.event_id == values['event_id'],
|
||||
EventLog.workspace_id == values['workspace_id'],
|
||||
)
|
||||
.values(**updates)
|
||||
)
|
||||
|
||||
async def get_event(
|
||||
self,
|
||||
event_id: str,
|
||||
|
||||
@@ -343,6 +343,10 @@ class MonitoringRouterGroup(group.RouterGroup):
|
||||
)
|
||||
async def get_sessions(request_context: RequestContext) -> str:
|
||||
"""Get session information"""
|
||||
if quart.request.args.getlist('status') or quart.request.args.get('mode', 'all') != 'all':
|
||||
return self.http_status(
|
||||
400, 'invalid_request', 'Session lists do not support execution status or mode filters'
|
||||
)
|
||||
# Parse query parameters
|
||||
bot_ids = quart.request.args.getlist('botId')
|
||||
pipeline_ids = quart.request.args.getlist('pipelineId')
|
||||
@@ -428,7 +432,7 @@ class MonitoringRouterGroup(group.RouterGroup):
|
||||
|
||||
@self.route('/data', methods=['GET'], permission=Permission.RESOURCE_VIEW)
|
||||
async def get_all_data(request_context: RequestContext) -> str:
|
||||
"""Get all monitoring data in a single request"""
|
||||
"""Read monitoring data; session summaries use only bot, pipeline and time filters."""
|
||||
execution_filters = {
|
||||
'execution_statuses': quart.request.args.getlist('status') or None,
|
||||
'mode': quart.request.args.get('mode', 'all'),
|
||||
@@ -491,10 +495,9 @@ class MonitoringRouterGroup(group.RouterGroup):
|
||||
offset=0,
|
||||
)
|
||||
|
||||
# Get sessions
|
||||
# Sessions have conversation scope, independent of execution status/mode.
|
||||
sessions, sessions_total = await self.ap.monitoring_service.get_sessions(
|
||||
request_context,
|
||||
**execution_filters,
|
||||
bot_ids=bot_ids if bot_ids else None,
|
||||
pipeline_ids=pipeline_ids if pipeline_ids else None,
|
||||
start_time=start_time,
|
||||
|
||||
@@ -5,6 +5,7 @@ import uuid
|
||||
import datetime
|
||||
import functools
|
||||
import json
|
||||
from contextlib import asynccontextmanager
|
||||
import sqlalchemy
|
||||
from sqlalchemy.dialects import postgresql as postgresql_dialect
|
||||
from sqlalchemy.dialects import sqlite as sqlite_dialect
|
||||
@@ -20,6 +21,12 @@ from ..authz import WorkspaceRequiredError
|
||||
from ..context import ExecutionContext
|
||||
from .tenant import TenantContext, require_workspace_uuid
|
||||
from .monitoring_execution_details import ExecutionDetailsMixin
|
||||
from ....persistence.session_summary_contract import (
|
||||
SessionListFilter,
|
||||
SessionSummaryReader,
|
||||
)
|
||||
from ....persistence.session_summary_reader import SqlSessionSummaryReader, SUMMARY_FIELDS
|
||||
from ....persistence.session_summary_writer import SessionSummaryWriter, message_summary_change
|
||||
|
||||
|
||||
_DEFAULT_MONITORING_PAGE_ROWS = 1000
|
||||
@@ -147,6 +154,28 @@ class MonitoringService(ExecutionDetailsMixin):
|
||||
|
||||
def __init__(self, ap: app.Application) -> None:
|
||||
self.ap = ap
|
||||
self._session_list: SessionSummaryReader = SqlSessionSummaryReader(self._summary_session)
|
||||
|
||||
@asynccontextmanager
|
||||
async def _summary_session(self, workspace: str):
|
||||
"""Join the owning Workspace transaction, including source writes."""
|
||||
async with self.ap.persistence_mgr.tenant_uow(workspace) as uow:
|
||||
yield uow.session
|
||||
|
||||
def _summary_writer(self):
|
||||
return SessionSummaryWriter(self.ap.persistence_mgr.get_db_engine().dialect.name)
|
||||
|
||||
@asynccontextmanager
|
||||
async def _summary_write_session(self, workspace: str):
|
||||
from ....persistence.session_summary_backfill import require_initialized
|
||||
|
||||
async with self._summary_session(workspace) as session:
|
||||
await require_initialized(session, workspace)
|
||||
yield session
|
||||
|
||||
def _serialize_session(self, row):
|
||||
values = self.ap.persistence_mgr.serialize_model(persistence_monitoring.MonitoringSession, row)
|
||||
return {field: values[field] for field in SUMMARY_FIELDS}
|
||||
|
||||
def _configured_query_limit(self, name: str, default: int, hard_max: int) -> int:
|
||||
config = (
|
||||
@@ -292,6 +321,12 @@ class MonitoringService(ExecutionDetailsMixin):
|
||||
)
|
||||
|
||||
tables_and_columns: list[tuple[str, type, sqlalchemy.Column, sqlalchemy.Column]] = [
|
||||
(
|
||||
'monitoring_session_receipts',
|
||||
persistence_monitoring.MonitoringSessionReceipt,
|
||||
persistence_monitoring.MonitoringSessionReceipt.timestamp,
|
||||
persistence_monitoring.MonitoringSessionReceipt.source_key,
|
||||
),
|
||||
(
|
||||
'monitoring_messages',
|
||||
persistence_monitoring.MonitoringMessage,
|
||||
@@ -359,7 +394,8 @@ class MonitoringService(ExecutionDetailsMixin):
|
||||
else:
|
||||
deleted_counts = await delete_records()
|
||||
|
||||
if sum(deleted_counts.values()) > 0:
|
||||
# Receipt expiry alone must not trigger database-wide VACUUM work.
|
||||
if any(count for table, count in deleted_counts.items() if table != 'monitoring_session_receipts'):
|
||||
await self._release_sqlite_space()
|
||||
|
||||
return deleted_counts
|
||||
@@ -375,15 +411,28 @@ class MonitoringService(ExecutionDetailsMixin):
|
||||
max_batches: int,
|
||||
) -> int:
|
||||
workspace_uuid = self._require_write_context(context)
|
||||
if model_cls is persistence_monitoring.MonitoringSessionReceipt:
|
||||
return await self._expire_session_receipts(workspace_uuid, cutoff, batch_size, max_batches)
|
||||
deleted_total = 0
|
||||
|
||||
for _batch_number in range(max_batches):
|
||||
|
||||
async def delete_batch() -> tuple[int, int]:
|
||||
key_columns = list(model_cls.__table__.primary_key.columns)
|
||||
conditions = [model_cls.workspace_uuid == workspace_uuid, ts_column < cutoff]
|
||||
if model_cls is persistence_monitoring.MonitoringSession:
|
||||
receipt = persistence_monitoring.MonitoringSessionReceipt
|
||||
conditions.append(
|
||||
~sqlalchemy.exists().where(
|
||||
receipt.workspace_uuid == workspace_uuid,
|
||||
receipt.bot_id == model_cls.bot_id,
|
||||
receipt.session_id == model_cls.session_id,
|
||||
)
|
||||
)
|
||||
select_result = await self.ap.persistence_mgr.execute_async(
|
||||
sqlalchemy.select(*key_columns)
|
||||
.where(model_cls.workspace_uuid == workspace_uuid, ts_column < cutoff)
|
||||
.where(*conditions)
|
||||
.order_by(ts_column, *key_columns)
|
||||
.limit(batch_size)
|
||||
)
|
||||
pk_values = [tuple(row) for row in select_result.all()]
|
||||
@@ -392,9 +441,8 @@ class MonitoringService(ExecutionDetailsMixin):
|
||||
|
||||
delete_result = await self.ap.persistence_mgr.execute_async(
|
||||
sqlalchemy.delete(model_cls).where(
|
||||
model_cls.workspace_uuid == workspace_uuid,
|
||||
*conditions,
|
||||
sqlalchemy.tuple_(*key_columns).in_(pk_values),
|
||||
ts_column < cutoff,
|
||||
)
|
||||
)
|
||||
return len(pk_values), int(delete_result.rowcount or 0)
|
||||
@@ -414,6 +462,47 @@ class MonitoringService(ExecutionDetailsMixin):
|
||||
|
||||
return deleted_total
|
||||
|
||||
async def _expire_session_receipts(self, workspace, cutoff, batch_size, max_batches):
|
||||
receipt = persistence_monitoring.MonitoringSessionReceipt
|
||||
expired = [receipt.workspace_uuid == workspace, receipt.timestamp < cutoff]
|
||||
deleted = 0
|
||||
for _ in range(max_batches):
|
||||
async with self._summary_session(workspace) as session:
|
||||
rows = (
|
||||
(
|
||||
await session.execute(
|
||||
sqlalchemy.select(receipt.bot_id, receipt.source_key)
|
||||
.where(*expired)
|
||||
.order_by(receipt.timestamp, receipt.bot_id, receipt.source_key)
|
||||
.limit(batch_size)
|
||||
)
|
||||
)
|
||||
.mappings()
|
||||
.all()
|
||||
)
|
||||
event_keys = [
|
||||
(row['bot_id'], row['source_key']) for row in rows if row['source_key'].startswith('event:')
|
||||
]
|
||||
deleted += await self._summary_writer().expire_ingress(session, workspace, cutoff, event_keys)
|
||||
# A message receipt can be locked by late linking, which next locks
|
||||
# summaries. Never hold a message lock while sealing ingress.
|
||||
message_keys = [
|
||||
(row['bot_id'], row['source_key']) for row in rows if not row['source_key'].startswith('event:')
|
||||
]
|
||||
if message_keys:
|
||||
async with self._summary_session(workspace) as session:
|
||||
result = await session.execute(
|
||||
sqlalchemy.delete(receipt).where(
|
||||
*expired,
|
||||
~receipt.source_key.startswith('event:'),
|
||||
sqlalchemy.tuple_(receipt.bot_id, receipt.source_key).in_(message_keys),
|
||||
)
|
||||
)
|
||||
deleted += result.rowcount
|
||||
if len(rows) < batch_size:
|
||||
break
|
||||
return deleted
|
||||
|
||||
async def _release_sqlite_space(self) -> None:
|
||||
database_type = self.ap.instance_config.data.get('database', {}).get('use', 'sqlite')
|
||||
if database_type != 'sqlite':
|
||||
@@ -587,9 +676,12 @@ class MonitoringService(ExecutionDetailsMixin):
|
||||
'parent_message_id': parent_message_id,
|
||||
}
|
||||
|
||||
await self.ap.persistence_mgr.execute_async(
|
||||
sqlalchemy.insert(persistence_monitoring.MonitoringMessage).values(message_data)
|
||||
)
|
||||
async with self._summary_write_session(workspace_uuid) as session:
|
||||
await session.execute(sqlalchemy.insert(persistence_monitoring.MonitoringMessage).values(message_data))
|
||||
await self._summary_writer().apply_once(
|
||||
session,
|
||||
message_summary_change(workspace_uuid, message_data),
|
||||
)
|
||||
|
||||
return message_id
|
||||
|
||||
@@ -745,96 +837,6 @@ class MonitoringService(ExecutionDetailsMixin):
|
||||
|
||||
return call_id
|
||||
|
||||
@_workspace_transaction
|
||||
async def record_session_start(
|
||||
self,
|
||||
context: ExecutionContext,
|
||||
session_id: str,
|
||||
bot_id: str,
|
||||
bot_name: str,
|
||||
pipeline_id: str,
|
||||
pipeline_name: str,
|
||||
platform: str | None = None,
|
||||
user_id: str | int | None = None,
|
||||
user_name: str | None = None,
|
||||
) -> None:
|
||||
"""Record a new session"""
|
||||
workspace_uuid = self._require_write_context(context)
|
||||
session_data = {
|
||||
'workspace_uuid': workspace_uuid,
|
||||
'session_id': session_id,
|
||||
'bot_id': bot_id,
|
||||
'bot_name': bot_name,
|
||||
'pipeline_id': pipeline_id,
|
||||
'pipeline_name': pipeline_name,
|
||||
'message_count': 1,
|
||||
'start_time': datetime.datetime.now(datetime.timezone.utc).replace(tzinfo=None),
|
||||
'last_activity': datetime.datetime.now(datetime.timezone.utc).replace(tzinfo=None),
|
||||
'is_active': True,
|
||||
'platform': platform,
|
||||
'user_id': _normalize_user_id(user_id),
|
||||
'user_name': user_name,
|
||||
}
|
||||
|
||||
model = persistence_monitoring.MonitoringSession
|
||||
dialect = self.ap.persistence_mgr.get_db_engine().dialect.name
|
||||
insert = postgresql_dialect.insert if dialect == 'postgresql' else sqlite_dialect.insert
|
||||
statement = insert(model).values(session_data)
|
||||
await self.ap.persistence_mgr.execute_async(
|
||||
statement.on_conflict_do_update(
|
||||
index_elements=['workspace_uuid', 'bot_id', 'session_id'],
|
||||
set_={
|
||||
'message_count': model.message_count + 1,
|
||||
'last_activity': statement.excluded.last_activity,
|
||||
'pipeline_id': statement.excluded.pipeline_id,
|
||||
'pipeline_name': statement.excluded.pipeline_name,
|
||||
},
|
||||
)
|
||||
)
|
||||
|
||||
@_workspace_transaction
|
||||
async def update_session_activity(
|
||||
self,
|
||||
context: ExecutionContext,
|
||||
session_id: str,
|
||||
pipeline_id: str | None = None,
|
||||
pipeline_name: str | None = None,
|
||||
bot_id: str | None = None,
|
||||
) -> bool:
|
||||
"""Update session last activity time and increment message count.
|
||||
|
||||
Also updates pipeline info if the bot's pipeline has changed.
|
||||
|
||||
Returns:
|
||||
True if session was found and updated, False if session doesn't exist.
|
||||
"""
|
||||
workspace_uuid = self._require_write_context(context)
|
||||
bot_id = bot_id if bot_id is not None else context.bot_uuid
|
||||
if not bot_id:
|
||||
raise ValueError('Session activity requires a bot_id')
|
||||
update_values = {
|
||||
'last_activity': datetime.datetime.now(datetime.timezone.utc).replace(tzinfo=None),
|
||||
'message_count': persistence_monitoring.MonitoringSession.message_count + 1,
|
||||
}
|
||||
|
||||
# Update pipeline info if provided (handles pipeline switch)
|
||||
if pipeline_id is not None:
|
||||
update_values['pipeline_id'] = pipeline_id
|
||||
if pipeline_name is not None:
|
||||
update_values['pipeline_name'] = pipeline_name
|
||||
|
||||
result = await self.ap.persistence_mgr.execute_async(
|
||||
sqlalchemy.update(persistence_monitoring.MonitoringSession)
|
||||
.where(
|
||||
persistence_monitoring.MonitoringSession.workspace_uuid == workspace_uuid,
|
||||
persistence_monitoring.MonitoringSession.session_id == session_id,
|
||||
persistence_monitoring.MonitoringSession.bot_id == bot_id,
|
||||
)
|
||||
.values(update_values)
|
||||
)
|
||||
# Check if any rows were updated
|
||||
return result.rowcount > 0
|
||||
|
||||
@_workspace_transaction
|
||||
async def record_error(
|
||||
self,
|
||||
@@ -2050,15 +2052,15 @@ class MonitoringService(ExecutionDetailsMixin):
|
||||
limit, offset = self.normalize_page_window(limit, offset)
|
||||
workspace_uuid = require_workspace_uuid(context)
|
||||
if bot_ids and session_ids and not pipeline_ids and not execution_statuses and mode == 'all':
|
||||
source = self._session_message_source(workspace_uuid, bot_ids)
|
||||
source = self._conversation_message_source(workspace_uuid, bot_ids)
|
||||
query = sqlalchemy.select(source).where(source.c.session_id.in_(session_ids))
|
||||
if start_time:
|
||||
query = query.where(source.c.timestamp >= start_time)
|
||||
if end_time:
|
||||
query = query.where(source.c.timestamp <= end_time)
|
||||
return await self._session_projection_page(
|
||||
return await self._conversation_projection_page(
|
||||
workspace_uuid,
|
||||
query.order_by(source.c.timestamp.desc(), source.c.id.desc()),
|
||||
query.order_by(source.c.timestamp.desc(), source.c.id.desc(), source.c._source),
|
||||
limit,
|
||||
offset,
|
||||
)
|
||||
@@ -2311,7 +2313,7 @@ class MonitoringService(ExecutionDetailsMixin):
|
||||
raise ValueError('bot_id and session_id are required')
|
||||
workspace = require_workspace_uuid(context)
|
||||
execute = self.ap.persistence_mgr.execute_async
|
||||
source = self._session_message_source(workspace, [bot_id])
|
||||
source = self._conversation_message_source(workspace, [bot_id])
|
||||
exists = (
|
||||
await execute(
|
||||
sqlalchemy.select(source.c.session_id)
|
||||
@@ -2396,81 +2398,22 @@ class MonitoringService(ExecutionDetailsMixin):
|
||||
is_active: bool | None = None,
|
||||
limit: int = 100,
|
||||
offset: int = 0,
|
||||
execution_statuses: list[str] | None = None,
|
||||
mode: str = 'all',
|
||||
) -> tuple[list[dict], int]:
|
||||
"""Get sessions with filters"""
|
||||
"""Interactive lists may only read the materialized summary contract."""
|
||||
workspace = require_workspace_uuid(context)
|
||||
limit, offset = self.normalize_page_window(limit, offset)
|
||||
workspace_uuid = require_workspace_uuid(context)
|
||||
if bot_ids and not pipeline_ids and not execution_statuses and mode == 'all':
|
||||
return await self.get_bot_conversation_sessions(
|
||||
workspace_uuid,
|
||||
bot_ids,
|
||||
start_time,
|
||||
end_time,
|
||||
user_query,
|
||||
is_active,
|
||||
limit,
|
||||
offset,
|
||||
)
|
||||
conditions = [persistence_monitoring.MonitoringSession.workspace_uuid == workspace_uuid]
|
||||
|
||||
conditions.append(
|
||||
self._monitoring_execution_condition(
|
||||
persistence_monitoring.MonitoringSession, workspace_uuid, execution_statuses, mode
|
||||
)
|
||||
)
|
||||
if bot_ids:
|
||||
conditions.append(persistence_monitoring.MonitoringSession.bot_id.in_(bot_ids))
|
||||
if pipeline_ids:
|
||||
conditions.append(
|
||||
self._monitoring_processor_condition(
|
||||
persistence_monitoring.MonitoringSession, workspace_uuid, pipeline_ids
|
||||
)
|
||||
)
|
||||
if start_time:
|
||||
conditions.append(persistence_monitoring.MonitoringSession.last_activity >= start_time)
|
||||
if end_time:
|
||||
conditions.append(persistence_monitoring.MonitoringSession.last_activity <= 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)
|
||||
|
||||
# Get total count
|
||||
count_query = sqlalchemy.select(sqlalchemy.func.count(persistence_monitoring.MonitoringSession.session_id))
|
||||
if conditions:
|
||||
count_query = count_query.where(sqlalchemy.and_(*conditions))
|
||||
|
||||
count_result = await self.ap.persistence_mgr.execute_async(count_query)
|
||||
total = count_result.scalar() or 0
|
||||
|
||||
# Get sessions
|
||||
query = sqlalchemy.select(persistence_monitoring.MonitoringSession).order_by(
|
||||
persistence_monitoring.MonitoringSession.last_activity.desc()
|
||||
)
|
||||
if conditions:
|
||||
query = query.where(sqlalchemy.and_(*conditions))
|
||||
|
||||
query = query.limit(limit).offset(offset)
|
||||
|
||||
result = await self.ap.persistence_mgr.execute_async(query)
|
||||
sessions_rows = result.all()
|
||||
|
||||
return (
|
||||
[
|
||||
self.ap.persistence_mgr.serialize_model(
|
||||
persistence_monitoring.MonitoringSession, row[0] if isinstance(row, tuple) else row
|
||||
)
|
||||
for row in sessions_rows
|
||||
],
|
||||
total,
|
||||
return await self._session_list.list_page(
|
||||
workspace,
|
||||
SessionListFilter(
|
||||
bot_ids=tuple(bot_ids or ()),
|
||||
pipeline_ids=tuple(pipeline_ids or ()),
|
||||
start_time=start_time,
|
||||
end_time=end_time,
|
||||
user_query=user_query,
|
||||
is_active=is_active,
|
||||
),
|
||||
limit,
|
||||
offset,
|
||||
)
|
||||
|
||||
async def get_errors(
|
||||
@@ -2728,7 +2671,7 @@ class MonitoringService(ExecutionDetailsMixin):
|
||||
return {
|
||||
'session_id': session_id,
|
||||
'found': True,
|
||||
'session': self.ap.persistence_mgr.serialize_model(persistence_monitoring.MonitoringSession, session),
|
||||
'session': self._serialize_session(session),
|
||||
'message_stats': {
|
||||
'total': int(message_stats.total or 0),
|
||||
'success': int(message_stats.success or 0),
|
||||
|
||||
@@ -100,7 +100,20 @@ def transcript_item(row):
|
||||
|
||||
|
||||
class ExecutionDetailsMixin:
|
||||
def _session_message_source(self, workspace, bot_ids, *, summary_only=False):
|
||||
def _conversation_message_source(self, workspace, bot_ids):
|
||||
"""Count and page identities without carrying historical payloads."""
|
||||
fields = ('id', 'workspace_uuid', 'timestamp', 'bot_id', 'session_id')
|
||||
return sa.union_all(
|
||||
*[
|
||||
query.with_only_columns(
|
||||
*[query.selected_columns[field] for field in fields],
|
||||
sa.literal(origin).label('_source'),
|
||||
)
|
||||
for origin, query in zip(('message', 'event'), self._conversation_message_queries(workspace, bot_ids))
|
||||
]
|
||||
).subquery()
|
||||
|
||||
def _conversation_message_queries(self, workspace, bot_ids):
|
||||
"""Combine delivered messages with ingress missing from the legacy ledger."""
|
||||
message = models.MonitoringMessage
|
||||
conversation = sa.func.coalesce(
|
||||
@@ -148,11 +161,7 @@ class ExecutionDetailsMixin:
|
||||
'event_id': EventLog.event_id,
|
||||
'run_id': EventLog.run_id,
|
||||
}
|
||||
columns = [
|
||||
column
|
||||
for column in message.__table__.columns
|
||||
if not summary_only or column.name not in {'message_content', 'variables'}
|
||||
]
|
||||
columns = list(message.__table__.columns)
|
||||
stored = sa.select(*columns).where(message.workspace_uuid == workspace, message.bot_id.in_(bot_ids))
|
||||
incoming = sa.select(
|
||||
*[values.get(column.name, sa.literal(None)).label(column.name) for column in columns]
|
||||
@@ -168,94 +177,59 @@ class ExecutionDetailsMixin:
|
||||
message.workspace_uuid == workspace,
|
||||
message.bot_id == EventLog.bot_id,
|
||||
sa.func.coalesce(message.role, 'user') != 'assistant',
|
||||
sa.or_(message.event_id == EventLog.event_id, message.id == EventLog.event_id),
|
||||
message.event_id == EventLog.event_id,
|
||||
)
|
||||
),
|
||||
~sa.exists(
|
||||
sa.select(message.id).where(
|
||||
message.workspace_uuid == workspace,
|
||||
message.bot_id == EventLog.bot_id,
|
||||
sa.func.coalesce(message.role, 'user') != 'assistant',
|
||||
message.id == EventLog.event_id,
|
||||
)
|
||||
),
|
||||
)
|
||||
return sa.union_all(stored, incoming).subquery()
|
||||
return stored, incoming
|
||||
|
||||
async def _session_projection_page(self, workspace, statement, limit, offset):
|
||||
async def _conversation_projection_page(self, workspace, statement, limit, offset):
|
||||
count = (
|
||||
await self._execution_query(workspace, sa.select(sa.func.count()).select_from(statement.subquery()))
|
||||
).scalar_one()
|
||||
rows = (await self._execution_query(workspace, statement.limit(limit).offset(offset))).mappings().all()
|
||||
return [
|
||||
{key: value.isoformat() if isinstance(value, datetime.datetime) else value for key, value in row.items()}
|
||||
for row in rows
|
||||
], count
|
||||
|
||||
async def get_bot_conversation_sessions(
|
||||
self, workspace, bot_ids, start_time, end_time, user_query, is_active, limit, offset
|
||||
):
|
||||
"""Read session summaries across processor types without mutating history."""
|
||||
# Window functions sort and retain their input rows. Carrying historical
|
||||
# inline media through that sort can consume gigabytes for a summary.
|
||||
messages = self._session_message_source(workspace, bot_ids, summary_only=True)
|
||||
key = [messages.c.workspace_uuid, messages.c.bot_id, messages.c.session_id]
|
||||
ranked = (
|
||||
sa.select(
|
||||
*messages.c,
|
||||
sa.func.row_number()
|
||||
.over(
|
||||
partition_by=key,
|
||||
order_by=[
|
||||
sa.case((messages.c.role == 'user', 0), else_=1),
|
||||
messages.c.timestamp.desc(),
|
||||
messages.c.id.desc(),
|
||||
],
|
||||
)
|
||||
.label('position'),
|
||||
sa.func.min(messages.c.timestamp).over(partition_by=key).label('first_seen'),
|
||||
sa.func.max(messages.c.timestamp).over(partition_by=key).label('last_seen'),
|
||||
sa.func.count().over(partition_by=key).label('count'),
|
||||
await self._execution_query(
|
||||
workspace, sa.select(sa.func.count()).select_from(statement.order_by(None).subquery())
|
||||
)
|
||||
.where(messages.c.session_id != '')
|
||||
.subquery()
|
||||
)
|
||||
session = models.MonitoringSession
|
||||
fields = list(session.__table__.columns)
|
||||
projected = {
|
||||
'start_time': ranked.c.first_seen,
|
||||
'last_activity': ranked.c.last_seen,
|
||||
'message_count': ranked.c['count'],
|
||||
'is_active': sa.literal(True),
|
||||
}
|
||||
derived = sa.select(
|
||||
*[
|
||||
(projected[field.name] if field.name in projected else ranked.c[field.name]).label(field.name)
|
||||
for field in fields
|
||||
]
|
||||
).where(ranked.c.position == 1)
|
||||
existing = sa.select(*fields).where(session.workspace_uuid == workspace, session.bot_id.in_(bot_ids))
|
||||
combined = sa.union_all(existing, derived).subquery()
|
||||
group = [combined.c.workspace_uuid, combined.c.bot_id, combined.c.session_id]
|
||||
merged = sa.select(
|
||||
*[
|
||||
combined.c[field.name]
|
||||
for field in fields
|
||||
if field.name not in {'message_count', 'start_time', 'last_activity'}
|
||||
],
|
||||
sa.func.max(combined.c.message_count).over(partition_by=group).label('message_count'),
|
||||
sa.func.min(combined.c.start_time).over(partition_by=group).label('start_time'),
|
||||
sa.func.max(combined.c.last_activity).over(partition_by=group).label('last_activity'),
|
||||
sa.func.row_number().over(partition_by=group, order_by=combined.c.last_activity.desc()).label('position'),
|
||||
).subquery()
|
||||
query = sa.select(*[merged.c[field.name] for field in fields]).where(merged.c.position == 1)
|
||||
if start_time:
|
||||
query = query.where(merged.c.last_activity >= start_time)
|
||||
if end_time:
|
||||
query = query.where(merged.c.last_activity <= end_time)
|
||||
if user_query and user_query.strip():
|
||||
pattern = f'%{user_query.strip()}%'
|
||||
query = query.where(sa.or_(merged.c.user_id.ilike(pattern), merged.c.user_name.ilike(pattern)))
|
||||
if is_active is not None:
|
||||
query = query.where(merged.c.is_active == is_active)
|
||||
return await self._session_projection_page(
|
||||
workspace,
|
||||
query.order_by(merged.c.last_activity.desc(), merged.c.session_id),
|
||||
limit,
|
||||
offset,
|
||||
)
|
||||
).scalar_one()
|
||||
page = (await self._execution_query(workspace, statement.limit(limit).offset(offset))).mappings().all()
|
||||
if not page:
|
||||
return [], count
|
||||
|
||||
# Fetch payloads only for identities selected by the bounded page.
|
||||
queries = self._conversation_message_queries(workspace, list({row['bot_id'] for row in page}))
|
||||
rows = {}
|
||||
for origin, query in zip(('message', 'event'), queries):
|
||||
keys = [(row['bot_id'], row['id']) for row in page if row['_source'] == origin]
|
||||
if not keys:
|
||||
continue
|
||||
selected = query.selected_columns
|
||||
result = await self._execution_query(
|
||||
workspace,
|
||||
sa.select(*selected).where(
|
||||
selected.workspace_uuid == workspace,
|
||||
sa.tuple_(selected.bot_id, selected.id).in_(keys),
|
||||
),
|
||||
)
|
||||
for row in result.mappings():
|
||||
rows[(origin, row['bot_id'], row['id'])] = row
|
||||
|
||||
messages = []
|
||||
for identity in page:
|
||||
row = rows.get((identity['_source'], identity['bot_id'], identity['id']))
|
||||
if row is not None:
|
||||
messages.append(
|
||||
{
|
||||
key: value.isoformat() if isinstance(value, datetime.datetime) else value
|
||||
for key, value in row.items()
|
||||
}
|
||||
)
|
||||
return messages, count
|
||||
|
||||
def _execution_json_text(self, column, key):
|
||||
# SQLite JSON functions accept TEXT directly; PostgreSQL operators
|
||||
@@ -276,12 +250,21 @@ class ExecutionDetailsMixin:
|
||||
)
|
||||
.values(run_id=run_id, event_id=sa.func.coalesce(models.MonitoringMessage.event_id, event_id))
|
||||
)
|
||||
tenant_uow = getattr(self.ap.persistence_mgr, 'tenant_uow', None)
|
||||
if callable(tenant_uow):
|
||||
async with tenant_uow(workspace) as uow:
|
||||
await uow.session.execute(statement)
|
||||
else:
|
||||
await self.ap.persistence_mgr.execute_async(statement)
|
||||
async with self._summary_write_session(workspace) as session:
|
||||
message = (
|
||||
await session.execute(
|
||||
sa.select(
|
||||
models.MonitoringMessage.bot_id,
|
||||
models.MonitoringMessage.event_id,
|
||||
models.MonitoringMessage.role,
|
||||
).where(
|
||||
models.MonitoringMessage.workspace_uuid == workspace, models.MonitoringMessage.id == message_id
|
||||
)
|
||||
)
|
||||
).first()
|
||||
await session.execute(statement)
|
||||
if message is not None and not message.event_id and message.role != 'assistant':
|
||||
await self._summary_writer().link_event(session, workspace, message.bot_id, message_id, event_id)
|
||||
|
||||
async def record_ingress_event(
|
||||
self,
|
||||
@@ -308,41 +291,44 @@ class ExecutionDetailsMixin:
|
||||
# of storing an unbounded adapter object in the event journal.
|
||||
content = parsed(self._sanitize_message_content(json.dumps(payload, ensure_ascii=False, default=str)), {})
|
||||
summary = event_preview(content, event_type=event_type)
|
||||
await store.append_event(
|
||||
event_id=event_id,
|
||||
event_type=event_type,
|
||||
source='platform',
|
||||
workspace_id=workspace,
|
||||
bot_id=bot_id,
|
||||
actor_id=actor_id,
|
||||
actor_name=actor_name,
|
||||
conversation_id=conversation_id,
|
||||
actor_type='user' if actor_id else 'system',
|
||||
input_summary=summary,
|
||||
input_json=content,
|
||||
metadata={'status': status, 'bot_name': bot_name, 'platform': platform},
|
||||
)
|
||||
safe_routes = []
|
||||
for route in routes or ():
|
||||
if isinstance(route, BaseException):
|
||||
safe_routes.append({'status': 'failed', 'reason': str(route) or type(route).__name__})
|
||||
elif isinstance(route, dict):
|
||||
safe_routes.append(
|
||||
{
|
||||
key: route.get(key)
|
||||
for key in (
|
||||
'status',
|
||||
'reason',
|
||||
'target_type',
|
||||
'target_uuid',
|
||||
'run_id',
|
||||
'failure_code',
|
||||
)
|
||||
}
|
||||
)
|
||||
metadata = {'status': status, 'bot_name': bot_name, 'platform': platform}
|
||||
if routes is not None:
|
||||
safe_routes = []
|
||||
for route in routes:
|
||||
if isinstance(route, BaseException):
|
||||
safe_routes.append({'status': 'failed', 'reason': str(route) or type(route).__name__})
|
||||
elif isinstance(route, dict):
|
||||
safe_routes.append(
|
||||
{
|
||||
key: route.get(key)
|
||||
for key in (
|
||||
'status',
|
||||
'reason',
|
||||
'target_type',
|
||||
'target_uuid',
|
||||
'run_id',
|
||||
'failure_code',
|
||||
)
|
||||
}
|
||||
)
|
||||
metadata = {'status': status, 'bot_name': bot_name, 'platform': platform, 'routes': safe_routes}
|
||||
async with AsyncSession(self.ap.persistence_mgr.get_db_engine()) as session:
|
||||
metadata['routes'] = safe_routes
|
||||
async with self._summary_write_session(workspace) as session:
|
||||
await store.append_event(
|
||||
event_id=event_id,
|
||||
event_type=event_type,
|
||||
source='platform',
|
||||
workspace_id=workspace,
|
||||
bot_id=bot_id,
|
||||
actor_id=actor_id,
|
||||
actor_name=actor_name,
|
||||
conversation_id=conversation_id,
|
||||
actor_type='user' if actor_id else 'system',
|
||||
input_summary=summary,
|
||||
input_json=content,
|
||||
metadata=metadata,
|
||||
session=session,
|
||||
)
|
||||
if routes is not None:
|
||||
await session.execute(
|
||||
sa.update(EventLog)
|
||||
.where(
|
||||
@@ -351,7 +337,31 @@ class ExecutionDetailsMixin:
|
||||
)
|
||||
.values(metadata_json=json.dumps(metadata, ensure_ascii=False))
|
||||
)
|
||||
await session.commit()
|
||||
if event_type == 'message.received':
|
||||
from ....persistence.session_summary_writer import ingress_summary_change
|
||||
|
||||
event = (
|
||||
(
|
||||
await session.execute(
|
||||
sa.select(
|
||||
EventLog.bot_id,
|
||||
EventLog.event_id,
|
||||
EventLog.conversation_id,
|
||||
EventLog.created_at,
|
||||
EventLog.event_time,
|
||||
EventLog.actor_id,
|
||||
EventLog.actor_name,
|
||||
EventLog.metadata_json,
|
||||
).where(EventLog.workspace_id == workspace, EventLog.event_id == event_id)
|
||||
)
|
||||
)
|
||||
.mappings()
|
||||
.one()
|
||||
)
|
||||
await self._summary_writer().apply_once(
|
||||
session,
|
||||
ingress_summary_change(workspace, event),
|
||||
)
|
||||
|
||||
async def _execution_query(self, workspace, statement):
|
||||
tenant_uow = getattr(self.ap.persistence_mgr, 'tenant_uow', None)
|
||||
|
||||
@@ -188,7 +188,7 @@ class LangBotMCPServer:
|
||||
return _dump(ap.box_service.get_recent_errors(context))
|
||||
|
||||
@mcp.tool(
|
||||
description='Read bounded Workspace runtime records. LLM calls include optional details (timing in ms, complete provider usage, structured error); older records have null details. Filters follow the monitoring service: bot_ids, pipeline_ids, session_ids, start_time, end_time, mode (all/real/debug), execution_statuses (owning execution status groups), knowledge_base_id, user_query, is_active as supported by the record kind.'
|
||||
description='Read bounded Workspace runtime records. LLM calls include optional details (timing in ms, complete provider usage, structured error); older records have null details. Filters follow the monitoring service: bot_ids, pipeline_ids, session_ids, start_time, end_time, mode (all/real/debug), execution_statuses (owning execution status groups), knowledge_base_id, user_query, is_active as supported by the record kind. Sessions read stored conversation summaries and do not support mode or execution_statuses.'
|
||||
)
|
||||
async def get_monitoring_records(
|
||||
kind: typing.Literal['messages', 'llm_calls', 'tool_calls', 'embedding_calls', 'sessions', 'errors'],
|
||||
@@ -207,6 +207,7 @@ class LangBotMCPServer:
|
||||
if kind == 'embedding_calls':
|
||||
allowed.add('knowledge_base_id')
|
||||
if kind == 'sessions':
|
||||
allowed -= {'mode', 'execution_statuses'}
|
||||
allowed |= {'user_query', 'is_active'}
|
||||
kwargs = dict(filters or {})
|
||||
if set(kwargs) - allowed:
|
||||
|
||||
@@ -359,6 +359,9 @@ class Application:
|
||||
return task
|
||||
|
||||
async def run(self):
|
||||
from ..persistence.session_summary_backfill import initialize_application_summaries
|
||||
|
||||
await initialize_application_summaries(self)
|
||||
self.event_loop_monitor.start()
|
||||
from ..utils.execution_lease import maintain_execution_leases
|
||||
|
||||
|
||||
@@ -112,6 +112,7 @@ class AgentRun(Base):
|
||||
"""Additional metadata JSON."""
|
||||
|
||||
__table_args__ = (
|
||||
sqlalchemy.Index('ix_agent_run_summary_event', 'workspace_id', 'bot_id', 'event_id', 'id'),
|
||||
sqlalchemy.Index('ix_agent_run_media_ttl', 'workspace_id', 'created_at', 'id'),
|
||||
sqlalchemy.Index(
|
||||
'ix_agent_run_scope_status', 'bot_id', 'workspace_id', 'conversation_id', 'thread_id', 'status'
|
||||
|
||||
@@ -39,9 +39,35 @@ class MonitoringMessage(Base):
|
||||
parent_message_id = sqlalchemy.Column(sqlalchemy.String(255), nullable=True, index=True)
|
||||
|
||||
__table_args__ = (
|
||||
sqlalchemy.Index('ix_monitoring_messages_workspace_timestamp', 'workspace_uuid', 'timestamp'),
|
||||
sqlalchemy.Index('ix_monitoring_messages_summary_event', 'workspace_uuid', 'bot_id', 'event_id', 'role'),
|
||||
# Time-windowed aggregates must not fetch execution metadata past historical payloads.
|
||||
sqlalchemy.Index(
|
||||
'ix_monitoring_messages_workspace_timestamp',
|
||||
'workspace_uuid',
|
||||
'timestamp',
|
||||
'id',
|
||||
'status',
|
||||
'role',
|
||||
'bot_id',
|
||||
'session_id',
|
||||
'parent_message_id',
|
||||
'run_id',
|
||||
'pipeline_id',
|
||||
),
|
||||
sqlalchemy.Index('ix_monitoring_messages_media_ttl', 'workspace_uuid', 'timestamp', 'id'),
|
||||
sqlalchemy.Index('ix_monitoring_messages_workspace_session', 'workspace_uuid', 'session_id'),
|
||||
# Execution membership must not traverse historical payload overflow pages.
|
||||
sqlalchemy.Index(
|
||||
'ix_monitoring_messages_execution_filter',
|
||||
'workspace_uuid',
|
||||
'status',
|
||||
'role',
|
||||
'bot_id',
|
||||
'id',
|
||||
'session_id',
|
||||
'parent_message_id',
|
||||
'run_id',
|
||||
),
|
||||
sqlalchemy.Index(
|
||||
'ix_monitoring_messages_conversation_page', 'workspace_uuid', 'bot_id', 'session_id', 'timestamp', 'id'
|
||||
),
|
||||
@@ -144,12 +170,70 @@ class MonitoringSession(Base):
|
||||
user_id = sqlalchemy.Column(sqlalchemy.String(255), nullable=True)
|
||||
user_name = sqlalchemy.Column(sqlalchemy.String(255), nullable=True) # User display name
|
||||
|
||||
metadata_timestamp = sqlalchemy.Column(sqlalchemy.DateTime, nullable=True)
|
||||
metadata_role = sqlalchemy.Column(sqlalchemy.String(50), nullable=True)
|
||||
metadata_source_key = sqlalchemy.Column(sqlalchemy.String(512), nullable=True)
|
||||
base_snapshot = sqlalchemy.Column(sqlalchemy.Text, nullable=True)
|
||||
source_count = sqlalchemy.Column(sqlalchemy.Integer, nullable=False, default=0, server_default='0')
|
||||
|
||||
__table_args__ = (
|
||||
sqlalchemy.Index('ix_monitoring_sessions_workspace_activity', 'workspace_uuid', 'last_activity'),
|
||||
sqlalchemy.Index('ix_monitoring_sessions_workspace_active', 'workspace_uuid', 'is_active'),
|
||||
sqlalchemy.Index('ix_monitoring_sessions_workspace_bot', 'workspace_uuid', 'bot_id', 'last_activity'),
|
||||
)
|
||||
|
||||
|
||||
class MonitoringSessionReceipt(Base):
|
||||
"""Retained message identities and replaceable, optionally detached event credits."""
|
||||
|
||||
__tablename__ = 'monitoring_session_receipts'
|
||||
|
||||
workspace_uuid = sqlalchemy.Column(
|
||||
sqlalchemy.String(36), sqlalchemy.ForeignKey('workspaces.uuid', ondelete='CASCADE'), primary_key=True
|
||||
)
|
||||
bot_id = sqlalchemy.Column(sqlalchemy.String(255), primary_key=True)
|
||||
source_key = sqlalchemy.Column(sqlalchemy.String(512), primary_key=True)
|
||||
session_id = sqlalchemy.Column(sqlalchemy.String(255), nullable=True)
|
||||
event_id = sqlalchemy.Column(sqlalchemy.String(255), nullable=True)
|
||||
role = sqlalchemy.Column(sqlalchemy.String(50), nullable=False)
|
||||
covered = sqlalchemy.Column(sqlalchemy.Boolean, nullable=False, server_default='false')
|
||||
timestamp = sqlalchemy.Column(sqlalchemy.DateTime, nullable=False)
|
||||
candidate_snapshot = sqlalchemy.Column(sqlalchemy.Text, nullable=True)
|
||||
source_timestamp = sqlalchemy.Column(sqlalchemy.DateTime, nullable=True)
|
||||
|
||||
__table_args__ = (
|
||||
sqlalchemy.ForeignKeyConstraint(
|
||||
['workspace_uuid', 'bot_id', 'session_id'],
|
||||
['monitoring_sessions.workspace_uuid', 'monitoring_sessions.bot_id', 'monitoring_sessions.session_id'],
|
||||
ondelete='CASCADE',
|
||||
),
|
||||
sqlalchemy.Index('ix_session_receipts_session', 'workspace_uuid', 'bot_id', 'session_id'),
|
||||
sqlalchemy.Index('ix_session_receipts_expiry', 'workspace_uuid', 'timestamp', 'bot_id', 'source_key'),
|
||||
)
|
||||
|
||||
|
||||
# Only uncovered ingress needs a replaceable metadata candidate. C/BINARY order
|
||||
# agrees with UTF-8 source-key ordering in the projection on both databases.
|
||||
sqlalchemy.Index(
|
||||
'ix_session_receipts_candidate',
|
||||
MonitoringSessionReceipt.workspace_uuid,
|
||||
MonitoringSessionReceipt.bot_id,
|
||||
MonitoringSessionReceipt.session_id,
|
||||
MonitoringSessionReceipt.source_timestamp.desc(),
|
||||
MonitoringSessionReceipt.source_key.collate('C').desc(),
|
||||
postgresql_where=MonitoringSessionReceipt.candidate_snapshot.is_not(None),
|
||||
).ddl_if(dialect='postgresql')
|
||||
sqlalchemy.Index(
|
||||
'ix_session_receipts_candidate',
|
||||
MonitoringSessionReceipt.workspace_uuid,
|
||||
MonitoringSessionReceipt.bot_id,
|
||||
MonitoringSessionReceipt.session_id,
|
||||
MonitoringSessionReceipt.source_timestamp.desc(),
|
||||
MonitoringSessionReceipt.source_key.collate('BINARY').desc(),
|
||||
sqlite_where=MonitoringSessionReceipt.candidate_snapshot.is_not(None),
|
||||
).ddl_if(dialect='sqlite')
|
||||
|
||||
|
||||
class MonitoringError(Base):
|
||||
"""Error log records"""
|
||||
|
||||
|
||||
@@ -0,0 +1,179 @@
|
||||
"""Persist idempotent conversation summaries independently of message history."""
|
||||
|
||||
import contextlib
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
revision = '0040_session_summaries'
|
||||
down_revision = '0039_execution_lease_indexes'
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
TABLE = 'monitoring_session_receipts'
|
||||
TIME_INDEX = 'ix_monitoring_messages_workspace_timestamp'
|
||||
INDEXES = (
|
||||
('monitoring_messages', 'ix_monitoring_messages_summary_event', ['workspace_uuid', 'bot_id', 'event_id', 'role']),
|
||||
('agent_run', 'ix_agent_run_summary_event', ['workspace_id', 'bot_id', 'event_id', 'id']),
|
||||
(
|
||||
'monitoring_messages',
|
||||
'ix_monitoring_messages_execution_filter',
|
||||
['workspace_uuid', 'status', 'role', 'bot_id', 'id', 'session_id', 'parent_message_id', 'run_id'],
|
||||
),
|
||||
(
|
||||
'monitoring_messages',
|
||||
TIME_INDEX,
|
||||
[
|
||||
'workspace_uuid',
|
||||
'timestamp',
|
||||
'id',
|
||||
'status',
|
||||
'role',
|
||||
'bot_id',
|
||||
'session_id',
|
||||
'parent_message_id',
|
||||
'run_id',
|
||||
'pipeline_id',
|
||||
],
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
@contextlib.contextmanager
|
||||
def _suspend_summary_forced_rls(connection):
|
||||
"""Let the table-owning migrator mark history without a Workspace scope."""
|
||||
if connection.dialect.name != 'postgresql':
|
||||
yield
|
||||
return
|
||||
tables = (
|
||||
connection.execute(
|
||||
sa.text(
|
||||
'SELECT relname FROM pg_class WHERE relnamespace = current_schema()::regnamespace '
|
||||
'AND relname IN :tables AND relforcerowsecurity'
|
||||
).bindparams(sa.bindparam('tables', expanding=True)),
|
||||
{'tables': ('workspaces', 'workspace_metadata', 'monitoring_sessions', 'monitoring_messages', 'event_log')},
|
||||
)
|
||||
.scalars()
|
||||
.all()
|
||||
)
|
||||
quote = connection.dialect.identifier_preparer.quote
|
||||
try:
|
||||
for table in tables:
|
||||
connection.execute(sa.text(f'ALTER TABLE {quote(table)} NO FORCE ROW LEVEL SECURITY'))
|
||||
yield
|
||||
finally:
|
||||
for table in tables:
|
||||
connection.execute(sa.text(f'ALTER TABLE {quote(table)} FORCE ROW LEVEL SECURITY'))
|
||||
|
||||
|
||||
def upgrade():
|
||||
connection = op.get_bind()
|
||||
for table, index, fields in INDEXES:
|
||||
if not sa.inspect(connection).has_table(table):
|
||||
continue
|
||||
existing = {item['name']: item['column_names'] for item in sa.inspect(connection).get_indexes(table)}
|
||||
if existing.get(index) == fields:
|
||||
continue
|
||||
if index in existing:
|
||||
op.drop_index(index, table_name=table)
|
||||
op.create_index(index, table, fields)
|
||||
# Bare Alembic and partial-schema upgrades rely on ORM bootstrap for core tables.
|
||||
if not sa.inspect(connection).has_table('monitoring_sessions'):
|
||||
return
|
||||
columns = {column['name'] for column in sa.inspect(connection).get_columns('monitoring_sessions')}
|
||||
for name, type_ in [
|
||||
('metadata_timestamp', sa.DateTime()),
|
||||
('metadata_role', sa.String(50)),
|
||||
('metadata_source_key', sa.String(512)),
|
||||
('base_snapshot', sa.Text()),
|
||||
]:
|
||||
if name not in columns:
|
||||
op.add_column('monitoring_sessions', sa.Column(name, type_, nullable=True))
|
||||
if 'source_count' not in columns:
|
||||
op.add_column(
|
||||
'monitoring_sessions', sa.Column('source_count', sa.Integer(), nullable=False, server_default='0')
|
||||
)
|
||||
# Legacy metadata provenance is unknown. Keep existing display values, but
|
||||
# leave provenance and the base snapshot NULL until bounded projection.
|
||||
if not sa.inspect(connection).has_table(TABLE):
|
||||
op.create_table(
|
||||
TABLE,
|
||||
sa.Column(
|
||||
'workspace_uuid', sa.String(36), sa.ForeignKey('workspaces.uuid', ondelete='CASCADE'), primary_key=True
|
||||
),
|
||||
sa.Column('bot_id', sa.String(255), primary_key=True),
|
||||
sa.Column('source_key', sa.String(512), primary_key=True),
|
||||
sa.Column('session_id', sa.String(255), nullable=True),
|
||||
sa.Column('event_id', sa.String(255), nullable=True),
|
||||
sa.Column('role', sa.String(50), nullable=False),
|
||||
sa.Column('covered', sa.Boolean(), nullable=False, server_default='false'),
|
||||
sa.Column('timestamp', sa.DateTime(), nullable=False),
|
||||
sa.Column('candidate_snapshot', sa.Text(), nullable=True),
|
||||
sa.Column('source_timestamp', sa.DateTime(), nullable=True),
|
||||
sa.ForeignKeyConstraint(
|
||||
['workspace_uuid', 'bot_id', 'session_id'],
|
||||
['monitoring_sessions.workspace_uuid', 'monitoring_sessions.bot_id', 'monitoring_sessions.session_id'],
|
||||
ondelete='CASCADE',
|
||||
),
|
||||
)
|
||||
op.create_index('ix_session_receipts_session', TABLE, ['workspace_uuid', 'bot_id', 'session_id'])
|
||||
op.create_index('ix_session_receipts_expiry', TABLE, ['workspace_uuid', 'timestamp', 'bot_id', 'source_key'])
|
||||
# Historical receipts must be initialized offline before live projection.
|
||||
if sa.inspect(connection).has_table('workspace_metadata'):
|
||||
sources = ['EXISTS (SELECT 1 FROM monitoring_sessions s WHERE s.workspace_uuid=w.uuid)']
|
||||
if sa.inspect(connection).has_table('monitoring_messages'):
|
||||
sources.append('EXISTS (SELECT 1 FROM monitoring_messages m WHERE m.workspace_uuid=w.uuid)')
|
||||
if sa.inspect(connection).has_table('event_log'):
|
||||
sources.append('EXISTS (SELECT 1 FROM event_log e WHERE e.workspace_id=w.uuid)')
|
||||
with _suspend_summary_forced_rls(connection):
|
||||
connection.execute(
|
||||
sa.text(
|
||||
'INSERT INTO workspace_metadata (workspace_uuid, key, value) '
|
||||
"SELECT w.uuid, 'session_summary_backfill_v1', :cursor FROM workspaces w WHERE ("
|
||||
+ ' OR '.join(sources)
|
||||
+ ') ON CONFLICT (workspace_uuid, key) DO UPDATE SET value=excluded.value'
|
||||
),
|
||||
{'cursor': '{"source": "messages"}'},
|
||||
)
|
||||
collation = '"C"' if connection.dialect.name == 'postgresql' else 'BINARY'
|
||||
op.execute(
|
||||
sa.text(
|
||||
f'CREATE INDEX IF NOT EXISTS ix_session_receipts_candidate ON {TABLE} '
|
||||
'(workspace_uuid, bot_id, session_id, source_timestamp DESC, '
|
||||
f'source_key COLLATE {collation} DESC) WHERE candidate_snapshot IS NOT NULL'
|
||||
)
|
||||
)
|
||||
if connection.dialect.name == 'postgresql':
|
||||
predicate = "workspace_uuid::text = NULLIF(current_setting('langbot.workspace_uuid', true), '')"
|
||||
op.execute(sa.text(f'ALTER TABLE {TABLE} ENABLE ROW LEVEL SECURITY'))
|
||||
op.execute(sa.text(f'ALTER TABLE {TABLE} FORCE ROW LEVEL SECURITY'))
|
||||
op.execute(sa.text(f'DROP POLICY IF EXISTS langbot_workspace_isolation ON {TABLE}'))
|
||||
op.execute(
|
||||
sa.text(
|
||||
f'CREATE POLICY langbot_workspace_isolation ON {TABLE} AS PERMISSIVE FOR ALL TO PUBLIC '
|
||||
f'USING ({predicate}) WITH CHECK ({predicate})'
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
def downgrade():
|
||||
connection = op.get_bind()
|
||||
for table, index, _ in reversed(INDEXES):
|
||||
if sa.inspect(connection).has_table(table):
|
||||
if index in {item['name'] for item in sa.inspect(connection).get_indexes(table)}:
|
||||
op.drop_index(index, table_name=table)
|
||||
if index == TIME_INDEX:
|
||||
op.create_index(index, table, ['workspace_uuid', 'timestamp'])
|
||||
if sa.inspect(connection).has_table(TABLE):
|
||||
op.drop_table(TABLE)
|
||||
if sa.inspect(connection).has_table('monitoring_sessions'):
|
||||
columns = {item['name'] for item in sa.inspect(connection).get_columns('monitoring_sessions')}
|
||||
for column in (
|
||||
'base_snapshot',
|
||||
'metadata_source_key',
|
||||
'metadata_role',
|
||||
'metadata_timestamp',
|
||||
'source_count',
|
||||
):
|
||||
if column in columns:
|
||||
op.drop_column('monitoring_sessions', column)
|
||||
@@ -82,6 +82,7 @@ _ALEMBIC_TENANT_TABLES = {
|
||||
'monitoring_llm_calls',
|
||||
'monitoring_tool_calls',
|
||||
'monitoring_sessions',
|
||||
'monitoring_session_receipts',
|
||||
'monitoring_errors',
|
||||
'monitoring_embedding_calls',
|
||||
'monitoring_feedback',
|
||||
@@ -273,6 +274,16 @@ class PersistenceManager:
|
||||
if self.mode == PersistenceMode.OSS_COMPAT:
|
||||
await self._install_oss_postgres_tenant_scope()
|
||||
else:
|
||||
current_revision = await alembic_runner.get_alembic_current(engine)
|
||||
if (
|
||||
current_revision is None
|
||||
or current_revision in _PRE_WORKSPACE_ALEMBIC_REVISIONS
|
||||
or current_revision == _WORKSPACE_ALEMBIC_REVISION
|
||||
):
|
||||
await self._run_alembic_migrations(_RESOURCE_SCOPE_ALEMBIC_REVISION)
|
||||
# Deferred parent tables must exist before later migrations
|
||||
# create dependent tables such as session summary receipts.
|
||||
await self.create_tables()
|
||||
await self._run_alembic_migrations()
|
||||
|
||||
# SQLite keeps the historical post-migration create_all pass. New
|
||||
@@ -294,7 +305,10 @@ class PersistenceManager:
|
||||
# without workspace_uuid and Workspace itself references the
|
||||
# account UUID introduced by 0009. Alembic must expand those
|
||||
# tables before SQLAlchemy may create any new tenant table.
|
||||
excluded_tables = _ALEMBIC_TENANT_TABLES if legacy_users else set()
|
||||
excluded_tables = set(_ALEMBIC_TENANT_TABLES) if legacy_users else set()
|
||||
# 0010 and 0023 rewrite the session primary key on an OSS bootstrap.
|
||||
# Only 0040 may create its dependent composite receipt foreign key.
|
||||
excluded_tables.add('monitoring_session_receipts')
|
||||
tables_to_create = [table for table in self.meta.sorted_tables if table.name not in excluded_tables]
|
||||
self.meta.create_all(sync_conn, tables=tables_to_create)
|
||||
|
||||
|
||||
@@ -0,0 +1,284 @@
|
||||
"""Resumable, bounded historical repair, never called by interactive reads."""
|
||||
|
||||
import asyncio
|
||||
import argparse
|
||||
import datetime
|
||||
import json
|
||||
import os
|
||||
from contextlib import asynccontextmanager
|
||||
from pathlib import Path
|
||||
|
||||
import sqlalchemy as sa
|
||||
from sqlalchemy.dialects import postgresql, sqlite
|
||||
|
||||
from ..entity.persistence.agent_run import AgentRun
|
||||
from ..entity.persistence.event_log import EventLog
|
||||
from ..entity.persistence.metadata import WorkspaceMetadata
|
||||
from ..entity.persistence.monitoring import MonitoringMessage
|
||||
from .session_summary_reader import SessionScope
|
||||
from .session_summary_writer import SessionSummaryWriter, ingress_summary_change, message_summary_change
|
||||
|
||||
|
||||
CURSOR_KEY = 'session_summary_backfill_v1'
|
||||
BATCH_SIZE = 100
|
||||
JSON_LIMIT = 65536
|
||||
|
||||
|
||||
async def _initialization_pending(session, workspace: str) -> bool:
|
||||
value = (
|
||||
await session.execute(
|
||||
sa.select(WorkspaceMetadata.value).where(
|
||||
WorkspaceMetadata.workspace_uuid == workspace,
|
||||
WorkspaceMetadata.key == CURSOR_KEY,
|
||||
)
|
||||
)
|
||||
).scalar_one_or_none()
|
||||
return value is not None and json.loads(value)['source'] != 'done'
|
||||
|
||||
|
||||
async def require_initialized(session, workspace: str):
|
||||
if await _initialization_pending(session, workspace):
|
||||
raise RuntimeError(
|
||||
f'Session summaries for Workspace {workspace} require offline initialization. '
|
||||
'Stop writers and run: uv run python -m langbot.pkg.persistence.session_summary_backfill '
|
||||
f'--workspace {workspace} --writers-stopped --database-url-env LANGBOT_SUMMARY_DATABASE_URL'
|
||||
)
|
||||
|
||||
|
||||
async def require_application_initialized(ap):
|
||||
"""Reject startup before producers can replay uninitialized history."""
|
||||
for binding in await ap.workspace_service.list_active_execution_bindings():
|
||||
async with ap.monitoring_service._summary_session(binding.workspace_uuid) as session:
|
||||
await require_initialized(session, binding.workspace_uuid)
|
||||
|
||||
|
||||
async def initialize_application_summaries(ap):
|
||||
"""Complete OSS initialization before starting HTTP or message producers."""
|
||||
from .mgr import PersistenceMode
|
||||
|
||||
if ap.persistence_mgr.mode != PersistenceMode.OSS_COMPAT:
|
||||
await require_application_initialized(ap)
|
||||
return
|
||||
|
||||
backfill = SessionSummaryBackfill(
|
||||
ap.monitoring_service._summary_session, ap.persistence_mgr.get_db_engine().dialect.name
|
||||
)
|
||||
for binding in await ap.workspace_service.list_active_execution_bindings():
|
||||
workspace = binding.workspace_uuid
|
||||
async with ap.monitoring_service._summary_session(workspace) as session:
|
||||
pending = await _initialization_pending(session, workspace)
|
||||
if not pending:
|
||||
continue
|
||||
ap.logger.info(f'Initializing session summaries for Workspace {workspace} before starting services.')
|
||||
while not await backfill.run_batch(workspace):
|
||||
await asyncio.sleep(0)
|
||||
ap.logger.info(f'Session summaries for Workspace {workspace} initialized; continuing startup.')
|
||||
|
||||
|
||||
class SessionSummaryBackfill:
|
||||
def __init__(self, session_scope: SessionScope, dialect: str):
|
||||
self._session_scope = session_scope
|
||||
self._dialect = dialect
|
||||
self._insert = {'postgresql': postgresql.insert, 'sqlite': sqlite.insert}[dialect]
|
||||
self._writer = SessionSummaryWriter(dialect)
|
||||
|
||||
def _json_text(self, column, key):
|
||||
expression = sa.type_coerce(column, sa.JSON) if self._dialect == 'sqlite' else sa.cast(column, sa.JSON)
|
||||
relevant = sa.and_(
|
||||
EventLog.source == 'platform',
|
||||
EventLog.event_type == 'message.received',
|
||||
sa.func.length(column) <= JSON_LIMIT,
|
||||
)
|
||||
return sa.case((relevant, expression[key].as_string()), else_=None)
|
||||
|
||||
async def run_batch(self, workspace: str, batch_size: int = BATCH_SIZE) -> bool:
|
||||
"""Commit at most one page and its cursor atomically; return completion."""
|
||||
if not 1 <= batch_size <= BATCH_SIZE:
|
||||
raise ValueError(f'batch_size must be between 1 and {BATCH_SIZE}')
|
||||
async with self._session_scope(workspace) as session:
|
||||
await session.execute(
|
||||
self._insert(WorkspaceMetadata)
|
||||
.values(
|
||||
workspace_uuid=workspace,
|
||||
key=CURSOR_KEY,
|
||||
value=json.dumps({'source': 'messages'}),
|
||||
)
|
||||
.on_conflict_do_nothing(index_elements=['workspace_uuid', 'key'])
|
||||
)
|
||||
cursor = json.loads(
|
||||
(
|
||||
await session.execute(
|
||||
sa.select(WorkspaceMetadata.value)
|
||||
.where(
|
||||
WorkspaceMetadata.workspace_uuid == workspace,
|
||||
WorkspaceMetadata.key == CURSOR_KEY,
|
||||
)
|
||||
.with_for_update()
|
||||
)
|
||||
).scalar_one()
|
||||
)
|
||||
source = cursor['source']
|
||||
if source == 'done':
|
||||
return True
|
||||
model = MonitoringMessage if source == 'messages' else EventLog
|
||||
timestamp = model.timestamp if source == 'messages' else model.created_at
|
||||
tenant = model.workspace_uuid if source == 'messages' else model.workspace_id
|
||||
# The same (Workspace, timestamp, id) indexes also drive media expiry.
|
||||
page = sa.select(model.id.label('id'), timestamp.label('cursor_time')).where(tenant == workspace)
|
||||
if cursor.get('after'):
|
||||
date, identity = cursor['after']
|
||||
page = page.where(
|
||||
sa.tuple_(timestamp, model.id) > sa.tuple_(datetime.datetime.fromisoformat(date), identity)
|
||||
)
|
||||
page = page.order_by(timestamp, model.id).limit(batch_size).subquery()
|
||||
if source == 'messages':
|
||||
fields = (
|
||||
'bot_id',
|
||||
'session_id',
|
||||
'timestamp',
|
||||
'bot_name',
|
||||
'pipeline_id',
|
||||
'pipeline_name',
|
||||
'platform',
|
||||
'user_id',
|
||||
'user_name',
|
||||
'role',
|
||||
'event_id',
|
||||
)
|
||||
statement = sa.select(page.c.cursor_time, model.id, *(getattr(model, field) for field in fields))
|
||||
else:
|
||||
known_conversation = sa.func.coalesce(
|
||||
model.conversation_id,
|
||||
sa.select(AgentRun.conversation_id)
|
||||
.where(
|
||||
AgentRun.workspace_id == workspace,
|
||||
AgentRun.bot_id == model.bot_id,
|
||||
AgentRun.event_id == model.event_id,
|
||||
)
|
||||
.order_by(AgentRun.id)
|
||||
.limit(1)
|
||||
.scalar_subquery(),
|
||||
)
|
||||
conversation = sa.func.coalesce(
|
||||
known_conversation,
|
||||
sa.case(
|
||||
(
|
||||
self._json_text(model.input_json, 'chat_type') == 'group',
|
||||
sa.literal('group_') + self._json_text(model.input_json, 'chat_id'),
|
||||
),
|
||||
(
|
||||
self._json_text(model.input_json, 'chat_type') == 'private',
|
||||
sa.literal('person_') + self._json_text(model.input_json, ('sender', 'id')),
|
||||
),
|
||||
),
|
||||
)
|
||||
statement = sa.select(
|
||||
page.c.cursor_time,
|
||||
model.id,
|
||||
model.bot_id,
|
||||
model.event_id,
|
||||
model.event_time,
|
||||
model.created_at,
|
||||
conversation.label('conversation_id'),
|
||||
sa.case(
|
||||
(
|
||||
sa.and_(
|
||||
model.source == 'platform',
|
||||
model.event_type == 'message.received',
|
||||
sa.func.length(model.metadata_json) <= JSON_LIMIT,
|
||||
),
|
||||
model.metadata_json,
|
||||
),
|
||||
else_=None,
|
||||
).label('metadata_json'),
|
||||
model.actor_id,
|
||||
model.actor_name,
|
||||
model.source,
|
||||
model.event_type,
|
||||
sa.or_(
|
||||
sa.func.length(model.metadata_json) > JSON_LIMIT,
|
||||
sa.and_(known_conversation.is_(None), sa.func.length(model.input_json) > JSON_LIMIT),
|
||||
).label('oversized'),
|
||||
)
|
||||
rows = (
|
||||
(
|
||||
await session.execute(
|
||||
statement.join(model, model.id == page.c.id).order_by(page.c.cursor_time, model.id)
|
||||
)
|
||||
)
|
||||
.mappings()
|
||||
.all()
|
||||
)
|
||||
for row in rows:
|
||||
if source == 'events' and (row['source'] != 'platform' or row['event_type'] != 'message.received'):
|
||||
continue
|
||||
if source == 'events' and row['oversized']:
|
||||
raise ValueError(f'EventLog {row["id"]} exceeds the {JSON_LIMIT}-character legacy metadata limit')
|
||||
if not row['bot_id']:
|
||||
continue
|
||||
await self._writer.apply_once(
|
||||
session,
|
||||
(message_summary_change if source == 'messages' else ingress_summary_change)(workspace, row),
|
||||
backfill=True,
|
||||
)
|
||||
if len(rows) < batch_size:
|
||||
cursor = {'source': 'events' if source == 'messages' else 'done'}
|
||||
else:
|
||||
cursor['after'] = [rows[-1]['cursor_time'].isoformat(), rows[-1]['id']]
|
||||
await session.execute(
|
||||
sa.update(WorkspaceMetadata)
|
||||
.where(
|
||||
WorkspaceMetadata.workspace_uuid == workspace,
|
||||
WorkspaceMetadata.key == CURSOR_KEY,
|
||||
)
|
||||
.values(value=json.dumps(cursor))
|
||||
)
|
||||
return cursor['source'] == 'done'
|
||||
|
||||
|
||||
async def _main(args):
|
||||
from sqlalchemy.engine import make_url
|
||||
from sqlalchemy.ext.asyncio import create_async_engine
|
||||
from .tenant_uow import TenantUnitOfWork
|
||||
|
||||
url = make_url(os.environ[args.database_url_env])
|
||||
if url.drivername not in {'sqlite+aiosqlite', 'postgresql+asyncpg'}:
|
||||
raise ValueError('Use an explicit sqlite+aiosqlite or postgresql+asyncpg database URL')
|
||||
if url.drivername == 'sqlite+aiosqlite' and (not url.database or not Path(url.database).is_file()):
|
||||
raise ValueError('The SQLite database must already exist and be migrated')
|
||||
engine = create_async_engine(url)
|
||||
if engine.dialect.name == 'sqlite':
|
||||
|
||||
@sa.event.listens_for(engine.sync_engine, 'connect')
|
||||
def enable_foreign_keys(connection, _record):
|
||||
cursor = connection.cursor()
|
||||
cursor.execute('PRAGMA foreign_keys=ON')
|
||||
cursor.close()
|
||||
|
||||
@asynccontextmanager
|
||||
async def scope(workspace):
|
||||
async with TenantUnitOfWork(engine, workspace) as uow:
|
||||
yield uow.session
|
||||
|
||||
try:
|
||||
async with engine.connect() as connection:
|
||||
columns = await connection.run_sync(
|
||||
lambda conn: {item['name'] for item in sa.inspect(conn).get_columns('monitoring_session_receipts')}
|
||||
)
|
||||
if not {'session_id', 'covered', 'timestamp'} <= columns:
|
||||
raise ValueError('Apply migration 0040_session_summaries before initializing summaries')
|
||||
backfill = SessionSummaryBackfill(scope, engine.dialect.name)
|
||||
for workspace in args.workspace:
|
||||
while not await backfill.run_batch(workspace):
|
||||
await asyncio.sleep(0)
|
||||
print(f'Workspace {workspace}: session summaries initialized')
|
||||
finally:
|
||||
await engine.dispose()
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
parser = argparse.ArgumentParser(description='Initialize session summaries offline; stop all writers first')
|
||||
parser.add_argument('--workspace', action='append', required=True)
|
||||
parser.add_argument('--database-url-env', default='LANGBOT_SUMMARY_DATABASE_URL')
|
||||
parser.add_argument('--writers-stopped', action='store_true', required=True)
|
||||
asyncio.run(_main(parser.parse_args()))
|
||||
@@ -0,0 +1,45 @@
|
||||
"""Payload-free contracts for interactive conversation lists."""
|
||||
|
||||
from dataclasses import dataclass
|
||||
import datetime
|
||||
from typing import Protocol
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class SessionListFilter:
|
||||
bot_ids: tuple[str, ...] = ()
|
||||
pipeline_ids: tuple[str, ...] = ()
|
||||
start_time: datetime.datetime | None = None
|
||||
end_time: datetime.datetime | None = None
|
||||
user_query: str | None = None
|
||||
is_active: bool | None = None
|
||||
|
||||
|
||||
class SessionSummaryReader(Protocol):
|
||||
"""Read stored summaries without consulting or repairing historical ledgers."""
|
||||
|
||||
async def list_page(
|
||||
self, workspace: str, filters: SessionListFilter, limit: int, offset: int
|
||||
) -> tuple[list[dict], int]: ...
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class SessionSummaryChange:
|
||||
workspace: str
|
||||
bot_id: str
|
||||
session_id: str
|
||||
source_key: str
|
||||
timestamp: datetime.datetime
|
||||
bot_name: str
|
||||
pipeline_id: str = ''
|
||||
pipeline_name: str = ''
|
||||
platform: str | None = None
|
||||
user_id: str | None = None
|
||||
user_name: str | None = None
|
||||
role: str = 'user'
|
||||
event_id: str | None = None
|
||||
|
||||
|
||||
def message_source_key(message_id: str) -> str:
|
||||
"""Distinct message records remain distinct even when they share an event."""
|
||||
return f'message:{message_id}'
|
||||
@@ -0,0 +1,74 @@
|
||||
"""Only the summary table is available to the interactive list implementation."""
|
||||
|
||||
from collections.abc import Callable
|
||||
from contextlib import AbstractAsyncContextManager
|
||||
import datetime
|
||||
|
||||
import sqlalchemy as sa
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from ..entity.persistence.monitoring import MonitoringSession
|
||||
from .session_summary_contract import SessionListFilter
|
||||
|
||||
|
||||
SessionScope = Callable[[str], AbstractAsyncContextManager[AsyncSession]]
|
||||
SUMMARY_FIELDS = (
|
||||
'workspace_uuid',
|
||||
'bot_id',
|
||||
'session_id',
|
||||
'bot_name',
|
||||
'pipeline_id',
|
||||
'pipeline_name',
|
||||
'message_count',
|
||||
'start_time',
|
||||
'last_activity',
|
||||
'is_active',
|
||||
'platform',
|
||||
'user_id',
|
||||
'user_name',
|
||||
)
|
||||
|
||||
|
||||
class SqlSessionSummaryReader:
|
||||
def __init__(self, session_scope: SessionScope):
|
||||
self._session_scope = session_scope
|
||||
|
||||
async def list_page(
|
||||
self, workspace: str, filters: SessionListFilter, limit: int, offset: int
|
||||
) -> tuple[list[dict], int]:
|
||||
model = MonitoringSession
|
||||
conditions = [model.workspace_uuid == workspace]
|
||||
if filters.bot_ids:
|
||||
conditions.append(model.bot_id.in_(filters.bot_ids))
|
||||
if filters.pipeline_ids:
|
||||
conditions.append(model.pipeline_id.in_(filters.pipeline_ids))
|
||||
if filters.start_time is not None:
|
||||
conditions.append(model.last_activity >= filters.start_time)
|
||||
if filters.end_time is not None:
|
||||
conditions.append(model.last_activity <= filters.end_time)
|
||||
if filters.user_query and filters.user_query.strip():
|
||||
pattern = f'%{filters.user_query.strip()}%'
|
||||
conditions.append(sa.or_(model.user_id.ilike(pattern), model.user_name.ilike(pattern)))
|
||||
if filters.is_active is not None:
|
||||
conditions.append(model.is_active == filters.is_active)
|
||||
async with self._session_scope(workspace) as session:
|
||||
total = (
|
||||
await session.execute(sa.select(sa.func.count()).select_from(model).where(*conditions))
|
||||
).scalar_one()
|
||||
rows = (
|
||||
(
|
||||
await session.execute(
|
||||
sa.select(*(getattr(model, field) for field in SUMMARY_FIELDS))
|
||||
.where(*conditions)
|
||||
.order_by(model.last_activity.desc(), model.bot_id, model.session_id)
|
||||
.limit(limit)
|
||||
.offset(offset)
|
||||
)
|
||||
)
|
||||
.mappings()
|
||||
.all()
|
||||
)
|
||||
return [
|
||||
{key: value.isoformat() if isinstance(value, datetime.datetime) else value for key, value in row.items()}
|
||||
for row in rows
|
||||
], total
|
||||
@@ -0,0 +1,363 @@
|
||||
"""Transaction-bound message counts and replaceable ingress credits."""
|
||||
|
||||
import datetime
|
||||
import json
|
||||
|
||||
import sqlalchemy as sa
|
||||
from sqlalchemy.dialects import postgresql, sqlite
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from ..entity.persistence.monitoring import MonitoringSession, MonitoringSessionReceipt
|
||||
from .datetime_utils import as_naive_utc
|
||||
from .session_summary_contract import SessionSummaryChange, message_source_key
|
||||
|
||||
|
||||
SESSION_KEY = ['workspace_uuid', 'bot_id', 'session_id']
|
||||
RECEIPT_KEY = ['workspace_uuid', 'bot_id', 'source_key']
|
||||
|
||||
|
||||
def message_summary_change(workspace: str, row) -> SessionSummaryChange:
|
||||
"""Use the same stored message facts for live writes and historical repair."""
|
||||
return SessionSummaryChange(
|
||||
workspace=workspace,
|
||||
bot_id=row['bot_id'],
|
||||
session_id=row['session_id'] or '',
|
||||
source_key=message_source_key(row['id']),
|
||||
event_id=row['event_id'],
|
||||
timestamp=row['timestamp'],
|
||||
bot_name=row['bot_name'] or '',
|
||||
pipeline_id=row['pipeline_id'] or '',
|
||||
pipeline_name=row['pipeline_name'] or '',
|
||||
platform=row['platform'],
|
||||
user_id=row['user_id'],
|
||||
user_name=row['user_name'],
|
||||
role=row['role'] or 'user',
|
||||
)
|
||||
|
||||
|
||||
def ingress_summary_change(workspace: str, row) -> SessionSummaryChange:
|
||||
"""Project persisted ingress identities and the first route with a target."""
|
||||
metadata = json.loads(row['metadata_json'] or '{}')
|
||||
if not isinstance(metadata, dict):
|
||||
raise ValueError('Ingress metadata must be a JSON object')
|
||||
route = next(
|
||||
(route for route in metadata.get('routes') or () if isinstance(route, dict) and route.get('target_uuid')),
|
||||
{},
|
||||
)
|
||||
return SessionSummaryChange(
|
||||
workspace=workspace,
|
||||
bot_id=row['bot_id'],
|
||||
session_id=row['conversation_id'] or '',
|
||||
source_key=f'event:{row["event_id"]}',
|
||||
timestamp=row['event_time'] or row['created_at'],
|
||||
bot_name=metadata.get('bot_name') or '',
|
||||
pipeline_id=route.get('target_uuid') or '',
|
||||
pipeline_name=route.get('target_type') or '',
|
||||
platform=metadata.get('platform'),
|
||||
user_id=row['actor_id'],
|
||||
user_name=row['actor_name'],
|
||||
)
|
||||
|
||||
|
||||
SNAPSHOT_FIELDS = ('bot_name', 'platform', 'user_id', 'user_name', 'pipeline_id', 'pipeline_name')
|
||||
|
||||
|
||||
def _snapshot(change):
|
||||
return {
|
||||
'source_key': change.source_key,
|
||||
'timestamp': as_naive_utc(change.timestamp).isoformat(),
|
||||
'role': change.role,
|
||||
**{field: getattr(change, field) for field in SNAPSHOT_FIELDS},
|
||||
}
|
||||
|
||||
|
||||
def _rank(snapshot):
|
||||
if snapshot['source_key'] is None:
|
||||
return (-1, '', b'')
|
||||
return (int(snapshot['role'] != 'assistant'), snapshot['timestamp'], snapshot['source_key'].encode('utf-8'))
|
||||
|
||||
|
||||
class SessionSummaryWriter:
|
||||
def __init__(self, dialect: str):
|
||||
self._insert = {'postgresql': postgresql.insert, 'sqlite': sqlite.insert}[dialect]
|
||||
self._collation = 'C' if dialect == 'postgresql' else 'BINARY'
|
||||
|
||||
@staticmethod
|
||||
def _scope(model, workspace, bot_id=None, identity=None):
|
||||
conditions = [model.workspace_uuid == workspace]
|
||||
if bot_id is not None:
|
||||
conditions.append(model.bot_id == bot_id)
|
||||
if identity is not None:
|
||||
conditions.append(model.session_id == identity)
|
||||
return conditions
|
||||
|
||||
async def _lock_event(self, session, workspace, bot_id, event_id, timestamp):
|
||||
"""Serialize an event even when it has not arrived yet."""
|
||||
receipt = MonitoringSessionReceipt
|
||||
return (
|
||||
(
|
||||
await session.execute(
|
||||
self._insert(receipt)
|
||||
.values(
|
||||
workspace_uuid=workspace,
|
||||
bot_id=bot_id,
|
||||
source_key=f'event:{event_id}',
|
||||
event_id=event_id,
|
||||
role='event',
|
||||
covered=False,
|
||||
timestamp=timestamp,
|
||||
)
|
||||
.on_conflict_do_update(
|
||||
index_elements=RECEIPT_KEY,
|
||||
set_={
|
||||
'timestamp': sa.case((receipt.timestamp < timestamp, timestamp), else_=receipt.timestamp)
|
||||
},
|
||||
)
|
||||
.returning(receipt.session_id, receipt.covered)
|
||||
)
|
||||
)
|
||||
.mappings()
|
||||
.one()
|
||||
)
|
||||
|
||||
async def _cover_event(self, session, workspace, bot_id, event_id, timestamp):
|
||||
previous = await self._lock_event(session, workspace, bot_id, event_id, timestamp)
|
||||
receipt = MonitoringSessionReceipt
|
||||
await session.execute(
|
||||
sa.update(receipt)
|
||||
.where(*self._scope(receipt, workspace, bot_id), receipt.source_key == f'event:{event_id}')
|
||||
.values(covered=True, session_id=None, candidate_snapshot=None, source_timestamp=None)
|
||||
)
|
||||
return previous['session_id']
|
||||
|
||||
async def _lock_summaries(self, session, workspace, identities, change=None):
|
||||
"""Lock sorted (bot, session) keys before message receipt FK checks."""
|
||||
model = MonitoringSession
|
||||
for bot_id, identity in sorted(identities):
|
||||
key = self._scope(model, workspace, bot_id, identity)
|
||||
if change is not None and (bot_id, identity) == (change.bot_id, change.session_id):
|
||||
timestamp = as_naive_utc(change.timestamp)
|
||||
# Updating the key takes FOR UPDATE, and recreates a deleted row.
|
||||
await session.execute(
|
||||
self._insert(model)
|
||||
.values(
|
||||
workspace_uuid=workspace,
|
||||
bot_id=bot_id,
|
||||
session_id=identity,
|
||||
bot_name='',
|
||||
pipeline_id='',
|
||||
pipeline_name='',
|
||||
message_count=0,
|
||||
source_count=0,
|
||||
start_time=timestamp,
|
||||
last_activity=timestamp,
|
||||
is_active=True,
|
||||
)
|
||||
.on_conflict_do_update(index_elements=SESSION_KEY, set_={'session_id': identity})
|
||||
)
|
||||
else:
|
||||
await session.execute(sa.select(model.session_id).where(*key).with_for_update())
|
||||
row = (await session.execute(sa.select(model.__table__).where(*key))).mappings().one_or_none()
|
||||
if row is not None:
|
||||
# Legacy values are an unranked fallback, seeded one row at a time.
|
||||
# Never promote a replaceable candidate into the immutable base.
|
||||
if row['base_snapshot'] is None and row['metadata_source_key'] is None and row['message_count'] > 0:
|
||||
fallback = {
|
||||
'source_key': None,
|
||||
'timestamp': None,
|
||||
'role': None,
|
||||
**{field: row[field] for field in SNAPSHOT_FIELDS},
|
||||
}
|
||||
await session.execute(sa.update(model).where(*key).values(base_snapshot=json.dumps(fallback)))
|
||||
|
||||
async def _merge_base(self, session, workspace, bot_id, identity, snapshot):
|
||||
"""Fold immutable messages and expired ingress under the summary lock."""
|
||||
model = MonitoringSession
|
||||
key = self._scope(model, workspace, bot_id, identity)
|
||||
encoded = (await session.execute(sa.select(model.base_snapshot).where(*key))).scalar_one()
|
||||
previous = json.loads(encoded) if encoded else None
|
||||
if previous is None or _rank(snapshot) >= _rank(previous):
|
||||
await session.execute(sa.update(model).where(*key).values(base_snapshot=json.dumps(snapshot)))
|
||||
|
||||
async def _refresh(self, session, workspace, bot_id, identity, delta=0, *, backfill=False, observed=None):
|
||||
model, receipt = MonitoringSession, MonitoringSessionReceipt
|
||||
key = self._scope(model, workspace, bot_id, identity)
|
||||
row = (await session.execute(sa.select(model.__table__).where(*key))).mappings().one()
|
||||
candidate = (
|
||||
await session.execute(
|
||||
sa.select(receipt.candidate_snapshot)
|
||||
.where(
|
||||
*self._scope(receipt, workspace, bot_id, identity),
|
||||
receipt.candidate_snapshot.is_not(None),
|
||||
)
|
||||
.order_by(receipt.source_timestamp.desc(), receipt.source_key.collate(self._collation).desc())
|
||||
.limit(1)
|
||||
)
|
||||
).scalar_one_or_none()
|
||||
snapshots = [json.loads(value) for value in (row['base_snapshot'], candidate) if value is not None]
|
||||
count = row['source_count'] + delta
|
||||
displayed = max(row['message_count'], count) if backfill else row['message_count'] + delta
|
||||
if count < 0 or displayed < 0:
|
||||
raise ValueError('Session summary counts cannot be negative')
|
||||
values = dict(source_count=count, message_count=displayed)
|
||||
if snapshots:
|
||||
selected = max(snapshots, key=_rank)
|
||||
values.update({field: selected[field] for field in SNAPSHOT_FIELDS})
|
||||
values.update(
|
||||
metadata_source_key=selected['source_key'],
|
||||
metadata_role=selected['role'],
|
||||
metadata_timestamp=(
|
||||
datetime.datetime.fromisoformat(selected['timestamp']) if selected['timestamp'] else None
|
||||
),
|
||||
)
|
||||
if observed is not None:
|
||||
values.update(
|
||||
start_time=min(row['start_time'], observed), last_activity=max(row['last_activity'], observed)
|
||||
)
|
||||
await session.execute(sa.update(model).where(*key).values(**values))
|
||||
if count == 0 and displayed == 0:
|
||||
await session.execute(sa.delete(model).where(*key))
|
||||
|
||||
async def link_event(self, session: AsyncSession, workspace: str, bot_id: str, message_id: str, event_id: str):
|
||||
"""Keep the message credit and retire an uncovered ingress credit once."""
|
||||
timestamp = datetime.datetime.now(datetime.timezone.utc).replace(tzinfo=None)
|
||||
state = await self._lock_event(session, workspace, bot_id, event_id, timestamp)
|
||||
receipt = MonitoringSessionReceipt
|
||||
predicates = [*self._scope(receipt, workspace, bot_id), receipt.source_key == f'message:{message_id}']
|
||||
statement = sa.select(receipt.session_id, receipt.event_id, receipt.role).where(*predicates)
|
||||
message = (await session.execute(statement)).mappings().one_or_none()
|
||||
if message is None or message['role'] == 'assistant':
|
||||
return
|
||||
identities = {(bot_id, message['session_id'])}
|
||||
owner = state['session_id']
|
||||
if owner is not None:
|
||||
identities.add((bot_id, owner))
|
||||
# The same lock order as apply_once also serializes a message replay.
|
||||
await self._lock_summaries(session, workspace, identities)
|
||||
message = (await session.execute(statement.with_for_update())).mappings().one_or_none()
|
||||
if message is None:
|
||||
return
|
||||
if message['event_id'] is not None:
|
||||
if message['event_id'] != event_id:
|
||||
raise ValueError('A message cannot be linked to two events')
|
||||
return
|
||||
await session.execute(
|
||||
sa.update(receipt)
|
||||
.where(*self._scope(receipt, workspace, bot_id), receipt.source_key == f'event:{event_id}')
|
||||
.values(covered=True, session_id=None, candidate_snapshot=None, source_timestamp=None)
|
||||
)
|
||||
await session.execute(sa.update(receipt).where(*predicates).values(event_id=event_id, timestamp=timestamp))
|
||||
if owner is not None:
|
||||
await self._refresh(session, workspace, bot_id, owner, -1)
|
||||
|
||||
async def apply_once(self, session: AsyncSession, change: SessionSummaryChange, *, backfill: bool = False) -> bool:
|
||||
"""Project a source in its transaction; distinct message rows always count."""
|
||||
if not change.session_id:
|
||||
return False
|
||||
receipt = MonitoringSessionReceipt
|
||||
workspace, bot_id, identity = change.workspace, change.bot_id, change.session_id
|
||||
timestamp = as_naive_utc(change.timestamp)
|
||||
deltas = {}
|
||||
ingress = change.source_key.startswith('event:')
|
||||
if ingress:
|
||||
state = await self._lock_event(
|
||||
session, workspace, bot_id, change.source_key.removeprefix('event:'), timestamp
|
||||
)
|
||||
if state['covered']:
|
||||
return False
|
||||
if state['session_id'] is not None and state['session_id'] != identity:
|
||||
raise ValueError('An ingress event cannot belong to two conversations')
|
||||
claimed = state['session_id'] is None
|
||||
elif change.role != 'assistant':
|
||||
# Legacy message.id matching is part of the historical detail contract.
|
||||
for event_id in sorted({change.source_key.removeprefix('message:'), change.event_id} - {None}):
|
||||
owner = await self._cover_event(session, workspace, bot_id, event_id, timestamp)
|
||||
if owner is not None:
|
||||
deltas[owner] = deltas.get(owner, 0) - 1
|
||||
await self._lock_summaries(session, workspace, {(bot_id, key) for key in deltas} | {(bot_id, identity)}, change)
|
||||
if ingress:
|
||||
await session.execute(
|
||||
sa.update(receipt)
|
||||
.where(*self._scope(receipt, workspace, bot_id), receipt.source_key == change.source_key)
|
||||
.values(
|
||||
session_id=identity, candidate_snapshot=json.dumps(_snapshot(change)), source_timestamp=timestamp
|
||||
)
|
||||
)
|
||||
else:
|
||||
result = await session.execute(
|
||||
self._insert(receipt)
|
||||
.values(
|
||||
workspace_uuid=workspace,
|
||||
bot_id=bot_id,
|
||||
session_id=identity,
|
||||
source_key=change.source_key,
|
||||
event_id=change.event_id,
|
||||
role=change.role,
|
||||
covered=False,
|
||||
timestamp=timestamp,
|
||||
)
|
||||
.on_conflict_do_nothing(index_elements=RECEIPT_KEY)
|
||||
)
|
||||
claimed = bool(result.rowcount)
|
||||
if claimed:
|
||||
await self._merge_base(session, workspace, bot_id, identity, _snapshot(change))
|
||||
else:
|
||||
previous = (
|
||||
await session.execute(
|
||||
sa.select(receipt.session_id, receipt.role, receipt.event_id).where(
|
||||
*self._scope(receipt, workspace, bot_id), receipt.source_key == change.source_key
|
||||
)
|
||||
)
|
||||
).one()
|
||||
if tuple(previous) != (identity, change.role, change.event_id):
|
||||
raise ValueError('A summary message identity cannot change')
|
||||
deltas[identity] = deltas.get(identity, 0) + int(claimed)
|
||||
for owner, delta in sorted(deltas.items()):
|
||||
await self._refresh(
|
||||
session,
|
||||
workspace,
|
||||
bot_id,
|
||||
owner,
|
||||
delta,
|
||||
backfill=backfill,
|
||||
observed=timestamp if owner == identity else None,
|
||||
)
|
||||
return claimed
|
||||
|
||||
async def expire_ingress(self, session: AsyncSession, workspace: str, cutoff, keys) -> int:
|
||||
"""Seal expired candidates before deleting their event anchors, in one transaction."""
|
||||
if not keys:
|
||||
return 0
|
||||
receipt = MonitoringSessionReceipt
|
||||
rows = (
|
||||
(
|
||||
await session.execute(
|
||||
sa.select(receipt.__table__)
|
||||
.where(
|
||||
receipt.workspace_uuid == workspace,
|
||||
receipt.source_key.startswith('event:'),
|
||||
receipt.timestamp < cutoff,
|
||||
sa.tuple_(receipt.bot_id, receipt.source_key).in_(keys),
|
||||
)
|
||||
.order_by(receipt.bot_id, receipt.source_key)
|
||||
.with_for_update(skip_locked=True)
|
||||
)
|
||||
)
|
||||
.mappings()
|
||||
.all()
|
||||
)
|
||||
owners = {(row['bot_id'], row['session_id']) for row in rows if row['session_id'] is not None}
|
||||
await self._lock_summaries(session, workspace, owners)
|
||||
for row in rows:
|
||||
if row['candidate_snapshot'] is not None:
|
||||
await self._merge_base(
|
||||
session, workspace, row['bot_id'], row['session_id'], json.loads(row['candidate_snapshot'])
|
||||
)
|
||||
await session.execute(
|
||||
sa.delete(receipt).where(
|
||||
*self._scope(receipt, workspace, row['bot_id']), receipt.source_key == row['source_key']
|
||||
)
|
||||
)
|
||||
for bot_id, identity in sorted(owners):
|
||||
await self._refresh(session, workspace, bot_id, identity)
|
||||
return len(rows)
|
||||
@@ -71,6 +71,7 @@ TENANT_TABLE_COLUMNS: dict[str, str] = {
|
||||
'monitoring_llm_calls': 'workspace_uuid',
|
||||
'monitoring_tool_calls': 'workspace_uuid',
|
||||
'monitoring_sessions': 'workspace_uuid',
|
||||
'monitoring_session_receipts': 'workspace_uuid',
|
||||
'monitoring_errors': 'workspace_uuid',
|
||||
'monitoring_embedding_calls': 'workspace_uuid',
|
||||
'monitoring_feedback': 'workspace_uuid',
|
||||
|
||||
@@ -130,7 +130,7 @@ class MonitoringHelper:
|
||||
) -> str:
|
||||
"""Record the start of query processing, returns message_id"""
|
||||
try:
|
||||
# Check if session exists, if not, record session start
|
||||
# Message persistence updates the summary in the same transaction.
|
||||
session_id = f'{query.launcher_type.value if hasattr(query.launcher_type, "value") else query.launcher_type}_{query.launcher_id}'
|
||||
|
||||
# Get sender name from message event
|
||||
@@ -171,31 +171,6 @@ class MonitoringHelper:
|
||||
variables=None, # Will be updated in record_query_success
|
||||
)
|
||||
|
||||
# Update session activity or create new session if it doesn't exist
|
||||
# Always pass pipeline info to handle pipeline switches
|
||||
session_updated = await ap.monitoring_service.update_session_activity(
|
||||
get_query_execution_context(query),
|
||||
session_id,
|
||||
bot_id=bot_id,
|
||||
pipeline_id=pipeline_id,
|
||||
pipeline_name=pipeline_name,
|
||||
)
|
||||
if not session_updated:
|
||||
# Session doesn't exist, create it
|
||||
await ap.monitoring_service.record_session_start(
|
||||
get_query_execution_context(query),
|
||||
session_id=session_id,
|
||||
bot_id=bot_id,
|
||||
bot_name=bot_name,
|
||||
pipeline_id=pipeline_id,
|
||||
pipeline_name=pipeline_name,
|
||||
platform=query.launcher_type.value
|
||||
if hasattr(query.launcher_type, 'value')
|
||||
else str(query.launcher_type),
|
||||
user_id=query.sender_id,
|
||||
user_name=sender_name,
|
||||
)
|
||||
|
||||
return message_id
|
||||
except Exception as e:
|
||||
ap.logger.error(f'Failed to record query start: {e}')
|
||||
|
||||
@@ -1385,26 +1385,6 @@ class RuntimeBot:
|
||||
user_name=sender_name,
|
||||
)
|
||||
|
||||
# Ensure the session exists so the message appears in the session monitor.
|
||||
# Don't overwrite pipeline info — a session may have messages from
|
||||
# multiple pipelines; discarding shouldn't change the displayed pipeline.
|
||||
session_updated = await self.ap.monitoring_service.update_session_activity(
|
||||
self.execution_context,
|
||||
session_id,
|
||||
)
|
||||
if not session_updated:
|
||||
# No session yet (first message for this launcher was discarded).
|
||||
await self.ap.monitoring_service.record_session_start(
|
||||
self.execution_context,
|
||||
session_id=session_id,
|
||||
bot_id=self.bot_entity.uuid,
|
||||
bot_name=self.bot_entity.name or self.bot_entity.uuid,
|
||||
pipeline_id=self.PIPELINE_DISCARD,
|
||||
pipeline_name=self.PIPELINE_DISCARD_DISPLAY_NAME,
|
||||
platform=platform,
|
||||
user_id=str(sender_id),
|
||||
user_name=sender_name,
|
||||
)
|
||||
except Exception as e:
|
||||
await self.logger.error(f'Failed to record discarded message: {e}')
|
||||
|
||||
|
||||
Reference in New Issue
Block a user