@@ -262,6 +262,46 @@ def _init_posthog(settings: BackendSettings) -> None:
262262# product-scoped dashboards.
263263_PRODUCT_TAG = "jobagent"
264264
265+ # Header the browser sets on anonymous calls (review M21) so a server-side
266+ # funnel event can be attributed to the SAME PostHog person as the client-side
267+ # session, instead of every anonymous visitor collapsing onto one shared
268+ # "anonymous" distinct id. The browser sends `posthog.get_distinct_id()`; on
269+ # login the client calls `posthog.identify(userId)`, which auto-aliases that
270+ # anonymous id to the Supabase id — so the whole pre-login → signup path
271+ # stitches into one person and anonymous→signup conversion is computable.
272+ #
273+ # PostHog distinct ids are client-controlled by design, so trusting this header
274+ # for analytics attribution carries no security weight: a signed-in caller's
275+ # Supabase id ALWAYS takes precedence (see `resolve_distinct_id`), and the value
276+ # is never used for auth, ownership, or quota — only as a PostHog person key.
277+ POSTHOG_DISTINCT_ID_HEADER = "X-PostHog-Distinct-Id"
278+
279+ # Upper bound on the browser-supplied id we'll honor. PostHog's own ids are
280+ # short uuids; this just stops a pathological header from ballooning an event.
281+ _MAX_BROWSER_DISTINCT_ID_LEN = 200
282+
283+
284+ def resolve_distinct_id (
285+ user_id : str | None ,
286+ browser_distinct_id : str | None = None ,
287+ ) -> str :
288+ """Pick the best PostHog distinct id for an event (review M21).
289+
290+ Precedence: the authenticated Supabase id, then the browser's own anonymous
291+ distinct id (so anonymous funnel events join the client-side session rather
292+ than collapsing onto one shared ``"anonymous"`` person), then the
293+ ``"anonymous"`` constant as a last resort for server-to-server callers with
294+ neither. The browser value is length-clamped and never trusted for anything
295+ but this person key.
296+ """
297+ resolved = (user_id or "" ).strip ()
298+ if resolved :
299+ return resolved
300+ browser = (browser_distinct_id or "" ).strip ()
301+ if browser :
302+ return browser [:_MAX_BROWSER_DISTINCT_ID_LEN ]
303+ return "anonymous"
304+
265305
266306def capture_event (
267307 distinct_id : str ,
@@ -359,6 +399,21 @@ def shutdown_observability() -> None:
359399 "recovery_threshold" : 1 ,
360400}
361401
402+ # The tier-aware retention sweeper (backend/maintenance.py) runs daily and
403+ # deletes Free workspaces > 7d / Pro > 30d. Unmonitored, a stopped cron would
404+ # silently leave Free-tier data past its stated retention / GDPR promise with
405+ # nothing to page the operator (review M22). Daily at 03:00 UTC; reconcile the
406+ # `value` here with the actual prod crontab if it differs.
407+ SAVED_WORKSPACES_RETENTION_MONITOR_SLUG = "saved-workspaces-retention"
408+ SAVED_WORKSPACES_RETENTION_MONITOR_CONFIG : dict [str , Any ] = {
409+ "schedule" : {"type" : "crontab" , "value" : "0 3 * * *" },
410+ "timezone" : "UTC" ,
411+ "checkin_margin" : 60 ,
412+ "max_runtime" : 10 ,
413+ "failure_issue_threshold" : 1 ,
414+ "recovery_threshold" : 1 ,
415+ }
416+
362417
363418def _sentry_active () -> bool :
364419 """True when cron check-ins should actually be sent.
@@ -441,3 +496,99 @@ def sentry_cron_monitor(
441496 duration = time .monotonic () - started ,
442497 monitor_config = monitor_config ,
443498 )
499+
500+
501+ # ---------------------------------------------------------------------------
502+ # Sentry scope-enrichment helpers (review M23)
503+ #
504+ # Analysis (POST /workspace/{id}/run) and artifact export raise into Sentry as
505+ # bare 5xx with no actor, no pipeline stage, and no export descriptor — every
506+ # issue reads identically and triage starts from zero. These thin wrappers add
507+ # the actor (set_user), the failing stage (set_tag + breadcrumb), and the
508+ # export descriptor (set_context) onto the active Sentry scope.
509+ #
510+ # Telemetry must never break the request it decorates: each helper is a no-op
511+ # when Sentry is inactive (or under pytest) and swallows every error. They are
512+ # intentionally tiny pass-throughs so callers don't import sentry_sdk directly
513+ # or repeat the hasattr/guard dance — and so the orchestrator's own control
514+ # flow (praised, left untouched) never has to learn about Sentry.
515+ # ---------------------------------------------------------------------------
516+
517+
518+ def _sentry_sdk_or_none ():
519+ """Return the ``sentry_sdk`` module when enrichment should apply, else None.
520+
521+ Mirrors ``_sentry_active``'s guard (pytest off, client active) and folds in
522+ the import so every helper below is a one-liner that can't raise on a
523+ missing/disabled SDK.
524+ """
525+ if not _sentry_active ():
526+ return None
527+ try :
528+ import sentry_sdk
529+ except Exception : # pragma: no cover — defensive
530+ return None
531+ return sentry_sdk
532+
533+
534+ def set_sentry_user (user_id : str | None ) -> None :
535+ """Attach the acting user's id to the Sentry scope (just the id, no PII).
536+
537+ Lets an analysis/export 5xx be filtered to a single account instead of
538+ reading as an anonymous platform-wide failure. No-op for anonymous calls
539+ (falsy ``user_id``) and when Sentry is inactive.
540+ """
541+ if not user_id :
542+ return
543+ sentry_sdk = _sentry_sdk_or_none ()
544+ if sentry_sdk is None :
545+ return
546+ with suppress (Exception ):
547+ if hasattr (sentry_sdk , "set_user" ):
548+ sentry_sdk .set_user ({"id" : str (user_id )})
549+
550+
551+ def set_sentry_tag (key : str , value : Any ) -> None :
552+ """Set an indexed Sentry tag (e.g. ``pipeline_stage``) on the active scope."""
553+ sentry_sdk = _sentry_sdk_or_none ()
554+ if sentry_sdk is None :
555+ return
556+ with suppress (Exception ):
557+ if hasattr (sentry_sdk , "set_tag" ):
558+ sentry_sdk .set_tag (key , value )
559+
560+
561+ def set_sentry_context (key : str , data : dict [str , Any ]) -> None :
562+ """Attach a structured context block (e.g. the export descriptor)."""
563+ sentry_sdk = _sentry_sdk_or_none ()
564+ if sentry_sdk is None :
565+ return
566+ with suppress (Exception ):
567+ if hasattr (sentry_sdk , "set_context" ):
568+ sentry_sdk .set_context (key , dict (data ))
569+
570+
571+ def add_sentry_breadcrumb (
572+ * ,
573+ category : str ,
574+ message : str ,
575+ data : dict [str , Any ] | None = None ,
576+ level : str = "info" ,
577+ ) -> None :
578+ """Drop a breadcrumb so a later error carries the trail that led to it.
579+
580+ Used at orchestrator stage boundaries (``category="agent"``) so an analysis
581+ failure shows which stage was entered last — without touching the
582+ orchestrator's own control flow.
583+ """
584+ sentry_sdk = _sentry_sdk_or_none ()
585+ if sentry_sdk is None :
586+ return
587+ with suppress (Exception ):
588+ if hasattr (sentry_sdk , "add_breadcrumb" ):
589+ sentry_sdk .add_breadcrumb (
590+ category = category ,
591+ message = message ,
592+ level = level ,
593+ data = dict (data or {}),
594+ )
0 commit comments