atman-linguistic-sensor / lib /observability.py
hleserg's picture
feat: initial deploy β€” Atman Linguistic Sensor v1
fe62539
Raw
History Blame Contribute Delete
5.48 kB
"""Sentry observability for the HF Space demo.
Mirrors the public surface of `src/atman/adapters/observability/sentry.py`
but keeps the demo standalone (no `atman.*` imports). All helpers no-op
silently when `SENTRY_DSN` is not set or `sentry-sdk` is not installed,
so the Space stays runnable without observability credentials.
Setup on HuggingFace Spaces:
Settings β†’ Variables and secrets β†’ New secret:
SENTRY_DSN=https://<key>@<org>.ingest.sentry.io/<project>
Optional:
SENTRY_ENVIRONMENT=demo (default)
SENTRY_TRACES_SAMPLE_RATE=0.2 (default)
"""
from __future__ import annotations
import logging
import os
from collections.abc import Callable, Generator
from contextlib import contextmanager, suppress
from functools import wraps
from typing import Any, ParamSpec, TypeVar
_LOG = logging.getLogger(__name__)
_initialized = False
P = ParamSpec("P")
R = TypeVar("R")
def is_enabled() -> bool:
return _initialized
def init_sentry_from_env() -> bool:
"""Initialize Sentry SDK from `SENTRY_DSN`. Returns True on success."""
global _initialized
dsn = os.getenv("SENTRY_DSN", "").strip()
if not dsn:
_LOG.info("SENTRY_DSN not set β€” observability disabled.")
return False
environment = os.getenv("SENTRY_ENVIRONMENT", "demo")
try:
import sentry_sdk
from sentry_sdk.integrations.httpx import HttpxIntegration
from sentry_sdk.integrations.logging import LoggingIntegration
except ImportError:
_LOG.warning("sentry-sdk not installed β€” observability disabled.")
return False
sample_rate = float(os.getenv("SENTRY_TRACES_SAMPLE_RATE", "0.2"))
sentry_sdk.init(
dsn=dsn,
environment=environment,
release=os.getenv("SPACE_ID") or os.getenv("HF_SPACE_ID"),
traces_sample_rate=sample_rate,
profiles_sample_rate=0.0,
integrations=[
HttpxIntegration(),
LoggingIntegration(level=logging.WARNING, event_level=logging.ERROR),
],
send_default_pii=False,
max_breadcrumbs=100,
)
_initialized = True
_LOG.info("Sentry initialized (env=%s, sample_rate=%.2f)", environment, sample_rate)
return True
@contextmanager
def pipeline_span(op: str, description: str = "") -> Generator[None, None, None]:
"""Child span for a pipeline stage. No-op when Sentry is off."""
if not _initialized:
yield
return
import sentry_sdk
with sentry_sdk.start_span(op=op, description=description or op):
yield
def capture_empty_result(
tab: str,
locale: str,
input_text: str,
*,
reason: str = "empty",
signals: dict[str, Any] | None = None,
max_text_chars: int = 4000,
) -> None:
"""Record an empty/failed analysis event to Sentry (no-op when disabled).
Used by the demo to track how often analyzers return nothing β€” so we
know where the rule-based / NER layer needs more lexicon / patterns.
The input text is included verbatim (truncated) because the verbatim
failing input is the actionable diagnostic for fixing detector gaps.
Disclosed to users in the demo footer.
"""
if not _initialized:
return
try:
import sentry_sdk
with sentry_sdk.new_scope() as scope:
scope.set_tag("event_type", "empty_result")
scope.set_tag("tab", tab)
scope.set_tag("locale", locale)
scope.set_tag("reason", reason)
raw = input_text or ""
scope.set_context(
"input",
{
"text": raw[:max_text_chars],
"length": len(raw),
"truncated": len(raw) > max_text_chars,
},
)
if signals is not None:
scope.set_extra("signals", signals)
sentry_sdk.capture_message(f"empty_result:{tab}", level="info")
except Exception: # nosec B110 β€” observability must never raise
pass
def capture_silent_exception(exc: BaseException, context: str = "", **extra: Any) -> None:
"""Capture an exception that was handled (logged, fallback applied) to Sentry."""
if not _initialized:
return
try:
import sentry_sdk
with sentry_sdk.new_scope() as scope:
if context:
scope.set_tag("silent_context", context)
for k, v in extra.items():
scope.set_extra(k, str(v))
sentry_sdk.capture_exception(exc)
except Exception: # nosec B110 β€” observability must never raise
pass
def traced(op: str) -> Callable[[Callable[P, R]], Callable[P, R]]:
"""Decorator: wrap a Gradio handler in a Sentry span + capture unhandled errors.
On exception: report to Sentry, then re-raise so Gradio surfaces the error
to the user (UX > silent failure).
"""
def decorator(fn: Callable[P, R]) -> Callable[P, R]:
@wraps(fn)
def wrapper(*args: P.args, **kwargs: P.kwargs) -> R:
with pipeline_span(op, fn.__name__):
try:
return fn(*args, **kwargs)
except Exception as exc:
if _initialized:
with suppress(Exception):
import sentry_sdk
sentry_sdk.capture_exception(exc)
raise
return wrapper
return decorator