Skip to content

Commit 9504301

Browse files
vsilentCopilot
andcommitted
feat(alerting): implement real Slack webhook notifications
- Add --slack-webhook CLI flag to sniff command - Read STACKDOG_SLACK_WEBHOOK_URL env var (CLI overrides env) - Implement actual HTTP POST to Slack incoming webhook API - Build proper JSON payloads with serde_json (color-coded by severity) - Add reqwest blocking feature for synchronous notification delivery - Wire NotificationConfig through SniffConfig → Orchestrator → Reporter - Add STACKDOG_WEBHOOK_URL env var support - Update .env.sample with notification channel examples - Add 3 tests for Slack webhook config (CLI, env, override priority) Usage: stackdog sniff --once --slack-webhook https://hooks.slack.com/services/T/B/xxx # or via env: export STACKDOG_SLACK_WEBHOOK_URL=https://hooks.slack.com/services/T/B/xxx Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
1 parent 01942f8 commit 9504301

7 files changed

Lines changed: 137 additions & 41 deletions

File tree

.env.sample

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,3 +17,9 @@ RUST_BACKTRACE=full
1717
#STACKDOG_AI_API_URL=http://localhost:11434/v1
1818
#STACKDOG_AI_API_KEY=
1919
#STACKDOG_AI_MODEL=llama3
20+
21+
# Notification Channels
22+
# Slack: create an incoming webhook at https://api.slack.com/messaging/webhooks
23+
#STACKDOG_SLACK_WEBHOOK_URL=https://hooks.slack.com/services/T.../B.../xxxxx
24+
# Generic webhook endpoint for alert notifications
25+
#STACKDOG_WEBHOOK_URL=https://example.com/webhook

Cargo.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -48,7 +48,7 @@ r2d2 = "0.8"
4848
bollard = "0.16"
4949

5050
# HTTP client (for LLM API)
51-
reqwest = { version = "0.12", features = ["json"] }
51+
reqwest = { version = "0.12", features = ["json", "blocking"] }
5252

5353
# Compression
5454
zstd = "0.13"

src/alerting/notifications.rs

Lines changed: 44 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -111,14 +111,39 @@ impl NotificationChannel {
111111
Ok(NotificationResult::Success("sent to console".to_string()))
112112
}
113113

114-
/// Send to Slack
114+
/// Send to Slack via incoming webhook
115115
fn send_slack(&self, alert: &Alert, config: &NotificationConfig) -> Result<NotificationResult> {
116-
// In production, this would make HTTP request to Slack webhook
117-
// For now, just log
118-
if config.slack_webhook().is_some() {
119-
log::info!("Would send to Slack: {}", alert.message());
120-
Ok(NotificationResult::Success("sent to Slack".to_string()))
116+
if let Some(webhook_url) = config.slack_webhook() {
117+
let payload = build_slack_message(alert);
118+
log::debug!("Sending Slack notification to webhook");
119+
log::trace!("Slack payload: {}", payload);
120+
121+
// Blocking HTTP POST — notification sending is synchronous in this codebase
122+
let client = reqwest::blocking::Client::new();
123+
match client
124+
.post(webhook_url)
125+
.header("Content-Type", "application/json")
126+
.body(payload)
127+
.send()
128+
{
129+
Ok(resp) => {
130+
if resp.status().is_success() {
131+
log::info!("Slack notification sent successfully");
132+
Ok(NotificationResult::Success("sent to Slack".to_string()))
133+
} else {
134+
let status = resp.status();
135+
let body = resp.text().unwrap_or_default();
136+
log::warn!("Slack API returned {}: {}", status, body);
137+
Ok(NotificationResult::Failure(format!("Slack returned {}: {}", status, body)))
138+
}
139+
}
140+
Err(e) => {
141+
log::warn!("Failed to send Slack notification: {}", e);
142+
Ok(NotificationResult::Failure(format!("Slack request failed: {}", e)))
143+
}
144+
}
121145
} else {
146+
log::debug!("Slack webhook not configured, skipping");
122147
Ok(NotificationResult::Failure("Slack webhook not configured".to_string()))
123148
}
124149
}
@@ -211,27 +236,19 @@ pub fn severity_to_slack_color(severity: AlertSeverity) -> &'static str {
211236

212237
/// Build Slack message payload
213238
pub fn build_slack_message(alert: &Alert) -> String {
214-
format!(
215-
r#"{{
216-
"text": "Security Alert",
217-
"attachments": [{{
218-
"color": "{}",
219-
"title": "{:?} ",
220-
"text": "{}",
221-
"fields": [
222-
{{"title": "Severity", "value": "{}", "short": true}},
223-
{{"title": "Status", "value": "{}", "short": true}},
224-
{{"title": "Time", "value": "{}", "short": true}}
225-
]
226-
}}]
227-
}}"#,
228-
severity_to_slack_color(alert.severity()),
229-
alert.alert_type(),
230-
alert.message(),
231-
alert.severity(),
232-
alert.status(),
233-
alert.timestamp()
234-
)
239+
serde_json::json!({
240+
"text": "🐕 Stackdog Security Alert",
241+
"attachments": [{
242+
"color": severity_to_slack_color(alert.severity()),
243+
"title": format!("{:?}", alert.alert_type()),
244+
"text": alert.message(),
245+
"fields": [
246+
{"title": "Severity", "value": alert.severity().to_string(), "short": true},
247+
{"title": "Status", "value": alert.status().to_string(), "short": true},
248+
{"title": "Time", "value": alert.timestamp().to_rfc3339(), "short": true}
249+
]
250+
}]
251+
}).to_string()
235252
}
236253

