This commit is contained in:
2026-03-21 15:16:40 -04:00
commit ebe1836513
8 changed files with 2628 additions and 0 deletions

11
.gitignore vendored Normal file
View File

@@ -0,0 +1,11 @@
*
!*/
!sql/migrations/*.sql
!src/**/*.rs
!.gitignore
!Cargo.lock
!Cargo.toml
!README.md

2234
Cargo.lock generated Normal file

File diff suppressed because it is too large Load Diff

17
Cargo.toml Normal file
View File

@@ -0,0 +1,17 @@
[package]
name = "playground-exn"
version = "0.1.0"
edition = "2024"
publish = false
[dependencies]
derive_more = { version = "2.1.1", features = ["display"] }
exn = "0.3.0"
sqlx = { version = "0.8", features = ["runtime-tokio", "sqlite", "chrono", "uuid"] }
tokio = { version = "1.50.0", features = ["full"] }
uuid = { version = "1.19.0", features = ["v4", "v7", "serde"] }
tracing = "0.1"
tracing-subscriber = { version = "0.3", features = ["env-filter", "json"] }
serde = { version = "1", features = ["derive"] }
serde_json = "1.0.149"
chrono = { version = "0.4.44", features = ["serde"] }

17
README.md Normal file
View File

@@ -0,0 +1,17 @@
# Playground - Exn
Context aware errors.
https://github.com/fast/exn
https://fast.github.io/blog/stop-forwarding-errors-start-designing-them/
example output:
```
the program died, at src/main.rs:35:34
|
|-> failed to create file: res/foo.txt, at src/main.rs:79:43
|
|-> Permission denied (os error 13), at src/main.rs:79:43
```

5
sql/migrations/init.sql Normal file
View File

@@ -0,0 +1,5 @@
CREATE TABLE IF NOT EXISTS foobar (
id TEXT PRIMARY KEY,
foo TEXT UNIQUE,
bar TEXT UNIQUE
);

60
src/db.rs Normal file
View File

@@ -0,0 +1,60 @@
use std::str::FromStr;
use exn::{Result, ResultExt};
use sqlx::{
Pool, Sqlite,
sqlite::{SqliteConnectOptions, SqlitePoolOptions},
};
use uuid::Uuid;
use crate::{FooBar, FooBarRequest, error::DatabaseError};
/// Shared database pool type
pub type DbPool = Pool<Sqlite>;
pub async fn create_pool(path: &str) -> Result<DbPool, DatabaseError> {
let err = || DatabaseError::Initialization {
path: path.to_string(),
};
let options = SqliteConnectOptions::from_str(path)
.or_raise(err)?
.create_if_missing(true);
let pool = SqlitePoolOptions::new()
.max_connections(5)
.connect_with(options)
.await
.or_raise(err)?;
migrate(&pool).await.or_raise(err)?;
Ok(pool)
}
pub static MIGRATOR: sqlx::migrate::Migrator = sqlx::migrate!("sql/migrations");
/// Run database migrations
pub async fn migrate(pool: &DbPool) -> Result<(), DatabaseError> {
MIGRATOR
.run(pool)
.await
.or_raise(|| DatabaseError::Migration)?;
Ok(())
}
pub async fn insert_foobar(db: &DbPool, foobar: FooBarRequest) -> Result<FooBar, DatabaseError> {
Ok(sqlx::query_as::<_, FooBar>(
"INSERT INTO foobar (id, foo, bar)
VALUES (?, ?, ?, ?)
RETURNING *",
)
.bind(Uuid::now_v7().to_string())
.bind(foobar.foo)
.bind(foobar.bar)
.fetch_one(db)
.await
.map_err(|e| DatabaseError::SqlxError(e)) // We are wraping the original error into Exn() as SQLX error does not implement std::error::Error.
.or_raise(|| DatabaseError::Query)?)
}

185
src/error.rs Normal file
View File

