Spaces:
Running
Running
File size: 10,739 Bytes
5ac32c1 3c65377 5ac32c1 3c65377 5ac32c1 63e8227 5ac32c1 d4c98c6 5ac32c1 d4c98c6 5ac32c1 d4c98c6 5ac32c1 d4c98c6 5ac32c1 d4c98c6 5ac32c1 3c65377 5ac32c1 3c65377 5ac32c1 3c65377 5ac32c1 3c65377 5ac32c1 3c65377 5ac32c1 d4c98c6 3c65377 5ac32c1 | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 | """
Session Logger — Structured pipeline event logger.
Writes events to both a local JSONL file and the Supabase `session_logs` table.
Falls back gracefully to file-only logging if Supabase is unavailable.
===========================================================================
Supabase table setup (run once in Supabase SQL editor):
===========================================================================
CREATE TABLE session_logs (
id BIGSERIAL PRIMARY KEY,
session_id TEXT NOT NULL,
user_email TEXT,
ts TIMESTAMPTZ DEFAULT NOW(),
stage TEXT,
event TEXT NOT NULL,
duration_ms INTEGER,
error TEXT,
meta JSONB
);
CREATE INDEX ON session_logs (session_id);
CREATE INDEX ON session_logs (user_email);
CREATE INDEX ON session_logs (ts DESC);
===========================================================================
Usage:
from session_logger import init_session_logger, get_session_logger
logger = init_session_logger("20260319_143000", user_email="alice@example.com")
t = logger.log_start("research")
# ... do work ...
logger.log_end("research", t, model="gpt-4o", rows=42)
logger.log("deploy", "Liveboard created", liveboard_id="abc-123")
"""
import json
import re
import sys
import time
import uuid
from datetime import datetime, timezone
from pathlib import Path
from typing import Any, Optional
# ---------------------------------------------------------------------------
# Payload sanitization
# ---------------------------------------------------------------------------
_SECRET_KEY_RE = re.compile(r"key|secret|password|token|credential|auth", re.IGNORECASE)
_MAX_PAYLOAD_STR = 2000
def sanitize_payload(value: Any, _depth: int = 0) -> Any:
"""
Return a JSON-safe copy of `value` suitable for the session_logs meta column.
- Dict values whose key looks secret (key/secret/password/token/credential/auth)
are replaced with a length marker, never logged.
- Strings longer than 2000 chars are truncated.
- Anything non-JSON-serializable is coerced to str.
"""
if _depth > 6:
return str(value)
if isinstance(value, dict):
out = {}
for k, v in value.items():
if _SECRET_KEY_RE.search(str(k)) and isinstance(v, str) and v:
out[str(k)] = f"<redacted {len(v)} chars>"
else:
out[str(k)] = sanitize_payload(v, _depth + 1)
return out
if isinstance(value, (list, tuple)):
return [sanitize_payload(v, _depth + 1) for v in value]
if isinstance(value, str):
if len(value) > _MAX_PAYLOAD_STR:
return value[:_MAX_PAYLOAD_STR] + f"… <truncated, {len(value)} chars total>"
return value
if isinstance(value, (int, float, bool)) or value is None:
return value
return str(value)
# ---------------------------------------------------------------------------
# SessionLogger class
# ---------------------------------------------------------------------------
class SessionLogger:
"""Structured event logger that writes to file and Supabase session_logs."""
TABLE = "session_logs"
def __init__(self, session_id: str, user_email: str = None, log_level: str = 'regular'):
"""
Initialize the session logger.
Args:
session_id: Unique ID for this build session (e.g. datetime string).
user_email: Email of the user running this session.
log_level: 'off' = no logging, 'regular' = stage start/end only,
'verbose' = stage start/end + sub-step detail at trouble spots.
"""
self.session_id = session_id
self.user_email = user_email
self.log_level = log_level # 'off', 'regular', 'verbose'
# File log path: logs/sessions/{session_id}.log (one JSON line per event)
script_dir = Path(__file__).parent
log_dir = script_dir / "logs" / "sessions"
try:
log_dir.mkdir(parents=True, exist_ok=True)
except Exception as e:
print(f"[SessionLogger] Could not create log directory {log_dir}: {e}", file=sys.stderr)
self._log_file = log_dir / f"{session_id}.log"
# Try to initialise Supabase — never raise
self._supabase_ok = False
self._client = None
self._init_supabase()
def _init_supabase(self):
"""Attempt to connect to Supabase. Sets self._supabase_ok and self._client."""
try:
# Lazy import to avoid circular imports
from supabase_client import SupabaseSettings
ss = SupabaseSettings()
if ss.is_enabled():
self._client = ss.client
self._supabase_ok = True
except Exception as e:
print(f"[SessionLogger] Supabase unavailable, falling back to file-only logging: {e}",
file=sys.stderr)
# ------------------------------------------------------------------
# Core log method
# ------------------------------------------------------------------
def log(self, stage: str, event: str, duration_ms: int = None,
error: str = None, **meta):
"""
Log one pipeline event.
Args:
stage: Pipeline stage name (e.g. 'research', 'deploy').
event: Short description of what happened.
duration_ms: Optional elapsed time in milliseconds.
error: Optional error message if the event represents a failure.
**meta: Arbitrary key/value pairs stored in the meta JSONB column.
"""
if self.log_level == 'off':
return
ts = datetime.now(timezone.utc).isoformat()
record = {
"session_id": self.session_id,
"user_email": self.user_email,
"ts": ts,
"stage": stage,
"event": event,
"duration_ms": duration_ms,
"error": error,
"meta": meta if meta else None,
}
# Always write to file
self._write_file(record)
# Write to Supabase if available
if self._supabase_ok:
self._write_supabase(record)
def _write_file(self, record: dict):
"""Append one JSON line to the session log file. Never raises."""
try:
with open(self._log_file, "a", encoding="utf-8") as fh:
fh.write(json.dumps(record, default=str) + "\n")
except Exception as e:
print(f"[SessionLogger] File write failed: {e}", file=sys.stderr)
def _write_supabase(self, record: dict):
"""Insert one row into session_logs. Never raises."""
try:
# Build the insert payload, omitting None values for cleanliness
payload = {k: v for k, v in record.items() if v is not None}
self._client.table(self.TABLE).insert(payload).execute()
except Exception as e:
# Supabase write failure is non-fatal — demote to stderr
print(f"[SessionLogger] Supabase write failed: {e}", file=sys.stderr)
# Mark Supabase as unavailable so we stop trying for this session
self._supabase_ok = False
# ------------------------------------------------------------------
# Convenience helpers
# ------------------------------------------------------------------
def log_verbose(self, stage: str, event: str, error: str = None, **meta):
"""
Log a sub-step event — only written when log_level is 'verbose'.
Use for trouble-spot detail: individual API calls, batch results, etc.
"""
if self.log_level == 'verbose':
self.log(stage, event, error=error, **meta)
def log_start(self, stage: str) -> float:
"""
Log that a pipeline stage has started.
Returns:
Monotonic start time (pass to log_end).
"""
self.log(stage, f"{stage} started")
return time.monotonic()
def log_end(self, stage: str, start_time: float, error: str = None, **meta):
"""
Log that a pipeline stage has ended, computing duration from start_time.
Args:
stage: Pipeline stage name (must match the one passed to log_start).
start_time: Value returned by the corresponding log_start call.
error: Optional error message if the stage failed.
**meta: Arbitrary key/value pairs stored in the meta column.
"""
elapsed_ms = int((time.monotonic() - start_time) * 1000)
event = f"{stage} failed" if error else f"{stage} completed"
self.log(stage, event, duration_ms=elapsed_ms, error=error, **meta)
# ---------------------------------------------------------------------------
# Logger factory and legacy singleton helpers
# ---------------------------------------------------------------------------
_current_logger: Optional[SessionLogger] = None
def build_session_id(test_tag: str = "", now: datetime = None, random_suffix: str = None) -> str:
"""Build a collision-resistant session id for one pipeline run."""
timestamp = (now or datetime.now()).strftime("%Y%m%d_%H%M%S_%f")
clean_tag = re.sub(r"[^A-Za-z0-9_-]+", "", test_tag or "")[:24]
suffix = clean_tag or random_suffix or uuid.uuid4().hex[:10]
return f"{timestamp}_{suffix}"
def get_session_logger() -> Optional[SessionLogger]:
"""Return the legacy active SessionLogger, or None if not yet initialised."""
return _current_logger
def create_session_logger(session_id: str, user_email: str = None) -> SessionLogger:
"""
Create an independent SessionLogger.
Args:
session_id: Unique ID for this build session.
user_email: Email of the user running this session.
Returns:
The newly created SessionLogger instance.
"""
# Read log level from admin settings; default to 'regular' if not set
try:
from supabase_client import get_admin_setting
log_level = get_admin_setting('LOG_LEVEL', required=False) or 'regular'
if log_level not in ('off', 'regular', 'verbose'):
log_level = 'regular'
except Exception:
log_level = 'regular'
return SessionLogger(session_id, user_email, log_level=log_level)
def init_session_logger(session_id: str, user_email: str = None) -> SessionLogger:
"""
Legacy helper that creates and stores the module-level SessionLogger.
New run-scoped code should use create_session_logger() and pass the returned
logger explicitly.
"""
global _current_logger
_current_logger = create_session_logger(session_id, user_email)
return _current_logger
|