11//! Centralised error reporting for the core, plus a Sentry
2- //! `before_send` filter that drops per-attempt transient-upstream
3- //! provider failures.
2+ //! `before_send` filters that drop deterministic provider noise:
3+ //! per-attempt transient-upstream failures and budget-exhausted user-state .
44//!
55//! Wraps `tracing::error!` (which the global subscriber forwards to Sentry via
66//! `sentry-tracing`) inside a `sentry::with_scope` so each captured event
@@ -61,6 +61,7 @@ pub enum ExpectedErrorKind {
6161 LocalAiBinaryMissing ,
6262 BackendUserError ,
6363 LocalAiCapabilityUnavailable ,
64+ BudgetExhausted ,
6465}
6566
6667pub fn expected_error_kind ( message : & str ) -> Option < ExpectedErrorKind > {
@@ -86,6 +87,9 @@ pub fn expected_error_kind(message: &str) -> Option<ExpectedErrorKind> {
8687 if is_local_ai_capability_unavailable_message ( & lower) {
8788 return Some ( ExpectedErrorKind :: LocalAiCapabilityUnavailable ) ;
8889 }
90+ if crate :: openhuman:: providers:: is_budget_exhausted_message ( message) {
91+ return Some ( ExpectedErrorKind :: BudgetExhausted ) ;
92+ }
8993 None
9094}
9195
@@ -321,6 +325,22 @@ fn report_expected_message(kind: ExpectedErrorKind, message: &str, domain: &str,
321325 "[observability] {domain}.{operation} skipped expected local-ai capability-unavailable error: {message}"
322326 ) ;
323327 }
328+ ExpectedErrorKind :: BudgetExhausted => {
329+ // User-state condition: the backend reports the user is out of
330+ // budget / credits / balance (HTTP 400 from the OpenHuman backend,
331+ // surfaced by `providers::is_budget_exhausted_message`). The UI
332+ // already surfaces this as an actionable toast — Sentry would
333+ // turn each affected turn into noise (OPENHUMAN-TAURI-3M / -12 /
334+ // -13). Demote to info so it still appears in breadcrumbs but
335+ // never spawns a Sentry error event.
336+ tracing:: info!(
337+ domain = domain,
338+ operation = operation,
339+ kind = "budget" ,
340+ error = %message,
341+ "[observability] {domain}.{operation} skipped expected budget-exhausted error: {message}"
342+ ) ;
343+ }
324344 }
325345}
326346
@@ -533,6 +553,47 @@ pub fn is_transient_message_failure(msg: &str) -> bool {
533553 || contains_transient_transport_phrase ( & lower)
534554}
535555
556+ /// Returns true when a Sentry event is a budget-exhausted 400 that should be
557+ /// dropped from `before_send`.
558+ ///
559+ /// Match criteria (all required):
560+ /// - tag `failure == "non_2xx"`
561+ /// - tag `status == "400"`
562+ /// - the event message or any exception value contains one of the tight
563+ /// budget-exhaustion phrases
564+ ///
565+ /// Note: `domain` is intentionally not gated here as defense-in-depth over
566+ /// the emit-site classifier — any non_2xx/400 event that carries the
567+ /// budget-exhausted phrasing is dropped regardless of which domain produced
568+ /// it, so a future re-emitter under a different tag still gets filtered.
569+ pub fn is_budget_event ( event : & sentry:: protocol:: Event < ' _ > ) -> bool {
570+ let tags = & event. tags ;
571+ if tags. get ( "failure" ) . map ( String :: as_str) != Some ( "non_2xx" ) {
572+ return false ;
573+ }
574+ if tags. get ( "status" ) . map ( String :: as_str) != Some ( "400" ) {
575+ return false ;
576+ }
577+ event_contains_budget_exhausted_message ( event)
578+ }
579+
580+ fn event_contains_budget_exhausted_message ( event : & sentry:: protocol:: Event < ' _ > ) -> bool {
581+ if event
582+ . message
583+ . as_deref ( )
584+ . is_some_and ( crate :: openhuman:: providers:: is_budget_exhausted_message)
585+ {
586+ return true ;
587+ }
588+
589+ event. exception . values . iter ( ) . any ( |exception| {
590+ exception
591+ . value
592+ . as_deref ( )
593+ . is_some_and ( crate :: openhuman:: providers:: is_budget_exhausted_message)
594+ } )
595+ }
596+
536597#[ cfg( test) ]
537598mod tests {
538599 use super :: * ;
@@ -1153,6 +1214,50 @@ mod tests {
11531214 }
11541215 }
11551216
1217+ #[ test]
1218+ fn budget_filter_drops_budget_message_on_tagged_400 ( ) {
1219+ let event = event_with_tags_and_message (
1220+ & [ ( "failure" , "non_2xx" ) , ( "status" , "400" ) ] ,
1221+ r#"OpenHuman API error (400 Bad Request): {"success":false,"error":"Insufficient budget"}"# ,
1222+ ) ;
1223+
1224+ assert ! ( is_budget_event( & event) ) ;
1225+ }
1226+
1227+ #[ test]
1228+ fn budget_filter_drops_budget_exception_on_tagged_400 ( ) {
1229+ let mut event = event_with_tags ( & [ ( "failure" , "non_2xx" ) , ( "status" , "400" ) ] ) ;
1230+ event. exception . values . push ( sentry:: protocol:: Exception {
1231+ value : Some ( "Budget exceeded — add credits to continue" . to_string ( ) ) ,
1232+ ..Default :: default ( )
1233+ } ) ;
1234+
1235+ assert ! ( is_budget_event( & event) ) ;
1236+ }
1237+
1238+ #[ test]
1239+ fn budget_filter_keeps_non_budget_400 ( ) {
1240+ let event = event_with_tags_and_message (
1241+ & [ ( "failure" , "non_2xx" ) , ( "status" , "400" ) ] ,
1242+ "Bad request: missing field" ,
1243+ ) ;
1244+
1245+ assert ! ( !is_budget_event( & event) ) ;
1246+ }
1247+
1248+ #[ test]
1249+ fn budget_filter_requires_non_2xx_failure_and_400_status ( ) {
1250+ let message = "Budget exceeded — add credits to continue" ;
1251+ for tags in [
1252+ vec ! [ ( "failure" , "transport" ) , ( "status" , "400" ) ] ,
1253+ vec ! [ ( "failure" , "non_2xx" ) , ( "status" , "500" ) ] ,
1254+ vec ! [ ( "failure" , "non_2xx" ) ] ,
1255+ ] {
1256+ let event = event_with_tags_and_message ( & tags, message) ;
1257+ assert ! ( !is_budget_event( & event) ) ;
1258+ }
1259+ }
1260+
11561261 #[ test]
11571262 fn report_error_or_expected_does_not_panic ( ) {
11581263 report_error_or_expected (
0 commit comments