@@ -0,0 +1,185 @@
use crate::MainError;
use chrono::Utc;
use derive_more::Display;
use exn::{Exn, Frame};
use serde_json::{Value, json};
use std::{collections::BTreeMap, fmt::Write};
use tracing::{Event, Subscriber};
use tracing_subscriber::{
Layer,
fmt::{FmtContext, FormatEvent, FormatFields, format::Writer},
registry::LookupSpan,
};
#[derive(Debug, Display)]
pub enum DatabaseError {
#[display("failed to initialize database connection to: {path}")]
Initialization { path: String },
#[display("failed to run db migration")]
Migration,
#[display("{_0}")]
SqlxError(sqlx::Error),
#[display("failed to execute query")]
Query,
}
impl std::error::Error for DatabaseError {}
// Example Output:
// Error: fatal error occurred in application:
// 0: failed to run app, at examples/src/custom-layout.rs:74:30
// 1: failed to send request to server: https://example.com, at examples/src/custom-layout.rs:94:9
pub fn err_to_custom_string_example<E: std::error::Error + Send + Sync>(err: Exn<E>) -> String {
fn collect_frames(report: &mut String, i: usize, frame: &Frame) {
if i > 0 {
report.push('\n');
}
write!(report, "{}: {}, at {}", i, frame.error(), frame.location()).unwrap();
for child in frame.children() {
collect_frames(report, i + 1, child);
}
}
let mut report = String::new();
collect_frames(&mut report, 0, err.frame());
report
}
fn frame_to_json(frame: &Frame) -> Value {
let msg = frame.error().to_string(); // or .downcast_ref::<YourErrorType>()
let loc = frame.location().to_string(); // gives "file.rs:line:column"
let mut obj = json!({
"message": msg.trim(),
"location": loc,
});
let children: Vec<Value> = frame.children().iter().map(frame_to_json).collect();
if !children.is_empty() {
obj["causes"] = Value::Array(children);
}
obj
}
fn error_to_json_value<E: std::error::Error + Send + Sync + 'static>(exn: &Exn<E>) -> Value {
let root_frame = exn.frame();
let mut root = frame_to_json(root_frame);
// Optional: promote root message & location to top-level fields
// (you can keep everything nested if you prefer)
let top_level = json!({
"message": root.get("message").unwrap_or(&Value::String("unknown error".into())).as_str().unwrap_or("unknown"),
"location": root.get("location").unwrap_or(&Value::String("unknown".into())).as_str().unwrap_or("unknown"),
"causes": root.get("causes").cloned().unwrap_or_else(|| json!([])),
});
top_level
}
pub fn exn_to_json_value<E>(exn: &Exn<E>) -> Value
where
E: std::fmt::Display + std::fmt::Debug + std::error::Error + Send + Sync + 'static,
{
fn frame_to_value(frame: &Frame) -> Value {
// Use .to_string() → calls Display on the dyn Error
// (most errors implement Display reasonably well)
let msg = frame.error().to_string();
// or for Debug output (often more detailed):
// let msg = format!("{:?}", frame.error());
let loc = frame.location().to_string();
let mut obj = json!({
"message": msg.trim(),
"location": loc.trim(),
});
let children: Vec<Value> = frame.children().iter().map(frame_to_value).collect();
if !children.is_empty() {
obj["causes"] = Value::Array(children);
}
obj
}
frame_to_value(exn.frame())
}
pub struct ExnLayer;
impl<S> Layer<S> for ExnLayer
where
S: tracing::Subscriber,
{
fn on_event(
&self,
event: &tracing::Event<'_>,
_ctx: tracing_subscriber::layer::Context<'_, S>,
) {
// Covert the values into a JSON object
let mut fields = BTreeMap::new();
let mut visitor = JsonVisitor(&mut fields);
event.record(&mut visitor);
// Output the event in JSON
let output = serde_json::json!({
"target": event.metadata().target(),
"name": event.metadata().name(),
"level": format!("{:?}", event.metadata().level()),
"fields": fields,
});
println!("{}", serde_json::to_string_pretty(&output).unwrap());
}
}
struct JsonVisitor<'a>(&'a mut BTreeMap<String, serde_json::Value>);
impl<'a> tracing::field::Visit for JsonVisitor<'a> {
fn record_f64(&mut self, field: &tracing::field::Field, value: f64) {
self.0
.insert(field.name().to_string(), serde_json::json!(value));
}
fn record_i64(&mut self, field: &tracing::field::Field, value: i64) {
self.0
.insert(field.name().to_string(), serde_json::json!(value));
}
fn record_u64(&mut self, field: &tracing::field::Field, value: u64) {
self.0
.insert(field.name().to_string(), serde_json::json!(value));
}
fn record_bool(&mut self, field: &tracing::field::Field, value: bool) {
self.0
.insert(field.name().to_string(), serde_json::json!(value));
}
fn record_str(&mut self, field: &tracing::field::Field, value: &str) {
self.0
.insert(field.name().to_string(), serde_json::json!(value));
}
fn record_error(
&mut self,
field: &tracing::field::Field,
value: &(dyn std::error::Error + 'static),
) {
self.0.insert(
field.name().to_string(),
serde_json::json!(value.to_string()),
);
}
fn record_debug(&mut self, field: &tracing::field::Field, value: &dyn std::fmt::Debug) {
self.0.insert(
field.name().to_string(),
serde_json::json!(format!("{:?}", value)),
);
}
}

99
src/main.rs Normal file
View File

@@ -0,0 +1,99 @@
use derive_more::Display;
use exn::{Result, ResultExt, bail};
use serde::{Deserialize, Serialize};
use sqlx::prelude::FromRow;
use tracing_subscriber::{layer::SubscriberExt, util::SubscriberInitExt};
use uuid::Uuid;
use crate::error::{ExnLayer, exn_to_json_value};
mod db;
mod error;
#[derive(Debug, Clone, FromRow)]
pub struct FooBar {
id: Uuid,
foo: String,
bar: String,
}
pub struct FooBarRequest {
foo: String,
bar: String,
}
#[derive(Debug, Display, Serialize, Deserialize)]
#[display("the program died")]
pub struct MainError;
impl std::error::Error for MainError {}
#[tokio::main]
async fn main() -> Result<(), MainError> {
let err = || MainError;
init_tracing();
//files::write().or_raise(err)?;
let _ = match files::write().or_raise(err) {
Ok(_) => (),
Err(e) => {
tracing::error!(exn = %exn_to_json_value(&e));
bail!(MainError)
}
};
let pool = db::create_pool("foobar.db").await.or_raise(err)?;
let foobar = db::insert_foobar(
&pool,
FooBarRequest {
foo: "foo".to_string(),
bar: "bar".to_string(),
},
)
.await
.or_raise(err)?;
println!("FOOBAR! {:?}", foobar);
Ok(())
}
mod files {
use std::{fs::File, io::Write};
use derive_more::Display;
use exn::{Result, ResultExt};
#[derive(Debug, Display)]
pub enum FileError {
#[display("failed to create file: {file}")]
Create { file: String },
#[display("failed to write to file: {text}")]
Write { text: String },
}
impl std::error::Error for FileError {}
pub fn write() -> Result<(), FileError> {
// mkdir res; chown root:root
// will get the error to test output
let path = "res/foo.txt";
let mut file = File::create(path).or_raise(|| FileError::Create {
file: path.to_string(),
})?;
let text = "Hello, world!";
file.write_all(text.as_bytes())
.or_raise(|| FileError::Write {
text: text.to_string(),
})?;
Ok(())
}
}
fn init_tracing() {
tracing_subscriber::registry().with(ExnLayer).init();
//tracing_subscriber::fmt().pretty().json().init();
}