Compare commits

...
Author SHA1 Message Date
Brandon Thomas 398c56d7eb page works 2026-03-25 12:29:38 -04:00
Brandon Thomas ba21368323 don't log these 2026-03-25 10:47:31 -04:00
Brandon Thomas 0750b9fe10 that worked! 2026-03-25 10:46:57 -04:00
Brandon Thomas 05a26b124e seems to work 2026-03-25 10:41:58 -04:00
Brandon Thomas a394ee5a96 create alert api 2026-03-25 02:30:11 -04:00
Brandon Thomas c35ea620b3 add rootly stub 2026-03-25 02:18:58 -04:00
17 changed files with 668 additions and 0 deletions
Generated
+16
View File
@@ -9584,6 +9584,22 @@ dependencies = [
"syn 1.0.109",
]
[[package]]
name = "rootly_client"
version = "0.0.1"
dependencies = [
"env",
"env_logger 0.11.9",
"errors",
"log",
"reqwest 0.12.23",
"serde",
"serde_derive",
"serde_json",
"tokio",
"workspace-build-acceleration",
]
[[package]]
name = "rsa"
version = "0.9.6"
+2
View File
@@ -18,6 +18,7 @@ members = [
"crates/api_clients/muapi_client",
"crates/api_clients/openai_sora_client",
"crates/api_clients/openart_client",
"crates/api_clients/rootly_client",
"crates/api_clients/runwayml_client",
"crates/api_clients/seedance2pro_client",
"crates/api_clients/thumbnail_generator",
@@ -239,6 +240,7 @@ ffmpeg_utils = { path = "crates/lib/ffmpeg_utils" }
file_to_base64_url = { path = "crates/lib/files/file_to_base64_url" }
filesys = { path = "crates/lib/files/filesys" }
grok_client = { path = "crates/api_clients/grok_client" }
rootly_client = { path = "crates/api_clients/rootly_client" }
http_headers = { path = "crates/lib/http_headers" }
images = { path = "crates/lib/files/images" }
jobs_common = { path = "crates/lib/jobs_common" }
@@ -0,0 +1,31 @@
[package]
name = "rootly_client"
edition = "2024"
version = "0.0.1"
publish = false
[package.metadata.cargo-udeps.ignore]
normal = ["workspace-build-acceleration"]
[lib]
name = "rootly_client"
path = "src/lib.rs"
[dependencies]
# Workspace hack (faster compile times)
workspace-build-acceleration.workspace = true
# Internal
errors.workspace = true
# External
log.workspace = true
reqwest.workspace = true
serde.workspace = true
serde_derive.workspace = true
serde_json.workspace = true
[dev-dependencies]
env.workspace = true
env_logger.workspace = true
tokio = { version = "1", features = ["full"] }
@@ -0,0 +1 @@
pub mod rootly_api_key;
@@ -0,0 +1,11 @@
/// Holds the API key for authenticating with the Rootly API.
#[derive(Clone)]
pub struct RootlyApiKey {
pub (crate) api_key: String,
}
impl RootlyApiKey {
pub fn new(api_key: String) -> Self {
Self { api_key }
}
}
@@ -0,0 +1,4 @@
pub mod rootly_client_error;
pub mod rootly_error;
pub mod rootly_generic_api_error;
pub mod rootly_specific_api_error;
@@ -0,0 +1,22 @@
use std::error::Error;
use std::fmt::{Display, Formatter};
#[derive(Debug)]
pub enum RootlyClientError {
/// No API key is present.
NoApiKeyPresent,
/// An error was encountered in building the reqwest client.
ReqwestClientError(reqwest::Error),
}
impl Error for RootlyClientError {}
impl Display for RootlyClientError {
fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
match self {
Self::NoApiKeyPresent => write!(f, "No API key present."),
Self::ReqwestClientError(err) => write!(f, "Reqwest client error (during client creation): {}", err),
}
}
}
@@ -0,0 +1,41 @@
use crate::error::rootly_client_error::RootlyClientError;
use crate::error::rootly_generic_api_error::RootlyGenericApiError;
use crate::error::rootly_specific_api_error::RootlySpecificApiError;
use std::error::Error;
#[derive(Debug)]
pub enum RootlyError {
Client(RootlyClientError),
ApiSpecific(RootlySpecificApiError),
ApiGeneric(RootlyGenericApiError),
}
impl Error for RootlyError {}
impl std::fmt::Display for RootlyError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::Client(e) => write!(f, "RootlyClientError: {:?}", e),
Self::ApiSpecific(e) => write!(f, "RootlySpecificApiError: {:?}", e),
Self::ApiGeneric(e) => write!(f, "RootlyGenericApiError: {:?}", e),
}
}
}
impl From<RootlyClientError> for RootlyError {
fn from(error: RootlyClientError) -> Self {
Self::Client(error)
}
}
impl From<RootlySpecificApiError> for RootlyError {
fn from(error: RootlySpecificApiError) -> Self {
Self::ApiSpecific(error)
}
}
impl From<RootlyGenericApiError> for RootlyError {
fn from(error: RootlyGenericApiError) -> Self {
Self::ApiGeneric(error)
}
}
@@ -0,0 +1,35 @@
use std::error::Error;
use std::fmt::{Display, Formatter};
use reqwest::StatusCode;
#[derive(Debug)]
pub enum RootlyGenericApiError {
/// serde_json::Error, likely from JSON deserialization schema mismatch.
/// Includes the original body.
SerdeResponseParseErrorWithBody(serde_json::Error, String),
/// An uncategorized bad HTTP response.
UncategorizedBadResponse(String),
/// An uncategorized bad HTTP response with status code and body.
UncategorizedBadResponseWithStatusAndBody {
status_code: StatusCode,
body: String,
},
/// An uncaught error from the HTTP client.
ReqwestError(reqwest::Error),
}
impl Error for RootlyGenericApiError {}
impl Display for RootlyGenericApiError {
fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
match self {
Self::SerdeResponseParseErrorWithBody(err, body) => write!(f, "Failed to parse response body: {:?}. Body: {}", err, body),
Self::UncategorizedBadResponse(msg) => write!(f, "Uncategorized bad response: {}", msg),
Self::UncategorizedBadResponseWithStatusAndBody { status_code, body } => write!(f, "Uncategorized bad response: status code {}, body: {}", status_code, body),
Self::ReqwestError(err) => write!(f, "Reqwest client error: {}", err),
}
}
}
@@ -0,0 +1,18 @@
use std::error::Error;
use std::fmt::{Display, Formatter};
#[derive(Debug)]
pub enum RootlySpecificApiError {
/// The API key is invalid or expired.
Unauthorized,
}
impl Error for RootlySpecificApiError {}
impl Display for RootlySpecificApiError {
fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
match self {
Self::Unauthorized => write!(f, "Unauthorized: API key is invalid or expired."),
}
}
}
@@ -0,0 +1,7 @@
#[cfg(test)]
pub (crate) mod test_utils;
// Public API
pub mod creds;
pub mod error;
pub mod requests;
@@ -0,0 +1,350 @@
use log::info;
use crate::creds::rootly_api_key::RootlyApiKey;
use crate::error::rootly_error::RootlyError;
use crate::error::rootly_client_error::RootlyClientError;
use crate::error::rootly_generic_api_error::RootlyGenericApiError;
use crate::requests::create_alert::http_request::*;
const ROOTLY_API_BASE_URL: &str = "https://api.rootly.com/v1";
// ======================== Public args ========================
pub struct CreateAlertArgs {
pub api_key: RootlyApiKey,
/// The source of the alert (e.g. "artcraft", "seedance2pro_job").
pub source: String,
/// A short summary of the alert.
pub summary: String,
/// An optional longer description.
pub description: Option<String>,
/// The initial status. Defaults to "open" if not provided.
pub status: Option<String>,
/// Optional service IDs to associate.
pub service_ids: Option<Vec<String>>,
/// Optional group IDs to associate.
pub group_ids: Option<Vec<String>>,
/// Optional environment IDs to associate.
pub environment_ids: Option<Vec<String>>,
/// Optional external ID for linking to an external system.
pub external_id: Option<String>,
/// Optional external URL for linking to an external system.
pub external_url: Option<String>,
/// Optional alert urgency ID (get IDs from GET /v1/alert_urgencies).
pub alert_urgency_id: Option<String>,
/// Who to notify. One of: "User", "Group", "EscalationPolicy", "Service".
/// Requires On-Call to be enabled.
pub notification_target_type: Option<String>,
/// The ID of the notification target (user ID, group ID, escalation policy ID, or service ID).
pub notification_target_id: Option<String>,
/// Optional labels as key-value pairs.
pub labels: Option<Vec<(String, String)>>,
/// Optional deduplication key for grouping related alerts.
pub deduplication_key: Option<String>,
}
// ======================== Public response ========================
#[derive(Debug)]
pub struct CreateAlertSuccess {
/// The Rootly alert ID.
pub id: String,
/// The short human-readable ID (e.g. "ALR-123").
pub short_id: Option<String>,
/// The status of the alert.
pub status: Option<String>,
/// The source of the alert.
pub source: Option<String>,
}
// ======================== Implementation ========================
pub async fn create_alert(args: CreateAlertArgs) -> Result<CreateAlertSuccess, RootlyError> {
let url = format!("{}/alerts", ROOTLY_API_BASE_URL);
info!("Creating Rootly alert: source={}, summary={}", args.source, args.summary);
let labels = args.labels.map(|pairs| {
pairs.into_iter().map(|(key, value)| CreateAlertLabel { key, value }).collect()
});
let request_body = CreateAlertRequest {
data: CreateAlertRequestData {
data_type: "alerts",
attributes: CreateAlertRequestAttributes {
source: args.source,
summary: args.summary,
description: args.description,
status: args.status,
service_ids: args.service_ids,
group_ids: args.group_ids,
environment_ids: args.environment_ids,
started_at: None,
ended_at: None,
external_id: args.external_id,
external_url: args.external_url,
alert_urgency_id: args.alert_urgency_id,
notification_target_type: args.notification_target_type,
notification_target_id: args.notification_target_id,
labels,
deduplication_key: args.deduplication_key,
},
},
};
let body_json = serde_json::to_string(&request_body)
.map_err(|err| RootlyGenericApiError::SerdeResponseParseErrorWithBody(err, String::new()))?;
info!("Rootly create alert URL: {}", url);
info!("Rootly create alert body: {}", body_json);
let client = reqwest::Client::builder()
.build()
.map_err(|err| RootlyClientError::ReqwestClientError(err))?;
let bearer = format!("Bearer {}", args.api_key.api_key);
// NB: Use .body() instead of .json() to preserve the Content-Type header.
// .json() would override it with "application/json", but Rootly requires "application/vnd.api+json".
let response = client.post(&url)
.header("Authorization", bearer)
.header("Content-Type", "application/vnd.api+json")
.body(body_json)
.send()
.await
.map_err(|err| RootlyGenericApiError::ReqwestError(err))?;
let status = response.status();
let response_body = response.text()
.await
.map_err(|err| RootlyGenericApiError::ReqwestError(err))?;
info!("Rootly create alert response: status={}, body={}", status, response_body);
if !status.is_success() {
return Err(RootlyGenericApiError::UncategorizedBadResponseWithStatusAndBody {
status_code: status,
body: response_body,
}.into());
}
let parsed: CreateAlertResponse = serde_json::from_str(&response_body)
.map_err(|err| RootlyGenericApiError::SerdeResponseParseErrorWithBody(err, response_body.clone()))?;
Ok(CreateAlertSuccess {
id: parsed.data.id,
short_id: parsed.data.attributes.short_id,
status: parsed.data.attributes.status,
source: parsed.data.attributes.source,
})
}
#[cfg(test)]
mod tests {
use super::*;
use crate::test_utils::get_test_api_key::get_test_api_key;
use errors::AnyhowResult;
fn test_api_key() -> AnyhowResult<RootlyApiKey> {
let key = get_test_api_key()?;
Ok(RootlyApiKey::new(key))
}
#[tokio::test]
#[ignore] // manually test — requires real API key
async fn test_create_basic_alert() -> AnyhowResult<()> {
let api_key = test_api_key()?;
let result = create_alert(CreateAlertArgs {
api_key,
source: "artcraft-test".to_string(),
summary: "Test alert from artcraft integration test".to_string(),
description: Some("This is a test alert created by an automated test. Please ignore.".to_string()),
status: None,
service_ids: None,
group_ids: None,
environment_ids: None,
external_id: None,
external_url: None,
alert_urgency_id: None,
notification_target_type: None,
notification_target_id: None,
labels: None,
deduplication_key: None,
}).await?;
println!("Alert ID: {}", result.id);
println!("Short ID: {:?}", result.short_id);
println!("Status: {:?}", result.status);
println!("Source: {:?}", result.source);
assert!(!result.id.is_empty());
Ok(())
}
#[tokio::test]
#[ignore] // manually test — requires real API key
async fn test_create_alert_with_labels() -> AnyhowResult<()> {
let api_key = test_api_key()?;
let result = create_alert(CreateAlertArgs {
api_key,
source: "artcraft-test".to_string(),
summary: "Test alert with labels".to_string(),
description: Some("Testing label support.".to_string()),
status: None,
service_ids: None,
group_ids: None,
environment_ids: None,
external_id: None,
external_url: None,
alert_urgency_id: None,
notification_target_type: None,
notification_target_id: None,
labels: Some(vec![
("environment".to_string(), "test".to_string()),
("component".to_string(), "seedance2pro_job".to_string()),
]),
deduplication_key: None,
}).await?;
println!("Alert ID: {}", result.id);
println!("Short ID: {:?}", result.short_id);
assert!(!result.id.is_empty());
Ok(())
}
#[tokio::test]
#[ignore] // manually test — requires real API key
async fn test_create_alert_with_dedup_key() -> AnyhowResult<()> {
let api_key = test_api_key()?;
let dedup_key = "artcraft-test-dedup-key-001".to_string();
let result = create_alert(CreateAlertArgs {
api_key,
source: "artcraft-test".to_string(),
summary: "Test alert with deduplication key".to_string(),
description: None,
status: None,
service_ids: None,
group_ids: None,
environment_ids: None,
external_id: Some("ext-test-123".to_string()),
external_url: Some("https://artcraft.com/test".to_string()),
alert_urgency_id: None,
notification_target_type: None,
notification_target_id: None,
labels: None,
deduplication_key: Some(dedup_key),
}).await?;
println!("Alert ID: {}", result.id);
println!("Short ID: {:?}", result.short_id);
assert!(!result.id.is_empty());
Ok(())
}
/// WARNING: This test triggers a real page via the SRE escalation policy.
/// Only run manually when you want to verify paging works end-to-end.
#[tokio::test]
#[ignore] // manually test — triggers a real page!
async fn test_create_paging_sev1_alert() -> AnyhowResult<()> {
let api_key = test_api_key()?;
// IDs from our Rootly org (GET /v1/alert_urgencies, GET /v1/escalation_policies)
let high_urgency_id = "62fde143-1258-4639-9ee6-1400ebf7308d"; // "High"
let sre_escalation_policy_id = "d1f176a9-edb8-48f3-a2df-c505835498e5"; // "SRE (Site Reliability Engineering) Team"
let result = create_alert(CreateAlertArgs {
api_key,
source: "artcraft".to_string(),
summary: "[TEST] SEV-1: Critical production issue — paging test".to_string(),
description: Some(
"This is a test SEV-1 alert to verify paging works. \
If you received this page, the Rootly integration is working correctly. \
Please acknowledge and resolve.".to_string()
),
status: Some("triggered".to_string()),
service_ids: None,
group_ids: None,
environment_ids: None,
external_id: None,
external_url: None,
alert_urgency_id: Some(high_urgency_id.to_string()),
notification_target_type: Some("EscalationPolicy".to_string()),
notification_target_id: Some(sre_escalation_policy_id.to_string()),
labels: Some(vec![
("severity".to_string(), "sev-1".to_string()),
("environment".to_string(), "production".to_string()),
("test".to_string(), "true".to_string()),
]),
deduplication_key: None,
}).await?;
println!("Alert ID: {}", result.id);
println!("Short ID: {:?}", result.short_id);
println!("Status: {:?}", result.status);
println!("Source: {:?}", result.source);
assert!(!result.id.is_empty());
Ok(())
}
/// WARNING: This test triggers a real page directly to Brandon Thomas.
/// Only run manually when you want to verify paging works end-to-end.
#[tokio::test]
#[ignore] // manually test — triggers a real page!
async fn test_create_paging_sev1_alert_direct_user() -> AnyhowResult<()> {
let api_key = test_api_key()?;
// IDs from our Rootly org
let high_urgency_id = "62fde143-1258-4639-9ee6-1400ebf7308d"; // "High"
let brandon_user_id = "236693"; // Brandon Thomas
let result = create_alert(CreateAlertArgs {
api_key,
source: "artcraft".to_string(),
summary: "[TEST] SEV-1: Direct user page test".to_string(),
description: Some(
"This is a test SEV-1 alert sent directly to a user (not via escalation policy). \
If you received this page, the Rootly direct-user paging works correctly. \
Please acknowledge and resolve.".to_string()
),
status: Some("triggered".to_string()),
service_ids: None,
group_ids: None,
environment_ids: None,
external_id: None,
external_url: None,
alert_urgency_id: Some(high_urgency_id.to_string()),
notification_target_type: Some("User".to_string()),
notification_target_id: Some(brandon_user_id.to_string()),
labels: Some(vec![
("severity".to_string(), "sev-1".to_string()),
("environment".to_string(), "production".to_string()),
("test".to_string(), "true".to_string()),
]),
deduplication_key: None,
}).await?;
println!("Alert ID: {}", result.id);
println!("Short ID: {:?}", result.short_id);
println!("Status: {:?}", result.status);
println!("Source: {:?}", result.source);
assert!(!result.id.is_empty());
Ok(())
}
}
@@ -0,0 +1,116 @@
use serde_derive::{Deserialize, Serialize};
// ======================== Request ========================
#[derive(Serialize, Debug)]
pub (crate) struct CreateAlertRequest {
pub data: CreateAlertRequestData,
}
#[derive(Serialize, Debug)]
pub (crate) struct CreateAlertRequestData {
#[serde(rename = "type")]
pub data_type: &'static str,
pub attributes: CreateAlertRequestAttributes,
}
#[derive(Serialize, Debug)]
pub (crate) struct CreateAlertRequestAttributes {
pub source: String,
pub summary: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub description: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub status: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub service_ids: Option<Vec<String>>,
#[serde(skip_serializing_if = "Option::is_none")]
pub group_ids: Option<Vec<String>>,
#[serde(skip_serializing_if = "Option::is_none")]
pub environment_ids: Option<Vec<String>>,
#[serde(skip_serializing_if = "Option::is_none")]
pub started_at: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub ended_at: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub external_id: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub external_url: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub alert_urgency_id: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub notification_target_type: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub notification_target_id: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub labels: Option<Vec<CreateAlertLabel>>,
#[serde(skip_serializing_if = "Option::is_none")]
pub deduplication_key: Option<String>,
}
#[derive(Serialize, Debug)]
pub (crate) struct CreateAlertLabel {
pub key: String,
pub value: String,
}
// ======================== Response ========================
#[derive(Deserialize, Debug)]
pub (crate) struct CreateAlertResponse {
pub data: CreateAlertResponseData,
}
#[derive(Deserialize, Debug)]
pub (crate) struct CreateAlertResponseData {
pub id: String,
#[serde(rename = "type")]
pub data_type: String,
pub attributes: CreateAlertResponseAttributes,
}
#[derive(Deserialize, Debug)]
pub (crate) struct CreateAlertResponseAttributes {
pub short_id: Option<String>,
pub source: Option<String>,
pub summary: Option<String>,
pub description: Option<String>,
pub status: Option<String>,
pub noise: Option<String>,
pub created_at: Option<String>,
pub updated_at: Option<String>,
pub external_id: Option<String>,
pub external_url: Option<String>,
pub alert_urgency_id: Option<String>,
pub deduplication_key: Option<String>,
pub started_at: Option<String>,
pub ended_at: Option<String>,
pub service_ids: Option<Vec<String>>,
pub group_ids: Option<Vec<String>>,
pub environment_ids: Option<Vec<String>>,
pub is_group_leader_alert: Option<bool>,
pub group_leader_alert_id: Option<String>,
pub labels: Option<Vec<CreateAlertResponseLabel>>,
}
#[derive(Deserialize, Debug)]
pub (crate) struct CreateAlertResponseLabel {
pub key: Option<String>,
pub value: Option<String>,
}
@@ -0,0 +1,2 @@
pub mod create_alert;
pub (crate) mod http_request;
@@ -0,0 +1 @@
pub mod create_alert;
@@ -0,0 +1,9 @@
use errors::AnyhowResult;
use std::fs::read_to_string;
#[cfg(test)]
pub fn get_test_api_key() -> AnyhowResult<String> {
let api_key = read_to_string("/Users/bt/Artcraft/credentials/rootly_api_key.txt")?;
let api_key = api_key.trim().to_string();
Ok(api_key)
}
@@ -0,0 +1,2 @@
#[cfg(test)]
pub (crate) mod get_test_api_key;