From 4190fc2620624ea27a85f92bd6ea194c5cc0a644 Mon Sep 17 00:00:00 2001 From: Jon Seager Date: Wed, 11 Feb 2026 08:37:03 +0000 Subject: [PATCH] feat: log payload fields in trace spans by redacting image data Add ImageData newtype that wraps Option with a custom Debug impl showing Some()/None instead of raw base64. Replace image fields on all 14 submission structs and remove payload from tracing skip lists so textual/numeric fields appear in spans. --- src/application/routes/api/coffee/bags.rs | 9 ++-- src/application/routes/api/coffee/brews.rs | 45 ++++++++++--------- src/application/routes/api/coffee/cafes.rs | 13 +++--- src/application/routes/api/coffee/checkin.rs | 7 +-- src/application/routes/api/coffee/cups.rs | 9 ++-- src/application/routes/api/coffee/gear.rs | 31 +++++++------ src/application/routes/api/coffee/roasters.rs | 13 +++--- src/application/routes/api/coffee/roasts.rs | 39 ++++++++-------- src/application/routes/api/coffee/scan.rs | 9 ++-- src/domain/images.rs | 37 +++++++++++++++ 10 files changed, 132 insertions(+), 80 deletions(-) diff --git a/src/application/routes/api/coffee/bags.rs b/src/application/routes/api/coffee/bags.rs index 0f6dd4a..e3bb099 100644 --- a/src/application/routes/api/coffee/bags.rs +++ b/src/application/routes/api/coffee/bags.rs @@ -17,6 +17,7 @@ use crate::application::routes::support::{ use crate::application::state::AppState; use crate::domain::bags::{BagFilter, BagSortKey, BagWithRoast, NewBag, UpdateBag}; use crate::domain::ids::{BagId, RoastId}; +use crate::domain::images::ImageData; use crate::domain::listing::{ListRequest, SortDirection}; use crate::presentation::web::templates::BagListTemplate; use crate::presentation::web::views::{BagView, ListNavigator, Paginated}; @@ -53,7 +54,7 @@ pub(crate) async fn load_bag_page( Ok(BagPageData { bags, navigator }) } -#[tracing::instrument(skip(state, _auth_user, headers, query, payload))] +#[tracing::instrument(skip(state, _auth_user, headers, query))] pub(crate) async fn create_bag( State(state): State, _auth_user: AuthenticatedUser, @@ -139,7 +140,7 @@ pub(crate) struct UpdateBagSubmission { #[serde(default)] created_at: Option>, #[serde(default)] - image: Option, + image: ImageData, } impl UpdateBagSubmission { @@ -153,7 +154,7 @@ impl UpdateBagSubmission { finished_at: self.finished_at, created_at: self.created_at, }; - (update, self.image) + (update, self.image.into_inner()) } } @@ -168,7 +169,7 @@ impl_has_changes!( created_at ); -#[tracing::instrument(skip(state, _auth_user, headers, query, payload))] +#[tracing::instrument(skip(state, _auth_user, headers, query))] pub(crate) async fn update_bag( State(state): State, _auth_user: AuthenticatedUser, diff --git a/src/application/routes/api/coffee/brews.rs b/src/application/routes/api/coffee/brews.rs index 3015023..1c0bce4 100644 --- a/src/application/routes/api/coffee/brews.rs +++ b/src/application/routes/api/coffee/brews.rs @@ -21,6 +21,7 @@ use crate::domain::brews::{ }; use crate::domain::gear::{GearCategory, GearFilter, GearSortKey}; use crate::domain::ids::{BagId, BrewId, GearId}; +use crate::domain::images::ImageData; use crate::domain::listing::{ListRequest, PageSize, SortDirection}; use crate::presentation::web::templates::BrewListTemplate; use crate::presentation::web::views::{ @@ -198,11 +199,11 @@ pub(crate) struct NewBrewSubmission { #[serde(default)] created_at: Option>, #[serde(default)] - image: Option, + image: ImageData, } impl NewBrewSubmission { - fn into_new_brew(self) -> Result { + fn into_parts(self) -> Result<(NewBrew, Option), AppError> { if self.coffee_weight <= 0.0 { return Err(AppError::validation("coffee weight must be positive")); } @@ -223,23 +224,26 @@ impl NewBrewSubmission { return Err(AppError::validation("brew time must be positive")); } - Ok(NewBrew { - bag_id: self.bag_id, - coffee_weight: self.coffee_weight, - grinder_id: self.grinder_id, - grind_setting: self.grind_setting, - brewer_id: self.brewer_id, - filter_paper_id: self.filter_paper_id, - water_volume: self.water_volume, - water_temp: self.water_temp, - quick_notes: self.quick_notes, - brew_time: self.brew_time, - created_at: self.created_at, - }) + Ok(( + NewBrew { + bag_id: self.bag_id, + coffee_weight: self.coffee_weight, + grinder_id: self.grinder_id, + grind_setting: self.grind_setting, + brewer_id: self.brewer_id, + filter_paper_id: self.filter_paper_id, + water_volume: self.water_volume, + water_temp: self.water_temp, + quick_notes: self.quick_notes, + brew_time: self.brew_time, + created_at: self.created_at, + }, + self.image.into_inner(), + )) } } -#[tracing::instrument(skip(state, _auth_user, headers, query, payload))] +#[tracing::instrument(skip(state, _auth_user, headers, query))] pub(crate) async fn create_brew( State(state): State, _auth_user: AuthenticatedUser, @@ -249,8 +253,7 @@ pub(crate) async fn create_brew( ) -> Result { let (request, search) = query.into_request_and_search::(); let (submission, source) = payload.into_parts(); - let image_data_url = submission.image.clone(); - let new_brew = submission.into_new_brew().map_err(ApiError::from)?; + let (new_brew, image_data_url) = submission.into_parts().map_err(ApiError::from)?; let enriched = state .brew_service @@ -350,7 +353,7 @@ pub(crate) struct UpdateBrewSubmission { #[serde(default)] created_at: Option>, #[serde(default)] - image: Option, + image: ImageData, } impl UpdateBrewSubmission { @@ -372,7 +375,7 @@ impl UpdateBrewSubmission { brew_time: self.brew_time, created_at: self.created_at, }; - (update, self.image) + (update, self.image.into_inner()) } } @@ -391,7 +394,7 @@ impl_has_changes!( created_at ); -#[tracing::instrument(skip(state, _auth_user, headers, payload))] +#[tracing::instrument(skip(state, _auth_user, headers))] pub(crate) async fn update_brew( State(state): State, _auth_user: AuthenticatedUser, diff --git a/src/application/routes/api/coffee/cafes.rs b/src/application/routes/api/coffee/cafes.rs index d09a615..1e07686 100644 --- a/src/application/routes/api/coffee/cafes.rs +++ b/src/application/routes/api/coffee/cafes.rs @@ -18,6 +18,7 @@ use crate::application::routes::support::{ use crate::application::state::AppState; use crate::domain::cafes::{Cafe, CafeSortKey, NewCafe, UpdateCafe}; use crate::domain::ids::CafeId; +use crate::domain::images::ImageData; use crate::domain::listing::{ListRequest, SortDirection}; use crate::infrastructure::foursquare; use crate::presentation::web::templates::{CafeListTemplate, NearbyCafesFragment}; @@ -71,7 +72,7 @@ pub(crate) struct NewCafeSubmission { #[serde(default)] created_at: Option>, #[serde(default)] - image: Option, + image: ImageData, } impl NewCafeSubmission { @@ -85,11 +86,11 @@ impl NewCafeSubmission { website: self.website, created_at: self.created_at, }; - (cafe, self.image) + (cafe, self.image.into_inner()) } } -#[tracing::instrument(skip(state, _auth_user, headers, query, payload))] +#[tracing::instrument(skip(state, _auth_user, headers, query))] pub(crate) async fn create_cafe( State(state): State, _auth_user: AuthenticatedUser, @@ -159,7 +160,7 @@ pub(crate) struct UpdateCafeSubmission { #[serde(default)] created_at: Option>, #[serde(default)] - image: Option, + image: ImageData, } impl UpdateCafeSubmission { @@ -173,7 +174,7 @@ impl UpdateCafeSubmission { website: self.website, created_at: self.created_at, }; - (update, self.image) + (update, self.image.into_inner()) } } @@ -181,7 +182,7 @@ impl_has_changes!( UpdateCafe, name, city, country, latitude, longitude, website, created_at ); -#[tracing::instrument(skip(state, _auth_user, headers, payload))] +#[tracing::instrument(skip(state, _auth_user, headers))] pub(crate) async fn update_cafe( State(state): State, _auth_user: AuthenticatedUser, diff --git a/src/application/routes/api/coffee/checkin.rs b/src/application/routes/api/coffee/checkin.rs index 81e1926..32d0dea 100644 --- a/src/application/routes/api/coffee/checkin.rs +++ b/src/application/routes/api/coffee/checkin.rs @@ -14,6 +14,7 @@ use crate::application::state::AppState; use crate::domain::cafes::NewCafe; use crate::domain::cups::NewCup; use crate::domain::ids::{CafeId, RoastId}; +use crate::domain::images::ImageData; #[derive(Debug, Deserialize)] pub(crate) struct CheckInSubmission { @@ -33,12 +34,12 @@ pub(crate) struct CheckInSubmission { cafe_website: Option, roast_id: String, #[serde(default)] - cafe_image: Option, + cafe_image: ImageData, #[serde(default)] - cup_image: Option, + cup_image: ImageData, } -#[tracing::instrument(skip(state, _auth_user, headers, payload))] +#[tracing::instrument(skip(state, _auth_user, headers))] pub(crate) async fn submit_checkin( State(state): State, _auth_user: AuthenticatedUser, diff --git a/src/application/routes/api/coffee/cups.rs b/src/application/routes/api/coffee/cups.rs index 1164c73..bd71f4f 100644 --- a/src/application/routes/api/coffee/cups.rs +++ b/src/application/routes/api/coffee/cups.rs @@ -19,6 +19,7 @@ use crate::application::routes::support::{ use crate::application::state::AppState; use crate::domain::cups::{CupFilter, CupSortKey, CupWithDetails, NewCup, UpdateCup}; use crate::domain::ids::{CafeId, CupId, RoastId}; +use crate::domain::images::ImageData; use crate::domain::listing::{ListRequest, SortDirection}; use crate::presentation::web::templates::CupListTemplate; use crate::presentation::web::views::{CupView, ListNavigator, Paginated}; @@ -49,7 +50,7 @@ pub(crate) async fn load_cup_page( )) } -#[tracing::instrument(skip(state, _auth_user, headers, query, payload))] +#[tracing::instrument(skip(state, _auth_user, headers, query))] pub(crate) async fn create_cup( State(state): State, _auth_user: AuthenticatedUser, @@ -104,7 +105,7 @@ pub(crate) struct UpdateCupSubmission { #[serde(default)] created_at: Option>, #[serde(default)] - image: Option, + image: ImageData, } impl UpdateCupSubmission { @@ -114,13 +115,13 @@ impl UpdateCupSubmission { cafe_id: self.cafe_id, created_at: self.created_at, }; - (update, self.image) + (update, self.image.into_inner()) } } impl_has_changes!(UpdateCup, roast_id, cafe_id, created_at); -#[tracing::instrument(skip(state, _auth_user, headers, payload))] +#[tracing::instrument(skip(state, _auth_user, headers))] pub(crate) async fn update_cup( State(state): State, _auth_user: AuthenticatedUser, diff --git a/src/application/routes/api/coffee/gear.rs b/src/application/routes/api/coffee/gear.rs index 64bcad8..921ed28 100644 --- a/src/application/routes/api/coffee/gear.rs +++ b/src/application/routes/api/coffee/gear.rs @@ -22,6 +22,7 @@ use crate::application::routes::support::{ use crate::application::state::AppState; use crate::domain::gear::{Gear, GearCategory, GearFilter, GearSortKey, NewGear, UpdateGear}; use crate::domain::ids::GearId; +use crate::domain::images::ImageData; use crate::domain::listing::{ListRequest, SortDirection}; use crate::presentation::web::templates::GearListTemplate; use crate::presentation::web::views::{GearView, ListNavigator, Paginated}; @@ -51,7 +52,7 @@ pub(crate) async fn load_gear_page( )) } -#[tracing::instrument(skip(state, _auth_user, headers, query, payload))] +#[tracing::instrument(skip(state, _auth_user, headers, query))] pub(crate) async fn create_gear( State(state): State, _auth_user: AuthenticatedUser, @@ -61,8 +62,7 @@ pub(crate) async fn create_gear( ) -> Result { let (request, search) = query.into_request_and_search::(); let (submission, source) = payload.into_parts(); - let image_data_url = submission.image.clone(); - let new_gear = submission.into_new_gear().map_err(ApiError::from)?; + let (new_gear, image_data_url) = submission.into_parts().map_err(ApiError::from)?; let gear = state .gear_service @@ -136,7 +136,7 @@ pub(crate) struct UpdateGearSubmission { #[serde(default)] created_at: Option>, #[serde(default)] - image: Option, + image: ImageData, } impl UpdateGearSubmission { @@ -146,13 +146,13 @@ impl UpdateGearSubmission { model: self.model, created_at: self.created_at, }; - (update, self.image) + (update, self.image.into_inner()) } } impl_has_changes!(UpdateGear, make, model, created_at); -#[tracing::instrument(skip(state, _auth_user, headers, payload))] +#[tracing::instrument(skip(state, _auth_user, headers))] pub(crate) async fn update_gear( State(state): State, _auth_user: AuthenticatedUser, @@ -210,11 +210,11 @@ pub(crate) struct NewGearSubmission { #[serde(default)] created_at: Option>, #[serde(default)] - image: Option, + image: ImageData, } impl NewGearSubmission { - fn into_new_gear(self) -> Result { + fn into_parts(self) -> Result<(NewGear, Option), AppError> { let category = GearCategory::from_str(&self.category) .map_err(|()| AppError::validation("invalid category"))?; @@ -226,12 +226,15 @@ impl NewGearSubmission { return Err(AppError::validation("model cannot be empty")); } - Ok(NewGear { - category, - make: self.make, - model: self.model, - created_at: self.created_at, - }) + Ok(( + NewGear { + category, + make: self.make, + model: self.model, + created_at: self.created_at, + }, + self.image.into_inner(), + )) } } diff --git a/src/application/routes/api/coffee/roasters.rs b/src/application/routes/api/coffee/roasters.rs index c8b3d6e..b186ef2 100644 --- a/src/application/routes/api/coffee/roasters.rs +++ b/src/application/routes/api/coffee/roasters.rs @@ -17,6 +17,7 @@ use crate::application::routes::support::{ }; use crate::application::state::AppState; use crate::domain::ids::RoasterId; +use crate::domain::images::ImageData; use crate::domain::listing::{ListRequest, SortDirection}; use crate::domain::roasters::{NewRoaster, Roaster, RoasterSortKey, UpdateRoaster}; use crate::infrastructure::ai::{self, ExtractionInput}; @@ -72,7 +73,7 @@ pub(crate) struct NewRoasterSubmission { #[serde(default)] created_at: Option>, #[serde(default)] - image: Option, + image: ImageData, } impl NewRoasterSubmission { @@ -84,11 +85,11 @@ impl NewRoasterSubmission { homepage: self.homepage, created_at: self.created_at, }; - (roaster, self.image) + (roaster, self.image.into_inner()) } } -#[tracing::instrument(skip(state, _auth_user, headers, query, payload))] +#[tracing::instrument(skip(state, _auth_user, headers, query))] pub(crate) async fn create_roaster( State(state): State, _auth_user: AuthenticatedUser, @@ -154,7 +155,7 @@ pub(crate) struct UpdateRoasterSubmission { #[serde(default)] created_at: Option>, #[serde(default)] - image: Option, + image: ImageData, } impl UpdateRoasterSubmission { @@ -166,13 +167,13 @@ impl UpdateRoasterSubmission { homepage: self.homepage, created_at: self.created_at, }; - (update, self.image) + (update, self.image.into_inner()) } } impl_has_changes!(UpdateRoaster, name, country, city, homepage, created_at); -#[tracing::instrument(skip(state, _auth_user, headers, payload))] +#[tracing::instrument(skip(state, _auth_user, headers))] pub(crate) async fn update_roaster( State(state): State, _auth_user: AuthenticatedUser, diff --git a/src/application/routes/api/coffee/roasts.rs b/src/application/routes/api/coffee/roasts.rs index d808a5f..af8ef78 100644 --- a/src/application/routes/api/coffee/roasts.rs +++ b/src/application/routes/api/coffee/roasts.rs @@ -17,6 +17,7 @@ use crate::application::routes::support::{ }; use crate::application::state::AppState; use crate::domain::ids::{RoastId, RoasterId}; +use crate::domain::images::ImageData; use crate::domain::listing::{ListRequest, SortDirection}; use crate::domain::roasts::{NewRoast, RoastSortKey, RoastWithRoaster, UpdateRoast}; use crate::infrastructure::ai::{self, ExtractionInput}; @@ -49,7 +50,7 @@ pub(crate) async fn load_roast_page( )) } -#[tracing::instrument(skip(state, _auth_user, headers, query, payload))] +#[tracing::instrument(skip(state, _auth_user, headers, query))] pub(crate) async fn create_roast( State(state): State, _auth_user: AuthenticatedUser, @@ -59,8 +60,7 @@ pub(crate) async fn create_roast( ) -> Result { let (request, search) = query.into_request_and_search::(); let (submission, source) = payload.into_parts(); - let image_data_url = submission.image.clone(); - let new_roast = submission.into_new_roast().map_err(ApiError::from)?; + let (new_roast, image_data_url) = submission.into_parts().map_err(ApiError::from)?; state .roaster_repo @@ -196,7 +196,7 @@ pub(crate) struct UpdateRoastSubmission { #[serde(default)] created_at: Option>, #[serde(default)] - image: Option, + image: ImageData, } impl UpdateRoastSubmission { @@ -211,7 +211,7 @@ impl UpdateRoastSubmission { process: self.process, created_at: self.created_at, }; - (update, self.image) + (update, self.image.into_inner()) } } @@ -227,7 +227,7 @@ impl_has_changes!( created_at ); -#[tracing::instrument(skip(state, _auth_user, headers, payload))] +#[tracing::instrument(skip(state, _auth_user, headers))] pub(crate) async fn update_roast( State(state): State, _auth_user: AuthenticatedUser, @@ -288,11 +288,11 @@ pub(crate) struct NewRoastSubmission { #[serde(default)] created_at: Option>, #[serde(default)] - image: Option, + image: ImageData, } impl NewRoastSubmission { - fn into_new_roast(self) -> Result { + fn into_parts(self) -> Result<(NewRoast, Option), AppError> { fn require(field: &str, value: String) -> Result { let trimmed = value.trim(); if trimmed.is_empty() { @@ -318,16 +318,19 @@ impl NewRoastSubmission { return Err(AppError::validation("tasting notes are required")); } - Ok(NewRoast { - roaster_id, - name, - origin, - region, - producer, - tasting_notes, - process, - created_at: self.created_at, - }) + Ok(( + NewRoast { + roaster_id, + name, + origin, + region, + producer, + tasting_notes, + process, + created_at: self.created_at, + }, + self.image.into_inner(), + )) } } diff --git a/src/application/routes/api/coffee/scan.rs b/src/application/routes/api/coffee/scan.rs index ce1f46b..08e6585 100644 --- a/src/application/routes/api/coffee/scan.rs +++ b/src/application/routes/api/coffee/scan.rs @@ -14,6 +14,7 @@ use crate::application::state::AppState; use crate::domain::bags::NewBag; use crate::domain::errors::RepositoryError; use crate::domain::ids::RoastId; +use crate::domain::images::ImageData; use crate::domain::roasters::NewRoaster; use crate::domain::roasts::NewRoast; use crate::infrastructure::ai::{self, ExtractionInput, Usage}; @@ -158,7 +159,7 @@ fn default_tasting_notes() -> TastingNotesInput { #[derive(Debug, Deserialize)] pub(crate) struct BagScanSubmission { #[serde(default)] - image: Option, + image: ImageData, #[serde(default)] prompt: Option, #[serde(default)] @@ -186,7 +187,7 @@ pub(crate) struct BagScanSubmission { #[serde(default)] matched_roast_id: Option, #[serde(default)] - scan_image: Option, + scan_image: ImageData, } #[derive(Debug, Serialize)] @@ -250,7 +251,7 @@ async fn extract_into_submission( } #[allow(clippy::too_many_lines)] -#[tracing::instrument(skip(state, auth_user, headers, payload))] +#[tracing::instrument(skip(state, auth_user, headers))] pub(crate) async fn submit_scan( State(state): State, auth_user: AuthenticatedUser, @@ -274,7 +275,7 @@ pub(crate) async fn submit_scan( let scan_image = submission .scan_image .take() - .or_else(|| submission.image.clone()) + .or_else(|| submission.image.cloned()) .filter(|s| !s.is_empty()); if has_raw_input { diff --git a/src/domain/images.rs b/src/domain/images.rs index de3d24f..8f30e5e 100644 --- a/src/domain/images.rs +++ b/src/domain/images.rs @@ -1,3 +1,7 @@ +use std::fmt; + +use serde::Deserialize; + /// An image associated with an entity (roaster, roast, gear, or cafe). pub struct EntityImage { pub entity_type: String, @@ -6,3 +10,36 @@ pub struct EntityImage { pub image_data: Vec, pub thumbnail_data: Vec, } + +/// Wrapper for image data URLs that redacts content in `Debug` output, +/// allowing payloads to be traced without logging raw base64 image data. +#[derive(Default, Deserialize)] +#[serde(transparent)] +pub struct ImageData(Option); + +impl ImageData { + pub fn into_inner(self) -> Option { + self.0 + } + + pub fn as_deref(&self) -> Option<&str> { + self.0.as_deref() + } + + pub fn take(&mut self) -> Option { + self.0.take() + } + + pub fn cloned(&self) -> Option { + self.0.clone() + } +} + +impl fmt::Debug for ImageData { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match &self.0 { + Some(_) => write!(f, "Some()"), + None => write!(f, "None"), + } + } +}