Files
openhuman/src/core/observability.rs
T

2199 lines
110 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! Centralised error reporting for the core, plus a Sentry
//! `before_send` filters that drop deterministic provider noise:
//! per-attempt transient-upstream failures, budget-exhausted user-state,
//! and transient updater failures.
//!
//! Wraps `tracing::error!` (which the global subscriber forwards to Sentry via
//! `sentry-tracing`) inside a `sentry::with_scope` so each captured event
//! carries consistent tags identifying the failing domain/operation plus any
//! callsite-specific context (session id, request id, tool name, …).
//!
//! Why this helper exists: errors that bubble up as `Result::Err` without ever
//! being logged at error level never reach Sentry. The agent-turn path is the
//! canonical example — `run_single` used to publish a `DomainEvent::AgentError`
//! and return `Err(_)`, but Sentry never saw it. Funnel error sites through
//! `report_error` so they show up tagged and grep-friendly in Sentry.
use std::fmt::Display;
/// A `(key, value)` pair attached as a Sentry tag. Tags are short, indexed,
/// and filterable in the Sentry UI — prefer them over free-form fields for
/// anything you'd want to facet on (`error_kind`, `tool_name`, `method`).
pub type Tag<'a> = (&'a str, &'a str);
/// HTTP status codes that the reliable-provider layer already handles via
/// retry + fallback, so per-attempt Sentry reports add noise without signal:
///
/// - **408** Request Timeout
/// - **429** Too Many Requests
/// - **502** Bad Gateway
/// - **503** Service Unavailable
/// - **504** Gateway Timeout
///
/// Single source of truth for both the call-site classifier
/// (`openhuman::inference::provider::ops::should_report_provider_http_failure`) and the
/// `before_send` filter (`is_transient_provider_http_failure`). Update here
/// and both sites pick it up — keeps the two layers from drifting.
pub const TRANSIENT_PROVIDER_HTTP_STATUSES: &[u16] = &[408, 429, 502, 503, 504, 520];
/// HTTP status codes that represent transient backend / integration transport
/// failures rather than application bugs. Keep this as strings because Sentry
/// tags are strings, and the before_send classifiers match tag values exactly.
pub const TRANSIENT_HTTP_STATUSES: &[&str] = &["408", "429", "502", "503", "504", "520"];
/// Transport-layer phrases observed from reqwest / hyper for temporary
/// upstream interruptions. Keep these specific so rare configuration failures
/// still reach Sentry.
pub const TRANSIENT_TRANSPORT_PHRASES: &[&str] = &[
"timeout",
"operation timed out",
"connection forcibly closed",
"connection reset",
"tls handshake eof",
"error sending request",
];
/// HTTP statuses from updater probes that are expected GitHub/network noise:
/// unauthenticated GitHub API rate-limit / policy 403s plus gateway/server
/// hiccups. Scoped to updater domains/messages by [`is_updater_transient_event`].
const UPDATER_TRANSIENT_HTTP_STATUSES: &[u16] = &[403, 500, 502, 503, 504];
/// Message fragments observed from Tauri/core updater transient failures.
/// Keep these updater-specific so unrelated GitHub or generic transport
/// failures still reach Sentry.
///
const UPDATER_TRANSIENT_MESSAGE_PHRASES: &[&str] = &[
"failed to check for updates: error sending request",
"github api error: 403",
"github api error: 5",
"error sending request for url (https://github.com/tinyhumansai/openhuman/releases/",
"update endpoint did not respond with a successful status code",
];
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ExpectedErrorKind {
LocalAiDisabled,
ApiKeyMissing,
NetworkUnreachable,
TransientUpstreamHttp,
LocalAiBinaryMissing,
BackendUserError,
/// Third-party provider (composio, gmail OAuth, …) surfaced a user-state
/// validation failure: a trigger registry mismatch, a toolkit that was
/// never enabled, an OAuth scope that the user did not grant, or a
/// required field that was left blank. The UI already shows an
/// actionable error and Sentry has no remediation path — see
/// [`is_provider_user_state_message`] for the exact body shapes.
///
/// Drops OPENHUMAN-TAURI-3R / -3S / -33 / -34 / -97 (~54 events): the
/// composio backend wraps several of these as HTTP 500 with the real
/// 4xx body embedded, which would otherwise escape the
/// [`is_backend_user_error_message`] 4xx-only matcher.
ProviderUserState,
/// A user-configured custom cloud provider (`custom_openai` → DeepSeek
/// / OpenRouter / Moonshot / …) rejected the request because of the
/// user's **model / parameter configuration**: an OpenHuman abstract
/// tier alias leaked to a provider that only speaks its native ids
/// (#2079), an unknown / stale model pin (#2202), or a model-specific
/// temperature constraint (#2076 — Moonshot Kimi K2). The provider
/// HTTP layer (`providers::ops::api_error`) already demotes its own
/// per-attempt event; this catches the *re-report* when the same
/// error is raised again by `agent.run_single` /
/// `web_channel.run_chat_task` under `domain=agent` / `web_channel`.
/// Deterministic user-config state surfaced in the UI — Sentry has no
/// remediation path (OPENHUMAN-TAURI-WJ / -QW / -HB / -NH, ~273
/// events). See
/// [`crate::openhuman::inference::provider::is_provider_config_rejection_message`]
/// for the polarity contract and exact body shapes.
ProviderConfigRejection,
LocalAiCapabilityUnavailable,
BudgetExhausted,
SessionExpired,
/// Boot-window failure where the in-process core HTTP listener
/// (`127.0.0.1:<port>`) is not yet accepting connections, so a sibling
/// component (frontend RPC relay, agent-integrations client) sees a TCP
/// connect refused. The condition self-resolves once the core finishes
/// binding — typically within a few seconds of app launch — and no retry
/// on the calling side can do better than waiting it out.
///
/// Distinct from [`ExpectedErrorKind::NetworkUnreachable`] (which covers
/// real user-environment network problems — VPN drop, captive portal,
/// ISP block) because:
///
/// - The remediation is internal lifecycle (the core's own startup), not
/// user action. Sentry has nothing to act on either way, but conflating
/// the two buckets makes "which class of transport failure is
/// spiking?" un-answerable.
/// - Loopback URLs (`127.0.0.1:` / `localhost:`) carry no PII, so the
/// demoted breadcrumb can stay sparse (debug level, metadata-only
/// fields) instead of warn-level with the full body included.
///
LoopbackUnavailable,
PromptInjectionBlocked,
ContextWindowExceeded,
/// The memory-store chunk DB's per-path circuit breaker is currently open
/// because too many consecutive SQLite init attempts failed. This is the
/// breaker doing its job — it opened *after* the underlying transient
/// SQLite I/O errors (typically Windows `xShmMap` / `unable to open
/// database file` against `chunks.db`, see `is_sqlite_io_transient` /
/// `is_io_open_error`) hit a threshold, and it self-resolves once the
/// reset window elapses and a subsequent init succeeds.
///
MemoryStoreBreakerOpen,
/// WhatsApp structured-ingest write hit a transient SQLite file lock
/// (`SQLITE_BUSY` / `SQLITE_LOCKED`) after exhausting the local retry
/// budget. This is an expected local-contention condition (typically on
/// Windows when another process briefly holds a file lock) and the
/// scanner retries on the next tick, so Sentry has no immediate
/// remediation path.
///
/// Anchored narrowly to the whatsapp ingest failure envelope plus the
/// SQLite lock text, so unrelated DB lock errors in other domains still
/// reach Sentry.
WhatsAppDataSqliteBusy,
/// Host disk is full — the filesystem returned `ENOSPC` to a write,
/// `mkdir`, or `open` syscall. The user cannot recover from this without
/// freeing space on their machine, and Sentry has no remediation path
/// because the failing path is bound to the user's local FS. Surfaces
/// from many call sites once the disk fills up (auth profile lock
/// creation, SQLite WAL grows, log rotation, `tokio::fs::write` for
/// state snapshots) — every one of them emits the same canonical errno
/// rendering.
DiskFull,
/// A user-supplied filesystem path failed an RPC-level validation
/// check — e.g. `openhuman.vault_create` was called with a
/// `root_path` that doesn't exist or points at a file rather than a
/// directory. The UI already shows the typed error to the user, and
/// Sentry has no remediation path (we can't `mkdir -p` a folder the
/// user hasn't actually picked yet). User-supplied paths can also
/// embed PII fragments (the home-directory segment leaks the OS
/// username), so demoting these out of the Sentry event stream is a
/// small privacy win on top of the noise reduction.
///
/// Drops Sentry TAURI-RUST-4QH (`root_path is not a directory:
/// /Users/<user>/Documents/<vault>`, observed on
/// `openhuman@0.56.0`) and preempts the symmetric
/// `hosted path is not a directory:` shape from
/// `openhuman::http_host::path_utils` once it starts surfacing.
/// See [`is_filesystem_user_path_invalid_message`] for the polarity
/// contract — the safety-guard variant in `skills::ops_install`
/// (`{path} is not a directory — refusing to remove`) is
/// deliberately not matched because that's an `rm -rf` invariant
/// violation, not user input.
FilesystemUserPathInvalid,
/// A memory-store write (document upsert or KV set) was rejected because
/// the namespace or key contained what the PII guard classified as a
/// personal identifier (national ID, phone number, formatted credential,
/// etc.). The guard fires *before* the write reaches SQLite so no data
/// is persisted, and the LLM or caller that triggered the write already
/// receives the error string. Sentry has no remediation path — the fix
/// is either a less aggressive namespace/key choice from the caller or a
/// PII-guard allowlist update — and the volume is high from a single user
/// (TAURI-RUST-54T: 915 events, escalating), indicating that the guard
/// is flagging false positives on valid channel names or usernames used
/// as namespace/key identifiers. Demote to `warn` so the breadcrumb
/// survives for local diagnosis but Sentry sees no error event.
///
/// Canonical wire shapes (from `memory_store/unified/documents.rs` and
/// `memory_store/kv.rs`):
///
/// - `"document namespace/key cannot contain personal identifiers"`
/// - `"kv key cannot contain personal identifiers"`
/// - `"kv namespace/key cannot contain personal identifiers"`
MemoryStorePiiRejection,
/// The provider/model completed a turn with a completely empty body
/// (`text_chars=0 thinking_chars=0 tool_calls=0`), so the agent harness
/// bailed with the user-facing `"The model returned an empty response.
/// Please try again."` string
/// (`agent::harness::session::turn`). This is a model/user-config
/// condition — a quirky or broken local fine-tune that returns nothing,
/// a provider that dropped the stream — not a code bug. The UI already
/// surfaces the typed error and the user can retry; Sentry has no
/// remediation path.
///
/// `agent::run_single` already suppresses the **agent-layer** Sentry
/// event for this condition via the typed
/// `AgentError::EmptyProviderResponse` + `AgentError::skips_sentry()`
/// (PR #2790, TAURI-RUST-4JX). But `channels::providers::web::
/// run_chat_task` **re-reports** the same failure under
/// `domain=web_channel operation=run_chat_task` after the typed error
/// has been flattened to a `String` at the native-bus boundary — so the
/// typed suppression can't reach it and it escapes as a fresh Sentry
/// event (TAURI-RUST-4Z1). This string classifier closes that second
/// emit site, mirroring how `MaxIterationsExceeded` is handled at both
/// layers. See [`is_empty_provider_response_message`].
///
/// Although the immediate trigger is the `web_channel.run_chat_task`
/// re-report, this classifier runs in the central `expected_error_kind`
/// dispatcher, so any caller of `report_error_or_expected`
/// (`channels/runtime/dispatch.rs`, `channels/runtime/supervision.rs`,
/// any future channel provider) whose error chain contains `"model
/// returned an empty response"` is also demoted — no per-channel typed
/// suppression needed.
EmptyProviderResponse,
/// Channel supervisor (`channels::runtime::supervision::spawn_supervised_listener`)
/// caught a transient error from a channel listener and restarted it. The
/// wrapper shape `"Channel <name> error: <inner>; restarting"` is the
/// signature; the underlying inner error can be anything — reqwest transport
/// errors, OS-localized WSAETIMEDOUT messages, TLS handshake failures, gateway
/// disconnect strings — all of which are self-resolving via the supervisor's
/// own backoff/retry loop. Sustained outages still surface via
/// `health.bus` / `FAIL_ESCALATE_THRESHOLD` (separate path, not affected by
/// this kind).
///
/// Drops Sentry TAURI-RUST-15 (~11.4 k events Discord gateway) and -BB
/// (~815 events Chinese-Windows variant) where the English-only
/// `is_network_unreachable_message` anchors miss the inner OS message.
ChannelSupervisorRestart,
ConfigLoadTimedOut,
}
pub fn expected_error_kind(message: &str) -> Option<ExpectedErrorKind> {
let lower = message.to_ascii_lowercase();
if lower.contains("local ai is disabled") {
return Some(ExpectedErrorKind::LocalAiDisabled);
}
// `_api_key is not configured` catches backend-reported environment variable
// phrases like `VOYAGE_API_KEY is not configured` and
// `COHERE_API_KEY is not configured` returned by the embeddings backend
// when the relevant env var is absent (TAURI-RUST-2H5, ~5 K events).
// The `_api_key` anchor (lower-cased suffix of an env-var name) keeps
// generic "X is not configured" prose from being silenced — only
// ALL_CAPS_API_KEY-style names match.
if lower.contains("api key not set")
|| lower.contains("missing api key")
|| lower.contains("_api_key is not configured")
{
return Some(ExpectedErrorKind::ApiKeyMissing);
}
// Check `ChannelSupervisorRestart` BEFORE `is_loopback_unavailable` and
// `is_network_unreachable_message`: the supervisor wrapper contains
// substrings (`error sending request for url`, OS-localized WSAETIMEDOUT
// bodies, occasionally `connection refused`) that would otherwise classify
// as `NetworkUnreachable` (which only demotes to `warn!` — still a Sentry
// event) or `LoopbackUnavailable`. The supervisor's own restart loop
// handles the condition; per-restart messages carry no actionable Sentry
// signal (TAURI-RUST-15 / -BB). Sustained outages still surface via
// `health.bus` / `FAIL_ESCALATE_THRESHOLD`, which is a separate path.
if is_channel_supervisor_restart_message(&lower) {
return Some(ExpectedErrorKind::ChannelSupervisorRestart);
}
// Check `is_loopback_unavailable` BEFORE `is_network_unreachable_message`:
// a loopback `Connection refused` body shape would otherwise demote to the
// broader `NetworkUnreachable` bucket and lose the boot-window vs.
// user-environment distinction. Mirrors the `ProviderUserState`-before-
// `BackendUserError` precedence pattern from #1795 (PR comment).
if is_loopback_unavailable(&lower) {
return Some(ExpectedErrorKind::LoopbackUnavailable);
}
// Check `is_ollama_user_config_rejection` BEFORE the generic network /
// backend-error matchers: the GX "daemon unreachable at localhost" shape
// contains a loopback host but no `Connection refused (os error …)`
// marker, and the XS / MA / KM 400/404 shapes are pure user-config —
// wrong model name, model not pulled, daemon opted-in but not running.
// Route them to the dedicated arm so they share the `ProviderUserState`
// bucket with the composio / OAuth user-state errors instead of falling
// through to capture. See `is_ollama_user_config_rejection`.
if is_ollama_user_config_rejection(&lower) {
return Some(ExpectedErrorKind::ProviderUserState);
}
if is_network_unreachable_message(&lower) {
return Some(ExpectedErrorKind::NetworkUnreachable);
}
if is_transient_upstream_http_message(&lower) {
return Some(ExpectedErrorKind::TransientUpstreamHttp);
}
if lower.contains("binary not found") {
return Some(ExpectedErrorKind::LocalAiBinaryMissing);
}
// Check `is_provider_user_state_message` BEFORE `is_backend_user_error_message`:
// composio's "Toolkit X is not enabled" lands as a 4xx that both would
// match, and the more specific `ProviderUserState` bucket is the right
// home — see the variant doc-comment for OPENHUMAN-TAURI-… coverage.
if is_provider_user_state_message(&lower) {
return Some(ExpectedErrorKind::ProviderUserState);
}
if is_backend_user_error_message(&lower) {
return Some(ExpectedErrorKind::BackendUserError);
}
// Check `is_session_expired_message` BEFORE `is_embedding_backend_auth_failure`:
// the OpenHuman-backend embedding 401 "Invalid token" envelope
// (`Embedding API error (401 …): {"error":"Invalid token"}`) is a
// recoverable session expiry (TAURI-RUST-4K5, #2786), not a generic
// backend error. The broader `is_embedding_backend_auth_failure` matcher
// below would otherwise demote that exact wire shape to `BackendUserError`
// first and swallow the re-auth signal. `is_session_expired_message` is
// narrowly anchored (parenthesised `(401` + the `"error":"Invalid token"`
// envelope), so the bare-status `Embedding API error 401 …` shape and
// BYO-key 401s still fall through to the matchers below.
if is_session_expired_message(message) {
return Some(ExpectedErrorKind::SessionExpired);
}
if is_embedding_backend_auth_failure(&lower) {
return Some(ExpectedErrorKind::SessionExpired);
}
// Provider config-rejection (unknown model / abstract tier leaked to a
// custom provider / model-specific temperature). Body-shape based and
// intrinsically scoped to third-party providers — the OpenHuman
// backend never emits these phrases. See the predicate's polarity
// contract. Drops OPENHUMAN-TAURI-WJ / -QW / -HB / -NH re-reports
// (#2079 / #2076 / #2202).
if crate::openhuman::inference::provider::is_provider_config_rejection_message(message) {
return Some(ExpectedErrorKind::ProviderConfigRejection);
}
if is_local_ai_capability_unavailable_message(&lower) {
return Some(ExpectedErrorKind::LocalAiCapabilityUnavailable);
}
if crate::openhuman::inference::provider::is_budget_exhausted_message(message) {
return Some(ExpectedErrorKind::BudgetExhausted);
}
if is_prompt_injection_blocked_message(&lower) {
return Some(ExpectedErrorKind::PromptInjectionBlocked);
}
// Context-window-exceeded re-report from a higher layer (agent /
// web_channel). The provider api_error cascade suppresses its own
// emit; this catches the re-raise. Delegates to the single-source
// provider matcher so the phrasing can't drift. Runs last so a more
// specific matcher always wins.
if crate::openhuman::inference::provider::is_context_window_exceeded_message(message) {
return Some(ExpectedErrorKind::ContextWindowExceeded);
}
if is_memory_store_breaker_open(&lower) {
return Some(ExpectedErrorKind::MemoryStoreBreakerOpen);
}
if is_whatsapp_data_sqlite_busy_message(&lower) {
return Some(ExpectedErrorKind::WhatsAppDataSqliteBusy);
}
if is_disk_full_message(&lower) {
return Some(ExpectedErrorKind::DiskFull);
}
if is_config_load_timed_out_message(&lower) {
return Some(ExpectedErrorKind::ConfigLoadTimedOut);
}
if is_memory_store_pii_rejection(&lower) {
return Some(ExpectedErrorKind::MemoryStorePiiRejection);
}
// Empty-provider-response re-report from the web-channel layer. Runs
// last so an earlier, more specific matcher always wins. See the
// variant doc-comment and [`is_empty_provider_response_message`] for
// the two-emit-site rationale (agent layer is handled by the typed
// `AgentError::skips_sentry()` in PR #2790; this covers the
// web_channel re-report where the type was flattened to a String).
if is_empty_provider_response_message(&lower) {
return Some(ExpectedErrorKind::EmptyProviderResponse);
}
// RPC-level filesystem path validation — explicit wire-shape anchors
// (root_path / hosted path) prevent accidental demotion of unrelated
// errors. See the variant doc-comment and
// [`is_filesystem_user_path_invalid_message`] polarity contract.
if is_filesystem_user_path_invalid_message(&lower) {
return Some(ExpectedErrorKind::FilesystemUserPathInvalid);
}
// Upstream rate-limit responses — provider throttles the account (429) or
// wraps the 429 inside an HTTP 500 (`"429 rate limit exceeded"` in the
// body). In both cases the reliable-provider layer already retries with
// backoff, and the embeddings path has a proactive token-bucket limiter
// (`embeddings::rate_limit`). The upstream quota is an account-capacity
// signal, not a code bug — Sentry has no remediation path and the
// per-attempt events generate pure noise (OPENHUMAN-TAURI-S: ~6 984
// events from HTTP 500 wrapping a "429 rate limit exceeded" body;
// OPENHUMAN-TAURI-6Y: ~19 849 events from direct 429s; OPENHUMAN-TAURI-2E:
// ~1 482 events carrying a `"rate_limit_error"` type in the JSON body;
// OPENHUMAN-TAURI-RQ: ~741 events from the embeddings path).
//
// Checked LAST inside `expected_error_kind` — transient HTTP status matches
// (`is_transient_upstream_http_message`) are already caught by the earlier
// arm, so this arm only adds coverage for the 500-wrapping-429 body shape
// and provider JSON envelopes that name the error type explicitly.
if is_upstream_rate_limit_message(&lower) {
return Some(ExpectedErrorKind::TransientUpstreamHttp);
}
None
}
/// Detect upstream rate-limit error bodies that bubble up from any provider
/// or embedding API call site.
///
/// Covers three observed wire shapes:
///
/// 1. **OpenAI / Anthropic JSON body** — `"rate_limit_error"` is the `"type"`
/// field in the structured error object:
/// `{"error":{"message":"Rate limit exceeded.","type":"rate_limit_error"}}`
/// (OPENHUMAN-TAURI-2E / -RQ).
///
/// 2. **OpenHuman backend wrapping upstream** — `"Upstream rate limit exceeded
/// for model 'summarization-v1'. Please retry shortly."` embedded in a 500
/// response body (OPENHUMAN-TAURI-6Y / -7H).
///
/// 3. **Plain phrase** — `"429 rate limit exceeded, please try again later"` /
/// `"rate limit exceeded"` from any other upstream (OPENHUMAN-TAURI-S).
///
/// The match is against the full lowercased error string (including any
/// caller wrapping prefix), so it survives `agent.run_single` / `rpc.invoke_method`
/// re-reports as well as the original call-site emit.
///
/// **Polarity contract**: this predicate is *inclusive* — it returns `true`
/// only for messages that are unambiguously rate-limit throttle signals. It
/// must NOT match unrelated errors that incidentally mention "limit" or "rate"
/// (e.g. action-budget `"Rate limit exceeded: action budget exhausted"`
/// from `security::policy` — distinguished by the `"action budget"` anchor).
pub fn is_upstream_rate_limit_message(lower: &str) -> bool {
// `"rate_limit_error"` is the structured error type from OpenAI / Anthropic
// compatible APIs. Tight anchor — colons and underscores don't appear in
// ordinary log text.
if lower.contains("rate_limit_error") {
return true;
}
// `"upstream rate limit exceeded"` is the OpenHuman backend's own phrase
// when it wraps an upstream provider 429 as an HTTP 500.
if lower.contains("upstream rate limit exceeded") {
return true;
}
// `"429 rate limit exceeded"` is the numeric-prefix form emitted by some
// backends (e.g. OPENHUMAN-TAURI-S: `"error":"429 rate limit exceeded"`).
// Anchored on the `"429 rate limit"` substring so a plain `"rate limit
// exceeded"` mention (which could appear in the `security::policy` action-
// budget message) is NOT matched here — the next arm handles clean phrase
// matches only when scoped by a provider API error prefix.
if lower.contains("429 rate limit") {
return true;
}
// `"rate limit exceeded"` on its own is matched ONLY when it appears inside
// a canonical provider API error envelope (`"api error ("` prefix from
// `ops::api_error` / `embeddings::openai`). This keeps the security::policy
// `"Rate limit exceeded: action budget exhausted"` message from being
// silently swallowed — that phrase does not carry an API error prefix.
if lower.contains("api error (") && lower.contains("rate limit exceeded") {
return true;
}
false
}
/// Detect filesystem-out-of-space errors that bubble up from any syscall
/// (`open`, `write`, `mkdir`, `rename`). Three platform-stable renderings:
///
/// - **POSIX `ENOSPC`** (Linux / macOS / BSD): `std::io::Error` renders as
/// `"No space left on device (os error 28)"`. The errno-name substring is
/// what we anchor on — case-folded to `"no space left on device"`.
/// - **Windows `ERROR_DISK_FULL` (112)**: `std::io::Error` renders as
/// `"There is not enough space on the disk. (os error 112)"`. Anchor on
/// `"not enough space on the disk"`.
/// - **Windows `ERROR_HANDLE_DISK_FULL` (39)**: same wire text but errno 39.
/// The text anchor already covers it.
fn is_disk_full_message(lower: &str) -> bool {
lower.contains("no space left on device") || lower.contains("not enough space on the disk")
}
/// Detect the literal `"Config loading timed out"` string produced by
/// [`crate::openhuman::config::ops::load_config_with_timeout`] /
/// [`crate::openhuman::config::ops::reload_config_snapshot_with_timeout`]
/// when `tokio::time::timeout` elapses around `Config::load_or_init` /
/// `Config::load_from_config_path`.
fn is_config_load_timed_out_message(lower: &str) -> bool {
lower.contains("config loading timed out")
}
/// Match whatsapp structured-ingest failures caused by transient SQLite lock
/// contention. Keep this matcher scoped to the whatsapp ingest envelope so we
/// don't demote unrelated database failures in other domains.
fn is_whatsapp_data_sqlite_busy_message(lower: &str) -> bool {
if !lower.contains("[whatsapp_data] ingest failed:") {
return false;
}
if !lower.contains("upsert wa_message") {
return false;
}
lower.contains("database is locked")
|| lower.contains("database table is locked")
|| lower.contains("database file is locked")
|| lower.contains("error code 5")
}
fn is_embedding_backend_auth_failure(lower: &str) -> bool {
// Skip the OpenHuman-backend envelope shape `{"success":false,"error":"invalid token"}`
// (TAURI-RUST-4K5) — that's a SessionExpired wire shape, not a BYO-key auth failure.
// `is_session_expired_message` claims it via the conjunctive
// `Embedding API error (401` + `"error":"Invalid token"` anchors.
if lower.contains("\"error\":\"invalid token\"") {
return false;
}
lower.contains("embedding api error")
&& lower.contains("401")
&& lower.contains("invalid token")
}
/// Detect the memory-store chunk DB's circuit-breaker-open message that
/// `memory_store::chunks::store::get_or_init_connection` emits via
/// `anyhow::bail!` when the per-path breaker rejects new init attempts.
///
/// Canonical wire shape (after the `chunk aggregates: …` context wrap added by
/// `memory_tree::tree::rpc::pipeline_status_rpc`):
///
/// ```text
/// chunk aggregates: [memory_tree] circuit breaker open for <path>: too many consecutive init failures
/// ```
///
/// The `[memory_tree]` tag is the anchor — it's specific to the chunk-store
/// emit site and won't collide with unrelated "circuit breaker" mentions in
/// other domains (provider reliability layer logs, doc strings, …). The
/// `circuit breaker open` substring is required so a log line that merely
/// mentions the `[memory_tree]` prefix doesn't get swallowed.
fn is_memory_store_breaker_open(lower: &str) -> bool {
lower.contains("[memory_tree]") && lower.contains("circuit breaker open")
}
/// Detect **app-session-expired** boundary errors that bubble up from any
/// backend-touching call site (agent, web channel, cron, integrations).
///
/// This is also the JSON-RPC dispatch-site classifier. Keep it stricter than
/// a bare "401 + unauthorized" pair: OpenAI / Anthropic BYO-key failures,
/// Composio scope failures, and channel-provider 401s are actionable scoped
/// errors, not proof that the user's OpenHuman app session expired.
///
/// The canonical OpenHuman session-expired wire shapes:
///
/// - `"OpenHuman API error (401 Unauthorized): {…\"Session expired. Please
/// log in again.\"…}"` — emitted by `providers::ops::api_error` from the
/// OpenHuman backend and re-raised through `agent::run_single` /
/// `channels::providers::web::run_chat_task` (OPENHUMAN-TAURI-26). The
/// `"session expired"` substring anchors the match to the OpenHuman
/// backend's session-renewal body, not the bare numeric status.
/// - `"OpenHuman API error (401 Unauthorized): {…\"error\":\"Invalid token\"…}"`
/// — same emit site, same wire shape as the `Session expired` body, but the
/// OpenHuman backend swaps in `"Invalid token"` for the JWT-validity
/// rejection branch (vs. the explicit session-renewal branch).
/// OPENHUMAN-TAURI-4P0. The conjunctive anchor — `"OpenHuman API error
/// (401"` **and** the envelope-shaped `"\"error\":\"Invalid token\""` —
/// keeps the #2286 contract intact: bare `"Invalid token"`, OpenAI /
/// Anthropic BYO-key 401s, Discord upstream-bot-token rejections, and
/// provider scope errors still route to Sentry as actionable.
/// - `"Embedding API error (401 Unauthorized): {…\"error\":\"Invalid token\"…}"`
/// — TAURI-RUST-4K5 (~118 events, escalating on 0.56.0). Same OpenHuman
/// backend session-expired envelope as 4P0, but the embedding client at
/// `src/openhuman/embeddings/openai.rs:139` wraps it with the
/// `"Embedding API error"` prefix instead of `"OpenHuman API error"`.
/// Uses the same conjunctive-anchor pattern so BYO-key embedding 401s
/// from third-party providers (OpenAI / Voyage / Cohere) still escalate
/// — guarded by `does_not_classify_embedding_byo_key_401_as_session_expired`.
/// - `"OpenHuman streaming API error (401 Unauthorized): {…\"error\":\"Invalid token\"…}"`
/// — TAURI-RUST-1EE (~110 events, ongoing on 0.56.0). Same envelope as
/// 4P0, wrapped by the streaming-chat path at
/// `inference/provider/compatible.rs:949` with the
/// `"OpenHuman streaming API error"` prefix. The `streaming` token means
/// the 4P0 anchor doesn't match, so it needs its own prefix arm; BYO-key
/// streaming 401s still escalate — guarded by
/// `does_not_classify_streaming_byo_key_401_as_session_expired`.
/// - `"SESSION_EXPIRED: backend session not active — sign in to resume LLM work"`
/// — the `scheduler_gate::is_signed_out` sentinel from
/// `providers::openhuman_backend::resolve_bearer`.
/// - `"no backend session token; run auth_store_session first"` and
/// `"session JWT required"` — local pre-flight guards that fire when the
/// stored profile is empty (`#1465`-ish onboarding spam) or has been
/// cleared by a previous 401 cycle. Both shapes are OpenHuman-specific.
/// - `"backend rejected session token on GET /payments/stripe/currentPlan"` and
/// all analogous `"{METHOD} {path}"` variants — the `BackendApiError::Unauthorized`
/// typed error surfaced by `api::rest::BackendOAuthClient::authed_json` when any
/// OpenHuman REST endpoint returns HTTP 401. The `get_authed_value` wrapper in
/// `billing::ops` stringifies this via `.to_string()`, producing the
/// `"backend rejected session token on …"` prefix. This is uniquely scoped to
/// the `BackendApiError::Unauthorized` variant (the phrase does not appear in
/// any third-party provider error path) so it is safe to classify as session
/// expiry without the conjunctive-anchor guard pattern needed for `"Invalid
/// token"`. Targets TAURI-RUST-E (~1 437 events from
/// `openhuman.billing_get_current_plan` polling on every background billing
/// refresh cycle after the user's JWT lapses).
///
/// At the JSON-RPC dispatch boundary the same strict match controls
/// `DomainEvent::SessionExpired` publication, so downstream/provider 401s stay
/// recoverable and do not clear the stored app session.
pub fn is_session_expired_message(msg: &str) -> bool {
let lower = msg.to_ascii_lowercase();
lower.contains("session expired")
|| lower.contains("no backend session token")
|| lower.contains("session jwt required")
|| msg.contains("SESSION_EXPIRED")
|| (msg.contains("OpenHuman API error (401") && msg.contains("\"error\":\"Invalid token\""))
|| (msg.contains("Embedding API error (401") && msg.contains("\"error\":\"Invalid token\""))
// TAURI-RUST-E — billing endpoint 401s via `BackendApiError::Unauthorized`
// stringified by `billing::ops::get_authed_value(..).map_err(|e| e.to_string())`.
// The display form is `"backend rejected session token on {METHOD} {path}"`;
// the phrase is uniquely scoped to `BackendApiError::Unauthorized` so no
// conjunctive guard is needed. Covers all billing RPC methods
// (billing_get_current_plan, billing_get_balance, etc.) and any other
// `authed_json` caller that stringifies via `.to_string()`.
|| lower.contains("backend rejected session token")
// OPENHUMAN-TAURI-4P0 — OpenHuman backend's "Invalid token" 401
// envelope. Both anchors must be present: the OpenHuman-scoped
// `"OpenHuman API error (401"` prefix (so a third-party provider's
// `"OpenAI API error (401 Unauthorized): invalid_api_key"` cannot
// match), AND the envelope-shaped `"\"error\":\"Invalid token\""`
// (so bare prose mentions of "invalid token" — Discord OAuth
// failures, generic upstream errors covered by #2286 — stay
// actionable in Sentry).
|| (msg.contains("OpenHuman API error (401")
&& msg.contains("\"error\":\"Invalid token\""))
// TAURI-RUST-4K5 / -T — same OpenHuman backend "Invalid token" envelope
// wrapped by `src/openhuman/embeddings/openai.rs:139` with the
// `"Embedding API error"` prefix instead of `"OpenHuman API error"`.
// Both parenthesized (`Embedding API error (401`) and bare-status
// (`Embedding API error 401`) wire shapes are observed in production
// and must classify identically. Same conjunctive-anchor pattern as
// 4P0: the embedding-scoped prefix gates the match so a third-party
// BYO-key embedding 401 (e.g. OpenAI/Voyage/Cohere rejecting the
// user's own API key) stays actionable — guarded by
// `does_not_classify_embedding_byo_key_401_as_session_expired`.
|| ((msg.contains("Embedding API error (401")
|| msg.contains("Embedding API error 401"))
&& msg.contains("\"error\":\"Invalid token\""))
// TAURI-RUST-1EE — same OpenHuman backend "Invalid token" envelope
// wrapped by the streaming-chat path at
// `inference/provider/compatible.rs:949` with the
// `"OpenHuman streaming API error"` prefix. The `streaming` token
// between `OpenHuman` and `API error` means the 4P0 anchor
// (`"OpenHuman API error (401"`) does not match it, so the
// streaming path needs its own prefix arm. Same conjunctive-anchor
// pattern keeps third-party BYO-key streaming 401s
// (`"OpenAI streaming API error (401): invalid_api_key"`)
// escalating — guarded by
// `does_not_classify_streaming_byo_key_401_as_session_expired`.
|| (msg.contains("OpenHuman streaming API error (401")
&& msg.contains("\"error\":\"Invalid token\""))
}
/// Detect the in-process-core boot-window shape: a sibling component
/// (frontend RPC relay, agent-integrations / composio HTTP clients) tried to
/// reach the embedded core's `127.0.0.1:<port>` listener before it finished
/// binding, so the kernel returned `Connection refused`. The condition
/// self-resolves once startup completes — Sentry has no remediation path.
///
/// Conjunctive match — both anchors must hit:
///
/// 1. **Loopback host with port**: substring `127.0.0.1:` or `localhost:` so
/// a doc URL or unrelated mention without a port (`localhost`,
/// `127.0.0.1\n`) does not match. Pinned to the colon+port pattern
/// because every observed shape from reqwest / hyper / our own
/// `IntegrationClient` wraps the host as `<host>:<port>` in the URL the
/// error chain renders.
/// 2. **Connection refused with platform errno**: `connection refused (os
/// error 61)` (macOS / BSD), `connection refused (os error 111)`
/// (Linux), or `connection refused (os error 10061)` (Windows
/// `WSAECONNREFUSED`). Pinning to `(os error N)` keeps the matcher from
/// swallowing higher-level wrappers that merely mention "connection
/// refused" in prose.
///
/// Drops OPENHUMAN-TAURI-R5 (~2.5k events, `integrations.get` emit site)
/// and OPENHUMAN-TAURI-R6 (~2.5k events, the `rpc.invoke_method` re-wrap of
/// the same trace). Both share `trace_id=6ebf5b62748d5144e541e2cddeabbbd0`
/// and the canonical body shape:
///
/// ```text
/// error sending request for url (http://127.0.0.1:18474/agent-integrations/composio/connections)
/// → client error (Connect) → tcp connect error → Connection refused (os error 61)
/// ```
///
/// Without this matcher the body falls through to
/// [`is_network_unreachable_message`] and demotes as `NetworkUnreachable`,
/// which conflates an internal lifecycle race with user-environment problems
/// (VPN drop, captive portal, ISP block) and makes the "what's spiking?"
/// question un-answerable. See [`ExpectedErrorKind::LoopbackUnavailable`].
fn is_loopback_unavailable(lower: &str) -> bool {
let has_loopback_host = lower.contains("127.0.0.1:") || lower.contains("localhost:");
if !has_loopback_host {
return false;
}
lower.contains("connection refused (os error 61)")
|| lower.contains("connection refused (os error 111)")
|| lower.contains("connection refused (os error 10061)")
}
/// Detect Ollama embed call sites that surface a user-config rejection from
/// the local Ollama daemon — pure user-state errors the UI already surfaces
/// (toast / settings page warning) where Sentry has no remediation path.
///
/// Three canonical wire shapes are covered, all emitted by
/// `openhuman::embeddings::ollama::OllamaEmbedding::embed` and the embed
/// service fallback path:
///
/// - **TAURI-RUST-XS** (~376 events on self-hosted Sentry): user pointed the
/// embedder at a chat / vision model id with a temperature suffix (e.g.
/// `qwen3-vl:4b@0.7`) which Ollama parses as malformed. Wire shape:
/// `ollama embed failed with status 400 Bad Request: {"error":"invalid model name"}`.
/// - **OPENHUMAN-TAURI-MA / -KM** (deferred follow-up from PR #2216), and
/// **TAURI-RUST-K** (~1990 events) / **TAURI-RUST-8K** (~411 events) on
/// self-hosted Sentry: user configured a model id that the local Ollama
/// daemon hasn't pulled yet. Wire shape:
/// `ollama embed failed with status 404 Not Found: {"error":"model \"<id>\" not found, try pulling it first"}`.
/// (Self-hosted Sentry events still flow from older client releases that
/// predate this matcher; they drop off naturally as users upgrade.)
/// - **OPENHUMAN-TAURI-GX**: user opted into Ollama embeddings but the
/// daemon isn't running on `localhost:11434`, so the embed service falls
/// back to cloud embeddings for the session. Wire shape:
/// `ollama embeddings opted-in but daemon unreachable at http://localhost:11434; falling back to cloud embeddings for this session`.
///
/// All three are user-config: the user picked the wrong model id, forgot to
/// pull it, or forgot to start the daemon. The remediation is "fix the
/// model id in Settings" / "run `ollama pull <id>`" / "start ollama" —
/// none of which Sentry can do for them.
///
/// The classifier is anchored on the `"ollama embed"` prefix
/// (`"ollama embed failed"` for the 400/404 shapes, `"ollama embeddings opted-in"`
/// for the daemon-unreachable fallback) so unrelated 400/404 errors elsewhere
/// in the codebase that happen to contain `"invalid model name"` or
/// `"not found"` substrings are not silenced.
///
/// Routes to [`ExpectedErrorKind::ProviderUserState`] — the same bucket that
/// holds the composio / gmail / OAuth user-state errors. We deliberately do
/// **not** introduce a dedicated Ollama enum variant: the demotion semantics
/// (drop to `info` log, skip Sentry capture) are identical and adding a new
/// variant for every provider would balloon the enum without changing
/// behavior.
fn is_ollama_user_config_rejection(lower: &str) -> bool {
// XS — 400-status user-config (invalid model name, including the
// temperature-suffix shape `qwen3-vl:4b@0.7` Ollama parses as malformed).
if lower.contains("ollama embed failed") && lower.contains("invalid model name") {
return true;
}
// MA / KM — 404-status pull-required. The wire shape is JSON-escaped
// (`\"<model-id>\" not found`); after lower-casing we still see the
// backslash-quoted form. Anchor on `model \"` + `\" not found` so an
// unrelated 404 that merely contains `"model"` and `"not found"` is not
// swallowed. The `\\"` byte pair in Rust source matches the literal
// `\"` sequence in the wire shape.
if lower.contains("ollama embed failed")
&& lower.contains("model \\\"")
&& lower.contains("\\\" not found")
{
return true;
}
if lower.contains("ollama embed failed")
&& lower.contains("this model does not support embeddings")
{
return true;
}
// TAURI-RUST-3E (~249 events) — 401-status auth failure from Ollama
// (user pointed the embedder at an authenticated Ollama endpoint
// without configuring credentials, e.g. self-hosted Ollama behind an
// auth proxy or Ollama Cloud without API key). Body shape:
// `{"error": "unauthorized"}`. Anchor on `ollama embed failed`
// + `status 401` so unrelated 401s from other call sites (provider
// chat, backend API) aren't silenced.
if lower.contains("ollama embed failed") && lower.contains("status 401") {
return true;
}
// GX — daemon-unreachable opt-in state. The wire shape is emitted by
// the embed service when the user has opted into Ollama in settings
// but the daemon isn't responding, so the service falls back to cloud
// embeddings for the session. Anchor on the full prefix to keep the
// matcher from colliding with unrelated `"daemon unreachable"`
// messages from other domains (e.g. backend connection-health logs).
if lower.contains("ollama embeddings opted-in but daemon unreachable at") {
return true;
}
false
}
/// Detect transport-level connection failures that fire before any HTTP status
/// is observed — DNS resolution failures, TCP connect refused/reset, TLS
/// handshake failures, or ISP/firewall blocks. The canonical shape is
/// reqwest's `"error sending request for url (…)"`, which surfaces from any
/// HTTP call site (provider chat, embeddings, backend RPC) when the request
/// can't reach the server at all.
///
/// These are user-environment problems — VPN drop, captive portal, ISP-level
/// block (OPENHUMAN-TAURI-32: user in RU couldn't reach `api.tinyhumans.ai`),
/// firewall — that no amount of retry / fallback on our side can resolve.
/// Sentry has no signal to act on (no status, no trace, no payload), so each
/// occurrence is pure noise. Classify them as expected so the report site
/// logs a breadcrumb rather than spawning an error event.
///
/// Loopback `127.0.0.1:<port>` `Connection refused` shapes are routed
/// through [`is_loopback_unavailable`] *before* this matcher so the
/// boot-window race against the embedded core keeps its own bucket — see
/// the precedence comment in [`expected_error_kind`].
///
/// Three additional substrings cover wire-shape variants observed in
/// Wave 4 that the original `"dns error"` / status-code matchers miss:
///
/// - `"failed to lookup address"` / `"nodename nor servname"` —
/// `getaddrinfo()` failure renderings on macOS / BSD libc and POSIX
/// resolvers (`OPENHUMAN-TAURI-44` ~50 events,
/// `[socket] Connection failed: WebSocket connect: IO error: failed to
/// lookup address information: nodename nor servname provided, or not
/// known`).
/// - `"http error: 200 ok"` — tungstenite's `WsError::Http(200)` render
/// when a corporate proxy / captive portal intercepts the WebSocket
/// handshake and returns a plain HTML 200 page (`OPENHUMAN-TAURI-4P`
/// ~66 events). Tungstenite-only — reqwest renders HTTP 200 as
/// `"HTTP status server error (200)"`, so this can't collide with the
/// regular HTTP call path.
/// - `"unexpected eof during handshake"` — `native-tls`'s render when the
/// peer (or an intercepting firewall / antivirus / corporate TLS proxy)
/// closes the TCP connection mid-TLS-handshake, surfacing as
/// `"TLS error: native-tls error: unexpected EOF during handshake"`
/// wrapped by `socket::ws_loop::run_connection` into
/// `"WebSocket connect: …"` (`TAURI-RUST-4ZD`, first seen on
/// `openhuman@0.56.0`, Windows). The existing `"tls handshake"` anchor
/// misses it because the words aren't contiguous (`"tls error"` …
/// `"during handshake"`). Same user-environment shape as the other
/// handshake-stage entries — the socket supervisor already retries with
/// exponential backoff and Sentry has no actionable signal.
/// - `"http version must be 1.1 or higher"` — tungstenite's
/// `ProtocolError::WrongHttpVersion` render. Fires when a server (or
/// intermediary proxy / HTTP/2-only edge) responds to the WebSocket
/// upgrade with HTTP/2+, which the WS spec forbids — the handshake
/// requires HTTP/1.1 (`CORE-RUST-DP`, ~2 events / 24h, first seen on
/// `openhuman@0.56.0`). Same shape as the existing handshake-stage
/// entries: a user-environment / infra misconfiguration that the
/// client cannot fix; Sentry has no actionable signal beyond what the
/// socket supervisor's exponential backoff already provides.
fn is_network_unreachable_message(lower: &str) -> bool {
lower.contains("error sending request for url")
|| lower.contains("dns error")
|| lower.contains("failed to lookup address")
|| lower.contains("nodename nor servname")
|| lower.contains("connection refused")
|| lower.contains("connection reset")
// OPENHUMAN-TAURI-EM (128 events): the channel supervisor wraps
// `discord_listen()`'s anyhow chain as `format!("Channel {} error:
// {e:#}; restarting", ...)`, which lands as
// `"Channel discord error: IO error: Operation timed out (os error
// 60); restarting"`. The discord gateway TCP/WebSocket connection
// timing out is transient network state, not a code bug — the
// supervisor already retries with exponential backoff. Same shape
// surfaces on every channel (slack/telegram/...) once the
// underlying socket hits ETIMEDOUT, so we match on the platform-
// agnostic phrase, symmetric with `"connection reset"` /
// `"connection refused"` above. Errno renderings are not pinned
// because `(os error 60)` (BSD/macOS), `(os error 110)` (Linux),
// `(os error 10060)` (Windows `WSAETIMEDOUT`), and bare prose
// `"operation timed out"` (hyper / tungstenite / std::io) all
// share the same lowercase substring.
|| lower.contains("operation timed out")
|| lower.contains("network is unreachable")
|| lower.contains("no route to host")
|| lower.contains("tls handshake")
|| lower.contains("unexpected eof during handshake")
|| lower.contains("certificate verify failed")
|| lower.contains("http error: 200 ok")
|| lower.contains("http version must be 1.1 or higher")
}
/// Detect the canonical supervisor-wrap shape emitted by
/// `channels::runtime::supervision::spawn_supervised_listener` —
/// `"Channel <name> error: <inner>; restarting"`. Language-agnostic
/// (anchored on the Rust wrapper, not the inner error wording) so it
/// covers OS-localized variants (TAURI-RUST-BB Chinese-Windows
/// WSAETIMEDOUT body) that escape the English-only network anchors in
/// [`is_network_unreachable_message`].
///
/// The supervisor restarts the listener with its own exponential backoff;
/// sustained outages surface via separate `health.bus` events /
/// `FAIL_ESCALATE_THRESHOLD`. Per-restart messages carry no actionable
/// Sentry signal — Sentry has no remediation path beyond what the
/// supervisor already does (TAURI-RUST-15 ~11.4 k events / -BB ~815
/// events on self-hosted `tauri-rust`).
///
/// Anchors on three substrings together to avoid false positives:
/// - leading `"channel "` (with trailing space disambiguates from
/// unrelated mentions like `"channels"` or `"channel-runtime"`)
/// - `" error:"` (the wrapper's literal separator)
/// - `"; restarting"` (the wrapper's literal trailer)
///
/// A bare `"…; restarting"` log line without the `"Channel <name> error:"`
/// preamble must NOT classify — that's a generic restart note from some
/// other subsystem and Sentry signal there may still be actionable.
fn is_channel_supervisor_restart_message(lower: &str) -> bool {
lower.starts_with("channel ") && lower.contains(" error:") && lower.contains("; restarting")
}
/// Detect transient upstream HTTP failures that have bubbled up out of the
/// provider layer and into higher-level domains (`agent`, `web_channel`, …).
///
/// The reliable-provider stack already retries / falls back on
/// [`TRANSIENT_PROVIDER_HTTP_STATUSES`] (408/429/502/503/504), and the
/// `before_send` filter drops the per-attempt provider events that carry
/// `domain=llm_provider`. But the same error is *also* returned via
/// `Result::Err` and re-reported by callers that wrap the provider — e.g.
/// `agent.run_single` (OPENHUMAN-TAURI-5Z), `web_channel.run_chat_task`,
/// scheduler tick handlers — under a different `domain` tag, escaping the
/// provider-scoped filter and producing one Sentry event per failed turn.
///
/// The canonical wire format from `providers::ops::api_error` is:
/// `"<provider> API error (<status>): <sanitized>"` — e.g.
/// `"OpenHuman API error (504 Gateway Timeout): error code: 504"`. Pin the
/// match to that exact `"api error (<status>"` prefix so an unrelated message
/// that merely mentions "504" (a log line, a doc URL) is not silenced.
///
/// Also matches the second canonical wire shape: tungstenite's
/// `WsError::Http(response)` Display, which renders as `"HTTP error: <status>"`
/// (and which `socket::ws_loop::run_connection` wraps as
/// `"WebSocket connect: HTTP error: 502 Bad Gateway"`). Per
/// OPENHUMAN-TAURI-5P (~110 events) and -EZ (~51 events), backend
/// staging/production load balancers emit HTTP 502/504 during the WebSocket
/// upgrade handshake; tungstenite surfaces those as `WsError::Http` and the
/// socket reconnect loop already handles them via exponential backoff. Each
/// `FAIL_ESCALATE_THRESHOLD` escalation fires `report_error_or_expected` with
/// the formatted reason, which would land in Sentry as `domain=socket`
/// noise without this matcher (the existing `domain=integrations`
/// before_send filter scopes too narrowly).
///
/// Three separator variants cover every observed shape: trailing space
/// (`"HTTP error: 502 Bad Gateway"`), trailing newline (`"HTTP error: 502\n…"`
/// from chained errors), and trailing colon (`"HTTP error: 502: …"`). Bare
/// `"HTTP error: 502"` at end-of-string is not matched on purpose — the
/// status integer alone could collide with unrelated log lines containing
/// `"HTTP error: 5023"` (port number, runbook ID).
fn is_transient_upstream_http_message(lower: &str) -> bool {
TRANSIENT_PROVIDER_HTTP_STATUSES.iter().any(|code| {
lower.contains(&format!("api error ({code}"))
|| lower.contains(&format!("api error {code} "))
|| lower.contains(&format!("http error: {code} "))
|| lower.contains(&format!("http error: {code}\n"))
|| lower.contains(&format!("http error: {code}:"))
})
}
/// Detect non-2xx HTTP failures returned from the backend integrations / composio
/// clients that are by definition user-input or user-auth-state problems — not
/// bugs Sentry can act on.
///
/// The canonical wire format from
/// [`crate::openhuman::integrations::client::IntegrationClient::post`] / `get`
/// and [`crate::openhuman::composio::client::ComposioClient`] is:
/// `"Backend returned <status> <reason> for <METHOD> <url>: <detail>"` — e.g.
/// `"Backend returned 400 Bad Request for POST https://api.tinyhumans.ai/agent-integrations/composio/authorize: Composio authorization failed: 400 …"`
/// (OPENHUMAN-TAURI-BC: user submitted SharePoint authorize without filling in
/// the required Tenant Name field). The backend correctly returned a 4xx; the
/// UI already surfaces the structured error to the user via toast — Sentry has
/// no remediation path because the request was malformed *by the user's
/// input*, not by our code.
///
/// We pin the match to the `"backend returned "` prefix so an unrelated
/// message merely mentioning "400" (a log line, doc URL) is not silenced.
///
/// We classify only 4xx codes, with **two exclusions**:
/// - `408 Request Timeout` and `429 Too Many Requests` are *transient* — they
/// are surfaced via [`is_transient_upstream_http_message`] for the provider
/// path and stay actionable for the backend path so a sustained 429 (rate
/// limit cliff) still pages.
///
/// 5xx is intentionally **not** classified here — server-side failures from
/// our backend are real bugs that should reach Sentry. The transient
/// 502/503/504 deduplication is handled by the threshold logic in callers
/// (see e.g. `openhuman::socket::ws_loop::FAIL_ESCALATE_THRESHOLD`).
fn is_backend_user_error_message(lower: &str) -> bool {
let Some(rest) = lower.split_once("backend returned ").map(|(_, r)| r) else {
return false;
};
let status_digits: String = rest.chars().take_while(|c| c.is_ascii_digit()).collect();
let Ok(status) = status_digits.parse::<u16>() else {
return false;
};
// 4xx (except transient 408 / 429 which are handled separately).
matches!(status, 400..=499) && status != 408 && status != 429
}
/// Detect third-party provider validation failures that bubble up as
/// user-state errors — composio trigger registry mismatch, toolkit not
/// enabled, OAuth scopes missing, required fields left blank.
///
/// Unlike [`is_backend_user_error_message`], this classifier is **body-text
/// shape-based** rather than HTTP-status-based, so it catches the cases
/// where the composio backend wraps a Composio API 4xx as a 500 with the
/// real validation message embedded in the body (OPENHUMAN-TAURI-3R / -3S
/// / -97 — `"Backend returned 500 … Trigger type GITHUB_PUSH_EVENT not
/// found"`, `"Backend returned 500 … Missing required fields: Your
/// Subdomain"`). These would otherwise escape the 4xx-only matcher and
/// fire as actionable Sentry events even though the underlying condition
/// is user-state (the trigger slug isn't in composio's registry, the
/// toolkit wasn't enabled by the user, the form field was left blank, …).
///
/// Also handles the gmail-sync 403 (OPENHUMAN-TAURI-33) where the
/// composio sync loop surfaces the upstream Google OAuth scopes error as
/// `"HTTP 403: Request had insufficient authentication scopes."`. The
/// remediation is "user re-authorizes with the right scope" — nothing
/// Sentry can act on.
///
/// All matches are substring-based against the lower-cased message so the
/// classifier survives caller wrapping (rpc.invoke_method, agent.run_single,
/// `[composio:gmail]` prefixes, anyhow chains, …).
fn is_provider_user_state_message(lower: &str) -> bool {
// OPENHUMAN-TAURI-3R / -3S: composio enable_trigger when the slug isn't
// in the trigger registry (e.g. user clicked a stale UI option).
// Backend returns 500 with `"Trigger type GITHUB_PUSH_EVENT not found"`.
// Also covers the alternate phrasing `"Cannot enable trigger … not found"`.
if (lower.contains("trigger type ") && lower.contains("not found"))
|| (lower.contains("cannot enable trigger") && lower.contains("not found"))
{
return true;
}
// OPENHUMAN-TAURI-34: composio rejected a tool call because the user
// hasn't enabled the toolkit yet. Wire shape:
// `Backend returned 400 … Toolkit "get" is not enabled`.
if lower.contains("toolkit ") && lower.contains("is not enabled") {
return true;
}
// OPENHUMAN-TAURI-XX: custom_openai upstream rejected the request with
// its own 400. Wire shape produced by
// `inference/provider/compatible.rs::is_custom_openai_upstream_bad_request_http_400`:
//
// custom_openai API error (400 Bad Request): {"error":{
// "message":"Bad request to upstream provider",
// "type":"upstream_error","status":400}}
//
// Anchored to the `custom_openai api error (400` prefix so this can't
// silence unrelated errors that happen to mention both
// "bad request to upstream provider" and "upstream_error" elsewhere
// (e.g. a future provider whose envelope reuses one of those strings).
if lower.contains("custom_openai api error (400")
&& lower.contains("bad request to upstream provider")
&& lower.contains("upstream_error")
{
return true;
}
// OPENHUMAN-TAURI-97: composio authorize with a blank required field —
// SharePoint Subdomain, WhatsApp WABA ID, Tenant Name, etc.
// Backend returns 500 with `"Missing required fields: …"` body.
//
// **Intentionally broad** — unlike the trigger/toolkit arms, this is a
// single substring with no second anchor. Composio's wire shape varies
// per provider (`Missing required fields: Tenant Name`, `Missing
// required fields: Your Subdomain (example: 'your-subdomain' for…)`,
// `Missing required fields: WABA ID (WhatsApp Business Account ID…)`)
// and embedding every variant would be brittle. Accepted false-positive
// surface: a non-composio caller whose error happens to contain
// `"missing required fields"` (e.g. `"Internal error: missing required
// fields in config"`) will also demote to info. This is fine — every
// current emit site routed through `report_error_or_expected` is scoped
// to composio / integrations envelopes, so a stray collision would have
// to come from a brand-new call site that explicitly opts in.
// See `unrelated_missing_required_fields_classifies_as_accepted_false_positive`
// for the documented surface.
if lower.contains("missing required fields") {
return true;
}
// OPENHUMAN-TAURI-33: gmail sync hit an OAuth scope wall —
// `HTTP 403: Request had insufficient authentication scopes.`
// (or any sibling OAuth scope rejection from composio's toolkits).
if lower.contains("insufficient authentication scopes") {
return true;
}
// OPENHUMAN-TAURI-S7: provider policy rejection on Kimi's coding
// endpoint when requests are not sent from an approved coding-agent
// client. Canonical body contains `access_terminated_error` and:
// "currently only available for Coding Agents ...".
if lower.contains("access_terminated_error")
|| lower.contains("currently only available for coding agents")
{
return true;
}
// TAURI-RUST-X9 (#1166): direct-mode composio call against the user's
// personal Composio v3 tenant rejected with a 401 because the stored
// API key is invalid / revoked / has the wrong prefix. The canonical
// wire shape rendered by
// `src/openhuman/composio/composio/tools/direct.rs::response_error`
// and the various direct-mode op wrappers is:
//
// `[composio-direct] list_connections failed: Composio v3
// connected_accounts failed: HTTP 401: Invalid API key: ak_…`
//
// The "Invalid API key" body is rendered for every direct-mode
// endpoint (list_connections / list_tools / authorize / etc.), so we
// gate on the **`[composio-direct]` prefix** + either of the two
// anchors that prove the failure came from the v3 auth wall:
// - `HTTP 401` (the status the v3 wall returns)
// - `Invalid API key` (the body Composio puts in the JSON)
//
// Requiring the `[composio-direct]` prefix keeps this from
// accidentally swallowing unrelated bugs — backend-mode 401s from
// `integrations/composio/*` still carry the `Backend returned 401`
// shape (handled by the failure-tag flow with `status="401"`),
// not the `HTTP 401: Invalid API key` shape.
//
// Remediation is purely user-state: the user must rotate / re-enter
// their Composio key via Settings → Composio → Direct mode. Sentry
// has no actionable signal — the UI surfaces the "Invalid API key"
// toast and the polling layer already retries every 5 s.
//
// Drops Sentry TAURI-RUST-X9 (~15.7 k events / ~22 h, single user,
// release openhuman@0.54.0+c25fc8e5fd3e).
//
// TAURI-RUST-322 (#2929): same direct-mode path but the Composio v3
// `/connected_accounts` API returns HTTP 403 instead of 401. This
// happens when the BYO API key exists and is syntactically valid but
// does not carry the `connected_accounts:read` permission (e.g. a
// scoped or legacy key). Wire shape:
//
// `[composio-direct] list_connections failed: Composio v3
// connected_accounts failed: HTTP 403`
//
// 403 from Composio v3 is a user-state condition (key permissions),
// not a bug in openhuman_core. Sentry has no remediation path — the
// user must regenerate their key with the correct scopes on
// app.composio.dev. The polling layer retries every 5 s and the UI
// already surfaces the error; flooding Sentry with 1,000+ events per
// user adds no signal.
//
// Drops Sentry TAURI-RUST-322 (1,021 events, multi-release).
if lower.contains("[composio-direct]")
&& (lower.contains("http 401")
|| lower.contains("http 403")
|| lower.contains("invalid api key"))
{
return true;
}
// TAURI-RUST-34H — composio backend endpoint (e.g.
// `/agent-integrations/composio/connections`) wraps an upstream
// Cloudflare anti-bot challenge as `Backend returned 500 Internal
// Server Error … 403 <!DOCTYPE html>…<title>Just a moment...</title>…`.
// The CF interstitial is keyed by the user's network reputation /
// geo / cookie state — there is nothing in `openhuman_core` that
// can act on it. Backend ops or the user's network is the
// remediation path; Sentry has no signal.
//
// Double-anchor on the Cloudflare challenge title + the literal
// "cloudflare" token to avoid colliding with unrelated bodies that
// merely mention "Just a moment" in a different context.
//
// Drops ~8.9 k events / 14d (TAURI-RUST-34H, sibling -32G / -34J /
// -323 share the same cascade).
if lower.contains("just a moment...") && lower.contains("cloudflare") {
return true;
}
// OPENHUMAN-TAURI-YJ: `inference/provider/ops.rs::list_models` probed a
// user-configured custom-provider's `/models` endpoint and the upstream
// server returned 404. Wire shape emitted at `ops.rs:118-122`:
//
// "provider returned 404: {\"error\":\"path \\\"/api/v1/models\\\" not found\"}"
//
// (the trailing body is whatever the upstream server wrote — `{"error":...}`,
// `{"detail":...}`, bare HTML, etc.; we only anchor on the `provider returned
// 404` prefix). The semantic is unambiguous: the user pointed a custom
// OpenAI-compatible provider at a base URL that does not host a `/models`
// listing endpoint (wrong base, model-only proxy, typo'd path). The model
// dropdown already surfaces the failure inline — Sentry has no remediation.
//
// **404 only**. Other 4xx from the same emit site stay actionable:
// - 401 / 403: BYO-key auth wall — actionable misconfiguration; the
// `does_not_classify_byo_key_provider_401_as_session_expired` contract
// (#2286) intentionally keeps these in Sentry.
// - 400: typically request-shape bugs in OUR client; must escalate.
// - 429 / 5xx: transient — handled by other matchers / retry policy.
//
// No `inference/provider/ops.rs::list_models` other than this site emits
// the `provider returned NNN` prefix (verified via grep), so the prefix
// alone is a sufficient anchor.
if lower.starts_with("provider returned 404") {
return true;
}
false
}
/// Detect "<capability> is disabled / unavailable for this RAM tier" errors
/// emitted by the local-AI service when the user's hardware tier doesn't
/// support a capability (OPENHUMAN-TAURI-3B: vision asset download invoked
/// on a 04 GB tier). These are pure user-state conditions — the local-AI
/// service surfaces them so the UI can prompt the user to switch tiers —
/// and carry no remediable signal for Sentry.
///
/// The two canonical wire shapes today both contain `"for this ram tier"`:
///
/// - `"Vision is disabled for this RAM tier. Switch to the 4-8 GB tier or
/// above to enable it."` — from `local_ai/service/assets.rs::ensure_capability_ready`
/// - `"vision summaries are unavailable for this RAM tier. Use OCR-only
/// summarization or switch to a higher local AI tier."` —
/// from `local_ai/service/vision_embed.rs::summarize`
///
/// Anchor the classifier to that exact substring so an unrelated message
/// that merely mentions "RAM tier" out of context is not silenced.
fn is_local_ai_capability_unavailable_message(lower: &str) -> bool {
lower.contains("for this ram tier")
}
/// Detect prompts rejected by the in-process prompt-injection guard.
///
/// Both enforcement actions that produce a user-visible error — `Blocked`
/// (score ≥ 0.70) and `ReviewBlocked` (score ≥ 0.55) — share a unique
/// prefix that cannot appear in any other error path. Anchored to the exact
/// strings emitted by `prompt_guard_user_message` in
/// `src/openhuman/inference/local/ops.rs`.
fn is_prompt_injection_blocked_message(lower: &str) -> bool {
lower.contains("prompt flagged for security review")
|| lower.contains("prompt blocked by security policy")
}
/// Detect an RPC-level filesystem path validation failure from user input.
///
/// Anchored on the two known wire shapes — both emitted at the RPC entry
/// boundary when a user typed/picked a path that doesn't resolve to an
/// existing directory:
///
/// - `"root_path is not a directory: <path>"` — historically emitted by the
/// now-removed knowledge-vault `vault_create` path when the chosen folder
/// didn't exist or pointed at a file (Sentry TAURI-RUST-4QH). Kept as a
/// classifier fixture since the wire shape may recur from other callers.
/// - `"hosted path is not a directory: <path>"` —
/// [`crate::openhuman::http_host::path_utils`] when an HTTP host config
/// references a missing directory. Not yet observed in Sentry but
/// shares the same user-input failure mode; preempts a future ID.
///
/// Both are deterministic Err returns at the validation gate of an RPC
/// handler, BEFORE any side-effect happens. The UI already surfaces the
/// typed error and Sentry has no remediation path.
///
/// **Polarity contract** — explicit wire-shape anchors prevent accidental
/// demotion of future errors whose bodies happen to contain "path is not
/// a directory:" in a different context:
///
/// - `skills::ops_install` emits `"{path} is not a directory — refusing
/// to remove"` (em-dash separator, no "root_path" or "hosted path"
/// prefix). That is an `rm -rf` safety guard catching an UNEXPECTED
/// state, not user input — it must STAY actionable.
/// - A generic `"input config path is not a directory: /etc/foo"` from a
/// future provider/wallet/storage error would NOT match (no known
/// prefix) and would reach Sentry as intended.
///
/// All matches are substring-based against the lower-cased message so
/// the classifier survives caller wrapping (`rpc.invoke_method`,
/// anyhow context chains, …).
fn is_filesystem_user_path_invalid_message(lower: &str) -> bool {
lower.contains("root_path is not a directory:")
|| lower.contains("hosted path is not a directory:")
}
/// Detect memory-store writes rejected because the namespace or key contained
/// a personal identifier detected by the PII guard.
///
/// The three canonical wire shapes are emitted by
/// `memory_store/unified/documents.rs` and `memory_store/kv.rs`:
///
/// - `"document namespace/key cannot contain personal identifiers"` —
/// `upsert_document` / `upsert_document_metadata_only`
/// - `"kv key cannot contain personal identifiers"` — `kv_set_global`
/// - `"kv namespace/key cannot contain personal identifiers"` — `kv_set_namespace`
///
/// These are expected user-content conditions: the PII guard classifies a
/// channel name, username, or LLM-generated key as a personal identifier and
/// rejects the write. The LLM or caller already receives the error message;
/// Sentry has no remediation path. Drops TAURI-RUST-54T (~915 events,
/// escalating — all from a single user hitting false positives on valid
/// namespace/key identifiers).
///
/// Anchor on `"cannot contain personal identifiers"` — the exact string
/// shared by all three sites — so typos or future rewordings that drop the
/// anchor still reach Sentry until explicitly classified.
fn is_memory_store_pii_rejection(lower: &str) -> bool {
lower.contains("cannot contain personal identifiers")
}
/// Detect the agent harness's empty-provider-response bail.
///
/// Anchored on the literal user-facing string emitted at
/// `agent::harness::session::turn` —
/// `"The model returned an empty response. Please try again."` — which is
/// preserved verbatim as the provider/model returns a body with
/// `text_chars=0 thinking_chars=0 tool_calls=0`.
///
/// This catches the **web-channel re-report** (Sentry TAURI-RUST-4Z1):
/// `channels::providers::web::run_chat_task` wraps the failure as
/// `"run_chat_task failed client_id=… error=The model returned an empty
/// response. Please try again."` and routes it through
/// `report_error_or_expected` after the typed
/// `AgentError::EmptyProviderResponse` was flattened to a `String` at the
/// native-bus boundary (so the agent-layer `skips_sentry()` suppression
/// from PR #2790 can't reach it).
///
/// Anchored on `"model returned an empty response"` (not the looser
/// `"empty response"`) so the sibling phrases stay actionable:
/// `"summarizer returned empty response, falling through"`
/// (`payload_summarizer`) and `"provider returned an empty response;
/// returning empty extraction"` (`subagent_runner::extract_tool`) are
/// internal fall-through paths with different wording and are NOT
/// silenced.
fn is_empty_provider_response_message(lower: &str) -> bool {
lower.contains("model returned an empty response")
}
/// Capture an error to Sentry with structured tags.
///
/// `domain` and `operation` are required and become tags `domain:<…>` and
/// `operation:<…>`. `extra` is an optional list of extra tag pairs. The error
/// itself is rendered via `Display` and emitted as a `tracing::error!` event,
/// which the Sentry tracing layer turns into a Sentry event under the active
/// scope.
///
/// Use stable, low-cardinality values for tag keys/values so Sentry can group
/// and aggregate. High-cardinality data (full IDs, payloads) belongs in the
/// error message body, not in tags.
pub fn report_error<E: Display + ?Sized>(
err: &E,
domain: &str,
operation: &str,
extra: &[Tag<'_>],
) {
// Use the alternate format specifier so `anyhow::Error` renders its full
// context chain (outer context + every wrapped cause, joined by ": ").
// Plain `Display` impls fall back to the standard representation. Without
// this, anyhow's default `to_string()` only emits the outermost context
// and the underlying cause (e.g. a `toml::de::Error` with line/column) is
// dropped — making the captured Sentry event undiagnosable. See
// OPENHUMAN-TAURI-B2 for an instance where this masked the real failure.
let message = format!("{err:#}");
report_error_message(&message, domain, operation, extra);
}
/// Report an error unless it is an expected user-state/config condition.
///
/// Expected conditions are logged at `info` or `warn` so the Sentry tracing
/// layer records at most a breadcrumb, not an error event.
pub fn report_error_or_expected<E: Display + ?Sized>(
err: &E,
domain: &str,
operation: &str,
extra: &[Tag<'_>],
) {
let message = format!("{err:#}");
if let Some(kind) = expected_error_kind(&message) {
report_expected_message(kind, &message, domain, operation);
return;
}
report_error_message(&message, domain, operation, extra);
}
fn report_expected_message(kind: ExpectedErrorKind, message: &str, domain: &str, operation: &str) {
match kind {
ExpectedErrorKind::LocalAiDisabled => {
tracing::info!(
domain = domain,
operation = operation,
error = %message,
"[observability] {domain}.{operation} skipped expected local-ai disabled error: {message}"
);
}
ExpectedErrorKind::ApiKeyMissing => {
tracing::warn!(
domain = domain,
operation = operation,
error = %message,
"[observability] {domain}.{operation} skipped expected API-key configuration error: {message}"
);
}
ExpectedErrorKind::NetworkUnreachable => {
tracing::warn!(
domain = domain,
operation = operation,
error = %message,
"[observability] {domain}.{operation} skipped expected network-unreachable error: {message}"
);
}
ExpectedErrorKind::TransientUpstreamHttp => {
tracing::warn!(
domain = domain,
operation = operation,
error = %message,
"[observability] {domain}.{operation} skipped transient upstream HTTP error: {message}"
);
}
ExpectedErrorKind::LocalAiBinaryMissing => {
// User-state condition: piper / whisper.cpp / Ollama binary
// isn't installed on this host. The error message itself is
// the user-facing instruction ("Set PIPER_BIN or install
// piper.") — Sentry has nothing to act on, since we can't
// install the binary for them. OPENHUMAN-TAURI-9N is the
// canonical instance: `local_ai_tts` fails immediately
// (elapsed_ms=1) on a Windows host without piper installed.
tracing::info!(
domain = domain,
operation = operation,
error = %message,
"[observability] {domain}.{operation} skipped expected local-ai binary-missing error: {message}"
);
}
ExpectedErrorKind::BackendUserError => {
// 4xx from the integrations / composio backend client —
// user-input or auth-state failure that the backend already
// surfaced to the user via the structured error toast.
// OPENHUMAN-TAURI-BC: SharePoint authorize 400 because the
// user didn't fill in the required Tenant Name field.
tracing::warn!(
domain = domain,
operation = operation,
error = %message,
"[observability] {domain}.{operation} skipped expected backend user-error response: {message}"
);
}
ExpectedErrorKind::ProviderUserState => {
// Third-party provider (composio, gmail OAuth, …) rejected the
// request for a user-state reason: trigger slug missing from
// composio's registry (OPENHUMAN-TAURI-3R / -3S), toolkit not
// enabled (OPENHUMAN-TAURI-34), OAuth scopes missing
// (OPENHUMAN-TAURI-33), or a required form field was left blank
// (OPENHUMAN-TAURI-97). The UI already surfaces the actionable
// error to the user — Sentry has no remediation path.
tracing::info!(
domain = domain,
operation = operation,
kind = "provider_user_state",
error = %message,
"[observability] {domain}.{operation} skipped expected provider-user-state error: {message}"
);
}
ExpectedErrorKind::ProviderConfigRejection => {
// User-config state: a custom cloud provider rejected the
// request because of the user's model / parameter setup — an
// OpenHuman abstract tier alias leaked to a provider that only
// speaks its native ids (#2079), an unknown / stale model pin
// (#2202), or a model-specific temperature constraint (#2076,
// Moonshot Kimi K2). The provider HTTP layer already demoted
// its own per-attempt event; this is the re-report raised
// again by agent.run_single / web_channel.run_chat_task. The
// UI surfaces an actionable "fix your model/provider settings"
// error — Sentry has no remediation path
// (OPENHUMAN-TAURI-WJ / -QW / -HB / -NH).
tracing::info!(
domain = domain,
operation = operation,
kind = "provider_config_rejection",
error = %message,
"[observability] {domain}.{operation} skipped expected provider config-rejection error: {message}"
);
}
ExpectedErrorKind::LocalAiCapabilityUnavailable => {
// User-state condition: the local-AI service refused a
// capability (vision summarization, vision asset download)
// because the user's RAM tier doesn't support it. The
// error message itself is the user-facing remediation
// ("Switch to the 4-8 GB tier or above to enable it.") —
// Sentry has nothing to act on. OPENHUMAN-TAURI-3B: 28
// hits in 4 days from `local_ai_download_asset` on a
// 04 GB tier requesting vision.
tracing::info!(
domain = domain,
operation = operation,
error = %message,
"[observability] {domain}.{operation} skipped expected local-ai capability-unavailable error: {message}"
);
}
ExpectedErrorKind::BudgetExhausted => {
// User-state condition: the backend reports the user is out of
// budget / credits / balance (HTTP 400 from the OpenHuman backend,
// surfaced by `providers::is_budget_exhausted_message`). The UI
// already surfaces this as an actionable toast — Sentry would
// turn each affected turn into noise (OPENHUMAN-TAURI-3M / -12 /
// -13). Demote to info so it still appears in breadcrumbs but
// never spawns a Sentry error event.
tracing::info!(
domain = domain,
operation = operation,
kind = "budget",
error = %message,
"[observability] {domain}.{operation} skipped expected budget-exhausted error: {message}"
);
}
ExpectedErrorKind::SessionExpired => {
// Auth-boundary condition: the user's JWT expired (or was never
// present). The JSON-RPC dispatch layer already handles the
// teardown — `Err` propagation publishes `DomainEvent::SessionExpired`
// which clears the stored token and flips the scheduler-gate
// signed-out override so background workers stand down — and the
// UI re-auths the user. The per-attempt error event from the
// upstream call site (agent.run_single, web_channel.run_chat_task)
// adds noise without signal: every mid-conversation 401 would
// emit one event before the cascade dampener kicks in
// (OPENHUMAN-TAURI-26, and the same upstream gap that
// OPENHUMAN-TAURI-1T's #1516 cascade fix dampened but did not
// close). Demote to info so the breadcrumb survives for trace
// correlation but Sentry sees no error event.
tracing::info!(
domain = domain,
operation = operation,
error = %message,
"[observability] {domain}.{operation} skipped expected session-expired error: {message}"
);
}
ExpectedErrorKind::LoopbackUnavailable => {
// In-process-core boot-window condition: a sibling component
// tried to reach `127.0.0.1:<port>` before the embedded core's
// HTTP listener finished binding (OPENHUMAN-TAURI-R5 / -R6).
// Self-resolves once startup completes. Demote at `debug!` —
// lower than the `warn!` we use for NetworkUnreachable because
// this isn't a user-environment problem; it's an internal
// lifecycle race that always recovers. We deliberately drop the
// raw `message` from the structured fields and format string and
// log only `domain` / `operation` / `kind` — the body adds no
// remediation signal (the URL is always loopback, the error is
// always "Connection refused") and keeping the breadcrumb sparse
// mirrors the per-#1719 review feedback (metadata over raw text
// for noise demotions).
tracing::debug!(
domain = domain,
operation = operation,
kind = "loopback_unavailable",
"[observability] {domain}.{operation} skipped expected loopback-unavailable error"
);
}
ExpectedErrorKind::PromptInjectionBlocked => {
tracing::info!(
domain = domain,
operation = operation,
kind = "prompt_injection_blocked",
"[observability] {domain}.{operation} skipped expected prompt-injection-blocked error"
);
}
ExpectedErrorKind::ContextWindowExceeded => {
// Request too long for the model's context window. The provider
// api_error cascade already demotes its own emit; this is the
// higher-layer re-report. Deterministic user-state — the UI
// shows the retry message and the user trims / starts a new
// chat. Demote to `warn!` (breadcrumb only) — same tier as the
// other usage-state conditions.
tracing::warn!(
domain = domain,
operation = operation,
kind = "context_window_exceeded",
error = %message,
"[observability] {domain}.{operation} skipped expected context-window-exceeded error: {message}"
);
}
ExpectedErrorKind::DiskFull => {
// Host filesystem out of space. The user must free space on
// their machine — Sentry can't help. Demote at `warn!` so a
// sustained spike still shows up in operator dashboards
// without turning every affected user-session into a Sentry
// error event. Drops TAURI-RUST-H4.
tracing::warn!(
domain = domain,
operation = operation,
kind = "disk_full",
"[observability] {domain}.{operation} skipped expected disk-full error"
);
}
ExpectedErrorKind::MemoryStoreBreakerOpen => {
tracing::warn!(
domain = domain,
operation = operation,
kind = "memory_store_breaker_open",
"[observability] {domain}.{operation} skipped expected memory-store circuit-breaker-open error"
);
}
ExpectedErrorKind::WhatsAppDataSqliteBusy => {
tracing::warn!(
domain = domain,
operation = operation,
kind = "whatsapp_data_sqlite_busy",
"[observability] {domain}.{operation} skipped expected whatsapp_data sqlite busy/locked error"
);
}
ExpectedErrorKind::FilesystemUserPathInvalid => {
// User-input validation failure surfaced at the RPC
// boundary — e.g. `openhuman.vault_create` called with a
// `root_path` that doesn't exist. The typed error is
// already shown to the user; Sentry has no remediation
// path. Demote to `info!` — same tier as
// `PromptInjectionBlocked`, which is the closest severity
// class ("user input we already surfaced a typed error for";
// not operator-actionable like `DiskFull` / `NetworkUnreachable`).
//
// **Do not include the raw `message` here.** The message
// body embeds the user's local filesystem layout (username,
// project name, document directory, …) and
// `sentry_tracing_layer` in `core::logging` maps
// `Level::INFO` to `EventFilter::Breadcrumb` — so any
// formatted body would be attached as a breadcrumb to
// every subsequent Sentry event from this hub, leaking
// user paths into unrelated reports. Log only `domain` /
// `operation` / `kind` (no PII), matching the
// `LoopbackUnavailable` arm above ("metadata over raw text
// for noise demotions", per the #1719 review feedback).
// Full-path diagnostics for local debugging stay available
// via `RUST_LOG=…=debug` since `Level::DEBUG` / `TRACE`
// are mapped to `EventFilter::Ignore`.
tracing::info!(
domain = domain,
operation = operation,
kind = "filesystem_user_path_invalid",
"[observability] {domain}.{operation} skipped expected filesystem path validation error"
);
}
ExpectedErrorKind::MemoryStorePiiRejection => {
// PII guard rejected a memory-store write because the namespace or
// key was classified as containing a personal identifier. The guard
// already logs a `[memory:safety]` warn at the write site; this
// match arm keeps the diagnostic breadcrumb at warn level (not
// error) so local log files retain the context without spawning a
// Sentry error event. TAURI-RUST-54T (~915 events from one user).
tracing::warn!(
domain = domain,
operation = operation,
kind = "memory_store_pii_rejection",
"[observability] {domain}.{operation} skipped expected memory-store PII rejection"
);
}
ExpectedErrorKind::EmptyProviderResponse => {
// Model/user-config condition — the provider returned a
// completely empty body and the agent harness bailed with the
// user-facing retry message. The agent layer already suppresses
// this via the typed `AgentError::skips_sentry()` (PR #2790);
// this arm covers the `web_channel.run_chat_task` re-report
// where the type was flattened to a String. Demote to `warn!`
// (breadcrumb only) — same tier as `MaxIterationsExceeded`,
// the other deterministic agent-state outcome surfaced to the
// user via the `chat_error` event.
tracing::warn!(
domain = domain,
operation = operation,
kind = "empty_provider_response",
error = %message,
"[observability] {domain}.{operation} skipped expected empty-provider-response error: {message}"
);
}
ExpectedErrorKind::ChannelSupervisorRestart => {
// Channel supervisor caught a transient error from a channel
// listener (`spawn_supervised_listener`) and restarted it. The
// wrapper is language-agnostic — anchored on the Rust supervisor
// shape, not the inner error wording — so this catches both the
// English Discord-gateway body (TAURI-RUST-15 ~11.4 k events) and
// OS-localized variants (TAURI-RUST-BB Chinese WSAETIMEDOUT,
// ~815 events) that the English-only `NetworkUnreachable`
// matchers miss. Self-resolving via the supervisor's exponential
// backoff — Sentry has no remediation path. Sustained outages
// still surface through `health.bus` / `FAIL_ESCALATE_THRESHOLD`
// (separate code path, not affected by this demotion). Demote to
// `info!` so the breadcrumb survives for trace correlation but
// Sentry sees no error or warn event.
tracing::info!(
domain = domain,
operation = operation,
kind = "channel_supervisor_restart",
error = %message,
"[observability] {domain}.{operation} skipped expected channel-supervisor restart: {message}"
);
}
ExpectedErrorKind::ConfigLoadTimedOut => {
tracing::warn!(
domain = domain,
operation = operation,
kind = "config_load_timed_out",
error = %message,
"[observability] {domain}.{operation} skipped expected config-load timeout: {message}"
);
}
}
}
/// Distinct `tracing::Metadata::target()` we set on the diagnostic
/// `tracing::error!` emitted from [`report_error_message`].
///
/// Sentry capture for this helper happens via an explicit
/// `sentry::capture_message` call below — not via the `sentry-tracing`
/// layer scooping up the `tracing::error!` event. The production
/// `sentry_tracing_layer()` in `core::logging` filters events with this
/// target to `EventFilter::Ignore` so we never double-report (one direct
/// `capture_message`, one tracing-bridge capture of the same condition).
///
/// Why direct capture instead of relying on the bridge: the bridge worked
/// in steady-state but flaked under parallel test scheduling
/// (`thread_not_found_rpc_error_does_not_report_to_sentry` repeatedly hit
/// `events.len() == 0` in CI even with a thread-default subscriber wired
/// up — likely a Linux-only thread-local ordering quirk in
/// `sentry-tracing`'s `Hub::current()` lookup at event-emit time). Direct
/// `sentry::capture_message` synchronously routes through the active hub
/// and is deterministic, which keeps both production reporting and tests
/// honest.
pub const REPORT_ERROR_TRACING_TARGET: &str = "openhuman::observability::report_error";
pub(crate) fn report_error_message(
message: &str,
domain: &str,
operation: &str,
extra: &[Tag<'_>],
) {
sentry::with_scope(
|scope| {
scope.set_tag("domain", domain);
scope.set_tag("operation", operation);
for (k, v) in extra {
scope.set_tag(k, v);
}
},
|| {
// Direct, synchronous Sentry capture — see
// `REPORT_ERROR_TRACING_TARGET` for why we don't rely on the
// `sentry-tracing` layer for this call site.
sentry::capture_message(message, sentry::Level::Error);
// Diagnostic log line for stderr / file appenders. Tagged with
// the marker target so the production sentry-tracing layer
// skips it (no double Sentry event).
tracing::error!(
target: REPORT_ERROR_TRACING_TARGET,
domain = domain,
operation = operation,
error = %message,
"[observability] {domain}.{operation} failed: {message}"
);
},
);
}
/// Returns true when a Sentry event is a per-attempt provider HTTP failure
/// that the reliable-provider layer already handles via retry + fallback.
///
/// The primary suppression lives at the call site
/// (`openhuman::inference::provider::ops::should_report_provider_http_failure`),
/// which short-circuits transient codes before `report_error` ever fires.
/// This helper is intended for use inside the `sentry::ClientOptions`
/// `before_send` hook as defense-in-depth — it catches any future call
/// site that emits a `tracing::error!` with the same shape but bypasses
/// the classifier.
///
/// Match criteria (all required):
/// - tag `domain == "llm_provider"` — pins the filter to provider-originated
/// events so an unrelated subsystem emitting `failure=non_2xx`/`status=503`
/// for its own reasons doesn't get silently dropped
/// - tag `failure == "non_2xx"` (the marker set by `ops::api_error`)
/// - tag `status` parses to one of [`TRANSIENT_PROVIDER_HTTP_STATUSES`]
pub fn is_transient_provider_http_failure(event: &sentry::protocol::Event<'_>) -> bool {
let tags = &event.tags;
if tags.get("domain").map(String::as_str) != Some("llm_provider") {
return false;
}
if tags.get("failure").map(String::as_str) != Some("non_2xx") {
return false;
}
let Some(status_u16) = tags.get("status").and_then(|s| s.parse::<u16>().ok()) else {
return false;
};
TRANSIENT_PROVIDER_HTTP_STATUSES.contains(&status_u16)
}
/// Returns true when a Sentry event's message/exception text contains the
/// canonical max-tool-iterations cap phrase (see
/// `openhuman::agent::error::MAX_ITERATIONS_ERROR_PREFIX`).
///
/// Defense-in-depth filter for the Sentry `before_send` hook: the primary
/// suppression lives at the call sites in `agent::harness::session::
/// runtime::run_single`, `channels::runtime::dispatch`, and
/// `channels::providers::web::run_chat_task`, all of which now skip
/// `report_error` when this variant is detected. This filter catches any
/// future call site that re-emits the message without going through those
/// funnels — e.g. a new wrapper that calls `tracing::error!` directly with
/// the typed error rendering — and keeps OPENHUMAN-TAURI-99 / -98
/// permanently off Sentry without requiring touch-ups at each new site.
///
/// Match strategy: scans `event.message` first (the path used by
/// `report_error_message` → `sentry::capture_message`) and falls back to
/// the last exception's `value` (the shape `sentry-tracing` produces when
/// stacktraces are attached). Both fields are checked for the canonical
/// prefix so the filter stays robust to future Sentry plumbing changes.
pub fn is_max_iterations_event(event: &sentry::protocol::Event<'_>) -> bool {
let direct = event.message.as_deref();
let from_exception = event.exception.last().and_then(|e| e.value.as_deref());
[direct, from_exception]
.into_iter()
.flatten()
.any(crate::openhuman::agent::error::is_max_iterations_error)
}
/// Tag + body classifier for the `before_send` chain — drops Sentry events
/// emitted at the OpenHuman backend / rpc layers for "401 Session
/// expired" or the pre-flight "no session token stored" guards.
///
/// Pairs with [`is_session_expired_message`] (which classifies the
/// message body at the emit site via `report_error_or_expected`). This
/// fn runs in `before_send` so it catches any future call site that
/// re-emits the same shape without routing through the classifier —
/// keeps OPENHUMAN-TAURI-25 / -1Q / -27 / -1G permanently off Sentry
/// (~185 events/day combined).
///
/// Scope: only the three domains that surface session-expired today
/// (`llm_provider`, `backend_api`, `rpc`). Composio's OAuth-state 401
/// is excluded — that's actionable and must reach Sentry.
pub fn is_session_expired_event(event: &sentry::protocol::Event<'_>) -> bool {
let tags = &event.tags;
let Some(domain) = tags.get("domain").map(String::as_str) else {
return false;
};
if !matches!(domain, "llm_provider" | "backend_api" | "rpc") {
return false;
}
let status_is_401 = tags
.get("status")
.and_then(|s| s.parse::<u16>().ok())
.is_some_and(|code| code == 401);
let direct = event.message.as_deref();
let from_exception = event.exception.last().and_then(|e| e.value.as_deref());
let body_matches = [direct, from_exception]
.into_iter()
.flatten()
.any(is_session_expired_message);
if status_is_401 && body_matches {
return true;
}
// Pre-flight rpc guard has no status tag — accept on body alone,
// scoped to the rpc dispatcher (other domains don't emit the
// "no session token stored" sentinel).
if domain == "rpc" && body_matches {
return true;
}
false
}
pub fn is_transient_http_status(status: &str) -> bool {
TRANSIENT_HTTP_STATUSES.contains(&status)
}
pub fn is_transient_http_status_code(status: u16) -> bool {
let status = status.to_string();
is_transient_http_status(status.as_str())
}
pub fn contains_transient_transport_phrase(message: &str) -> bool {
let lower = message.to_ascii_lowercase();
TRANSIENT_TRANSPORT_PHRASES
.iter()
.any(|phrase| lower.contains(phrase))
}
pub fn is_updater_transient_http_status(status: u16) -> bool {
UPDATER_TRANSIENT_HTTP_STATUSES.contains(&status)
}
pub fn is_updater_transient_message(message: &str) -> bool {
let lower = message.to_ascii_lowercase();
UPDATER_TRANSIENT_MESSAGE_PHRASES
.iter()
.any(|phrase| lower.contains(phrase))
}
fn event_has_transient_transport_phrase(event: &sentry::protocol::Event<'_>) -> bool {
event
.message
.as_deref()
.is_some_and(contains_transient_transport_phrase)
|| event
.logentry
.as_ref()
.is_some_and(|log| contains_transient_transport_phrase(&log.message))
|| event.exception.values.iter().any(|exception| {
exception
.value
.as_deref()
.is_some_and(contains_transient_transport_phrase)
})
}
fn event_has_updater_transient_message(event: &sentry::protocol::Event<'_>) -> bool {
event
.message
.as_deref()
.is_some_and(is_updater_transient_message)
|| event
.logentry
.as_ref()
.is_some_and(|log| is_updater_transient_message(&log.message))
|| event.exception.values.iter().any(|exception| {
exception
.value
.as_deref()
.is_some_and(is_updater_transient_message)
})
}
fn event_has_updater_domain(event: &sentry::protocol::Event<'_>) -> bool {
matches!(
event.tags.get("domain").map(String::as_str),
Some("update") | Some("update.check_releases") | Some("updater")
)
}
fn is_transient_domain_failure(event: &sentry::protocol::Event<'_>, domain: &str) -> bool {
let tags = &event.tags;
if tags.get("domain").map(String::as_str) != Some(domain) {
return false;
}
match tags.get("failure").map(String::as_str) {
Some("non_2xx") => tags
.get("status")
.is_some_and(|status| is_transient_http_status(status)),
Some("transport") => event_has_transient_transport_phrase(event),
_ => false,
}
}
/// Transient backend API failures (gateway hiccups, scheduled downtime).
/// Match by event tags written by report_error at the authed_json call site.
pub fn is_transient_backend_api_failure(event: &sentry::protocol::Event<'_>) -> bool {
is_transient_domain_failure(event, "backend_api")
}
/// Transient integrations / Composio failures (timeout, connection reset,
/// gateway hiccups).
///
/// Accepts both `domain="integrations"` (the shared
/// [`crate::openhuman::integrations::IntegrationClient`] HTTP wrapper that
/// fronts every backend-proxied integration) and `domain="composio"` (errors
/// reported from the Composio op layer in
/// [`crate::openhuman::composio::ops`]). Composio routes through the same
/// `IntegrationClient`, so the failure shape is identical — but op-level
/// reporters that wrap and re-emit those errors with their own domain tag
/// would otherwise escape the integrations-scoped filter (OPENHUMAN-TAURI-35
/// ~139ev, -2H ~26ev: `[composio] list_connections failed: Backend returned
/// 502 …` events that landed in Sentry under `domain=composio`).
pub fn is_transient_integrations_failure(event: &sentry::protocol::Event<'_>) -> bool {
is_transient_domain_failure(event, "integrations")
|| is_transient_domain_failure(event, "composio")
}
/// Transient updater failures from GitHub release probes/downloads.
///
/// Core-side reports carry structured tags (`domain=update`, often
/// `operation=check_releases`, plus `failure/status`). Tauri's updater plugin
/// can also emit message-only events such as
/// `"failed to check for updates: error sending request for url (...latest.json)"`.
/// Match both shapes, but never drop an arbitrary update-domain event unless
/// it also has a transient status/transport marker.
pub fn is_updater_transient_event(event: &sentry::protocol::Event<'_>) -> bool {
if event_has_updater_transient_message(event) {
return true;
}
if !event_has_updater_domain(event) {
return false;
}
match event.tags.get("failure").map(String::as_str) {
Some("non_2xx") => event
.tags
.get("status")
.and_then(|status| status.parse::<u16>().ok())
.is_some_and(is_updater_transient_http_status),
Some("transport") => event_has_transient_transport_phrase(event),
_ => false,
}
}
/// String tokens that mark a formatted error message as a transient HTTP
/// failure. Used at upstream emit sites (`rpc.invoke_method`,
/// `web_channel.run_chat_task`) where the error has already been stringified
/// and the original `status` / `failure` tag context is gone.
///
/// Each token combines a status code with a non-numeric anchor (parenthesis
/// or canonical reason phrase) so bare numeric coincidences ("process 502
/// exited") do not match.
const TRANSIENT_STATUS_MESSAGE_TOKENS: &[&str] = &[
"(408 ",
"(429 ",
"(502 ",
"(503 ",
"(504 ",
"(520 ",
"408 request timeout",
"429 too many requests",
"502 bad gateway",
"503 service unavailable",
"504 gateway timeout",
"520 <unknown status code>",
];
/// Returns true when a formatted error message describes a transient HTTP
/// or transport-layer failure that has already been demoted further down the
/// stack. Use at upstream re-emit sites (`rpc.invoke_method`,
/// `web_channel.run_chat_task`) where `report_error` is called with the
/// stringified downstream error and no `failure` / `status` tag context.
pub fn is_transient_message_failure(msg: &str) -> bool {
let lower = msg.to_ascii_lowercase();
TRANSIENT_STATUS_MESSAGE_TOKENS
.iter()
.any(|token| lower.contains(token))
|| contains_transient_transport_phrase(&lower)
}
/// Returns true when a Sentry event is a budget-exhausted 400 that should be
/// dropped from `before_send`.
///
/// **Two-tier match — either tier fires a drop:**
///
/// 1. **Tag-gated path** (primary suppression, added in PR #1633):
/// - tag `failure == "non_2xx"`
/// - tag `status == "400"`
/// - event message or any exception value contains one of the tight
/// budget-exhaustion phrases from
/// [`crate::openhuman::inference::provider::is_budget_exhausted_message`]
///
/// 2. **Text-only path** (defense-in-depth, covers OPENHUMAN-CORE-N /
/// TAURI-RUST-1P — GitHub issue #2935):
/// - event message or any exception value contains the exact phrase
/// `"Insufficient budget"` (the literal wire body the OpenHuman API
/// returns in `{"success":false,"error":"Insufficient budget"}`),
/// regardless of which tags are set.
///
/// The text-only tier is intentionally tighter than tier 1 — it only
/// matches the exact phrase the backend uses, never the looser
/// `"add credits"` / `"budget exceeded"` phrases that might appear in
/// unrelated product copy. This prevents future call sites that call
/// `report_error` without setting `failure` / `status` tags (or that
/// invoke `sentry::capture_message` directly) from leaking budget
/// exhaustion events to Sentry.
///
/// Note: `domain` is intentionally not gated here so a future re-emitter
/// under a different domain tag still gets filtered.
pub fn is_budget_event(event: &sentry::protocol::Event<'_>) -> bool {
// Tier 1 — tag-gated primary path.
let tags = &event.tags;
if tags.get("failure").map(String::as_str) == Some("non_2xx")
&& tags.get("status").map(String::as_str) == Some("400")
&& event_contains_budget_exhausted_message(event)
{
return true;
}
// Tier 2 — text-only defense-in-depth: drop any event whose message or
// exception value contains the exact wire phrase the OpenHuman backend
// emits for budget exhaustion, regardless of tags.
event_contains_budget_insufficient_phrase(event)
}
/// Defense-in-depth `before_send` filter for Sentry event CORE-RUST-EK
/// (~827 events): every call to the cloud embedding API (OpenAI
/// `text-embedding-3-large` or Voyage) that returns HTTP 401 fires a Sentry
/// error event via `report_error_or_expected` in
/// `src/openhuman/embeddings/openai.rs`.
///
/// 401 on the embedding call path means the configured API key is stale or
/// invalid. This is the same class of condition as the VOYAGE_API_KEY-missing
/// error (PR #2915) and the billing-expired 401 (PR #2924): the user's LLM
/// session was interrupted by an auth failure that the core will retry on the
/// next turn, but the Sentry volume-per-key ratio yields zero actionable signal.
///
/// Match criteria (all required):
/// - tag `domain == "embeddings"` — pins the filter to the embeddings call
/// path and avoids silencing unrelated 401s from provider chat, billing, or
/// the backend RPC layer
/// - tag `failure == "non_2xx"` — the marker set by `embeddings::openai::embed`
/// at the non-2xx path
/// - tag `status == "401"` — narrows to auth-rejection failures (429 / 500
/// are handled by the existing rate-limit filter)
///
/// The primary suppression for the OpenHuman-backend "Invalid token" shape
/// already lives in `expected_error_kind` / `is_session_expired_message`. This
/// filter is defense-in-depth: it catches any third-party provider 401 that
/// doesn't carry the OpenHuman-backend body (e.g. OpenAI's
/// `{"error":{"code":"invalid_api_key",...}}`), ensuring CORE-RUST-EK stays
/// off Sentry regardless of which embedding provider is configured.
pub fn is_embeddings_api_key_401_event(event: &sentry::protocol::Event<'_>) -> bool {
let tags = &event.tags;
if tags.get("domain").map(String::as_str) != Some("embeddings") {
return false;
}
if tags.get("failure").map(String::as_str) != Some("non_2xx") {
return false;
}
tags.get("status").map(String::as_str) == Some("401")
}
/// 404 on PATCH/DELETE to a channel-message path is an expected backend state
/// (user deleted the message provider-side, backend GC'd the relay row). The
/// primary suppression lives in `authed_json` via `parse_message_path` +
/// defense-in-depth inline check. This filter is the outermost safety net for
/// any future call site that bypasses both. Targets OPENHUMAN-TAURI-R7.
///
/// Match criteria (all required):
/// - tag `domain == "backend_api"`
/// - tag `failure == "non_2xx"`
/// - tag `status == "404"`
/// - tag `method == "PATCH"` or `"DELETE"`
/// - event message or exception value contains both `"/channels/"` and `"/messages/"`
pub fn is_channel_message_not_found_event(event: &sentry::protocol::Event<'_>) -> bool {
let tags = &event.tags;
if tags.get("domain").map(String::as_str) != Some("backend_api") {
return false;
}
if tags.get("failure").map(String::as_str) != Some("non_2xx") {
return false;
}
if tags.get("status").map(String::as_str) != Some("404") {
return false;
}
let method = tags.get("method").map(String::as_str).unwrap_or("");
if method != "PATCH" && method != "DELETE" {
return false;
}
event_contains_channel_message_path(event)
}
fn event_contains_channel_message_path(event: &sentry::protocol::Event<'_>) -> bool {
let has_pattern = |s: &str| s.contains("/channels/") && s.contains("/messages/");
if event.message.as_deref().is_some_and(has_pattern) {
return true;
}
event
.exception
.values
.iter()
.any(|exc| exc.value.as_deref().is_some_and(has_pattern))
}
fn event_contains_budget_exhausted_message(event: &sentry::protocol::Event<'_>) -> bool {
if event
.message
.as_deref()
.is_some_and(crate::openhuman::inference::provider::is_budget_exhausted_message)
{
return true;
}
event.exception.values.iter().any(|exception| {
exception
.value
.as_deref()
.is_some_and(crate::openhuman::inference::provider::is_budget_exhausted_message)
})
}
/// Tier-2 (text-only) budget check for [`is_budget_event`].
///
/// Matches the exact literal phrase `"Insufficient budget"` (case-insensitive)
/// in the event message or any exception value. This is the wire body the
/// OpenHuman backend returns: `{"success":false,"error":"Insufficient budget"}`.
///
/// Deliberately narrower than [`event_contains_budget_exhausted_message`]:
/// only the precise backend phrase is matched, never the looser
/// `"add credits"` / `"budget exceeded"` / `"insufficient balance"` phrases
/// which might appear in unrelated product copy that would otherwise produce
/// false positives.
fn event_contains_budget_insufficient_phrase(event: &sentry::protocol::Event<'_>) -> bool {
const PHRASE: &str = "insufficient budget";
let has_phrase = |s: &str| s.to_ascii_lowercase().contains(PHRASE);
if event.message.as_deref().is_some_and(has_phrase) {
return true;
}
event
.exception
.values
.iter()
.any(|exc| exc.value.as_deref().is_some_and(has_phrase))
}
#[cfg(test)]
#[path = "observability_tests.rs"]
mod tests;