237254
/// Build webhook payload

src/cli.rs

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -52,6 +52,10 @@ pub enum Command {
5252
/// AI API URL (e.g. "http://localhost:11434/v1" for Ollama)
5353
#[arg(long)]
5454
ai_api_url: Option<String>,
55+
56+
/// Slack webhook URL for alert notifications
57+
#[arg(long)]
58+
slack_webhook: Option<String>,
5559
},
5660
}
5761

@@ -76,7 +80,7 @@ mod tests {
7680
fn test_sniff_subcommand_defaults() {
7781
let cli = Cli::parse_from(["stackdog", "sniff"]);
7882
match cli.command {
79-
Some(Command::Sniff { once, consume, output, sources, interval, ai_provider, ai_model, ai_api_url }) => {
83+
Some(Command::Sniff { once, consume, output, sources, interval, ai_provider, ai_model, ai_api_url, slack_webhook }) => {
8084
assert!(!once);
8185
assert!(!consume);
8286
assert_eq!(output, "./stackdog-logs/");
@@ -85,6 +89,7 @@ mod tests {
8589
assert!(ai_provider.is_none());
8690
assert!(ai_model.is_none());
8791
assert!(ai_api_url.is_none());
92+
assert!(slack_webhook.is_none());
8893
}
8994
_ => panic!("Expected Sniff command"),
9095
}
@@ -120,9 +125,10 @@ mod tests {
120125
"--ai-provider", "openai",
121126
"--ai-model", "gpt-4o-mini",
122127
"--ai-api-url", "https://api.openai.com/v1",
128+
"--slack-webhook", "https://hooks.slack.com/services/T/B/xxx",
123129
]);
124130
match cli.command {
125-
Some(Command::Sniff { once, consume, output, sources, interval, ai_provider, ai_model, ai_api_url }) => {
131+
Some(Command::Sniff { once, consume, output, sources, interval, ai_provider, ai_model, ai_api_url, slack_webhook }) => {
126132
assert!(once);
127133
assert!(consume);
128134
assert_eq!(output, "/tmp/logs/");
@@ -131,6 +137,7 @@ mod tests {
131137
assert_eq!(ai_provider.unwrap(), "openai");
132138
assert_eq!(ai_model.unwrap(), "gpt-4o-mini");
133139
assert_eq!(ai_api_url.unwrap(), "https://api.openai.com/v1");
140+
assert_eq!(slack_webhook.unwrap(), "https://hooks.slack.com/services/T/B/xxx");
134141
}
135142
_ => panic!("Expected Sniff command"),
136143
}

src/main.rs

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -72,8 +72,8 @@ async fn main() -> io::Result<()> {
7272
info!("Architecture: {}", std::env::consts::ARCH);
7373

7474
match cli.command {
75-
Some(Command::Sniff { once, consume, output, sources, interval, ai_provider, ai_model, ai_api_url }) => {
76-
run_sniff(once, consume, output, sources, interval, ai_provider, ai_model, ai_api_url).await
75+
Some(Command::Sniff { once, consume, output, sources, interval, ai_provider, ai_model, ai_api_url, slack_webhook }) => {
76+
run_sniff(once, consume, output, sources, interval, ai_provider, ai_model, ai_api_url, slack_webhook).await
7777
}
7878
// Default: serve (backward compatible)
7979
Some(Command::Serve) | None => run_serve().await,
@@ -142,6 +142,7 @@ async fn run_sniff(
142142
ai_provider: Option<String>,
143143
ai_model: Option<String>,
144144
ai_api_url: Option<String>,
145+
slack_webhook: Option<String>,
145146
) -> io::Result<()> {
146147
let config = sniff::config::SniffConfig::from_env_and_args(
147148
once,
@@ -152,6 +153,7 @@ async fn run_sniff(
152153
ai_provider.as_deref(),
153154
ai_model.as_deref(),
154155
ai_api_url.as_deref(),
156+
slack_webhook.as_deref(),
155157
);
156158

157159
info!("🔍 Stackdog Sniff starting...");
@@ -162,6 +164,9 @@ async fn run_sniff(
162164
info!("AI Provider: {:?}", config.ai_provider);
163165
info!("AI Model: {}", config.ai_model);
164166
info!("AI API URL: {}", config.ai_api_url);
167+
if config.slack_webhook.is_some() {
168+
info!("Slack: configured ✓");
169+
}
165170

166171
let orchestrator = sniff::SniffOrchestrator::new(config)
167172
.map_err(|e| io::Error::new(io::ErrorKind::Other, e))?;

src/sniff/config.rs

Lines changed: 61 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,10 @@ pub struct SniffConfig {
4646
pub ai_model: String,
4747
/// Database URL
4848
pub database_url: String,
49+
/// Slack webhook URL for alert notifications
50+
pub slack_webhook: Option<String>,
51+
/// Generic webhook URL for alert notifications
52+
pub webhook_url: Option<String>,
4953
}
5054

5155
impl SniffConfig {
@@ -59,6 +63,7 @@ impl SniffConfig {
5963
ai_provider_arg: Option<&str>,
6064
ai_model_arg: Option<&str>,
6165
ai_api_url_arg: Option<&str>,
66+
slack_webhook_arg: Option<&str>,
6267
) -> Self {
6368
let env_sources = env::var("STACKDOG_LOG_SOURCES").unwrap_or_default();
6469
let mut extra_sources: Vec<String> = env_sources
@@ -116,6 +121,10 @@ impl SniffConfig {
116121
.unwrap_or_else(|| "llama3".into()),
117122
database_url: env::var("DATABASE_URL")
118123
.unwrap_or_else(|_| "./stackdog.db".into()),
124+
slack_webhook: slack_webhook_arg
125+
.map(|s| s.to_string())
126+
.or_else(|| env::var("STACKDOG_SLACK_WEBHOOK_URL").ok()),
127+
webhook_url: env::var("STACKDOG_WEBHOOK_URL").ok(),
119128
}
120129
}
121130
}
@@ -136,6 +145,8 @@ mod tests {
136145
env::remove_var("STACKDOG_AI_MODEL");
137146
env::remove_var("STACKDOG_SNIFF_OUTPUT_DIR");
138147
env::remove_var("STACKDOG_SNIFF_INTERVAL");
148+
env::remove_var("STACKDOG_SLACK_WEBHOOK_URL");
149+
env::remove_var("STACKDOG_WEBHOOK_URL");
139150
}
140151

141152
#[test]
@@ -152,7 +163,7 @@ mod tests {
152163
let _lock = ENV_MUTEX.lock().unwrap();
153164
clear_sniff_env();
154165

155-
let config = SniffConfig::from_env_and_args(false, false, "./stackdog-logs/", None, 30, None, None, None);
166+
let config = SniffConfig::from_env_and_args(false, false, "./stackdog-logs/", None, 30, None, None, None, None);
156167
assert!(!config.once);
157168
assert!(!config.consume);
158169
assert_eq!(config.output_dir, PathBuf::from("./stackdog-logs/"));
@@ -170,7 +181,7 @@ mod tests {
170181
clear_sniff_env();
171182

172183
let config = SniffConfig::from_env_and_args(
173-
true, true, "/tmp/output/", Some("/var/log/app.log"), 60, Some("candle"), None, None,
184+
true, true, "/tmp/output/", Some("/var/log/app.log"), 60, Some("candle"), None, None, None,
174185
);
175186

176187
assert!(config.once);
@@ -188,7 +199,7 @@ mod tests {
188199
env::set_var("STACKDOG_LOG_SOURCES", "/var/log/syslog,/var/log/auth.log");
189200

190201
let config = SniffConfig::from_env_and_args(
191-
false, false, "./stackdog-logs/", Some("/var/log/app.log,/var/log/syslog"), 30, None, None, None,
202+
false, false, "./stackdog-logs/", Some("/var/log/app.log,/var/log/syslog"), 30, None, None, None, None,
192203
);
193204

194205
assert!(config.extra_sources.contains(&"/var/log/syslog".to_string()));
@@ -209,7 +220,7 @@ mod tests {
209220
env::set_var("STACKDOG_SNIFF_INTERVAL", "45");
210221
env::set_var("STACKDOG_SNIFF_OUTPUT_DIR", "/data/logs/");
211222

212-
let config = SniffConfig::from_env_and_args(false, false, "./stackdog-logs/", None, 30, None, None, None);
223+
let config = SniffConfig::from_env_and_args(false, false, "./stackdog-logs/", None, 30, None, None, None, None);
213224
assert_eq!(config.ai_api_url, "https://api.openai.com/v1");
214225
assert_eq!(config.ai_api_key, Some("sk-test123".into()));
215226
assert_eq!(config.ai_model, "gpt-4o-mini");
@@ -226,7 +237,7 @@ mod tests {
226237

227238
let config = SniffConfig::from_env_and_args(
228239
false, false, "./stackdog-logs/", None, 30,
229-
Some("ollama"), Some("qwen2.5-coder:latest"), None,
240+
Some("ollama"), Some("qwen2.5-coder:latest"), None, None,
230241
);
231242
// "ollama" maps to OpenAi internally (same API protocol)
232243
assert_eq!(config.ai_provider, AiProvider::OpenAi);
@@ -245,12 +256,56 @@ mod tests {
245256

246257
let config = SniffConfig::from_env_and_args(
247258
false, false, "./stackdog-logs/", None, 30,
248-
None, Some("llama3"), Some("http://localhost:11434/v1"),
259+
None, Some("llama3"), Some("http://localhost:11434/v1"), None,
249260
);
250261
// CLI args take priority over env vars
251262
assert_eq!(config.ai_model, "llama3");
252263
assert_eq!(config.ai_api_url, "http://localhost:11434/v1");
253264

254265
clear_sniff_env();
255266
}
267+
268+
#[test]
269+
fn test_slack_webhook_from_cli() {
270+
let _lock = ENV_MUTEX.lock().unwrap();
271+
clear_sniff_env();
272+
273+
let config = SniffConfig::from_env_and_args(
274+
false, false, "./stackdog-logs/", None, 30,
275+
None, None, None, Some("https://hooks.slack.com/services/T/B/xxx"),
276+
);
277+
assert_eq!(config.slack_webhook.as_deref(), Some("https://hooks.slack.com/services/T/B/xxx"));
278+
279+
clear_sniff_env();
280+
}
281+
282+
#[test]
283+
fn test_slack_webhook_from_env() {
284+
let _lock = ENV_MUTEX.lock().unwrap();
285+
clear_sniff_env();
286+
env::set_var("STACKDOG_SLACK_WEBHOOK_URL", "https://hooks.slack.com/services/T/B/env");
287+
288+
let config = SniffConfig::from_env_and_args(
289+
false, false, "./stackdog-logs/", None, 30,
290+
None, None, None, None,
291+
);
292+
assert_eq!(config.slack_webhook.as_deref(), Some("https://hooks.slack.com/services/T/B/env"));
293+
294+
clear_sniff_env();
295+
}
296+
297+
#[test]
298+
fn test_slack_webhook_cli_overrides_env() {
299+
let _lock = ENV_MUTEX.lock().unwrap();
300+
clear_sniff_env();
301+
env::set_var("STACKDOG_SLACK_WEBHOOK_URL", "https://hooks.slack.com/services/T/B/env");
302+
303+
let config = SniffConfig::from_env_and_args(
304+
false, false, "./stackdog-logs/", None, 30,
305+
None, None, None, Some("https://hooks.slack.com/services/T/B/cli"),
306+
);
307+
assert_eq!(config.slack_webhook.as_deref(), Some("https://hooks.slack.com/services/T/B/cli"));
308+
309+
clear_sniff_env();
310+
}
256311
}

src/sniff/mod.rs

Lines changed: 9 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -33,7 +33,13 @@ impl SniffOrchestrator {
3333
let pool = create_pool(&config.database_url)?;
3434
init_database(&pool)?;
3535

36-
let notification_config = NotificationConfig::default();
36+
let mut notification_config = NotificationConfig::default();
37+
if let Some(ref url) = config.slack_webhook {
38+
notification_config = notification_config.with_slack_webhook(url.clone());
39+
}
40+
if let Some(ref url) = config.webhook_url {
41+
notification_config = notification_config.with_webhook_url(url.clone());
42+
}
3743
let reporter = Reporter::new(notification_config);
3844

3945
Ok(Self { config, pool, reporter })
@@ -226,7 +232,7 @@ mod tests {
226232
#[test]
227233
fn test_orchestrator_creates_with_memory_db() {
228234
let mut config = SniffConfig::from_env_and_args(
229-
true, false, "./stackdog-logs/", None, 30, None, None, None,
235+
true, false, "./stackdog-logs/", None, 30, None, None, None, None,
230236
);
231237
config.database_url = ":memory:".into();
232238

@@ -249,7 +255,7 @@ mod tests {
249255
let mut config = SniffConfig::from_env_and_args(
250256
true, false, "./stackdog-logs/",
251257
Some(&log_path.to_string_lossy()),
252-
30, Some("candle"), None, None,
258+
30, Some("candle"), None, None, None,
253259
);
254260
config.database_url = ":memory:".into();
255261

0 commit comments

Comments
 (0)