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:
彼方
2026-10-20 11:53:06 +08:00
committed by GitHub
37 changed files with 4949 additions and 480 deletions
+65 -52
View File
@@ -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,
+118 -175
View File
@@ -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)
+2 -1
View File
@@ -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:
+3
View File
@@ -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)
+15 -1
View File
@@ -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',
+1 -26
View File
@@ -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}')
-20
View File
@@ -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}')