Ashraf Al-Kassem Claude Opus 4.6 commited on
Commit
eab9f11
·
1 Parent(s): 6d44285

feat: Mission 18 — High-Volume Runtime Event Trail

Browse files

Unified runtime event logging across all subsystems (webhooks, runtime
execution, dispatch, email, inbox, Zoho sync) with retention policies,
correlation IDs, and support replay capabilities.

- RuntimeEventLog model with 4 composite indexes + JSON related_ids
- Non-throwing log_event() service with payload redaction (SG.*, Bearer, truncation)
- Instrumented ~30 flows: webhook (5), runtime (8), dispatch (6), inbox (2), email (3)
- Support Timeline API: workspace-scoped with JSON path filters
- Admin Runtime Events: cross-workspace list/detail with full filter set
- Frontend: TimelinePanel in inbox + admin runtime-events page
- Celery beat daily purge (03:00 UTC, batch 1000)
- Alembic migration e5f6g7h8i9j0
- 8 unit tests + 98 E2E tests passing

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

backend/alembic/versions/e5f6g7h8i9j0_mission_18_runtime_event_log.py ADDED
@@ -0,0 +1,56 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Mission 18 — Runtime Event Log
2
+
3
+ Revision ID: e5f6g7h8i9j0
4
+ Revises: d4e5f6g7h8i9
5
+ Create Date: 2026-02-27
6
+
7
+ """
8
+ from alembic import op
9
+ import sqlalchemy as sa
10
+
11
+ # revision identifiers, used by Alembic.
12
+ revision = "e5f6g7h8i9j0"
13
+ down_revision = "d4e5f6g7h8i9"
14
+ branch_labels = None
15
+ depends_on = None
16
+
17
+
18
+ def upgrade() -> None:
19
+ op.create_table(
20
+ "runtimeeventlog",
21
+ sa.Column("id", sa.Uuid(), nullable=False),
22
+ sa.Column("workspace_id", sa.Uuid(), nullable=True),
23
+ sa.Column("event_type", sa.String(), nullable=False),
24
+ sa.Column("source", sa.String(), nullable=False),
25
+ sa.Column("correlation_id", sa.String(), nullable=True),
26
+ sa.Column("related_ids", sa.JSON(), nullable=True),
27
+ sa.Column("actor_user_id", sa.Uuid(), nullable=True),
28
+ sa.Column("payload", sa.JSON(), nullable=True),
29
+ sa.Column("outcome", sa.String(), nullable=True),
30
+ sa.Column("error_message", sa.String(), nullable=True),
31
+ sa.Column("duration_ms", sa.Integer(), nullable=True),
32
+ sa.Column("created_at", sa.DateTime(), nullable=False),
33
+ sa.PrimaryKeyConstraint("id"),
34
+ )
35
+ op.create_index("ix_runtimeeventlog_workspace_id", "runtimeeventlog", ["workspace_id"])
36
+ op.create_index("ix_runtimeeventlog_event_type", "runtimeeventlog", ["event_type"])
37
+ op.create_index("ix_runtimeeventlog_source", "runtimeeventlog", ["source"])
38
+ op.create_index("ix_runtimeeventlog_correlation_id", "runtimeeventlog", ["correlation_id"])
39
+ op.create_index("ix_runtimeeventlog_created_at", "runtimeeventlog", ["created_at"])
40
+ op.create_index("idx_rte_ws_created", "runtimeeventlog", ["workspace_id", "created_at"])
41
+ op.create_index("idx_rte_correlation", "runtimeeventlog", ["correlation_id"])
42
+ op.create_index("idx_rte_type_created", "runtimeeventlog", ["event_type", "created_at"])
43
+ op.create_index("idx_rte_source_created", "runtimeeventlog", ["source", "created_at"])
44
+
45
+
46
+ def downgrade() -> None:
47
+ op.drop_index("idx_rte_source_created", table_name="runtimeeventlog")
48
+ op.drop_index("idx_rte_type_created", table_name="runtimeeventlog")
49
+ op.drop_index("idx_rte_correlation", table_name="runtimeeventlog")
50
+ op.drop_index("idx_rte_ws_created", table_name="runtimeeventlog")
51
+ op.drop_index("ix_runtimeeventlog_created_at", table_name="runtimeeventlog")
52
+ op.drop_index("ix_runtimeeventlog_correlation_id", table_name="runtimeeventlog")
53
+ op.drop_index("ix_runtimeeventlog_source", table_name="runtimeeventlog")
54
+ op.drop_index("ix_runtimeeventlog_event_type", table_name="runtimeeventlog")
55
+ op.drop_index("ix_runtimeeventlog_workspace_id", table_name="runtimeeventlog")
56
+ op.drop_table("runtimeeventlog")
backend/app/api/v1/admin.py CHANGED
@@ -25,6 +25,7 @@ from app.models.models import (
25
  Plan, PlanEntitlement, WorkspacePlan,
26
  WorkspaceEntitlementOverride, UsageMeter,
27
  AgencyAccount, AgencyMember, AgencyStatus, WorkspaceOwnership,
 
28
  )
29
  from app.schemas.envelope import ResponseEnvelope, wrap_data, wrap_error
30
  from app.core.modules import module_cache, ALL_MODULES, MODULE_ADMIN_PORTAL
@@ -356,6 +357,92 @@ async def get_audit_log_detail(
356
  })
357
 
358
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
359
  # ─── Users Endpoints ─────────────────────────────────────────────────────────
360
 
361
  @router.get("/users", response_model=ResponseEnvelope[dict])
 
25
  Plan, PlanEntitlement, WorkspacePlan,
26
  WorkspaceEntitlementOverride, UsageMeter,
27
  AgencyAccount, AgencyMember, AgencyStatus, WorkspaceOwnership,
28
+ RuntimeEventLog,
29
  )
30
  from app.schemas.envelope import ResponseEnvelope, wrap_data, wrap_error
31
  from app.core.modules import module_cache, ALL_MODULES, MODULE_ADMIN_PORTAL
 
357
  })
358
 
359
 
360
+ # ─── Runtime Events Endpoints (Mission 18) ────────────────────────────────────
361
+
362
+ def _rt_event_to_dict(e: RuntimeEventLog) -> dict:
363
+ return {
364
+ "id": str(e.id),
365
+ "workspace_id": str(e.workspace_id) if e.workspace_id else None,
366
+ "event_type": e.event_type,
367
+ "source": e.source,
368
+ "correlation_id": e.correlation_id,
369
+ "related_ids": e.related_ids,
370
+ "actor_user_id": str(e.actor_user_id) if e.actor_user_id else None,
371
+ "payload": e.payload,
372
+ "outcome": e.outcome,
373
+ "error_message": e.error_message,
374
+ "duration_ms": e.duration_ms,
375
+ "created_at": e.created_at.isoformat() if e.created_at else None,
376
+ }
377
+
378
+
379
+ @router.get("/runtime-events", response_model=ResponseEnvelope[dict])
380
+ async def list_runtime_events(
381
+ db: AsyncSession = Depends(get_db),
382
+ admin_user: User = Depends(require_superadmin),
383
+ skip: int = Query(0, ge=0),
384
+ limit: int = Query(50, ge=1, le=200),
385
+ workspace_id: Optional[str] = None,
386
+ source: Optional[str] = None,
387
+ event_type: Optional[str] = None,
388
+ outcome: Optional[str] = None,
389
+ correlation_id: Optional[str] = None,
390
+ date_from: Optional[str] = None,
391
+ date_to: Optional[str] = None,
392
+ ) -> Any:
393
+ """List all runtime events (admin only)."""
394
+ from datetime import datetime
395
+ query = select(RuntimeEventLog)
396
+
397
+ if workspace_id:
398
+ query = query.where(RuntimeEventLog.workspace_id == UUID(workspace_id))
399
+ if source:
400
+ query = query.where(RuntimeEventLog.source == source)
401
+ if event_type:
402
+ query = query.where(RuntimeEventLog.event_type == event_type)
403
+ if outcome:
404
+ query = query.where(RuntimeEventLog.outcome == outcome)
405
+ if correlation_id:
406
+ query = query.where(RuntimeEventLog.correlation_id == correlation_id)
407
+ if date_from:
408
+ try:
409
+ query = query.where(RuntimeEventLog.created_at >= datetime.fromisoformat(date_from))
410
+ except ValueError:
411
+ pass
412
+ if date_to:
413
+ try:
414
+ query = query.where(RuntimeEventLog.created_at <= datetime.fromisoformat(date_to))
415
+ except ValueError:
416
+ pass
417
+
418
+ count_query = select(func.count()).select_from(query.subquery())
419
+ total = (await db.execute(count_query)).scalar_one()
420
+
421
+ query = query.order_by(RuntimeEventLog.created_at.desc()).offset(skip).limit(limit)
422
+ entries = (await db.execute(query)).scalars().all()
423
+
424
+ return wrap_data({
425
+ "items": [_rt_event_to_dict(e) for e in entries],
426
+ "total": total,
427
+ "skip": skip,
428
+ "limit": limit,
429
+ })
430
+
431
+
432
+ @router.get("/runtime-events/{event_id}", response_model=ResponseEnvelope[dict])
433
+ async def get_runtime_event_detail(
434
+ event_id: str,
435
+ db: AsyncSession = Depends(get_db),
436
+ admin_user: User = Depends(require_superadmin),
437
+ ) -> Any:
438
+ """Get a single runtime event by ID (admin only)."""
439
+ entry = await db.get(RuntimeEventLog, UUID(event_id))
440
+ if not entry:
441
+ return wrap_error("Runtime event not found")
442
+
443
+ return wrap_data(_rt_event_to_dict(entry))
444
+
445
+
446
  # ─── Users Endpoints ─────────────────────────────────────────────────────────
447
 
448
  @router.get("/users", response_model=ResponseEnvelope[dict])
backend/app/api/v1/inbox.py CHANGED
@@ -24,6 +24,7 @@ from app.services.dispatch_service import DispatchService
24
  from app.workers.tasks import dispatch_message_task
25
  from app.services.entitlements import require_entitlement
26
  from app.services.audit_service import audit_event
 
27
 
28
  router = APIRouter()
29
 
@@ -196,6 +197,10 @@ async def reply_to_conversation(
196
  entity_id=str(conversation_id), actor_user_id=current_user.id,
197
  outcome="success", workspace_id=workspace.id, request=request,
198
  )
 
 
 
 
199
  await db.commit()
200
 
201
  # Enqueue dispatch
@@ -224,6 +229,7 @@ async def update_conversation_status(
224
  if not conv or conv.workspace_id != workspace.id:
225
  raise HTTPException(status_code=404, detail="Conversation not found")
226
 
 
227
  conv.status = ConversationStatus(new_status)
228
  conv.updated_at = datetime.utcnow()
229
  db.add(conv)
@@ -233,6 +239,10 @@ async def update_conversation_status(
233
  outcome="success", workspace_id=workspace.id, request=request,
234
  metadata={"new_status": new_status},
235
  )
 
 
 
 
236
  await db.commit()
237
 
238
  return wrap_data({"conversation_id": str(conv.id), "status": conv.status})
 
24
  from app.workers.tasks import dispatch_message_task
25
  from app.services.entitlements import require_entitlement
26
  from app.services.audit_service import audit_event
27
+ from app.services.runtime_event_service import log_event
28
 
29
  router = APIRouter()
30
 
 
197
  entity_id=str(conversation_id), actor_user_id=current_user.id,
198
  outcome="success", workspace_id=workspace.id, request=request,
199
  )
200
+ await log_event(db, event_type="inbox.manual_reply", source="inbox",
201
+ workspace_id=workspace.id, actor_user_id=current_user.id,
202
+ related_ids={"conversation_id": str(conversation_id), "message_id": str(new_msg.id)},
203
+ payload={"content_length": len(content), "platform": platform})
204
  await db.commit()
205
 
206
  # Enqueue dispatch
 
229
  if not conv or conv.workspace_id != workspace.id:
230
  raise HTTPException(status_code=404, detail="Conversation not found")
231
 
232
+ old_status = conv.status.value if conv.status else None
233
  conv.status = ConversationStatus(new_status)
234
  conv.updated_at = datetime.utcnow()
235
  db.add(conv)
 
239
  outcome="success", workspace_id=workspace.id, request=request,
240
  metadata={"new_status": new_status},
241
  )
242
+ await log_event(db, event_type="inbox.status_changed", source="inbox",
243
+ workspace_id=workspace.id, actor_user_id=current_user.id,
244
+ related_ids={"conversation_id": str(conversation_id)},
245
+ payload={"old_status": old_status, "new_status": new_status})
246
  await db.commit()
247
 
248
  return wrap_data({"conversation_id": str(conv.id), "status": conv.status})
backend/app/api/v1/support_timeline.py ADDED
@@ -0,0 +1,123 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Support Timeline Router — Mission 18
3
+ Workspace-scoped runtime event timeline endpoints.
4
+ """
5
+ from typing import Any, Optional
6
+ from uuid import UUID
7
+ from datetime import datetime
8
+
9
+ from fastapi import APIRouter, Depends, Query
10
+ from sqlalchemy.ext.asyncio import AsyncSession
11
+ from sqlalchemy import func as sa_func
12
+ from sqlmodel import select, func
13
+
14
+ from app.core.db import get_db
15
+ from app.api.deps import get_active_workspace, require_role
16
+ from app.models.models import Workspace, RuntimeEventLog
17
+ from app.schemas.envelope import wrap_data, wrap_error
18
+
19
+ router = APIRouter()
20
+
21
+
22
+ def _event_to_dict(e: RuntimeEventLog) -> dict:
23
+ """Serialize a runtime event entry to a dict."""
24
+ return {
25
+ "id": str(e.id),
26
+ "workspace_id": str(e.workspace_id) if e.workspace_id else None,
27
+ "event_type": e.event_type,
28
+ "source": e.source,
29
+ "correlation_id": e.correlation_id,
30
+ "related_ids": e.related_ids,
31
+ "actor_user_id": str(e.actor_user_id) if e.actor_user_id else None,
32
+ "payload": e.payload,
33
+ "outcome": e.outcome,
34
+ "error_message": e.error_message,
35
+ "duration_ms": e.duration_ms,
36
+ "created_at": e.created_at.isoformat() if e.created_at else None,
37
+ }
38
+
39
+
40
+ @router.get("/support/timeline")
41
+ async def list_timeline_events(
42
+ db: AsyncSession = Depends(get_db),
43
+ workspace: Workspace = Depends(get_active_workspace),
44
+ _=Depends(require_role(["owner", "member", "viewer"])),
45
+ skip: int = Query(0, ge=0),
46
+ limit: int = Query(50, ge=1, le=200),
47
+ contact_id: Optional[str] = None,
48
+ conversation_id: Optional[str] = None,
49
+ execution_id: Optional[str] = None,
50
+ correlation_id: Optional[str] = None,
51
+ source: Optional[str] = None,
52
+ event_type: Optional[str] = None,
53
+ outcome: Optional[str] = None,
54
+ date_from: Optional[str] = None,
55
+ date_to: Optional[str] = None,
56
+ ) -> Any:
57
+ """List runtime event timeline scoped to the current workspace."""
58
+ query = select(RuntimeEventLog).where(RuntimeEventLog.workspace_id == workspace.id)
59
+
60
+ if source:
61
+ query = query.where(RuntimeEventLog.source == source)
62
+ if event_type:
63
+ query = query.where(RuntimeEventLog.event_type == event_type)
64
+ if outcome:
65
+ query = query.where(RuntimeEventLog.outcome == outcome)
66
+ if correlation_id:
67
+ query = query.where(RuntimeEventLog.correlation_id == correlation_id)
68
+
69
+ # JSON path filters on related_ids using json_extract (SQLite compatible)
70
+ if contact_id:
71
+ query = query.where(
72
+ sa_func.json_extract(RuntimeEventLog.related_ids, "$.contact_id") == contact_id
73
+ )
74
+ if conversation_id:
75
+ query = query.where(
76
+ sa_func.json_extract(RuntimeEventLog.related_ids, "$.conversation_id") == conversation_id
77
+ )
78
+ if execution_id:
79
+ query = query.where(
80
+ sa_func.json_extract(RuntimeEventLog.related_ids, "$.execution_instance_id") == execution_id
81
+ )
82
+
83
+ if date_from:
84
+ try:
85
+ dt = datetime.fromisoformat(date_from)
86
+ query = query.where(RuntimeEventLog.created_at >= dt)
87
+ except ValueError:
88
+ pass
89
+ if date_to:
90
+ try:
91
+ dt = datetime.fromisoformat(date_to)
92
+ query = query.where(RuntimeEventLog.created_at <= dt)
93
+ except ValueError:
94
+ pass
95
+
96
+ # Count
97
+ count_query = select(func.count()).select_from(query.subquery())
98
+ total = (await db.execute(count_query)).scalar_one()
99
+
100
+ query = query.order_by(RuntimeEventLog.created_at.desc()).offset(skip).limit(limit)
101
+ entries = (await db.execute(query)).scalars().all()
102
+
103
+ return wrap_data({
104
+ "items": [_event_to_dict(e) for e in entries],
105
+ "total": total,
106
+ "skip": skip,
107
+ "limit": limit,
108
+ })
109
+
110
+
111
+ @router.get("/support/timeline/{event_id}")
112
+ async def get_timeline_event_detail(
113
+ event_id: str,
114
+ db: AsyncSession = Depends(get_db),
115
+ workspace: Workspace = Depends(get_active_workspace),
116
+ _=Depends(require_role(["owner", "member", "viewer"])),
117
+ ) -> Any:
118
+ """Get a single runtime event entry (workspace-scoped)."""
119
+ entry = await db.get(RuntimeEventLog, UUID(event_id))
120
+ if not entry or entry.workspace_id != workspace.id:
121
+ return wrap_error("Runtime event not found")
122
+
123
+ return wrap_data(_event_to_dict(entry))
backend/app/api/v1/webhooks.py CHANGED
@@ -15,6 +15,7 @@ from app.models.models import Integration, WebhookEventLog, WebhookStatus
15
  from app.workers.tasks import process_webhook_event
