browso-agent / observability.py
Omar AbedelKader
spaces updated
880c55b
Raw
History Blame Contribute Delete
5.81 kB
import json
import logging
import os
import sys
import threading
import time
from dataclasses import dataclass
from typing import Any
SENSITIVE_KEYS = {
"api_key",
"authorization",
"cookie",
"hf_token",
"password",
"secret",
"token",
}
@dataclass
class GenerationStats:
prompt_tokens: int = 0
completion_tokens: int = 0
queue_ms: float = 0
inference_ms: float = 0
first_token_ms: float | None = None
chunks: int = 0
output_chars: int = 0
class RuntimeMetrics:
def __init__(self, started_at: float | None = None) -> None:
self._lock = threading.Lock()
self._started_at = time.time() if started_at is None else started_at
self.http_requests = 0
self.http_errors = 0
self.completions = 0
self.completion_errors = 0
self.active_generations = 0
self.prompt_tokens = 0
self.completion_tokens = 0
def record_http(self, status_code: int) -> None:
with self._lock:
self.http_requests += 1
if status_code >= 500:
self.http_errors += 1
def generation_started(self) -> None:
with self._lock:
self.active_generations += 1
def generation_finished(
self,
stats: GenerationStats,
*,
failed: bool,
) -> None:
with self._lock:
self.active_generations = max(0, self.active_generations - 1)
self.completions += 1
self.prompt_tokens += stats.prompt_tokens
self.completion_tokens += stats.completion_tokens
if failed:
self.completion_errors += 1
def snapshot(self) -> dict[str, Any]:
with self._lock:
return {
"uptime_seconds": round(time.time() - self._started_at, 1),
"http_requests": self.http_requests,
"http_errors": self.http_errors,
"completions": self.completions,
"completion_errors": self.completion_errors,
"active_generations": self.active_generations,
"prompt_tokens": self.prompt_tokens,
"completion_tokens": self.completion_tokens,
}
def env_flag(name: str, default: bool = False) -> bool:
value = os.getenv(name)
if value is None:
return default
return value.strip().lower() in {"1", "true", "yes", "on"}
def is_sensitive_key(key: Any) -> bool:
normalized = str(key).strip().lower().replace("-", "_")
return (
normalized in SENSITIVE_KEYS
or normalized.endswith("_token")
or "api_key" in normalized
or "authorization" in normalized
or "cookie" in normalized
or "password" in normalized
or "secret" in normalized
)
def sanitize(value: Any, max_text_chars: int = 2_000) -> Any:
if isinstance(value, dict):
return {
str(key): (
"[REDACTED]"
if is_sensitive_key(key)
else sanitize(item, max_text_chars)
)
for key, item in value.items()
}
if isinstance(value, (list, tuple)):
return [sanitize(item, max_text_chars) for item in value]
if isinstance(value, str) and len(value) > max_text_chars:
omitted = len(value) - max_text_chars
return f"{value[:max_text_chars]}...[truncated {omitted} chars]"
return value
class JsonFormatter(logging.Formatter):
def format(self, record: logging.LogRecord) -> str:
payload = {
"timestamp": self.formatTime(record, "%Y-%m-%dT%H:%M:%S%z"),
"level": record.levelname,
"logger": record.name,
"message": record.getMessage(),
}
event_fields = getattr(record, "event_fields", None)
if isinstance(event_fields, dict):
payload.update(event_fields)
if record.exc_info:
payload["exception"] = self.formatException(record.exc_info)
return json.dumps(payload, ensure_ascii=True, default=str)
def configure_logger(name: str = "browso.server") -> logging.Logger:
logger = logging.getLogger(name)
level_name = os.getenv("LOG_LEVEL", "INFO").upper()
logger.setLevel(getattr(logging, level_name, logging.INFO))
logger.propagate = False
if not logger.handlers:
handler = logging.StreamHandler(sys.stdout)
handler.setFormatter(JsonFormatter())
logger.addHandler(handler)
return logger
def log_event(
logger: logging.Logger,
event: str,
*,
level: int = logging.INFO,
exc_info: bool = False,
max_text_chars: int = 2_000,
**fields: Any,
) -> None:
logger.log(
level,
event,
exc_info=exc_info,
extra={
"event_fields": {
"event": event,
**sanitize(fields, max_text_chars=max_text_chars),
}
},
)
def summarize_messages(messages: list[dict[str, Any]]) -> dict[str, Any]:
roles: dict[str, int] = {}
text_chars = 0
tool_calls = 0
for message in messages:
role = str(message.get("role", "unknown"))
roles[role] = roles.get(role, 0) + 1
content = message.get("content")
if isinstance(content, str):
text_chars += len(content)
elif isinstance(content, list):
for part in content:
if isinstance(part, dict) and isinstance(part.get("text"), str):
text_chars += len(part["text"])
if isinstance(message.get("tool_calls"), list):
tool_calls += len(message["tool_calls"])
return {
"message_count": len(messages),
"roles": roles,
"text_chars": text_chars,
"tool_call_count": tool_calls,
}