Rust error tracking
Know when your Rust app breaks, before your users do
ForgeOps connects the errors your Rust app reports to the deploys, traces, and changes around them, so when production breaks, the incident already says who's affected, what changed, and where to look.
A process-wide panic hook reports a panic on any thread, no per-framework middleware needed at all. Running on ForgeOps' own dedicated infrastructure, so nothing about a Rust app's errors or its users' data gets routed through a third-party error-tracking vendor. See an incident investigated →
Install
# Cargo.toml
[dependencies]
forge-ops-tracker = "0.14"
forge_ops_tracker::init(|c| {
c.dsn = Some("https://<api_key>@getforgeops.net/api/v1/events".to_string());
});
A process-wide panic hook reports a panic on any thread, no per-framework middleware needed at all.
Every client reports the same shape
Wherever it comes from, an event arrives with an exception class, a message, and a backtrace, plus whatever environment/release/server context that client can gather on its own. ForgeOps fingerprints and groups on the first three, so a Rust app's issues list works exactly the same way every other language's does: report the same bug a thousand times and it's still one row, not a thousand.
No agent, no sidecar: it's a plain HTTPS POST to ForgeOps' own ingestion API, so nothing about a Rust app's traffic gets routed through a third-party pipeline first.
Works with your framework
The Rust client hooks in at the framework level, not by wrapping individual calls, so nothing about how you already write Rust code has to change.
Any framework, any thread
std::panic::set_hook is genuinely process-wide, covering a web framework's own worker threads (Actix, Axum/Tokio) with no middleware to add. The hook waits briefly for the report to go out, so a panic on the main thread is delivered too.
Scripts and CLIs
Rust has no exit hook, so hold let _flush = forge_ops_tracker::flush_guard(Duration::from_secs(2)) for the whole of main (or call flush before it returns): anything still queued is sent first.
Plain Result::Err
capture_error reports an error you already have; the ResultReportExt trait reports on Err and passes the Result through unchanged.
What it looks like
The issue list
Every Rust issue reported into a project shows up here: title, status, assignee, event count, and when it was last seen, the same columns as any other language's project.
The issue detail
Every occurrence keeps its own timestamp, environment, release, and server, plus the backtrace exactly as Rust reported it, expandable per occurrence rather than flattened into one generic stack.
Alerting
A new Rust issue, a regression, or a spike past a threshold you set can reach Slack, Microsoft Teams, email, PagerDuty, Opsgenie, or a generic webhook, configured per project, per trigger.
Heartbeats
For the failure mode error tracking can't see on its own: a Rust cron job or recurring task that's supposed to run but silently stopped. Each heartbeat gets its own ping URL and its own grace period before it alerts.
Performance monitoring
Times whatever you wrap and reports one small aggregate per transaction, flushed on the performance_flush_interval, never one network call per timed call. This crate has no web framework integration, so nothing is timed automatically: you choose what to wrap. Keep transaction names low-cardinality ("GET /users/:id", not "GET /users/42"), since every distinct name is its own row. time_transaction records even if the closure panics. Turn it off with track_performance = false. The flush thread is a daemon and does not run on a normal process exit, so a short-lived program should call flush(timeout), which covers everything, or flush_performance() itself to send the last partial window. Each report also carries a small latency histogram, so ForgeOps shows an approximate p50/p95/p99 per transaction, not just an average, accurate to the width of the latency bucket a duration falls into. Requires a plan that includes performance monitoring; on a plan that doesn't, the periodic reports are accepted but not recorded (the response says why), so a working setup never looks broken.
// Wrap a whole request handler, or any block you want on the Performance page:
let response = forge_ops_tracker::time_transaction("GET /users/:id", || handle_request(req));
// Or record a duration you measured yourself:
forge_ops_tracker::record_performance("nightly-export", elapsed.as_secs_f64() * 1000.0);
Distributed tracing
This crate has no web framework integration, so you choose the unit of work to trace: wrap it in trace, and anything inside it on the same thread can add spans. Only a slow call's trace is ever sent: whether it crossed the threshold (one second by default, configurable) is decided entirely inside your process, so a fast call costs nothing over the wire. Requires a plan that includes distributed tracing.
let response = forge_ops_tracker::trace("GET /checkout", || {
let order = forge_ops_tracker::span("load order", "database", context! {"order_id" => 42}, || repo.find(42));
forge_ops_tracker::span("charge card", "service", HashMap::new(), || gateway.charge(&order));
render(&order)
});
// Something you timed yourself (kind is one of controller/service/database/redis/http/job/other;
// anything else is sent as "other"):
forge_ops_tracker::record_span("SELECT orders", "database", started_at, duration_ms, HashMap::new());
Make a request inside a trace through http_span and it's recorded as its own http span and handed the standard traceparent header to send, so the service you call nests its request under it; continue_trace picks the header up on the other side. An error captured inside the trace carries its trace id: with the other service on the Ruby SDK 0.12.0 and the two projects linked in ForgeOps, the failed checkout shows the API error from the very same request. Limit which hosts get the header with trace_propagation_targets. Needs forge-ops-tracker 0.10.0.
forge_ops_tracker::trace("POST /checkout", || {
let result = forge_ops_tracker::http_span("POST", url, HashMap::new(), |traceparent| {
let mut request = ureq::post(url);
if let Some(value) = traceparent {
request = request.set(forge_ops_tracker::TRACEPARENT_HEADER, value);
}
request.send_string(&body)
});
if let Err(err) = result {
forge_ops_tracker::capture_error(&err, context! {"order_id" => order.id}, None); // carries this trace's id
}
});
SQL in a request
When the request an issue failed in was traced, the issue opens with its slowest query: how long it took, its share of the request's time, how many queries the request ran, and a likely N+1 warning when the same statement ran five or more times. No database driver is instrumented, so wrap a query in database_span and pass its SQL; bind values are never taken. The statement is masked before it leaves your process, every string and number replaced with a question mark, and the same SQL shows under that span's bar in the trace's waterfall. Needs forge-ops-tracker 0.12.0, on a plan that includes distributed tracing.
use postgres::Client;
fn open_order_ids(db: &mut Client, customer_id: i64) -> Result<Vec<i64>, postgres::Error> {
const OPEN_ORDERS: &str = "SELECT id FROM orders WHERE customer_id = $1 AND status = 'open'";
forge_ops_tracker::database_span("load open orders", OPEN_ORDERS, Some("postgresql"), || {
let rows = db.query(OPEN_ORDERS, &[&customer_id])?;
Ok(rows.iter().map(|row| row.get(0)).collect())
})
}
// Sent as "SELECT id FROM orders WHERE customer_id = $1 AND status = ?"
What changed
A feature flag flip or a config edit can break things with no deploy at all. Record one with record_change and it shows under What changed on any issue that starts in the next two hours, and on the project's Changes page beside your deploys, labeled potentially relevant, never as the cause. init() also sends the Rust version the binary was built with, once per process, so a toolchain upgrade shows up with nothing to call; crates are left out, since a compiled binary keeps no record of them. Environment variable names (never values) are opt-in. Needs forge-ops-tracker 0.11.0, on a plan that includes change tracking.
use forge_ops_tracker::{context, Change};
forge_ops_tracker::init(|c| {
c.detect_changes = true; // the default; false sends no startup snapshot
c.track_env_var_names = true; // default false; names only, never values
});
forge_ops_tracker::record_change(Change {
details: context! {"flag" => "gateway_retry_v2", "from" => "10%", "to" => "100%"},
actor: Some("priya@example.com".to_string()),
..Change::new("feature_flag", "gateway_retry_v2 turned on for 100% of checkouts")
});
Custom metrics and infrastructure monitoring
Track a business event you name yourself (a signup, a payment) or a reading from one of your own hosts, with one explicit call each; nothing is automatic. Pass the value explicitly: 1 for a bare counter, or a real amount (it can be negative, for a refund). A captured value is stored as its own row, so counts and sums you compute later are exact. Calls are buffered and flushed as one batch in the background, so they are safe to make inside a request, and flush_metrics sends whatever is buffered right now (in a short-lived program, flush or flush_guard sends metrics and errors alike). A None hostname defaults to the configured server name. Requires a plan that includes custom metrics and infrastructure monitoring.
forge_ops_tracker::capture_metric("signup", 1.0); // a bare counter
forge_ops_tracker::capture_metric("payment", 49.0); // a real magnitude; it may be negative (a refund)
forge_ops_tracker::capture_infrastructure_metric("cpu", 0.42, None); // None defaults to server_name
forge_ops_tracker::capture_infrastructure_metric("disk", 0.81, Some("db-1"));
forge_ops_tracker::flush_metrics(); // send right now
Database errors
When an error comes from a database call, the issue shows which stored procedure, table or view its SQL touched, so you know where to start looking. A Rust error carries no SQL of its own, so report it with capture_error_with_sql where you ran the query. The names are sent by default and are identifiers, never values. The SQL statement itself is opt-in, with every string and number replaced by a question mark before it leaves your app, and a project setting can stop ForgeOps storing the statement at all.
if let Err(err) = sqlx::query(QUERY).bind(id).execute(&pool).await {
forge_ops_tracker::capture_error_with_sql(&err, QUERY, HashMap::new(), None);
}
// Opt in to also sending the masked statement (default false).
forge_ops_tracker::init(|c| {
c.capture_sql_statement = true;
});
Questions
Do I need to change how I already handle errors?
Almost never. Every client hooks the framework's own exception handling directly, the same way the install snippet above shows, so your own logs, error pages, and rescue blocks keep working exactly as they did before, and ForgeOps just also finds out about it. The one common exception is a background-job runner that doesn't forward through that same mechanism on its own; that needs one small hook added instead, not a change to anything already there.
Where does the data actually go?
Straight to ForgeOps' own ingestion API over plain HTTPS. No agent, no sidecar, and no third-party ingestion service in between, so nothing about your app's traffic or its users' data gets routed through anyone else's pipeline first.
What happens if the same bug fires a thousand times?
It's fingerprinted from its exception class, message, and backtrace, so a thousand reports of the same bug still show up as one issue with an event count of 1,000, not a thousand separate rows to dig through. And a runaway loop can't use up your monthly event quota: once one issue repeats faster than your plan's hourly limit (50 an hour on Free), further repeats are still counted, and still count toward spike alerts, but they aren't stored or charged to your quota.
Is anything scrubbed before it's stored?
Yes. Emails, credit card numbers, and known API key/token formats are redacted out of every event before it's even written, on every plan, with room for per-project custom field names on top of the defaults.
Try it with your own Rust app
Free plan included, no credit card required. New organizations start with 14 days of Business.
Get started freeGet