16
  from app.core.modules import require_module_enabled, MODULE_WEBHOOKS_INGESTION
17
  from app.services.entitlements import require_entitlement
 
18
 
19
  router = APIRouter()
20
 
@@ -81,14 +82,14 @@ async def whatsapp_webhook(
81
  # 1. Extract IDs
82
  phone_number_id = None
83
  event_id = None
84
-
85
  try:
86
  entry = payload.get("entry", [])[0]
87
  change = entry.get("changes", [])[0]
88
  value = change.get("value", {})
89
  metadata = value.get("metadata", {})
90
  phone_number_id = metadata.get("phone_number_id")
91
-
92
  messages = value.get("messages", [])
93
  if messages:
94
  event_id = messages[0].get("id")
@@ -97,9 +98,19 @@ async def whatsapp_webhook(
97
  except (IndexError, AttributeError):
98
  pass
99
 
 
 
 
100
  # 2. Resolve Workspace
101
  workspace_id = await resolve_workspace(db, "whatsapp", phone_number_id) if phone_number_id else None
102
-
 
 
 
 
 
 
 
103
  # 3. Store Event Log
104
  correlation_id = uuid4()
105
  event_log = WebhookEventLog(
@@ -110,11 +121,11 @@ async def whatsapp_webhook(
110
  correlation_id=correlation_id,
111
  status=WebhookStatus.RECEIVED
112
  )
113
-
114
  if not workspace_id:
115
  event_log.status = WebhookStatus.FAILED
116
  event_log.last_error = "workspace_not_found"
117
-
118
  try:
119
  db.add(event_log)
120
  await db.commit()
@@ -125,7 +136,11 @@ async def whatsapp_webhook(
125
  # 4. Dispatch Task ONLY if resolved
126
  if workspace_id:
127
  process_webhook_event.delay(str(event_log.id))
128
-
 
 
 
 
129
  return {"status": "ok", "correlation_id": str(correlation_id)}
130
 
131
  @router.post("/meta", dependencies=[Depends(require_module_enabled(MODULE_WEBHOOKS_INGESTION, "write"))])
@@ -137,15 +152,21 @@ async def meta_webhook(
137
  ):
138
  """Inbound Meta Webhook."""
139
  payload_bytes = await request.body()
140
-
141
  # 1. Signature Check (SHA-256 preferred, fallback to SHA-1)
142
  if x_hub_signature_256:
143
  if not verify_meta_signature(payload_bytes, x_hub_signature_256, algorithm="sha256"):
144
  if settings.META_APP_SECRET:
 
 
 
145
  raise HTTPException(status_code=403, detail="Invalid SHA-256 signature")
146
  elif x_hub_signature:
147
  if not verify_meta_signature(payload_bytes, x_hub_signature, algorithm="sha1"):
148
  if settings.META_APP_SECRET:
 
 
 
149
  raise HTTPException(status_code=403, detail="Invalid SHA-1 signature")
150
 
151
  try:
@@ -156,27 +177,37 @@ async def meta_webhook(
156
  # 2. Extract IDs
157
  page_id = None
158
  event_id = None
159
-
160
  try:
161
  entry = payload.get("entry", [])[0]
162
  page_id = entry.get("id")
163
-
164
  messaging = entry.get("messaging", [])
165
  if messaging:
166
  event_id = messaging[0].get("message", {}).get("mid")
167
-
168
  changes = entry.get("changes", [])
169
  if changes:
170
  event_id = changes[0].get("value", {}).get("leadgen_id")
171
-
172
  if not event_id:
173
  event_id = f"meta_{hashlib.md5(payload_bytes).hexdigest()}"
174
  except (IndexError, AttributeError):
175
  pass
176
 
 
 
 
177
  # 3. Resolve Workspace
178
  workspace_id = await resolve_workspace(db, "meta", page_id) if page_id else None
179
-
 
 
 
 
 
 
 
180
  # 4. Store & Dispatch
181
  correlation_id = uuid4()
182
  event_log = WebhookEventLog(
@@ -187,11 +218,11 @@ async def meta_webhook(
187
  correlation_id=correlation_id,
188
  status=WebhookStatus.RECEIVED
189
  )
190
-
191
  if not workspace_id:
192
  event_log.status = WebhookStatus.FAILED
193
  event_log.last_error = "workspace_not_found"
194
-
195
  try:
196
  db.add(event_log)
197
  await db.commit()
@@ -202,5 +233,9 @@ async def meta_webhook(
202
  # Dispatch ONLY if resolved
203
  if workspace_id:
204
  process_webhook_event.delay(str(event_log.id))
205
-
 
 
 
 
206
  return {"status": "ok", "correlation_id": str(correlation_id)}
 
15
  from app.workers.tasks import process_webhook_event
16
  from app.core.modules import require_module_enabled, MODULE_WEBHOOKS_INGESTION
17
  from app.services.entitlements import require_entitlement
18
+ from app.services.runtime_event_service import log_event
19
 
20
  router = APIRouter()
21
 
 
82
  # 1. Extract IDs
83
  phone_number_id = None
84
  event_id = None
85
+
86
  try:
87
  entry = payload.get("entry", [])[0]
88
  change = entry.get("changes", [])[0]
89
  value = change.get("value", {})
90
  metadata = value.get("metadata", {})
91
  phone_number_id = metadata.get("phone_number_id")
92
+
93
  messages = value.get("messages", [])
94
  if messages:
95
  event_id = messages[0].get("id")
 
98
  except (IndexError, AttributeError):
99
  pass
100
 
101
+ await log_event(db, event_type="webhook.received", source="webhook",
102
+ payload={"provider": "whatsapp", "phone_number_id": phone_number_id})
103
+
104
  # 2. Resolve Workspace
105
  workspace_id = await resolve_workspace(db, "whatsapp", phone_number_id) if phone_number_id else None
106
+
107
+ if workspace_id:
108
+ await log_event(db, event_type="webhook.workspace_resolved", source="webhook",
109
+ workspace_id=workspace_id, payload={"provider": "whatsapp"})
110
+ else:
111
+ await log_event(db, event_type="webhook.workspace_not_found", source="webhook",
112
+ outcome="failure", payload={"provider": "whatsapp", "phone_number_id": phone_number_id})
113
+
114
  # 3. Store Event Log
115
  correlation_id = uuid4()
116
  event_log = WebhookEventLog(
 
121
  correlation_id=correlation_id,
122
  status=WebhookStatus.RECEIVED
123
  )
124
+
125
  if not workspace_id:
126
  event_log.status = WebhookStatus.FAILED
127
  event_log.last_error = "workspace_not_found"
128
+
129
  try:
130
  db.add(event_log)
131
  await db.commit()
 
136
  # 4. Dispatch Task ONLY if resolved
137
  if workspace_id:
138
  process_webhook_event.delay(str(event_log.id))
139
+ await log_event(db, event_type="webhook.queued", source="webhook",
140
+ workspace_id=workspace_id, correlation_id=str(correlation_id),
141
+ related_ids={"webhook_event_id": str(event_log.id)})
142
+ await db.commit()
143
+
144
  return {"status": "ok", "correlation_id": str(correlation_id)}
145
 
146
  @router.post("/meta", dependencies=[Depends(require_module_enabled(MODULE_WEBHOOKS_INGESTION, "write"))])
 
152
  ):
153
  """Inbound Meta Webhook."""
154
  payload_bytes = await request.body()
155
+
156
  # 1. Signature Check (SHA-256 preferred, fallback to SHA-1)
157
  if x_hub_signature_256:
158
  if not verify_meta_signature(payload_bytes, x_hub_signature_256, algorithm="sha256"):
159
  if settings.META_APP_SECRET:
160
+ await log_event(db, event_type="webhook.signature_invalid", source="webhook",
161
+ outcome="failure", payload={"provider": "meta", "algorithm": "sha256"})
162
+ await db.commit()
163
  raise HTTPException(status_code=403, detail="Invalid SHA-256 signature")
164
  elif x_hub_signature:
165
  if not verify_meta_signature(payload_bytes, x_hub_signature, algorithm="sha1"):
166
  if settings.META_APP_SECRET:
167
+ await log_event(db, event_type="webhook.signature_invalid", source="webhook",
168
+ outcome="failure", payload={"provider": "meta", "algorithm": "sha1"})
169
+ await db.commit()
170
  raise HTTPException(status_code=403, detail="Invalid SHA-1 signature")
171
 
172
  try:
 
177
  # 2. Extract IDs
178
  page_id = None
179
  event_id = None
180
+
181
  try:
182
  entry = payload.get("entry", [])[0]
183
  page_id = entry.get("id")
184
+
185
  messaging = entry.get("messaging", [])
186
  if messaging:
187
  event_id = messaging[0].get("message", {}).get("mid")
188
+
189
  changes = entry.get("changes", [])
190
  if changes:
191
  event_id = changes[0].get("value", {}).get("leadgen_id")
192
+
193
  if not event_id:
194
  event_id = f"meta_{hashlib.md5(payload_bytes).hexdigest()}"
195
  except (IndexError, AttributeError):
196
  pass
197
 
198
+ await log_event(db, event_type="webhook.received", source="webhook",
199
+ payload={"provider": "meta", "page_id": page_id})
200
+
201
  # 3. Resolve Workspace
202
  workspace_id = await resolve_workspace(db, "meta", page_id) if page_id else None
203
+
204
+ if workspace_id:
205
+ await log_event(db, event_type="webhook.workspace_resolved", source="webhook",
206
+ workspace_id=workspace_id, payload={"provider": "meta"})
207
+ else:
208
+ await log_event(db, event_type="webhook.workspace_not_found", source="webhook",
209
+ outcome="failure", payload={"provider": "meta", "page_id": page_id})
210
+
211
  # 4. Store & Dispatch
212
  correlation_id = uuid4()
213
  event_log = WebhookEventLog(
 
218
  correlation_id=correlation_id,
219
  status=WebhookStatus.RECEIVED
220
  )
221
+
222
  if not workspace_id:
223
  event_log.status = WebhookStatus.FAILED
224
  event_log.last_error = "workspace_not_found"
225
+
226
  try:
227
  db.add(event_log)
228
  await db.commit()
 
233
  # Dispatch ONLY if resolved
234
  if workspace_id:
235
  process_webhook_event.delay(str(event_log.id))
236
+ await log_event(db, event_type="webhook.queued", source="webhook",
237
+ workspace_id=workspace_id, correlation_id=str(correlation_id),
238
+ related_ids={"webhook_event_id": str(event_log.id)})
239
+ await db.commit()
240
+
241
  return {"status": "ok", "correlation_id": str(correlation_id)}
backend/app/core/celery_app.py CHANGED
@@ -1,4 +1,5 @@
1
  from celery import Celery
 
2
  from app.core.config import settings
3
 
4
  celery_app = Celery(
@@ -26,6 +27,10 @@ celery_app.conf.beat_schedule = {
26
  "task": "app.workers.email_tasks.process_email_outbox",
27
  "schedule": 60.0,
28
  },
 
 
 
 
29
  }
30
 
31
  celery_app.autodiscover_tasks(["app.workers"])
 
1
  from celery import Celery
2
+ from celery.schedules import crontab
3
  from app.core.config import settings
4
 
5
  celery_app = Celery(
 
27
  "task": "app.workers.email_tasks.process_email_outbox",
28
  "schedule": 60.0,
29
  },
30
+ "purge_runtime_events_daily_0300": {
31
+ "task": "app.workers.tasks.purge_runtime_events_task",
32
+ "schedule": crontab(hour=3, minute=0),
33
+ },
34
  }
35
 
36
  celery_app.autodiscover_tasks(["app.workers"])
backend/app/core/config.py CHANGED
@@ -60,6 +60,9 @@ class Settings(BaseSettings):
60
  APP_BASE_URL: Optional[str] = None # For constructing links
61
  EMAIL_VERIFICATION_GRACE_DAYS: int = 7
62
 
 
 
 
63
  @model_validator(mode="after")
64
  def validate_email_settings(self) -> "Settings":
65
  import logging
 
60
  APP_BASE_URL: Optional[str] = None # For constructing links
61
  EMAIL_VERIFICATION_GRACE_DAYS: int = 7
62
 
63
+ # Runtime Event Trail
64
+ RUNTIME_EVENT_RETENTION_DAYS: int = 30
65
+
66
  @model_validator(mode="after")
67
  def validate_email_settings(self) -> "Settings":
68
  import logging
backend/app/domain/runtime.py CHANGED
@@ -25,6 +25,7 @@ from app.models.models import (
25
  ZohoLeadMapping
26
  )
27
  from app.integrations.zoho.adapter import ZohoAdapter
 
28
 
29
  logger = logging.getLogger(__name__)
30
 
@@ -106,6 +107,11 @@ async def execute_instance(instance_id: UUID):
106
  instance.abort_reason = f"conversation_{conversation.status.value}"
107
  instance.aborted_at = datetime.utcnow()
108
  session.add(instance)
 
 
 
 
 
109
  await session.commit()
110
  return
111
 
@@ -114,12 +120,17 @@ async def execute_instance(instance_id: UUID):
114
  execution_instance_id=instance.id,
115
  node_id=UUID(current_node_id)
116
  )
117
-
 
 
 
 
 
118
  try:
119
  # Execution Logic per Node Type
120
  output_data = {}
121
  node_config = node.get("config", {})
122
-
123
  if node_type == "AI_REPLY":
124
  output_data = await handle_ai_reply(session, instance, node_config)
125
  elif node_type == "SEND_MESSAGE":
@@ -129,10 +140,16 @@ async def execute_instance(instance_id: UUID):
129
  else:
130
  logger.warning(f"Unknown node type: {node_type}")
131
 
 
132
  step_log.output_data = output_data
133
- step_log.duration_ms = int((time.time() - start_time) * 1000)
134
  session.add(step_log)
135
-
 
 
 
 
 
136
  # Move to next node
137
  next_nodes = edge_map.get(current_node_id, [])
138
  if next_nodes:
@@ -141,16 +158,21 @@ async def execute_instance(instance_id: UUID):
141
  else:
142
  current_node_id = None
143
  instance.status = ExecutionStatus.COMPLETED
144
-
145
  session.add(instance)
146
  await session.commit()
147
-
148
  except Exception as e:
149
  logger.exception(f"Error executing node {current_node_id}")
150
  step_log.error_message = str(e)
151
  instance.status = ExecutionStatus.FAILED
152
  session.add(step_log)
153
  session.add(instance)
 
 
 
 
 
154
  await session.commit()
155
  break
156
 
@@ -180,6 +202,10 @@ async def handle_zoho_upsert(session: AsyncSession, instance: ExecutionInstance,
180
  if not contact:
181
  raise Exception("Contact not found")
182
 
 
 
 
 
183
  # 4. Prepare Payload via Service
184
  from app.services.zoho_payload_builder import build_zoho_payload
185
  # runtime.py's execute_instance has session, instance...
@@ -232,6 +258,10 @@ async def handle_zoho_upsert(session: AsyncSession, instance: ExecutionInstance,
232
  await asyncio.sleep(wait_time)
233
  continue
234
  else:
 
 
 
 
235
  raise e # Permanent error or max retries
236
 
237
  # Check for Token Refresh
@@ -244,7 +274,12 @@ async def handle_zoho_upsert(session: AsyncSession, instance: ExecutionInstance,
244
  contact.zoho_lead_id = zoho_id
245
  contact.zoho_last_synced_at = datetime.utcnow()
246
  session.add(contact)
247
-
 
 
 
 
 
248
  return {"zoho_lead_id": zoho_id, "action": action}
249
 
250
  async def handle_ai_reply(session: AsyncSession, instance: ExecutionInstance, config: Dict[str, Any]) -> Dict[str, Any]:
@@ -323,6 +358,11 @@ async def handle_ai_reply(session: AsyncSession, instance: ExecutionInstance, co
323
  session.add(new_msg)
324
  await session.flush() # Get the ID for Celery
325
 
 
 
 
 
 
326
  # 6. Trigger Dispatch (Mission 6)
327
  try:
328
  from app.workers.tasks import dispatch_message_task
 
25
  ZohoLeadMapping
26
  )
27
  from app.integrations.zoho.adapter import ZohoAdapter
28
+ from app.services.runtime_event_service import log_event
29
 
30
  logger = logging.getLogger(__name__)
31
 
 
107
  instance.abort_reason = f"conversation_{conversation.status.value}"
108
  instance.aborted_at = datetime.utcnow()
109
  session.add(instance)
110
+ await log_event(session, event_type="runtime.aborted_human_takeover", source="runtime",
111
+ workspace_id=instance.workspace_id, outcome="skipped",
112
+ related_ids={"execution_instance_id": str(instance_id),
113
+ "conversation_id": str(conversation.id) if conversation else None},
114
+ payload={"reason": instance.abort_reason})
115
  await session.commit()
116
  return
117
 
 
120
  execution_instance_id=instance.id,
121
  node_id=UUID(current_node_id)
122
  )
123
+
124
+ await log_event(session, event_type="runtime.step_started", source="runtime",
125
+ workspace_id=instance.workspace_id,
126
+ related_ids={"execution_instance_id": str(instance_id)},
127
+ payload={"node_type": node_type, "node_id": current_node_id})
128
+
129
  try:
130
  # Execution Logic per Node Type
131
  output_data = {}
132
  node_config = node.get("config", {})
133
+
134
  if node_type == "AI_REPLY":
135
  output_data = await handle_ai_reply(session, instance, node_config)
136
  elif node_type == "SEND_MESSAGE":
 
140
  else:
141
  logger.warning(f"Unknown node type: {node_type}")
142
 
143
+ step_duration_ms = int((time.time() - start_time) * 1000)
144
  step_log.output_data = output_data
145
+ step_log.duration_ms = step_duration_ms
146
  session.add(step_log)
147
+
148
+ await log_event(session, event_type="runtime.step_completed", source="runtime",
149
+ workspace_id=instance.workspace_id, duration_ms=step_duration_ms,
150
+ related_ids={"execution_instance_id": str(instance_id)},
151
+ payload={"node_type": node_type, "node_id": current_node_id})
152
+
153
  # Move to next node
154
  next_nodes = edge_map.get(current_node_id, [])
155
  if next_nodes:
 
158
  else:
159
  current_node_id = None
160
  instance.status = ExecutionStatus.COMPLETED
161
+
162
  session.add(instance)
163
  await session.commit()
164
+
165
  except Exception as e:
166
  logger.exception(f"Error executing node {current_node_id}")
167
  step_log.error_message = str(e)
168
  instance.status = ExecutionStatus.FAILED
169
  session.add(step_log)
170
  session.add(instance)
171
+ await log_event(session, event_type="runtime.step_failed", source="runtime",
172
+ workspace_id=instance.workspace_id, outcome="failure",
173
+ error_message=str(e), duration_ms=int((time.time() - start_time) * 1000),
174
+ related_ids={"execution_instance_id": str(instance_id)},
175
+ payload={"node_type": node_type, "node_id": current_node_id})
176
  await session.commit()
177
  break
178
 
 
202
  if not contact:
203
  raise Exception("Contact not found")
204
 
205
+ await log_event(session, event_type="zoho.sync_started", source="zoho",
206
+ workspace_id=instance.workspace_id,
207
+ related_ids={"execution_instance_id": str(instance.id), "contact_id": str(instance.contact_id)})
208
+
209
  # 4. Prepare Payload via Service
210
  from app.services.zoho_payload_builder import build_zoho_payload
211
  # runtime.py's execute_instance has session, instance...
 
258
  await asyncio.sleep(wait_time)
259
  continue
260
  else:
261
+ await log_event(session, event_type="zoho.sync_failed", source="zoho",
262
+ workspace_id=instance.workspace_id, outcome="failure",
263
+ error_message=str(e),
264
+ related_ids={"execution_instance_id": str(instance.id), "contact_id": str(instance.contact_id)})
265
  raise e # Permanent error or max retries
266
 
267
  # Check for Token Refresh
 
274
  contact.zoho_lead_id = zoho_id
275
  contact.zoho_last_synced_at = datetime.utcnow()
276
  session.add(contact)
277
+
278
+ await log_event(session, event_type="zoho.sync_succeeded", source="zoho",
279
+ workspace_id=instance.workspace_id,
280
+ related_ids={"execution_instance_id": str(instance.id), "contact_id": str(instance.contact_id)},
281
+ payload={"zoho_lead_id": zoho_id, "action": action})
282
+
283
  return {"zoho_lead_id": zoho_id, "action": action}
284
 
285
  async def handle_ai_reply(session: AsyncSession, instance: ExecutionInstance, config: Dict[str, Any]) -> Dict[str, Any]:
 
358
  session.add(new_msg)
359
  await session.flush() # Get the ID for Celery
360
 
361
+ await log_event(session, event_type="runtime.ai_reply_generated", source="runtime",
362
+ workspace_id=instance.workspace_id,
363
+ related_ids={"execution_instance_id": str(instance.id), "message_id": str(new_msg.id)},
364
+ payload={"content_length": len(reply_text), "platform": platform})
365
+
366
  # 6. Trigger Dispatch (Mission 6)
367
  try:
368
  from app.workers.tasks import dispatch_message_task
backend/app/models/models.py CHANGED
@@ -406,6 +406,29 @@ class EmailOutbox(BaseIDModel, table=True):
406
  locked_at: Optional[datetime] = None
407
  last_error: Optional[str] = None
408
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
409
  # --- Plans & Entitlements (Mission 14) ---
410
 
411
  class Plan(BaseIDModel, table=True):
 
406
  locked_at: Optional[datetime] = None
407
  last_error: Optional[str] = None
408
 
409
+ # --- Runtime Event Trail (Mission 18) ---
410
+
411
+ class RuntimeEventLog(SQLModel, table=True):
412
+ id: UUID = Field(default_factory=uuid4, primary_key=True)
413
+ workspace_id: Optional[UUID] = Field(default=None, index=True)
414
+ event_type: str = Field(index=True)
415
+ source: str = Field(index=True)
416
+ correlation_id: Optional[str] = Field(default=None, index=True)
417
+ related_ids: Optional[Dict[str, Any]] = Field(default=None, sa_column=Column(JSON))
418
+ actor_user_id: Optional[UUID] = Field(default=None)
419
+ payload: Optional[Dict[str, Any]] = Field(default=None, sa_column=Column(JSON))
420
+ outcome: Optional[str] = Field(default=None)
421
+ error_message: Optional[str] = Field(default=None)
422
+ duration_ms: Optional[int] = Field(default=None)
423
+ created_at: datetime = Field(default_factory=datetime.utcnow, index=True)
424
+
425
+ __table_args__ = (
426
+ Index("idx_rte_ws_created", "workspace_id", "created_at"),
427
+ Index("idx_rte_correlation", "correlation_id"),
428
+ Index("idx_rte_type_created", "event_type", "created_at"),
429
+ Index("idx_rte_source_created", "source", "created_at"),
430
+ )
431
+
432
  # --- Plans & Entitlements (Mission 14) ---
433
 
434
  class Plan(BaseIDModel, table=True):
backend/app/services/dispatch_service.py CHANGED
@@ -13,6 +13,7 @@ from app.models.models import Message, DeliveryStatus, Integration, ChannelIdent
13
  from app.core.security import decrypt_data
14
  from app.integrations.whatsapp.adapter import WhatsAppAdapter
15
  from app.integrations.meta.adapter import MetaAdapter
 
16
 
17
  logger = logging.getLogger(__name__)
18
 
@@ -89,6 +90,9 @@ class DispatchService:
89
 
90
  if not lock_acquired:
91
  logger.warning(f"Message {msg.id} is already being dispatched (locked). Skipping.")
 
 
 
92
  continue
93
 
94
  try:
@@ -124,6 +128,9 @@ class DispatchService:
124
  msg.delivery_status = DeliveryStatus.SENT
125
  msg.sent_at = msg.sent_at or datetime.utcnow()
126
  db.add(msg)
 
 
 
127
  await db.commit()
128
  return
129
 
@@ -144,6 +151,9 @@ class DispatchService:
144
  msg.sent_at = datetime.utcnow()
145
  msg.last_error = "Skipped by idempotency guard"
146
  db.add(msg)
 
 
 
147
  await db.commit()
148
  return
149
 
@@ -152,6 +162,10 @@ class DispatchService:
152
  msg.last_attempt_at = datetime.utcnow()
153
  msg.attempt_count += 1
154
  db.add(msg)
 
 
 
 
155
  await db.commit()
156
  await db.refresh(msg)
157
 
@@ -215,20 +229,31 @@ class DispatchService:
215
  msg.sent_at = datetime.utcnow()
216
  msg.last_error = None
217
  logger.info(f"Successfully sent message {msg.id}", extra=log_ctx)
218
-
 
 
 
 
219
  except Exception as e:
220
  error_str = str(e)
221
  is_permanent = "PERMANENT_FAILURE" in error_str
222
-
223
  logger.error(f"Dispatch error for message {msg.id}: {error_str}", extra=log_ctx)
224
  msg.last_error = error_str
225
-
226
  if is_permanent or msg.attempt_count >= 5:
227
  msg.delivery_status = DeliveryStatus.FAILED
228
  else:
229
  # Set back to FAILED for retry polling
230
- msg.delivery_status = DeliveryStatus.FAILED
231
-
 
 
 
 
 
 
 
232
  raise e
233
  finally:
234
  db.add(msg)
 
13
  from app.core.security import decrypt_data
14
  from app.integrations.whatsapp.adapter import WhatsAppAdapter
15
  from app.integrations.meta.adapter import MetaAdapter
16
+ from app.services.runtime_event_service import log_event
17
 
18
  logger = logging.getLogger(__name__)
19
 
 
90
 
91
  if not lock_acquired:
92
  logger.warning(f"Message {msg.id} is already being dispatched (locked). Skipping.")
93
+ await log_event(db, event_type="dispatch.lock_skipped", source="dispatch",
94
+ workspace_id=msg.workspace_id, outcome="skipped",
95
+ related_ids={"message_id": str(msg.id)})
96
  continue
97
 
98
  try:
 
128
  msg.delivery_status = DeliveryStatus.SENT
129
  msg.sent_at = msg.sent_at or datetime.utcnow()
130
  db.add(msg)
131
+ await log_event(db, event_type="dispatch.skipped_already_sent", source="dispatch",
132
+ workspace_id=msg.workspace_id, outcome="skipped",
133
+ related_ids={"message_id": str(msg.id)})
134
  await db.commit()
135
  return
136
 
 
151
  msg.sent_at = datetime.utcnow()
152
  msg.last_error = "Skipped by idempotency guard"
153
  db.add(msg)
154
+ await log_event(db, event_type="dispatch.skipped_idempotency", source="dispatch",
155
+ workspace_id=msg.workspace_id, outcome="skipped",
156
+ related_ids={"message_id": str(msg.id)})
157
  await db.commit()
158
  return
159
 
 
162
  msg.last_attempt_at = datetime.utcnow()
163
  msg.attempt_count += 1
164
  db.add(msg)
165
+ await log_event(db, event_type="dispatch.attempt_started", source="dispatch",
166
+ workspace_id=msg.workspace_id,
167
+ related_ids={"message_id": str(msg.id), "conversation_id": str(msg.conversation_id)},
168
+ payload={"platform": msg.platform, "attempt_count": msg.attempt_count})
169
  await db.commit()
170
  await db.refresh(msg)
171
 
 
229
  msg.sent_at = datetime.utcnow()
230
  msg.last_error = None
231
  logger.info(f"Successfully sent message {msg.id}", extra=log_ctx)
232
+ await log_event(db, event_type="dispatch.attempt_succeeded", source="dispatch",
233
+ workspace_id=msg.workspace_id,
234
+ related_ids={"message_id": str(msg.id)},
235
+ payload={"provider_message_id": provider_message_id, "platform": msg.platform})
236
+
237
  except Exception as e:
238
  error_str = str(e)
239
  is_permanent = "PERMANENT_FAILURE" in error_str
240
+
241
  logger.error(f"Dispatch error for message {msg.id}: {error_str}", extra=log_ctx)
242
  msg.last_error = error_str
243
+
244
  if is_permanent or msg.attempt_count >= 5:
245
  msg.delivery_status = DeliveryStatus.FAILED
246
  else:
247
  # Set back to FAILED for retry polling
248
+ msg.delivery_status = DeliveryStatus.FAILED
249
+
250
+ await log_event(db, event_type="dispatch.attempt_failed", source="dispatch",
251
+ workspace_id=msg.workspace_id,
252
+ outcome="failure" if is_permanent or msg.attempt_count >= 5 else "skipped",
253
+ error_message=error_str,
254
+ related_ids={"message_id": str(msg.id)},
255
+ payload={"platform": msg.platform, "attempt_count": msg.attempt_count})
256
+
257
  raise e
258
  finally:
259
  db.add(msg)
backend/app/services/runtime_event_service.py ADDED
@@ -0,0 +1,115 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import logging
2
+ import copy
3
+ import re
4
+ from typing import Optional, Dict, Any
5
+ from uuid import UUID
6
+ from sqlalchemy.ext.asyncio import AsyncSession
7
+
8
+ from app.core.audit import SENSITIVE_KEYS
9
+ from app.models.models import RuntimeEventLog
10
+
11
+ logger = logging.getLogger(__name__)
12
+
13
+ # Regex patterns for additional scrubbing
14
+ _SG_KEY_RE = re.compile(r"SG\.\S+")
15
+ _BEARER_RE = re.compile(r"Bearer\s+\S+")
16
+
17
+ MAX_STRING_LENGTH = 2048
18
+ MAX_ARRAY_LENGTH = 20
19
+ MAX_DICT_KEYS = 50
20
+
21
+
22
+ def redact_payload(payload: Any) -> Any:
23
+ """
24
+ Deep-redact a payload dict for safe storage.
25
+ - Reuses SENSITIVE_KEYS from app.core.audit
26
+ - Scrubs SG.* SendGrid keys and Bearer tokens
27
+ - Truncates long strings, arrays, and large dicts
28
+ """
29
+ if payload is None:
30
+ return None
31
+ if not isinstance(payload, (dict, list)):
32
+ return payload
33
+
34
+ data = copy.deepcopy(payload)
35
+ return _redact(data)
36
+
37
+
38
+ def _redact(data: Any) -> Any:
39
+ if isinstance(data, dict):
40
+ # Cap number of keys
41
+ if len(data) > MAX_DICT_KEYS:
42
+ keys = list(data.keys())[:MAX_DICT_KEYS]
43
+ data = {k: data[k] for k in keys}
44
+ data["_truncated"] = True
45
+
46
+ for k in list(data.keys()):
47
+ if any(sec in str(k).lower() for sec in SENSITIVE_KEYS):
48
+ data[k] = "***REDACTED***"
49
+ else:
50
+ data[k] = _redact(data[k])
51
+ return data
52
+ elif isinstance(data, list):
53
+ truncated = len(data) > MAX_ARRAY_LENGTH
54
+ items = [_redact(item) for item in data[:MAX_ARRAY_LENGTH]]
55
+ if truncated:
56
+ items.append({"_truncated": True})
57
+ return items
58
+ elif isinstance(data, str):
59
+ # Scrub SendGrid API keys
60
+ val = _SG_KEY_RE.sub("SG.***", data)
61
+ # Scrub Bearer tokens
62
+ val = _BEARER_RE.sub("Bearer ***", val)
63
+ # Truncate long strings
64
+ if len(val) > MAX_STRING_LENGTH:
65
+ val = val[:MAX_STRING_LENGTH] + "...[truncated]"
66
+ return val
67
+ return data
68
+
69
+
70
+ async def log_event(
71
+ db: AsyncSession,
72
+ *,
73
+ event_type: str,
74
+ source: str,
75
+ workspace_id: Optional[UUID] = None,
76
+ correlation_id: Optional[str] = None,
77
+ related_ids: Optional[Dict[str, Any]] = None,
78
+ actor_user_id: Optional[UUID] = None,
79
+ payload: Optional[Dict[str, Any]] = None,
80
+ outcome: str = "success",
81
+ error_message: Optional[str] = None,
82
+ duration_ms: Optional[int] = None,
83
+ ):
84
+ """
85
+ Non-throwing runtime event logger.
86
+ Adds to session but does NOT commit — caller commits.
87
+ """
88
+ try:
89
+ safe_payload = redact_payload(payload) if payload else None
90
+ safe_error = error_message[:MAX_STRING_LENGTH] if error_message and len(error_message) > MAX_STRING_LENGTH else error_message
91
+
92
+ # Coerce string UUIDs to UUID objects for SQLAlchemy compatibility
93
+ ws_id = UUID(str(workspace_id)) if workspace_id and not isinstance(workspace_id, UUID) else workspace_id
94
+ actor_id = UUID(str(actor_user_id)) if actor_user_id and not isinstance(actor_user_id, UUID) else actor_user_id
95
+
96
+ event = RuntimeEventLog(
97
+ workspace_id=ws_id,
98
+ event_type=event_type,
99
+ source=source,
100
+ correlation_id=str(correlation_id) if correlation_id else None,
101
+ related_ids=related_ids,
102
+ actor_user_id=actor_id,
103
+ payload=safe_payload,
104
+ outcome=outcome,
105
+ error_message=safe_error,
106
+ duration_ms=duration_ms,
107
+ )
108
+ db.add(event)
109
+ await db.flush()
110
+ except Exception as e:
111
+ logger.warning(f"Failed to log runtime event ({event_type}): {e}")
112
+ try:
113
+ await db.rollback()
114
+ except Exception:
115
+ pass
backend/app/workers/email_tasks.py CHANGED
@@ -244,8 +244,13 @@ def send_email_task_v2(self, outbox_id: str):
244
  # --- PRE-SEND STATE (Two-Phase Commit) ---
245
  email_log.status = EmailStatus.SENDING
246
  email_log.provider_message_id = message_id
 
 
 
 
 
247
  await db.commit()
248
-
249
  # --- DISPATCH ---
250
  try:
251
  success = False
@@ -262,21 +267,32 @@ def send_email_task_v2(self, outbox_id: str):
262
 
263
  if not success:
264
  raise Exception("EmailService returned False indicating failure.")
265
-
266
  # --- DB ATOMIC UPDATE ON SUCCESS ---
267
  email_log.status = EmailStatus.SENT
268
  email_log.sent_at = datetime.utcnow()
269
  email_log.attempt_count = outbox.attempt_count + 1
270
  email_log.error_message = None
271
-
272
  outbox.status = EmailOutboxStatus.SENT
273
  outbox.attempt_count += 1
274
  outbox.last_error = None
 
 
 
275
  await db.commit()
276
-
277
  except Exception as exc:
278
  await db.rollback() # Rollback anything in transit
279
 
 
 
 
 
 
 
 
 
280
  # Safe atomic update for failure status
281
  async with SessionLocal() as db_err:
282
  import uuid
 
244
  # --- PRE-SEND STATE (Two-Phase Commit) ---
245
  email_log.status = EmailStatus.SENDING
246
  email_log.provider_message_id = message_id
247
+ from app.services.runtime_event_service import log_event as _log_rt_event
248
+ await _log_rt_event(db, event_type="email.send_started", source="email",
249
+ workspace_id=email_log.workspace_id,
250
+ related_ids={"email_outbox_id": outbox_id},
251
+ payload={"email_type": email_type, "to_email": to_email})
252
  await db.commit()
253
+
254
  # --- DISPATCH ---
255
  try:
256
  success = False
 
267
 
268
  if not success:
269
  raise Exception("EmailService returned False indicating failure.")
270
+
271
  # --- DB ATOMIC UPDATE ON SUCCESS ---
272
  email_log.status = EmailStatus.SENT
273
  email_log.sent_at = datetime.utcnow()
274
  email_log.attempt_count = outbox.attempt_count + 1
275
  email_log.error_message = None
276
+
277
  outbox.status = EmailOutboxStatus.SENT
278
  outbox.attempt_count += 1
279
  outbox.last_error = None
280
+ await _log_rt_event(db, event_type="email.send_succeeded", source="email",
281
+ workspace_id=email_log.workspace_id,
282
+ related_ids={"email_outbox_id": outbox_id})
283
  await db.commit()
284
+
285
  except Exception as exc:
286
  await db.rollback() # Rollback anything in transit
287
 
288
+ # Log runtime event for failure
289
+ async with SessionLocal() as db_rt:
290
+ from app.services.runtime_event_service import log_event as _log_rt_event2
291
+ await _log_rt_event2(db_rt, event_type="email.send_failed", source="email",
292
+ outcome="failure", error_message=str(exc),
293
+ related_ids={"email_outbox_id": outbox_id})
294
+ await db_rt.commit()
295
+
296
  # Safe atomic update for failure status
297
  async with SessionLocal() as db_err:
298
  import uuid
backend/app/workers/tasks.py CHANGED
@@ -85,7 +85,8 @@ def process_webhook_event(event_id: str):
85
  logger.info(f"Processing webhook event: {event_id}")
86
  from app.core.db import engine
87
  from sqlalchemy.ext.asyncio import AsyncSession
88
-
 
89
  async with AsyncSession(engine) as session:
90
  event = await session.get(WebhookEventLog, UUID(event_id))
91
  if not event or not event.workspace_id:
@@ -94,6 +95,9 @@ def process_webhook_event(event_id: str):
94
 
95
  event.status = WebhookStatus.QUEUED
96
  session.add(event)
 
 
 
97
  await session.commit()
98
 
99
  try:
@@ -114,7 +118,7 @@ def process_webhook_event(event_id: str):
114
  msg_event = entry["messaging"][0]
115
  if msg_event.get("message", {}).get("is_echo"):
116
  is_echo = True
117
-
118
  if is_echo:
119
  logger.info(f"Ignoring echo event {event_id} to prevent loops")
120
  event.status = WebhookStatus.PROCESSED
@@ -133,13 +137,13 @@ def process_webhook_event(event_id: str):
133
 
134
  # 2. Resolve Contact & Store Message
135
  contact, identity, conversation = await resolve_or_create_contact(
136
- session,
137
- event.workspace_id,
138
- event.provider,
139
  info["provider_user_id"],
140
  first_name=info["first_name"]
141
  )
142
-
143
  if info["trigger_type"] == "MESSAGE_INBOUND":
144
  inbound_msg = Message(
145
  workspace_id=event.workspace_id,
@@ -150,22 +154,22 @@ def process_webhook_event(event_id: str):
150
  delivery_status="delivered"
151
  )
152
  session.add(inbound_msg)
153
-
154
  # 3. Find Matching Published Flow
155
  flow_query = select(FlowVersion).join(Flow).where(
156
  Flow.workspace_id == event.workspace_id,
157
  FlowVersion.is_published == True
158
  ).order_by(FlowVersion.created_at.desc())
159
-
160
  flow_version = (await session.execute(flow_query)).scalars().first()
161
-
162
  if flow_version:
163
  # 4. Create Execution Instance
164
  nodes = flow_version.definition_json.get("nodes", [])
165
  start_node = next((n for n in nodes if n.get("type") == "TRIGGER"), None)
166
  if not start_node and nodes:
167
  start_node = nodes[0]
168
-
169
  instance = ExecutionInstance(
170
  workspace_id=event.workspace_id,
171
  flow_version_id=flow_version.id,
@@ -174,8 +178,11 @@ def process_webhook_event(event_id: str):
174
  current_node_id=UUID(start_node["id"]) if start_node else None
175
  )
176
  session.add(instance)
 
 
 
177
  await session.commit()
178
-
179
  # 5. Execute Runtime
180
  await execute_instance(instance.id)
181
 
@@ -183,14 +190,21 @@ def process_webhook_event(event_id: str):
183
  event.status = WebhookStatus.PROCESSED
184
  event.processed_at = datetime.utcnow()
185
  session.add(event)
 
 
 
186
  await session.commit()
187
-
188
  except Exception as e:
189
  logger.exception(f"Failed to process event {event_id}")
190
  event.status = WebhookStatus.FAILED
191
  event.last_error = str(e)
192
  event.attempts += 1
193
  session.add(event)
 
 
 
 
194
  await session.commit()
195
 
196
  run_async(_run())
@@ -222,8 +236,40 @@ def dispatch_pending_task(workspace_id: Optional[str] = None):
222
  async def _run():
223
  async with AsyncSession(engine) as session:
224
  await DispatchService.dispatch_pending_messages(
225
- session,
226
  workspace_id=UUID(workspace_id) if workspace_id else None
227
  )
228
-
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
229
  run_async(_run())
 
85
  logger.info(f"Processing webhook event: {event_id}")
86
  from app.core.db import engine
87
  from sqlalchemy.ext.asyncio import AsyncSession
88
+ from app.services.runtime_event_service import log_event
89
+
90
  async with AsyncSession(engine) as session:
91
  event = await session.get(WebhookEventLog, UUID(event_id))
92
  if not event or not event.workspace_id:
 
95
 
96
  event.status = WebhookStatus.QUEUED
97
  session.add(event)
98
+ await log_event(session, event_type="webhook.processing_started", source="webhook",
99
+ workspace_id=event.workspace_id, correlation_id=str(event.correlation_id),
100
+ related_ids={"webhook_event_id": event_id})
101
  await session.commit()
102
 
103
  try:
 
118
  msg_event = entry["messaging"][0]
119
  if msg_event.get("message", {}).get("is_echo"):
120
  is_echo = True
121
+
122
  if is_echo:
123
  logger.info(f"Ignoring echo event {event_id} to prevent loops")
124
  event.status = WebhookStatus.PROCESSED
 
137
 
138
  # 2. Resolve Contact & Store Message
139
  contact, identity, conversation = await resolve_or_create_contact(
140
+ session,
141
+ event.workspace_id,
142
+ event.provider,
143
  info["provider_user_id"],
144
  first_name=info["first_name"]
145
  )
146
+
147
  if info["trigger_type"] == "MESSAGE_INBOUND":
148
  inbound_msg = Message(
149
  workspace_id=event.workspace_id,
 
154
  delivery_status="delivered"
155
  )
156
  session.add(inbound_msg)
157
+
158
  # 3. Find Matching Published Flow
159
  flow_query = select(FlowVersion).join(Flow).where(
160
  Flow.workspace_id == event.workspace_id,
161
  FlowVersion.is_published == True
162
  ).order_by(FlowVersion.created_at.desc())
163
+
164
  flow_version = (await session.execute(flow_query)).scalars().first()
165
+
166
  if flow_version:
167
  # 4. Create Execution Instance
168
  nodes = flow_version.definition_json.get("nodes", [])
169
  start_node = next((n for n in nodes if n.get("type") == "TRIGGER"), None)
170
  if not start_node and nodes:
171
  start_node = nodes[0]
172
+
173
  instance = ExecutionInstance(
174
  workspace_id=event.workspace_id,
175
  flow_version_id=flow_version.id,
 
178
  current_node_id=UUID(start_node["id"]) if start_node else None
179
  )
180
  session.add(instance)
181
+ await log_event(session, event_type="runtime.execution_created", source="runtime",
182
+ workspace_id=event.workspace_id, correlation_id=str(event.correlation_id),
183
+ related_ids={"webhook_event_id": event_id, "execution_instance_id": str(instance.id)})
184
  await session.commit()
185
+
186
  # 5. Execute Runtime
187
  await execute_instance(instance.id)
188
 
 
190
  event.status = WebhookStatus.PROCESSED
191
  event.processed_at = datetime.utcnow()
192
  session.add(event)
193
+ await log_event(session, event_type="webhook.processing_completed", source="webhook",
194
+ workspace_id=event.workspace_id, correlation_id=str(event.correlation_id),
195
+ related_ids={"webhook_event_id": event_id})
196
  await session.commit()
197
+
198
  except Exception as e:
199
  logger.exception(f"Failed to process event {event_id}")
200
  event.status = WebhookStatus.FAILED
201
  event.last_error = str(e)
202
  event.attempts += 1
203
  session.add(event)
204
+ await log_event(session, event_type="webhook.processing_failed", source="webhook",
205
+ workspace_id=event.workspace_id, correlation_id=str(event.correlation_id),
206
+ outcome="failure", error_message=str(e),
207
+ related_ids={"webhook_event_id": event_id})
208
  await session.commit()
209
 
210
  run_async(_run())
 
236
  async def _run():
237
  async with AsyncSession(engine) as session:
238
  await DispatchService.dispatch_pending_messages(
239
+ session,
240
  workspace_id=UUID(workspace_id) if workspace_id else None
241
  )
242
+
243
+ run_async(_run())
244
+
245
+
246
+ @celery_app.task(name="app.workers.tasks.purge_runtime_events_task")
247
+ def purge_runtime_events_task():
248
+ """Daily task to purge old runtime events based on retention policy."""
249
+ from app.core.config import settings as app_settings
250
+ from app.core.db import engine
251
+ from sqlalchemy.ext.asyncio import AsyncSession
252
+ from app.models.models import RuntimeEventLog
253
+ from datetime import timedelta
254
+ from sqlalchemy import delete as sa_delete
255
+
256
+ async def _run():
257
+ cutoff = datetime.utcnow() - timedelta(days=app_settings.RUNTIME_EVENT_RETENTION_DAYS)
258
+ total_purged = 0
259
+ async with AsyncSession(engine) as session:
260
+ while True:
261
+ query = select(RuntimeEventLog.id).where(
262
+ RuntimeEventLog.created_at < cutoff
263
+ ).limit(1000)
264
+ result = await session.execute(query)
265
+ ids = [row[0] for row in result.all()]
266
+ if not ids:
267
+ break
268
+ await session.execute(
269
+ sa_delete(RuntimeEventLog).where(RuntimeEventLog.id.in_(ids))
270
+ )
271
+ await session.commit()
272
+ total_purged += len(ids)
273
+ logger.info(f"Purged {total_purged} runtime events older than {app_settings.RUNTIME_EVENT_RETENTION_DAYS} days")
274
+
275
  run_async(_run())
backend/main.py CHANGED
@@ -15,6 +15,7 @@ from app.api.v1.diagnostics import router as diagnostics_router
15
  from app.api.v1.agency import router as agency_router
16
  from app.api.v1.settings import router as settings_router
17
  from app.api.v1.audit_logs import router as audit_logs_router
 
18
  from fastapi import HTTPException
19
  import uuid
20
  import logging
@@ -152,6 +153,7 @@ app.include_router(diagnostics_router, prefix=f"{settings.API_V1_STR}/diagnostic
152
  app.include_router(agency_router, prefix=f"{settings.API_V1_STR}/agency", tags=["agency"])
153
  app.include_router(settings_router, prefix=f"{settings.API_V1_STR}/settings/workspace", tags=["settings"])
154
  app.include_router(audit_logs_router, prefix=f"{settings.API_V1_STR}/audit-logs", tags=["audit-logs"])
 
155
 
156
  @app.get("/")
157
  async def root():
 
15
  from app.api.v1.agency import router as agency_router
16
  from app.api.v1.settings import router as settings_router
17
  from app.api.v1.audit_logs import router as audit_logs_router
18
+ from app.api.v1.support_timeline import router as support_timeline_router
19
  from fastapi import HTTPException
20
  import uuid
21
  import logging
 
153
  app.include_router(agency_router, prefix=f"{settings.API_V1_STR}/agency", tags=["agency"])
154
  app.include_router(settings_router, prefix=f"{settings.API_V1_STR}/settings/workspace", tags=["settings"])
155
  app.include_router(audit_logs_router, prefix=f"{settings.API_V1_STR}/audit-logs", tags=["audit-logs"])
156
+ app.include_router(support_timeline_router, prefix=f"{settings.API_V1_STR}", tags=["support-timeline"])
157
 
158
  @app.get("/")
159
  async def root():
backend/tests/test_runtime_events.py ADDED
@@ -0,0 +1,142 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Mission 18 — Runtime Event Trail Tests
3
+ """
4
+ import pytest
5
+ from sqlalchemy.ext.asyncio import AsyncSession
6
+ from sqlmodel import select, func
7
+ from uuid import uuid4
8
+
9
+ from app.models.models import RuntimeEventLog
10
+ from app.services.runtime_event_service import log_event, redact_payload
11
+
12
+
13
+ # ── Helpers ──────────────────────────────────────────────────────────────────
14
+
15
+ async def _count_events(db: AsyncSession, **filters) -> int:
16
+ q = select(func.count(RuntimeEventLog.id))
17
+ for k, v in filters.items():
18
+ q = q.where(getattr(RuntimeEventLog, k) == v)
19
+ result = await db.execute(q)
20
+ return result.scalar_one()
21
+
22
+
23
+ # ── Service Unit Tests ───────────────────────────────────────────────────────
24
+
25
+ @pytest.mark.asyncio
26
+ async def test_log_event_stores_fields(db_session: AsyncSession):
27
+ """log_event() should persist all provided fields."""
28
+ ws_id = uuid4()
29
+ corr = str(uuid4())
30
+ await log_event(
31
+ db_session,
32
+ event_type="webhook.received",
33
+ source="webhook",
34
+ workspace_id=ws_id,
35
+ correlation_id=corr,
36
+ related_ids={"webhook_event_id": "evt-123"},
37
+ payload={"provider": "whatsapp", "phone_number_id": "12345"},
38
+ outcome="success",
39
+ duration_ms=42,
40
+ )
41
+ await db_session.commit()
42
+
43
+ result = await db_session.execute(
44
+ select(RuntimeEventLog).where(RuntimeEventLog.correlation_id == corr)
45
+ )
46
+ entry = result.scalars().first()
47
+
48
+ assert entry is not None
49
+ assert entry.event_type == "webhook.received"
50
+ assert entry.source == "webhook"
51
+ assert entry.workspace_id == ws_id
52
+ assert entry.outcome == "success"
53
+ assert entry.duration_ms == 42
54
+ assert entry.related_ids["webhook_event_id"] == "evt-123"
55
+ assert entry.payload["provider"] == "whatsapp"
56
+
57
+
58
+ @pytest.mark.asyncio
59
+ async def test_log_event_redacts_payload(db_session: AsyncSession):
60
+ """Sensitive keys and Bearer tokens should be redacted in stored payload."""
61
+ await log_event(
62
+ db_session,
63
+ event_type="test.redact",
64
+ source="test",
65
+ payload={
66
+ "access_token": "secret-token-value",
67
+ "provider": "meta",
68
+ "authorization": "Bearer sk-1234",
69
+ },
70
+ )
71
+ await db_session.commit()
72
+
73
+ result = await db_session.execute(
74
+ select(RuntimeEventLog).where(RuntimeEventLog.event_type == "test.redact")
75
+ )
76
+ entry = result.scalars().first()
77
+
78
+ assert entry is not None
79
+ assert entry.payload["access_token"] == "***REDACTED***"
80
+ assert entry.payload["authorization"] == "***REDACTED***"
81
+ assert entry.payload["provider"] == "meta"
82
+
83
+
84
+ @pytest.mark.asyncio
85
+ async def test_log_event_swallows_exceptions(db_session: AsyncSession):
86
+ """log_event() should not raise even on invalid data."""
87
+ # Pass a non-UUID for workspace_id to trigger a potential error — but log_event is non-throwing
88
+ # Actually, let's just ensure it handles gracefully without crashing
89
+ try:
90
+ await log_event(
91
+ db_session,
92
+ event_type="test.error_handling",
93
+ source="test",
94
+ )
95
+ await db_session.commit()
96
+ except Exception:
97
+ pytest.fail("log_event() should not raise exceptions")
98
+
99
+
100
+ @pytest.mark.asyncio
101
+ async def test_redact_payload_truncates_long_strings():
102
+ """Strings longer than 2048 chars should be truncated."""
103
+ long_string = "x" * 5000
104
+ result = redact_payload({"data": long_string})
105
+ assert len(result["data"]) < 5000
106
+ assert result["data"].endswith("...[truncated]")
107
+
108
+
109
+ @pytest.mark.asyncio
110
+ async def test_redact_payload_truncates_arrays():
111
+ """Arrays with > 20 items should be truncated."""
112
+ long_array = list(range(50))
113
+ result = redact_payload({"items": long_array})
114
+ assert len(result["items"]) == 21 # 20 items + truncation marker
115
+ assert result["items"][-1] == {"_truncated": True}
116
+
117
+
118
+ @pytest.mark.asyncio
119
+ async def test_redact_payload_scrubs_sendgrid_keys():
120
+ """SG.* patterns should be scrubbed."""
121
+ result = redact_payload({"api_response": "API key is SG.abc123xyz.foobar"})
122
+ assert "SG.abc123xyz" not in result["api_response"]
123
+ assert "SG.***" in result["api_response"]
124
+
125
+
126
+ @pytest.mark.asyncio
127
+ async def test_redact_payload_scrubs_bearer_tokens():
128
+ """Bearer tokens should be scrubbed."""
129
+ result = redact_payload({"header": "Bearer eyJhbGciOiJIUzI1NiJ9.payload.sig"})
130
+ assert "eyJhbGciOiJIUzI1NiJ9" not in result["header"]
131
+ assert "Bearer ***" in result["header"]
132
+
133
+
134
+ @pytest.mark.asyncio
135
+ async def test_redact_payload_caps_dict_keys():
136
+ """Dicts with > 50 keys should be truncated."""
137
+ big_dict = {f"key_{i}": i for i in range(80)}
138
+ result = redact_payload(big_dict)
139
+ assert "_truncated" in result
140
+ assert result["_truncated"] is True
141
+ # Should have at most 51 keys (50 original + _truncated)
142
+ assert len(result) <= 51
frontend/src/app/(admin)/admin/runtime-events/page.tsx ADDED
@@ -0,0 +1,306 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ "use client";
2
+
3
+ import { useEffect, useState, useCallback } from "react";
4
+ import { adminApi, RuntimeEventEntry } from "@/lib/admin-api";
5
+ import { Loader2, ChevronLeft, ChevronRight, ChevronDown, ChevronUp, Copy, Check, AlertCircle, CheckCircle, MinusCircle } from "lucide-react";
6
+
7
+ function formatDate(iso: string) {
8
+ try {
9
+ return new Date(iso).toLocaleString(undefined, { dateStyle: "medium", timeStyle: "medium" });
10
+ } catch {
11
+ return iso;
12
+ }
13
+ }
14
+
15
+ const SOURCE_COLORS: Record<string, string> = {
16
+ webhook: "bg-blue-500/20 text-blue-300 border-blue-500/30",
17
+ runtime: "bg-purple-500/20 text-purple-300 border-purple-500/30",
18
+ dispatch: "bg-emerald-500/20 text-emerald-300 border-emerald-500/30",
19
+ email: "bg-orange-500/20 text-orange-300 border-orange-500/30",
20
+ zoho: "bg-cyan-500/20 text-cyan-300 border-cyan-500/30",
21
+ inbox: "bg-slate-500/20 text-slate-400 border-slate-500/30",
22
+ };
23
+
24
+ function SourceBadge({ source }: { source: string }) {
25
+ const cls = SOURCE_COLORS[source] || SOURCE_COLORS.inbox;
26
+ return (
27
+ <span className={`inline-flex items-center px-2 py-0.5 rounded text-[10px] font-semibold uppercase tracking-wider border ${cls}`}>
28
+ {source}
29
+ </span>
30
+ );
31
+ }
32
+
33
+ function OutcomeBadge({ outcome }: { outcome?: string | null }) {
34
+ if (!outcome) return <span className="text-[10px] text-slate-500">—</span>;
35
+ switch (outcome) {
36
+ case "success":
37
+ return (
38
+ <span className="inline-flex items-center gap-1 px-2 py-0.5 rounded text-[10px] font-semibold uppercase tracking-wider border bg-emerald-500/20 text-emerald-300 border-emerald-500/30">
39
+ <CheckCircle className="w-3 h-3" /> success
40
+ </span>
41
+ );
42
+ case "failure":
43
+ return (
44
+ <span className="inline-flex items-center gap-1 px-2 py-0.5 rounded text-[10px] font-semibold uppercase tracking-wider border bg-red-500/20 text-red-300 border-red-500/30">
45
+ <AlertCircle className="w-3 h-3" /> failure
46
+ </span>
47
+ );
48
+ case "skipped":
49
+ return (
50
+ <span className="inline-flex items-center gap-1 px-2 py-0.5 rounded text-[10px] font-semibold uppercase tracking-wider border bg-amber-500/20 text-amber-300 border-amber-500/30">
51
+ <MinusCircle className="w-3 h-3" /> skipped
52
+ </span>
53
+ );
54
+ default:
55
+ return <span className="text-[10px] text-slate-500">{outcome}</span>;
56
+ }
57
+ }
58
+
59
+ function CopyButton({ text }: { text: string }) {
60
+ const [copied, setCopied] = useState(false);
61
+ return (
62
+ <button
63
+ onClick={() => { navigator.clipboard.writeText(text); setCopied(true); setTimeout(() => setCopied(false), 1500); }}
64
+ className="p-0.5 hover:bg-white/10 rounded transition-colors"
65
+ title="Copy"
66
+ >
67
+ {copied ? <Check className="w-3 h-3 text-emerald-400" /> : <Copy className="w-3 h-3 text-slate-500" />}
68
+ </button>
69
+ );
70
+ }
71
+
72
+ export default function AdminRuntimeEventsPage() {
73
+ const [events, setEvents] = useState<RuntimeEventEntry[]>([]);
74
+ const [total, setTotal] = useState(0);
75
+ const [loading, setLoading] = useState(true);
76
+ const [skip, setSkip] = useState(0);
77
+ const limit = 50;
78
+ const [expandedId, setExpandedId] = useState<string | null>(null);
79
+
80
+ // Filters
81
+ const [sourceFilter, setSourceFilter] = useState("");
82
+ const [eventTypeFilter, setEventTypeFilter] = useState("");
83
+ const [outcomeFilter, setOutcomeFilter] = useState("");
84
+ const [workspaceIdFilter, setWorkspaceIdFilter] = useState("");
85
+
86
+ const fetchEvents = useCallback(async () => {
87
+ setLoading(true);
88
+ try {
89
+ const res = await adminApi.getRuntimeEvents({
90
+ skip,
91
+ limit,
92
+ source: sourceFilter || undefined,
93
+ event_type: eventTypeFilter || undefined,
94
+ outcome: outcomeFilter || undefined,
95
+ workspace_id: workspaceIdFilter || undefined,
96
+ });
97
+ setEvents(res.data?.items || []);
98
+ setTotal(res.data?.total || 0);
99
+ } catch {
100
+ setEvents([]);
101
+ } finally {
102
+ setLoading(false);
103
+ }
104
+ }, [skip, sourceFilter, eventTypeFilter, outcomeFilter, workspaceIdFilter]);
105
+
106
+ useEffect(() => { fetchEvents(); }, [fetchEvents]);
107
+
108
+ const totalPages = Math.ceil(total / limit);
109
+ const currentPage = Math.floor(skip / limit) + 1;
110
+
111
+ return (
112
+ <div className="p-6 space-y-6">
113
+ <div className="flex items-center justify-between">
114
+ <div>
115
+ <h1 className="text-2xl font-bold text-white">Runtime Events</h1>
116
+ <p className="text-sm text-slate-400 mt-1">High-volume system event trail across all subsystems</p>
117
+ </div>
118
+ <div className="text-sm text-slate-400">{total} total events</div>
119
+ </div>
120
+
121
+ {/* Filters */}
122
+ <div className="flex flex-wrap gap-3">
123
+ <select
124
+ value={sourceFilter}
125
+ onChange={(e) => { setSourceFilter(e.target.value); setSkip(0); }}
126
+ className="bg-slate-800 border border-slate-700 rounded-lg text-sm text-white px-3 py-2 focus:ring-2 focus:ring-blue-500/50 focus:border-blue-500"
127
+ >
128
+ <option value="">All Sources</option>
129
+ <option value="webhook">Webhook</option>
130
+ <option value="runtime">Runtime</option>
131
+ <option value="dispatch">Dispatch</option>
132
+ <option value="email">Email</option>
133
+ <option value="zoho">Zoho</option>
134
+ <option value="inbox">Inbox</option>
135
+ </select>
136
+ <input
137
+ type="text"
138
+ placeholder="Event type..."
139
+ value={eventTypeFilter}
140
+ onChange={(e) => { setEventTypeFilter(e.target.value); setSkip(0); }}
141
+ className="bg-slate-800 border border-slate-700 rounded-lg text-sm text-white px-3 py-2 w-48 focus:ring-2 focus:ring-blue-500/50 focus:border-blue-500 placeholder:text-slate-500"
142
+ />
143
+ <select
144
+ value={outcomeFilter}
145
+ onChange={(e) => { setOutcomeFilter(e.target.value); setSkip(0); }}
146
+ className="bg-slate-800 border border-slate-700 rounded-lg text-sm text-white px-3 py-2 focus:ring-2 focus:ring-blue-500/50 focus:border-blue-500"
147
+ >
148
+ <option value="">All Outcomes</option>
149
+ <option value="success">Success</option>
150
+ <option value="failure">Failure</option>
151
+ <option value="skipped">Skipped</option>
152
+ </select>
153
+ <input
154
+ type="text"
155
+ placeholder="Workspace ID..."
156
+ value={workspaceIdFilter}
157
+ onChange={(e) => { setWorkspaceIdFilter(e.target.value); setSkip(0); }}
158
+ className="bg-slate-800 border border-slate-700 rounded-lg text-sm text-white px-3 py-2 w-64 focus:ring-2 focus:ring-blue-500/50 focus:border-blue-500 placeholder:text-slate-500"
159
+ />
160
+ </div>
161
+
162
+ {/* Table */}
163
+ {loading ? (
164
+ <div className="flex items-center justify-center py-20">
165
+ <Loader2 className="w-6 h-6 animate-spin text-blue-500" />
166
+ </div>
167
+ ) : (
168
+ <div className="bg-slate-800/50 border border-slate-700/50 rounded-xl overflow-hidden">
169
+ <table className="w-full text-sm">
170
+ <thead>
171
+ <tr className="border-b border-slate-700/50 text-left text-xs uppercase tracking-wider text-slate-400">
172
+ <th className="px-4 py-3 w-8"></th>
173
+ <th className="px-4 py-3">Time</th>
174
+ <th className="px-4 py-3">Source</th>
175
+ <th className="px-4 py-3">Event Type</th>
176
+ <th className="px-4 py-3">Outcome</th>
177
+ <th className="px-4 py-3">Duration</th>
178
+ <th className="px-4 py-3">Correlation ID</th>
179
+ </tr>
180
+ </thead>
181
+ <tbody>
182
+ {events.map((evt) => {
183
+ const isExpanded = expandedId === evt.id;
184
+ return (
185
+ <>
186
+ <tr
187
+ key={evt.id}
188
+ className="border-b border-slate-700/30 hover:bg-slate-700/20 cursor-pointer transition-colors"
189
+ onClick={() => setExpandedId(isExpanded ? null : evt.id)}
190
+ >
191
+ <td className="px-4 py-3">
192
+ {isExpanded ? (
193
+ <ChevronUp className="w-3.5 h-3.5 text-slate-500" />
194
+ ) : (
195
+ <ChevronDown className="w-3.5 h-3.5 text-slate-500" />
196
+ )}
197
+ </td>
198
+ <td className="px-4 py-3 text-slate-300 whitespace-nowrap text-xs">
199
+ {formatDate(evt.created_at)}
200
+ </td>
201
+ <td className="px-4 py-3"><SourceBadge source={evt.source} /></td>
202
+ <td className="px-4 py-3 text-slate-200 font-mono text-xs">{evt.event_type}</td>
203
+ <td className="px-4 py-3"><OutcomeBadge outcome={evt.outcome} /></td>
204
+ <td className="px-4 py-3 text-slate-400 font-mono text-xs">
205
+ {evt.duration_ms !== null && evt.duration_ms !== undefined ? `${evt.duration_ms}ms` : "—"}
206
+ </td>
207
+ <td className="px-4 py-3">
208
+ {evt.correlation_id ? (
209
+ <div className="flex items-center gap-1">
210
+ <span className="font-mono text-[10px] text-slate-500 truncate max-w-[120px]">
211
+ {evt.correlation_id}
212
+ </span>
213
+ <CopyButton text={evt.correlation_id} />
214
+ </div>
215
+ ) : (
216
+ <span className="text-slate-600">—</span>
217
+ )}
218
+ </td>
219
+ </tr>
220
+ {isExpanded && (
221
+ <tr key={`${evt.id}-detail`} className="bg-slate-900/50">
222
+ <td colSpan={7} className="px-6 py-4">
223
+ <div className="grid grid-cols-2 gap-4 text-xs">
224
+ <div>
225
+ <span className="text-slate-500 font-semibold uppercase tracking-wider text-[10px]">Event ID</span>
226
+ <div className="text-slate-300 font-mono mt-1 flex items-center gap-1">
227
+ {evt.id} <CopyButton text={evt.id} />
228
+ </div>
229
+ </div>
230
+ <div>
231
+ <span className="text-slate-500 font-semibold uppercase tracking-wider text-[10px]">Workspace</span>
232
+ <div className="text-slate-300 font-mono mt-1">
233
+ {evt.workspace_id || "—"}
234
+ </div>
235
+ </div>
236
+ {evt.error_message && (
237
+ <div className="col-span-2">
238
+ <span className="text-slate-500 font-semibold uppercase tracking-wider text-[10px]">Error</span>
239
+ <div className="text-red-400 bg-red-900/20 p-2 rounded mt-1 font-mono break-all">
240
+ {evt.error_message}
241
+ </div>
242
+ </div>
243
+ )}
244
+ {evt.related_ids && Object.keys(evt.related_ids).length > 0 && (
245
+ <div className="col-span-2">
246
+ <span className="text-slate-500 font-semibold uppercase tracking-wider text-[10px]">Related IDs</span>
247
+ <div className="mt-1 flex flex-wrap gap-2">
248
+ {Object.entries(evt.related_ids).map(([k, v]) => (
249
+ <span key={k} className="bg-slate-800 px-2 py-1 rounded text-[10px] font-mono text-slate-300 border border-slate-700">
250
+ {k}: {v}
251
+ </span>
252
+ ))}
253
+ </div>
254
+ </div>
255
+ )}
256
+ {evt.payload && Object.keys(evt.payload).length > 0 && (
257
+ <div className="col-span-2">
258
+ <span className="text-slate-500 font-semibold uppercase tracking-wider text-[10px]">Payload</span>
259
+ <pre className="bg-slate-800 p-3 rounded mt-1 text-[11px] font-mono text-slate-300 overflow-x-auto max-h-48 border border-slate-700">
260
+ {JSON.stringify(evt.payload, null, 2)}
261
+ </pre>
262
+ </div>
263
+ )}
264
+ </div>
265
+ </td>
266
+ </tr>
267
+ )}
268
+ </>
269
+ );
270
+ })}
271
+ </tbody>
272
+ </table>
273
+
274
+ {events.length === 0 && (
275
+ <div className="py-12 text-center text-slate-500 text-sm">No runtime events found</div>
276
+ )}
277
+ </div>
278
+ )}
279
+
280
+ {/* Pagination */}
281
+ {totalPages > 1 && (
282
+ <div className="flex items-center justify-between">
283
+ <span className="text-sm text-slate-400">
284
+ Page {currentPage} of {totalPages}
285
+ </span>
286
+ <div className="flex gap-2">
287
+ <button
288
+ disabled={skip === 0}
289
+ onClick={() => setSkip(Math.max(0, skip - limit))}
290
+ className="flex items-center gap-1 px-3 py-1.5 bg-slate-800 border border-slate-700 rounded-lg text-sm text-slate-300 hover:bg-slate-700 disabled:opacity-40 disabled:cursor-not-allowed transition-colors"
291
+ >
292
+ <ChevronLeft className="w-4 h-4" /> Previous
293
+ </button>
294
+ <button
295
+ disabled={skip + limit >= total}
296
+ onClick={() => setSkip(skip + limit)}
297
+ className="flex items-center gap-1 px-3 py-1.5 bg-slate-800 border border-slate-700 rounded-lg text-sm text-slate-300 hover:bg-slate-700 disabled:opacity-40 disabled:cursor-not-allowed transition-colors"
298
+ >
299
+ Next <ChevronRight className="w-4 h-4" />
300
+ </button>
301
+ </div>
302
+ </div>
303
+ )}
304
+ </div>
305
+ );
306
+ }
frontend/src/app/(dashboard)/inbox/page.tsx CHANGED
@@ -15,13 +15,15 @@ import {
15
  Bot,
16
  UserCircle,
17
  Archive,
18
- CloudUpload
 
19
  } from "lucide-react";
20
  import { formatDistanceToNow } from "date-fns";
21
  import { cn } from "@/lib/utils";
22
  import { apiClient } from "@/lib/api";
23
  import { useSearchParams, useRouter } from "next/navigation";
24
  import { toast } from "sonner";
 
25
 
26
  // --- Types ---
27
 
@@ -59,6 +61,7 @@ export default function InboxPage() {
59
  const [replyContent, setReplyContent] = useState("");
60
  const [sending, setSending] = useState(false);
61
  const [syncingZoho, setSyncingZoho] = useState(false);
 
62
 
63
  // Filters
64
  const [searchQuery, setSearchQuery] = useState("");
@@ -239,7 +242,7 @@ export default function InboxPage() {
239
  </div>
240
 
241
  {/* Right Panel: Thread & Composer */}
242
- <div className="flex-1 flex flex-col bg-white">
243
  {selectedId && thread ? (
244
  <>
245
  {/* Header */}
@@ -273,6 +276,18 @@ export default function InboxPage() {
273
  Return to AI
274
  </button>
275
  )}
 
 
 
 
 
 
 
 
 
 
 
 
276
  <button
277
  onClick={() => handleUpdateStatus(selectedId, thread.status === "closed" ? "bot_active" : "closed")}
278
  className={cn(
@@ -369,6 +384,13 @@ export default function InboxPage() {
369
  </div>
370
  )}
371
  </div>
 
 
 
 
 
 
 
372
  </div>
373
  );
374
  }
 
15
  Bot,
16
  UserCircle,
17
  Archive,
18
+ CloudUpload,
19
+ Clock
20
  } from "lucide-react";
21
  import { formatDistanceToNow } from "date-fns";
22
  import { cn } from "@/lib/utils";
23
  import { apiClient } from "@/lib/api";
24
  import { useSearchParams, useRouter } from "next/navigation";
25
  import { toast } from "sonner";
26
+ import TimelinePanel from "@/components/inbox/TimelinePanel";
27
 
28
  // --- Types ---
29
 
 
61
  const [replyContent, setReplyContent] = useState("");
62
  const [sending, setSending] = useState(false);
63
  const [syncingZoho, setSyncingZoho] = useState(false);
64
+ const [showTimeline, setShowTimeline] = useState(false);
65
 
66
  // Filters
67
  const [searchQuery, setSearchQuery] = useState("");
 
242
  </div>
243
 
244
  {/* Right Panel: Thread & Composer */}
245
+ <div className={cn("flex flex-col bg-white", showTimeline ? "flex-1 min-w-0" : "flex-1")}>
246
  {selectedId && thread ? (
247
  <>
248
  {/* Header */}
 
276
  Return to AI
277
  </button>
278
  )}
279
+ <button
280
+ onClick={() => setShowTimeline(!showTimeline)}
281
+ className={cn(
282
+ "flex items-center gap-2 px-3 py-1.5 rounded-md text-xs font-semibold transition-colors",
283
+ showTimeline
284
+ ? "bg-blue-100 text-blue-700 border border-blue-200"
285
+ : "bg-slate-50 text-slate-600 hover:bg-slate-100"
286
+ )}
287
+ >
288
+ <Clock className="w-3.5 h-3.5" />
289
+ Timeline
290
+ </button>
291
  <button
292
  onClick={() => handleUpdateStatus(selectedId, thread.status === "closed" ? "bot_active" : "closed")}
293
  className={cn(
 
384
  </div>
385
  )}
386
  </div>
387
+
388
+ {/* Timeline Side Panel */}
389
+ {showTimeline && selectedId && (
390
+ <div className="w-80 border-l border-slate-200 bg-white overflow-y-auto">
391
+ <TimelinePanel conversationId={selectedId} />
392
+ </div>
393
+ )}
394
  </div>
395
  );
396
  }
frontend/src/components/AdminSidebar.tsx CHANGED
@@ -38,6 +38,7 @@ const adminNavItems = [
38
  { name: "Zoho Health", href: "/admin/zoho-health", icon: Database },
39
  { name: "Monitoring", href: "/admin/monitoring", icon: Activity },
40
  { name: "Audit Log", href: "/admin/audit-log", icon: ScrollText },
 
41
  ];
42
 
43
  interface AdminSidebarProps {
 
38
  { name: "Zoho Health", href: "/admin/zoho-health", icon: Database },
39
  { name: "Monitoring", href: "/admin/monitoring", icon: Activity },
40
  { name: "Audit Log", href: "/admin/audit-log", icon: ScrollText },
41
+ { name: "Runtime Events",href: "/admin/runtime-events",icon: Activity },
42
  ];
43
 
44
  interface AdminSidebarProps {
frontend/src/components/inbox/TimelinePanel.tsx ADDED
@@ -0,0 +1,156 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ "use client";
2
+
3
+ import { useState, useEffect } from "react";
4
+ import { apiClient } from "@/lib/api";
5
+ import { ChevronDown, ChevronRight, Clock, AlertCircle, CheckCircle, MinusCircle } from "lucide-react";
6
+ import { cn } from "@/lib/utils";
7
+
8
+ type RuntimeEvent = {
9
+ id: string;
10
+ event_type: string;
11
+ source: string;
12
+ correlation_id?: string | null;
13
+ related_ids?: Record<string, string> | null;
14
+ payload?: Record<string, unknown> | null;
15
+ outcome?: string | null;
16
+ error_message?: string | null;
17
+ duration_ms?: number | null;
18
+ created_at: string;
19
+ };
20
+
21
+ const SOURCE_COLORS: Record<string, string> = {
22
+ webhook: "bg-blue-100 text-blue-700 border-blue-200",
23
+ runtime: "bg-purple-100 text-purple-700 border-purple-200",
24
+ dispatch: "bg-emerald-100 text-emerald-700 border-emerald-200",
25
+ email: "bg-orange-100 text-orange-700 border-orange-200",
26
+ zoho: "bg-cyan-100 text-cyan-700 border-cyan-200",
27
+ inbox: "bg-slate-100 text-slate-600 border-slate-200",
28
+ };
29
+
30
+ function OutcomeBadge({ outcome }: { outcome?: string | null }) {
31
+ if (!outcome) return null;
32
+ switch (outcome) {
33
+ case "success":
34
+ return <CheckCircle className="w-3.5 h-3.5 text-emerald-500" />;
35
+ case "failure":
36
+ return <AlertCircle className="w-3.5 h-3.5 text-red-500" />;
37
+ case "skipped":
38
+ return <MinusCircle className="w-3.5 h-3.5 text-amber-500" />;
39
+ default:
40
+ return null;
41
+ }
42
+ }
43
+
44
+ export default function TimelinePanel({ conversationId }: { conversationId: string }) {
45
+ const [events, setEvents] = useState<RuntimeEvent[]>([]);
46
+ const [loading, setLoading] = useState(true);
47
+ const [expandedId, setExpandedId] = useState<string | null>(null);
48
+
49
+ useEffect(() => {
50
+ if (!conversationId) return;
51
+ setLoading(true);
52
+ apiClient
53
+ .get<{ items: RuntimeEvent[]; total: number }>(
54
+ `/support/timeline?conversation_id=${conversationId}&limit=100`
55
+ )
56
+ .then((res) => setEvents(res.data?.items || []))
57
+ .catch(() => setEvents([]))
58
+ .finally(() => setLoading(false));
59
+ }, [conversationId]);
60
+
61
+ if (loading) {
62
+ return (
63
+ <div className="p-4 text-center text-slate-400 text-sm">Loading timeline...</div>
64
+ );
65
+ }
66
+
67
+ if (events.length === 0) {
68
+ return (
69
+ <div className="p-6 text-center text-slate-400 text-sm">
70
+ <Clock className="w-8 h-8 mx-auto mb-2 opacity-30" />
71
+ No runtime events for this conversation
72
+ </div>
73
+ );
74
+ }
75
+
76
+ return (
77
+ <div className="space-y-1 p-3">
78
+ <h3 className="text-xs font-bold uppercase tracking-wider text-slate-500 px-1 mb-3">
79
+ Event Timeline ({events.length})
80
+ </h3>
81
+ {events.map((evt) => {
82
+ const isExpanded = expandedId === evt.id;
83
+ const colorClass = SOURCE_COLORS[evt.source] || SOURCE_COLORS.inbox;
84
+
85
+ return (
86
+ <div key={evt.id} className="border border-slate-100 rounded-lg overflow-hidden">
87
+ <button
88
+ className="w-full flex items-center gap-2 px-3 py-2 text-left hover:bg-slate-50 transition-colors"
89
+ onClick={() => setExpandedId(isExpanded ? null : evt.id)}
90
+ >
91
+ {isExpanded ? (
92
+ <ChevronDown className="w-3.5 h-3.5 text-slate-400 flex-shrink-0" />
93
+ ) : (
94
+ <ChevronRight className="w-3.5 h-3.5 text-slate-400 flex-shrink-0" />
95
+ )}
96
+ <span
97
+ className={cn(
98
+ "text-[10px] font-bold uppercase px-1.5 py-0.5 rounded border flex-shrink-0",
99
+ colorClass
100
+ )}
101
+ >
102
+ {evt.source}
103
+ </span>
104
+ <span className="text-xs text-slate-700 truncate flex-1">
105
+ {evt.event_type}
106
+ </span>
107
+ <OutcomeBadge outcome={evt.outcome} />
108
+ <span className="text-[10px] text-slate-400 whitespace-nowrap">
109
+ {new Date(evt.created_at).toLocaleTimeString([], {
110
+ hour: "2-digit",
111
+ minute: "2-digit",
112
+ second: "2-digit",
113
+ })}
114
+ </span>
115
+ </button>
116
+
117
+ {isExpanded && (
118
+ <div className="px-3 pb-3 pt-1 bg-slate-50 text-xs space-y-2 border-t border-slate-100">
119
+ {evt.duration_ms !== null && evt.duration_ms !== undefined && (
120
+ <div className="text-slate-500">
121
+ Duration: <span className="font-mono">{evt.duration_ms}ms</span>
122
+ </div>
123
+ )}
124
+ {evt.correlation_id && (
125
+ <div className="text-slate-500 truncate">
126
+ Correlation: <span className="font-mono">{evt.correlation_id}</span>
127
+ </div>
128
+ )}
129
+ {evt.error_message && (
130
+ <div className="text-red-600 bg-red-50 p-2 rounded text-[11px] break-all">
131
+ {evt.error_message}
132
+ </div>
133
+ )}
134
+ {evt.payload && Object.keys(evt.payload).length > 0 && (
135
+ <pre className="bg-white p-2 rounded border border-slate-200 text-[10px] font-mono overflow-x-auto max-h-32">
136
+ {JSON.stringify(evt.payload, null, 2)}
137
+ </pre>
138
+ )}
139
+ {evt.related_ids && Object.keys(evt.related_ids).length > 0 && (
140
+ <div className="text-slate-500">
141
+ <span className="font-semibold">Related: </span>
142
+ {Object.entries(evt.related_ids).map(([k, v]) => (
143
+ <span key={k} className="font-mono text-[10px] mr-2">
144
+ {k}={v?.slice(0, 8)}...
145
+ </span>
146
+ ))}
147
+ </div>
148
+ )}
149
+ </div>
150
+ )}
151
+ </div>
152
+ );
153
+ })}
154
+ </div>
155
+ );
156
+ }
frontend/src/lib/admin-api.ts CHANGED
@@ -111,6 +111,21 @@ export interface AuditLogEntry {
111
  created_at: string;
112
  }
113
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
114
  // ---- Admin API methods ------------------------------------------------------
115
 
116
  export const adminApi = {
@@ -187,6 +202,29 @@ export const adminApi = {
187
  getAuditLogDetail: (logId: string) =>
188
  adminClient.get<AuditLogEntry>(`/admin/audit-log/${logId}`),
189
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
190
  // Email logs + retry
191
  getEmailLogs: (params?: { status?: string; email_type?: string; skip?: number; limit?: number }) => {
192
  const qs = new URLSearchParams();
 
111
  created_at: string;
112
  }
113
 
114
+ export interface RuntimeEventEntry {
115
+ id: string;
116
+ workspace_id?: string | null;
117
+ event_type: string;
118
+ source: string;
119
+ correlation_id?: string | null;
120
+ related_ids?: Record<string, string> | null;
121
+ actor_user_id?: string | null;
122
+ payload?: Record<string, unknown> | null;
123
+ outcome?: string | null;
124
+ error_message?: string | null;
125
+ duration_ms?: number | null;
126
+ created_at: string;
127
+ }
128
+
129
  // ---- Admin API methods ------------------------------------------------------
130
 
131
  export const adminApi = {
 
202
  getAuditLogDetail: (logId: string) =>
203
  adminClient.get<AuditLogEntry>(`/admin/audit-log/${logId}`),
204
 
205
+ // Runtime events (Mission 18)
206
+ getRuntimeEvents: (params?: {
207
+ skip?: number; limit?: number; source?: string; event_type?: string;
208
+ outcome?: string; workspace_id?: string; correlation_id?: string;
209
+ date_from?: string; date_to?: string;
210
+ }) => {
211
+ const qs = new URLSearchParams();
212
+ if (params?.skip !== undefined) qs.set("skip", String(params.skip));
213
+ if (params?.limit !== undefined) qs.set("limit", String(params.limit));
214
+ if (params?.source) qs.set("source", params.source);
215
+ if (params?.event_type) qs.set("event_type", params.event_type);
216
+ if (params?.outcome) qs.set("outcome", params.outcome);
217
+ if (params?.workspace_id) qs.set("workspace_id", params.workspace_id);
218
+ if (params?.correlation_id) qs.set("correlation_id", params.correlation_id);
219
+ if (params?.date_from) qs.set("date_from", params.date_from);
220
+ if (params?.date_to) qs.set("date_to", params.date_to);
221
+ return adminClient.get<{ items: RuntimeEventEntry[]; total: number; skip: number; limit: number }>(
222
+ `/admin/runtime-events?${qs.toString()}`
223
+ );
224
+ },
225
+ getRuntimeEventDetail: (eventId: string) =>
226
+ adminClient.get<RuntimeEventEntry>(`/admin/runtime-events/${eventId}`),
227
+
228
  // Email logs + retry
229
  getEmailLogs: (params?: { status?: string; email_type?: string; skip?: number; limit?: number }) => {
230
  const qs = new URLSearchParams();