mirror of
https://github.com/storytold/artcraft.git
synced 2026-10-20 17:02:53 -04:00
cleaned up pager (#1283)
This commit is contained in:
@@ -11,6 +11,30 @@
|
||||
- Fields that are optional use the `maybe_` prefix: `maybe_creator_user_token`, `maybe_prompt_token`
|
||||
- When implementing `fmt::Display` for error types, use the pattern: `write!(f, "{:?}", self)`
|
||||
|
||||
## File Layout (Top-to-Bottom Reading Order)
|
||||
|
||||
Organize code so a human reading top-to-bottom encounters the most important things first and
|
||||
can drill down into details only when curious. Think of it like a newspaper: headline first,
|
||||
then the story, then the fine print.
|
||||
|
||||
### Ordering within a file
|
||||
|
||||
1. **Constants** at the top, right after imports.
|
||||
2. **Structs, enums, and type definitions** next. If a struct contains another struct, place
|
||||
the outer/larger struct above the inner/smaller one.
|
||||
3. **Type ordering for API types**: Request, then Response (success), then Error.
|
||||
4. **`impl` blocks** after their type definitions.
|
||||
|
||||
### Ordering within an `impl` block
|
||||
|
||||
1. **Constructors** (`new`, `from_*`, `builder`) at the top.
|
||||
2. **Public methods** next — these are the main entry points a reader cares about.
|
||||
3. **Private helper methods** below public methods.
|
||||
- Among helpers: place "meaty" helpers (substantial logic, direct continuations of the
|
||||
public entry point) above "leaf" helpers (small formatters, simple lookups).
|
||||
- A method that calls another method in the same file should appear *above* the method
|
||||
it calls. This lets readers follow the call chain downward.
|
||||
|
||||
## Naming
|
||||
|
||||
- Handler functions: `{verb}_{noun}_handler` (e.g. `get_health_check_handler`, `login_handler`)
|
||||
|
||||
@@ -62,3 +62,14 @@ artcraft/
|
||||
- A mix of wreq and reqwest for Rust HTTP clients
|
||||
- TypeScript with Nx, React, Vite, Zustand, and Three.js
|
||||
- Use two spaces for indentation
|
||||
|
||||
### File Layout
|
||||
|
||||
Organize for top-to-bottom reading. Important things first, details later.
|
||||
|
||||
- **Constants** at the top (after imports)
|
||||
- **Structs/enums** next; outer structs above inner sub-structs
|
||||
- **API types** in order: Request, Response, Error
|
||||
- **In impl blocks**: constructors first, then public methods, then private helpers
|
||||
- Private helpers go *below* the methods that call them
|
||||
- Among helpers: meatier logic above leaf-level formatters
|
||||
|
||||
@@ -66,7 +66,11 @@ pub struct PageSentResult {
|
||||
pub short_id: Option<String>,
|
||||
}
|
||||
|
||||
// =============== Implementation ===============
|
||||
|
||||
impl PagerClient {
|
||||
// --- Constructor ---
|
||||
|
||||
pub fn new(
|
||||
client_config: PagerClientConfig,
|
||||
application_name: Option<String>,
|
||||
@@ -77,6 +81,8 @@ impl PagerClient {
|
||||
Self { client_config, application_name, environment, hostname, service_id }
|
||||
}
|
||||
|
||||
// --- Public interface ---
|
||||
|
||||
pub fn is_noop(&self) -> bool {
|
||||
matches!(self.client_config, PagerClientConfig::NoOp)
|
||||
}
|
||||
@@ -94,6 +100,8 @@ impl PagerClient {
|
||||
}
|
||||
}
|
||||
|
||||
// --- Private helpers (called by send_page) ---
|
||||
|
||||
async fn send_page_via_rootly(
|
||||
&self,
|
||||
notification: &NotificationDetails,
|
||||
@@ -115,92 +123,13 @@ impl PagerClient {
|
||||
|
||||
debug!("Sending page via Rootly (source={}): {}", source, notification.title);
|
||||
|
||||
let mut labels: Vec<(String, String)> = Vec::new();
|
||||
let labels = self.build_labels(notification);
|
||||
|
||||
if let Some(name) = &self.application_name {
|
||||
labels.push(("application_name".to_string(), name.clone()));
|
||||
}
|
||||
|
||||
if let Some(id) = &self.service_id {
|
||||
labels.push(("service_id".to_string(), id.clone()));
|
||||
}
|
||||
|
||||
if let Some(env) = &self.environment {
|
||||
labels.push(("environment".to_string(), env.clone()));
|
||||
}
|
||||
|
||||
if let Some(h) = &self.hostname {
|
||||
labels.push(("hostname".to_string(), h.clone()));
|
||||
}
|
||||
|
||||
if let Some(method) = ¬ification.http_method {
|
||||
labels.push(("http_method".to_string(), method.clone()));
|
||||
}
|
||||
|
||||
if let Some(path) = ¬ification.http_path {
|
||||
labels.push(("http_path".to_string(), path.clone()));
|
||||
}
|
||||
|
||||
if let Some(status_code) = notification.http_status_code {
|
||||
labels.push(("http_status_code".to_string(), status_code.to_string()));
|
||||
}
|
||||
|
||||
let labels = if labels.is_empty() { None } else { Some(labels) };
|
||||
|
||||
// Enrich the description with context and hostname if present.
|
||||
let description = match ¬ification.description {
|
||||
Some(desc) => {
|
||||
let mut parts = vec![desc.clone()];
|
||||
|
||||
match (&self.application_name, &self.service_id) {
|
||||
(Some(name), Some(id)) => {
|
||||
parts.push(format!("Application: {} (service_id: {})", name, id));
|
||||
}
|
||||
(Some(name), None) => {
|
||||
parts.push(format!("Application: {}", name));
|
||||
}
|
||||
(None, Some(id)) => {
|
||||
parts.push(format!("Service ID: {}", id));
|
||||
}
|
||||
(None, None) => {}
|
||||
}
|
||||
|
||||
if let Some(method) = ¬ification.http_method {
|
||||
parts.push(format!("HTTP Method: {}", method));
|
||||
}
|
||||
|
||||
if let Some(path) = ¬ification.http_path {
|
||||
parts.push(format!("HTTP Path: {}", path));
|
||||
}
|
||||
|
||||
if let Some(status_code) = notification.http_status_code {
|
||||
parts.push(format!("HTTP Status Code: {}", status_code));
|
||||
}
|
||||
|
||||
if let Some(user_token) = ¬ification.user_token {
|
||||
parts.push(format!("User Token: {}", user_token));
|
||||
}
|
||||
|
||||
if let Some(media_file_token) = ¬ification.media_file_token {
|
||||
parts.push(format!("Media File Token: {}", media_file_token));
|
||||
}
|
||||
|
||||
if let Some(inference_job_token) = ¬ification.inference_job_token {
|
||||
parts.push(format!("Inference Job Token: {}", inference_job_token));
|
||||
}
|
||||
|
||||
if let Some(third_party_id) = ¬ification.third_party_id {
|
||||
parts.push(format!("Third Party ID: {}", third_party_id));
|
||||
}
|
||||
|
||||
if let Some(h) = &self.hostname {
|
||||
parts.push(format!("Hostname: {}", h));
|
||||
}
|
||||
|
||||
Some(parts.join("\n\n"))
|
||||
}
|
||||
None => None,
|
||||
};
|
||||
let description = notification.build_enriched_description(
|
||||
self.application_name.as_deref(),
|
||||
self.service_id.as_deref(),
|
||||
self.hostname.as_deref(),
|
||||
);
|
||||
|
||||
// https://docs.rootly.com/api-reference/alerts/creates-an-alert
|
||||
let result = create_alert(CreateAlertArgs {
|
||||
@@ -241,4 +170,39 @@ impl PagerClient {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Build structured labels from the client config and notification fields.
|
||||
fn build_labels(&self, notification: &NotificationDetails) -> Option<Vec<(String, String)>> {
|
||||
let mut labels: Vec<(String, String)> = Vec::new();
|
||||
|
||||
if let Some(name) = &self.application_name {
|
||||
labels.push(("application_name".to_string(), name.clone()));
|
||||
}
|
||||
|
||||
if let Some(id) = &self.service_id {
|
||||
labels.push(("service_id".to_string(), id.clone()));
|
||||
}
|
||||
|
||||
if let Some(env) = &self.environment {
|
||||
labels.push(("environment".to_string(), env.clone()));
|
||||
}
|
||||
|
||||
if let Some(h) = &self.hostname {
|
||||
labels.push(("hostname".to_string(), h.clone()));
|
||||
}
|
||||
|
||||
if let Some(method) = ¬ification.http_method {
|
||||
labels.push(("http_method".to_string(), method.clone()));
|
||||
}
|
||||
|
||||
if let Some(path) = ¬ification.http_path {
|
||||
labels.push(("http_path".to_string(), path.clone()));
|
||||
}
|
||||
|
||||
if let Some(status_code) = notification.http_status_code {
|
||||
labels.push(("http_status_code".to_string(), status_code.to_string()));
|
||||
}
|
||||
|
||||
if labels.is_empty() { None } else { Some(labels) }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -63,6 +63,7 @@ mod tests {
|
||||
media_file_token: None,
|
||||
inference_job_token: None,
|
||||
third_party_id: None,
|
||||
maybe_error: None,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -222,6 +223,7 @@ mod tests {
|
||||
media_file_token: None,
|
||||
inference_job_token: None,
|
||||
third_party_id: None,
|
||||
maybe_error: None,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,162 +1,221 @@
|
||||
use chrono::{DateTime, Utc};
|
||||
use std::fmt::{Debug, Display};
|
||||
|
||||
use crate::notification::generate_deduplication_key::generate_deduplication_key;
|
||||
use crate::notification::notification_details_builder::NotificationDetailsBuilder;
|
||||
use crate::notification::notification_urgency::NotificationUrgency;
|
||||
|
||||
/// Details for a pager notification.
|
||||
#[derive(Clone, Debug)]
|
||||
#[derive(Debug)]
|
||||
pub struct NotificationDetails {
|
||||
/// Title or summary of the alert.
|
||||
pub title: String,
|
||||
pub(crate) title: String,
|
||||
|
||||
/// Full details for the alert.
|
||||
pub description: Option<String>,
|
||||
|
||||
/// When the event occurred.
|
||||
pub event_time: DateTime<Utc>,
|
||||
|
||||
/// HTTP method associated with the event, if any.
|
||||
pub http_method: Option<String>,
|
||||
|
||||
/// HTTP endpoint path associated with the event, if any.
|
||||
pub http_path: Option<String>,
|
||||
|
||||
/// HTTP status code associated with the event, if any.
|
||||
pub http_status_code: Option<u16>,
|
||||
|
||||
/// Whether this notification originated from an error.
|
||||
pub is_from_error: bool,
|
||||
pub(crate) description: Option<String>,
|
||||
|
||||
/// Urgency level for the notification.
|
||||
pub urgency: Option<NotificationUrgency>,
|
||||
pub(crate) urgency: Option<NotificationUrgency>,
|
||||
|
||||
/// When the event occurred.
|
||||
pub(crate) event_time: DateTime<Utc>,
|
||||
|
||||
/// The error that triggered this notification, if any.
|
||||
pub(crate) maybe_error: Option<Box<dyn std::error::Error + Send + Sync + 'static>>,
|
||||
|
||||
/// Whether this notification originated from an error.
|
||||
pub(crate) is_from_error: bool,
|
||||
|
||||
/// HTTP method associated with the event, if any.
|
||||
pub(crate) http_method: Option<String>,
|
||||
|
||||
/// HTTP endpoint path associated with the event, if any.
|
||||
pub(crate) http_path: Option<String>,
|
||||
|
||||
/// HTTP status code associated with the event, if any.
|
||||
pub(crate) http_status_code: Option<u16>,
|
||||
|
||||
/// User token associated with the event, if any.
|
||||
pub user_token: Option<String>,
|
||||
pub(crate) user_token: Option<String>,
|
||||
|
||||
/// Media file token associated with the event, if any.
|
||||
pub media_file_token: Option<String>,
|
||||
pub(crate) media_file_token: Option<String>,
|
||||
|
||||
/// Inference job token associated with the event, if any.
|
||||
pub inference_job_token: Option<String>,
|
||||
pub(crate) inference_job_token: Option<String>,
|
||||
|
||||
/// Third-party identifier associated with the event, if any.
|
||||
pub third_party_id: Option<String>,
|
||||
pub(crate) third_party_id: Option<String>,
|
||||
}
|
||||
|
||||
impl NotificationDetails {
|
||||
|
||||
/// Return a key that can group notifications for deduplication,
|
||||
/// which will prevent spamming the same page over and over.
|
||||
pub fn to_deduplication_key(&self) -> String {
|
||||
generate_deduplication_key(self)
|
||||
}
|
||||
|
||||
/// Create a notification with a title and description.
|
||||
pub fn with_title_and_description(title: String, description: String) -> Self {
|
||||
Self {
|
||||
title,
|
||||
description: Some(description),
|
||||
event_time: Utc::now(),
|
||||
http_method: None,
|
||||
http_path: None,
|
||||
http_status_code: None,
|
||||
is_from_error: false,
|
||||
urgency: None,
|
||||
user_token: None,
|
||||
media_file_token: None,
|
||||
inference_job_token: None,
|
||||
third_party_id: None,
|
||||
}
|
||||
}
|
||||
// =============== Description Building ===============
|
||||
|
||||
/// Create a notification with just a title.
|
||||
pub fn with_title(title: String) -> Self {
|
||||
Self {
|
||||
title,
|
||||
description: None,
|
||||
event_time: Utc::now(),
|
||||
http_method: None,
|
||||
http_path: None,
|
||||
http_status_code: None,
|
||||
is_from_error: false,
|
||||
urgency: None,
|
||||
user_token: None,
|
||||
media_file_token: None,
|
||||
inference_job_token: None,
|
||||
third_party_id: None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Create a notification from any error type.
|
||||
/// Build the full enriched description for this notification.
|
||||
///
|
||||
/// Formats the error into a structured description that includes:
|
||||
/// - The error message
|
||||
/// - The error's source chain (if any)
|
||||
/// - A backtrace (if available via `std::backtrace`)
|
||||
/// - The timestamp of the event
|
||||
pub fn from_error<E: Debug + Display>(error: &E) -> Self {
|
||||
let title = format!("{}", error);
|
||||
/// Assembles atomic sections in a consistent order:
|
||||
/// 1. User-provided description
|
||||
/// 2. Error chain (if a boxed error is attached)
|
||||
/// 3. Event time
|
||||
/// 4. HTTP context
|
||||
/// 5. Service tokens
|
||||
/// 6. Application/service identity and hostname
|
||||
pub(crate) fn build_enriched_description(
|
||||
&self,
|
||||
application_name: Option<&str>,
|
||||
service_id: Option<&str>,
|
||||
hostname: Option<&str>,
|
||||
) -> Option<String> {
|
||||
let mut sections: Vec<String> = Vec::new();
|
||||
|
||||
// Truncate the title to a reasonable length for alert titles.
|
||||
let title = if title.len() > 200 {
|
||||
format!("{}...", &title[..197])
|
||||
// 1. User-provided description
|
||||
if let Some(desc) = &self.description {
|
||||
sections.push(desc.clone());
|
||||
}
|
||||
|
||||
// 2. Error chain
|
||||
if let Some(error_section) = self.format_error_chain() {
|
||||
sections.push(error_section);
|
||||
}
|
||||
|
||||
// 3. Event time
|
||||
sections.push(self.format_event_time());
|
||||
|
||||
// 4. HTTP context
|
||||
if let Some(http_section) = self.format_http_context() {
|
||||
sections.push(http_section);
|
||||
}
|
||||
|
||||
// 5. Service tokens
|
||||
if let Some(tokens_section) = self.format_service_tokens() {
|
||||
sections.push(tokens_section);
|
||||
}
|
||||
|
||||
// 6. Application/service identity and hostname
|
||||
if let Some(identity_section) = Self::format_app_identity(application_name, service_id, hostname) {
|
||||
sections.push(identity_section);
|
||||
}
|
||||
|
||||
if sections.is_empty() {
|
||||
None
|
||||
} else {
|
||||
title
|
||||
};
|
||||
Some(sections.join("\n\n"))
|
||||
}
|
||||
}
|
||||
|
||||
let event_time = Utc::now();
|
||||
/// Format the error chain from the boxed error, walking the source chain.
|
||||
fn format_error_chain(&self) -> Option<String> {
|
||||
let error = self.maybe_error.as_ref()?;
|
||||
|
||||
let mut description_parts: Vec<String> = Vec::new();
|
||||
let mut parts: Vec<String> = Vec::new();
|
||||
|
||||
description_parts.push(format!("Event time: {}", event_time.format("%Y-%m-%d %H:%M:%S UTC")));
|
||||
description_parts.push(String::new());
|
||||
description_parts.push(format!("Error: {}", error));
|
||||
// Top-level error
|
||||
parts.push(format!("Error: {}", error));
|
||||
|
||||
// Include the Debug representation if it differs from Display (often has more detail).
|
||||
// Debug representation if it differs
|
||||
let debug_repr = format!("{:?}", error);
|
||||
let display_repr = format!("{}", error);
|
||||
if debug_repr != display_repr {
|
||||
description_parts.push(String::new());
|
||||
description_parts.push(format!("Debug: {}", debug_repr));
|
||||
parts.push(format!("Debug: {}", debug_repr));
|
||||
}
|
||||
|
||||
// Attempt to walk the error source chain.
|
||||
// NB: We use the Debug trait here since we can't require std::error::Error
|
||||
// (that would require E: 'static + Error which is too restrictive for callers).
|
||||
// Walk the source chain
|
||||
let mut depth = 0;
|
||||
let mut source = error.source();
|
||||
while let Some(cause) = source {
|
||||
depth += 1;
|
||||
parts.push(format!("Caused by ({depth}): {cause}"));
|
||||
|
||||
// Try to capture a backtrace from the current call site.
|
||||
#[cfg(feature = "backtrace")]
|
||||
{
|
||||
let bt = std::backtrace::Backtrace::capture();
|
||||
if bt.status() == std::backtrace::BacktraceStatus::Captured {
|
||||
description_parts.push(String::new());
|
||||
description_parts.push(format!("Backtrace:\n{}", bt));
|
||||
let cause_debug = format!("{:?}", cause);
|
||||
let cause_display = format!("{}", cause);
|
||||
if cause_debug != cause_display {
|
||||
parts.push(format!(" Debug ({depth}): {cause_debug}"));
|
||||
}
|
||||
|
||||
source = cause.source();
|
||||
}
|
||||
|
||||
let description = description_parts.join("\n");
|
||||
|
||||
Self {
|
||||
title,
|
||||
description: Some(description),
|
||||
event_time,
|
||||
http_method: None,
|
||||
http_path: None,
|
||||
http_status_code: None,
|
||||
is_from_error: true,
|
||||
urgency: None,
|
||||
user_token: None,
|
||||
media_file_token: None,
|
||||
inference_job_token: None,
|
||||
third_party_id: None,
|
||||
}
|
||||
Some(parts.join("\n"))
|
||||
}
|
||||
|
||||
/// Create a notification from an error, with a custom title prefix.
|
||||
pub fn from_error_with_context<E: Debug + Display>(context: &str, error: &E) -> Self {
|
||||
let mut notification = Self::from_error(error);
|
||||
notification.title = format!("{}: {}", context, notification.title);
|
||||
if notification.title.len() > 200 {
|
||||
notification.title = format!("{}...", ¬ification.title[..197]);
|
||||
/// Format the event time.
|
||||
fn format_event_time(&self) -> String {
|
||||
format!("Event time: {}", self.event_time.format("%Y-%m-%d %H:%M:%S UTC"))
|
||||
}
|
||||
|
||||
/// Format HTTP context (method, path, status code) if any fields are present.
|
||||
fn format_http_context(&self) -> Option<String> {
|
||||
let mut parts: Vec<String> = Vec::new();
|
||||
|
||||
if let Some(method) = &self.http_method {
|
||||
parts.push(format!("HTTP Method: {}", method));
|
||||
}
|
||||
notification
|
||||
|
||||
if let Some(path) = &self.http_path {
|
||||
parts.push(format!("HTTP Path: {}", path));
|
||||
}
|
||||
|
||||
if let Some(status_code) = self.http_status_code {
|
||||
parts.push(format!("HTTP Status Code: {}", status_code));
|
||||
}
|
||||
|
||||
if parts.is_empty() { None } else { Some(parts.join("\n")) }
|
||||
}
|
||||
|
||||
/// Format service tokens (user, media file, inference job, third party) if any are present.
|
||||
fn format_service_tokens(&self) -> Option<String> {
|
||||
let mut parts: Vec<String> = Vec::new();
|
||||
|
||||
if let Some(user_token) = &self.user_token {
|
||||
parts.push(format!("User Token: {}", user_token));
|
||||
}
|
||||
|
||||
if let Some(media_file_token) = &self.media_file_token {
|
||||
parts.push(format!("Media File Token: {}", media_file_token));
|
||||
}
|
||||
|
||||
if let Some(inference_job_token) = &self.inference_job_token {
|
||||
parts.push(format!("Inference Job Token: {}", inference_job_token));
|
||||
}
|
||||
|
||||
if let Some(third_party_id) = &self.third_party_id {
|
||||
parts.push(format!("Third Party ID: {}", third_party_id));
|
||||
}
|
||||
|
||||
if parts.is_empty() { None } else { Some(parts.join("\n")) }
|
||||
}
|
||||
|
||||
/// Format the application name, service ID, and hostname.
|
||||
fn format_app_identity(
|
||||
application_name: Option<&str>,
|
||||
service_id: Option<&str>,
|
||||
hostname: Option<&str>,
|
||||
) -> Option<String> {
|
||||
let mut parts: Vec<String> = Vec::new();
|
||||
|
||||
match (application_name, service_id) {
|
||||
(Some(name), Some(id)) => {
|
||||
parts.push(format!("Application: {} (service_id: {})", name, id));
|
||||
}
|
||||
(Some(name), None) => {
|
||||
parts.push(format!("Application: {}", name));
|
||||
}
|
||||
(None, Some(id)) => {
|
||||
parts.push(format!("Service ID: {}", id));
|
||||
}
|
||||
(None, None) => {}
|
||||
}
|
||||
|
||||
if let Some(h) = hostname {
|
||||
parts.push(format!("Hostname: {}", h));
|
||||
}
|
||||
|
||||
if parts.is_empty() { None } else { Some(parts.join("\n")) }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,17 +6,21 @@ use crate::notification::notification_urgency::NotificationUrgency;
|
||||
|
||||
pub struct NotificationDetailsBuilder {
|
||||
title: String,
|
||||
description: Option<String>,
|
||||
event_time: DateTime<Utc>,
|
||||
http_method: Option<String>,
|
||||
http_path: Option<String>,
|
||||
http_status_code: Option<u16>,
|
||||
is_from_error: bool,
|
||||
urgency: Option<NotificationUrgency>,
|
||||
user_token: Option<String>,
|
||||
media_file_token: Option<String>,
|
||||
inference_job_token: Option<String>,
|
||||
third_party_id: Option<String>,
|
||||
pub(crate) description: Option<String>,
|
||||
pub(crate) urgency: Option<NotificationUrgency>,
|
||||
pub(crate) event_time: DateTime<Utc>,
|
||||
|
||||
pub(crate) maybe_error: Option<Box<dyn std::error::Error + Send + Sync + 'static>>,
|
||||
pub(crate) is_from_error: bool,
|
||||
|
||||
pub(crate) http_method: Option<String>,
|
||||
pub(crate) http_path: Option<String>,
|
||||
pub(crate) http_status_code: Option<u16>,
|
||||
|
||||
pub(crate) user_token: Option<String>,
|
||||
pub(crate) media_file_token: Option<String>,
|
||||
pub(crate) inference_job_token: Option<String>,
|
||||
pub(crate) third_party_id: Option<String>,
|
||||
}
|
||||
|
||||
impl NotificationDetailsBuilder {
|
||||
@@ -25,12 +29,13 @@ impl NotificationDetailsBuilder {
|
||||
Self {
|
||||
title,
|
||||
description: None,
|
||||
urgency: None,
|
||||
event_time: Utc::now(),
|
||||
maybe_error: None,
|
||||
is_from_error: false,
|
||||
http_method: None,
|
||||
http_path: None,
|
||||
http_status_code: None,
|
||||
is_from_error: false,
|
||||
urgency: None,
|
||||
user_token: None,
|
||||
media_file_token: None,
|
||||
inference_job_token: None,
|
||||
@@ -38,29 +43,28 @@ impl NotificationDetailsBuilder {
|
||||
}
|
||||
}
|
||||
|
||||
/// Create a builder from an error.
|
||||
/// Create a builder from a boxed error.
|
||||
///
|
||||
/// Sets `is_from_error` to true and derives the summary and description
|
||||
/// from the error, matching the behavior of `NotificationDetails::from_error`.
|
||||
pub fn from_error<E: Debug + Display>(error: &E) -> Self {
|
||||
// TODO(bt,2026-03-30): Clean this up
|
||||
let details = NotificationDetails::from_error(error);
|
||||
/// Sets the title to a placeholder and attaches the error object.
|
||||
/// Callers should chain `.set_title()` to provide a meaningful title.
|
||||
pub fn from_error(error: Box<dyn std::error::Error + Send + Sync + 'static>) -> Self {
|
||||
Self {
|
||||
title: details.title,
|
||||
description: details.description,
|
||||
event_time: details.event_time,
|
||||
title: "Notification from Error".to_string(),
|
||||
description: None,
|
||||
urgency: None,
|
||||
event_time: Utc::now(),
|
||||
maybe_error: Some(error),
|
||||
is_from_error: true,
|
||||
http_method: None,
|
||||
http_path: None,
|
||||
http_status_code: None,
|
||||
is_from_error: true,
|
||||
urgency: None,
|
||||
user_token: None,
|
||||
media_file_token: None,
|
||||
inference_job_token: None,
|
||||
third_party_id: None,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
pub fn set_title(mut self, title: String) -> Self {
|
||||
self.title = title;
|
||||
self
|
||||
@@ -71,6 +75,16 @@ impl NotificationDetailsBuilder {
|
||||
self
|
||||
}
|
||||
|
||||
pub fn set_urgency(mut self, urgency: Option<NotificationUrgency>) -> Self {
|
||||
self.urgency = urgency;
|
||||
self
|
||||
}
|
||||
|
||||
pub fn set_error(mut self, error: Option<Box<dyn std::error::Error + Send + Sync + 'static>>) -> Self {
|
||||
self.maybe_error = error;
|
||||
self
|
||||
}
|
||||
|
||||
pub fn set_http_method(mut self, http_method: Option<String>) -> Self {
|
||||
self.http_method = http_method;
|
||||
self
|
||||
@@ -86,11 +100,6 @@ impl NotificationDetailsBuilder {
|
||||
self
|
||||
}
|
||||
|
||||
pub fn set_urgency(mut self, urgency: Option<NotificationUrgency>) -> Self {
|
||||
self.urgency = urgency;
|
||||
self
|
||||
}
|
||||
|
||||
pub fn set_user_token(mut self, user_token: Option<String>) -> Self {
|
||||
self.user_token = user_token;
|
||||
self
|
||||
@@ -115,12 +124,13 @@ impl NotificationDetailsBuilder {
|
||||
NotificationDetails {
|
||||
title: self.title,
|
||||
description: self.description,
|
||||
urgency: self.urgency,
|
||||
event_time: self.event_time,
|
||||
maybe_error: self.maybe_error,
|
||||
is_from_error: self.is_from_error,
|
||||
http_method: self.http_method,
|
||||
http_path: self.http_path,
|
||||
http_status_code: self.http_status_code,
|
||||
is_from_error: self.is_from_error,
|
||||
urgency: self.urgency,
|
||||
user_token: self.user_token,
|
||||
media_file_token: self.media_file_token,
|
||||
inference_job_token: self.inference_job_token,
|
||||
|
||||
@@ -11,7 +11,9 @@ pub fn alert_pager_and_return_err<T>(
|
||||
err: anyhow::Error,
|
||||
job: Option<&PendingSeedance2ProJob>,
|
||||
) -> anyhow::Result<T> {
|
||||
let mut builder = NotificationDetailsBuilder::from_error(&err)
|
||||
let err_message = format!("{:#}", err);
|
||||
|
||||
let mut builder = NotificationDetailsBuilder::from_error(err.into())
|
||||
.set_title(title.to_string())
|
||||
.set_urgency(Some(NotificationUrgency::Medium));
|
||||
|
||||
@@ -28,5 +30,5 @@ pub fn alert_pager_and_return_err<T>(
|
||||
error!("Failed to enqueue pager alert: {:?}", pager_err);
|
||||
}
|
||||
|
||||
Err(err)
|
||||
Err(anyhow::anyhow!(err_message))
|
||||
}
|
||||
|
||||
@@ -85,7 +85,7 @@ pub async fn process_failed_job(
|
||||
err,
|
||||
);
|
||||
|
||||
let notification = NotificationDetailsBuilder::from_error(&err)
|
||||
let notification = NotificationDetailsBuilder::from_error(err.into())
|
||||
.set_title("Seedance2Pro refund failed".to_string())
|
||||
.set_inference_job_token(Some(job.job_token.to_string()))
|
||||
.set_third_party_id(Some(job.order_id.to_string()))
|
||||
@@ -140,7 +140,7 @@ pub async fn process_failed_job(
|
||||
err
|
||||
);
|
||||
|
||||
let notification = NotificationDetailsBuilder::from_error(&err)
|
||||
let notification = NotificationDetailsBuilder::from_error(err.into())
|
||||
.set_title("Seedance2Pro mark job failed error".to_string())
|
||||
.set_urgency(Some(NotificationUrgency::Medium))
|
||||
.build();
|
||||
|
||||
@@ -9,7 +9,9 @@ pub fn alert_pager_and_return_err<T>(
|
||||
title: &str,
|
||||
err: anyhow::Error,
|
||||
) -> anyhow::Result<T> {
|
||||
let notification = NotificationDetailsBuilder::from_error(&err)
|
||||
let err_message = format!("{:#}", err);
|
||||
|
||||
let notification = NotificationDetailsBuilder::from_error(err.into())
|
||||
.set_title(title.to_string())
|
||||
.set_urgency(Some(NotificationUrgency::Medium))
|
||||
.build();
|
||||
@@ -18,5 +20,5 @@ pub fn alert_pager_and_return_err<T>(
|
||||
error!("Failed to enqueue pager alert: {:?}", pager_err);
|
||||
}
|
||||
|
||||
Err(err)
|
||||
Err(anyhow::anyhow!(err_message))
|
||||
}
|
||||
|
||||
+2
-2
@@ -222,7 +222,7 @@ pub async fn seedance_2p0_multi_function_video_gen_handler(
|
||||
Err(err) => {
|
||||
warn!("Regular session fallback also failed for user {:?}: {:?}", user_token, err);
|
||||
if let Err(page_err) = server_state.pager.enqueue_page(
|
||||
NotificationDetailsBuilder::from_error(&err)
|
||||
NotificationDetailsBuilder::from_error(err.into())
|
||||
.set_title("Seedance 2.0 generation failed (whitelist + fallback)".to_string())
|
||||
.set_urgency(Some(NotificationUrgency::High))
|
||||
.build()
|
||||
@@ -256,7 +256,7 @@ pub async fn seedance_2p0_multi_function_video_gen_handler(
|
||||
Err(err) => {
|
||||
warn!("Error calling seedance2pro generate_video: {:?}", err);
|
||||
if let Err(page_err) = server_state.pager.enqueue_page(
|
||||
NotificationDetailsBuilder::from_error(&err)
|
||||
NotificationDetailsBuilder::from_error(err.into())
|
||||
.set_title("Seedance 2.0 generation failed".to_string())
|
||||
.set_urgency(Some(NotificationUrgency::High))
|
||||
.build()
|
||||
|
||||
+24
-17
@@ -45,6 +45,8 @@ impl ResponseError for FalWebhookError {
|
||||
}
|
||||
}
|
||||
|
||||
impl std::error::Error for FalWebhookError {}
|
||||
|
||||
// NB: Not using derive_more::Display since Clion doesn't understand it.
|
||||
impl fmt::Display for FalWebhookError {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
@@ -117,27 +119,32 @@ pub async fn fal_webhook_handler(
|
||||
}
|
||||
};
|
||||
|
||||
if let Err(ref err) = result {
|
||||
error!("FAL webhook error for request_id {}: {:?}", request_id, err);
|
||||
match result {
|
||||
Ok(success) => Ok(success),
|
||||
Err(err) => {
|
||||
error!("FAL webhook error for request_id {}: {:?}", request_id, err);
|
||||
|
||||
let notification = NotificationDetailsBuilder::from_error(err)
|
||||
.set_title("FAL webhook processing failed".to_string())
|
||||
.set_description(Some(format!(
|
||||
"FAL webhook failed for request_id: {}\n\nError: {:?}\n\nWebhook JSON Payload: {}",
|
||||
request_id, err, raw_body,
|
||||
)))
|
||||
.set_third_party_id(Some(request_id.to_string()))
|
||||
.set_urgency(Some(NotificationUrgency::High))
|
||||
.set_http_method(Some(http_request.method().to_string()))
|
||||
.set_http_path(Some(http_request.path().to_string()))
|
||||
.build();
|
||||
let description = format!(
|
||||
"FAL webhook failed for request_id: {}\n\nError: {:?}\n\nWebhook JSON Payload: {}",
|
||||
request_id, err, raw_body,
|
||||
);
|
||||
|
||||
if let Err(pager_err) = server_state.pager.enqueue_page(notification) {
|
||||
error!("Failed to enqueue FAL webhook pager alert: {:?}", pager_err);
|
||||
let notification = NotificationDetailsBuilder::from_error(err.into())
|
||||
.set_title("FAL webhook processing failed".to_string())
|
||||
.set_description(Some(description))
|
||||
.set_third_party_id(Some(request_id.to_string()))
|
||||
.set_urgency(Some(NotificationUrgency::High))
|
||||
.set_http_method(Some(http_request.method().to_string()))
|
||||
.set_http_path(Some(http_request.path().to_string()))
|
||||
.build();
|
||||
|
||||
if let Err(pager_err) = server_state.pager.enqueue_page(notification) {
|
||||
error!("Failed to enqueue FAL webhook pager alert: {:?}", pager_err);
|
||||
}
|
||||
|
||||
Err(FalWebhookError::ServerError)
|
||||
}
|
||||
}
|
||||
|
||||
result
|
||||
}
|
||||
|
||||
/// Send a pager alert for early parse failures (before we have a request_id).
|
||||
|
||||
+1
-1
@@ -35,7 +35,7 @@ pub async fn db_health_checker_thread(
|
||||
Ok(_) => {},
|
||||
}
|
||||
|
||||
let notification = NotificationDetailsBuilder::from_error(&database_error)
|
||||
let notification = NotificationDetailsBuilder::from_error(database_error.into())
|
||||
.set_title("DB health check thread failed check".to_string())
|
||||
.set_urgency(Some(NotificationUrgency::High))
|
||||
.build();
|
||||
|
||||
Reference in New Issue
Block a user