Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
35 changes: 26 additions & 9 deletions app/src-tauri/Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

38 changes: 34 additions & 4 deletions src/api/rest.rs
Original file line number Diff line number Diff line change
Expand Up @@ -382,14 +382,44 @@ impl BackendOAuthClient {
request = request.json(&body);
}

let response = request
.send()
.await
.with_context(|| format!("backend request {} {}", method.as_str(), url.path()))?;
let response = request.send().await.map_err(|e| {
crate::core::observability::report_error(
e.to_string().as_str(),
"backend_api",
"authed_json",
&[
("method", method.as_str()),
("path", url.path()),
("failure", "transport"),
],
);
anyhow::Error::new(e).context(format!(
"backend request {} {}",
method.as_str(),
url.path()
))
})?;

let status = response.status();
let text = response.text().await.unwrap_or_default();
if !status.is_success() {
let status_str = status.as_u16().to_string();
crate::core::observability::report_error(
format!(
"{} {} failed ({status}): {text}",
method.as_str(),
url.path()
)
.as_str(),
"backend_api",
"authed_json",
&[
("method", method.as_str()),
("path", url.path()),
("status", status_str.as_str()),
("failure", "non_2xx"),
],
);
anyhow::bail!(
"{} {} failed ({status}): {text}",
method.as_str(),
Expand Down
14 changes: 13 additions & 1 deletion src/core/jsonrpc.rs
Original file line number Diff line number Diff line change
Expand Up @@ -55,7 +55,19 @@ pub async fn rpc_handler(State(state): State<AppState>, Json(req): Json<RpcReque
.into_response()
}
Err(message) => {
tracing::info!("[rpc] {} -> err ({}ms): {}", method, ms, message);
// Session-expired bubbles up as an "error" but is an expected
// boundary condition (auth handler clears the local token and the
// UI re-auths). Don't spam Sentry with it.
if !is_session_expired_error(&message) {
crate::core::observability::report_error(
message.as_str(),
"rpc",
"invoke_method",
&[("method", method.as_str()), ("elapsed_ms", &ms.to_string())],
);
} else {
tracing::info!("[rpc] {} -> err ({}ms): {}", method, ms, message);
}
(
StatusCode::OK,
Json(RpcFailure {
Expand Down
1 change: 1 addition & 0 deletions src/core/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ pub mod event_bus;
pub mod jsonrpc;
pub mod logging;
pub mod memory_cli;
pub mod observability;
pub mod rpc_log;
pub mod shutdown;
pub mod socketio;
Expand Down
85 changes: 85 additions & 0 deletions src/core/observability.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,85 @@
//! Centralised error reporting for the core.
//!
//! 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);

/// 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<'_>],
) {
let message = err.to_string();
sentry::with_scope(
|scope| {
scope.set_tag("domain", domain);
scope.set_tag("operation", operation);
for (k, v) in extra {
scope.set_tag(*k, *v);
}
},
|| {
tracing::error!(
domain = domain,
operation = operation,
error = %message,
"[observability] {domain}.{operation} failed: {message}"
);
},
);
}

#[cfg(test)]
mod tests {
use super::*;

/// Helper must accept `&anyhow::Error`, `&dyn std::error::Error`, and
/// plain `&str` — the three shapes that show up at error sites today.
#[test]
fn report_error_accepts_common_error_shapes() {
let anyhow_err = anyhow::anyhow!("boom");
report_error(&anyhow_err, "test", "anyhow_shape", &[]);

let io_err = std::io::Error::other("io failed");
report_error(&io_err, "test", "io_shape", &[("kind", "io")]);

report_error("plain message", "test", "str_shape", &[]);
}

#[test]
fn report_error_does_not_panic_with_many_tags() {
let err = anyhow::anyhow!("multi-tag");
report_error(
&err,
"test",
"multi_tag",
&[("a", "1"), ("b", "2"), ("c", "3"), ("d", "4")],
);
}
}
25 changes: 25 additions & 0 deletions src/openhuman/agent/harness/session/runtime.rs
Original file line number Diff line number Diff line change
Expand Up @@ -461,6 +461,21 @@ impl Agent {
"Prompt flagged for security review and was not processed."
}
};
let action_tag = match guard.action {
PromptEnforcementAction::Allow => "allow",
PromptEnforcementAction::Blocked => "blocked",
PromptEnforcementAction::ReviewBlocked => "review_blocked",
};
crate::core::observability::report_error(
user_message,
"agent",
"prompt_injection_blocked",
&[
("session_id", self.event_session_id()),
("channel", self.event_channel()),
("action", action_tag),
],
);
publish_global(DomainEvent::AgentError {
session_id: self.event_session_id().to_string(),
message: user_message.to_string(),
Expand All @@ -487,6 +502,16 @@ impl Agent {
}
Err(err) => {
let sanitized_message = Self::sanitize_event_error_message(&err);
crate::core::observability::report_error(
&err,
"agent",
"run_single",
&[
("session_id", self.event_session_id()),
("channel", self.event_channel()),
("error_kind", sanitized_message.as_str()),
],
);
publish_global(DomainEvent::AgentError {
session_id: self.event_session_id().to_string(),
message: sanitized_message,
Expand Down
Loading
Loading