Skip to content

Commit 9ace8f2

Browse files
shahnamiCopilot
andauthored
feat: Add payload mode for webhooks (#417)
* feat: Add payload mode for webhooks * fix: Docs * Fix linker OOM crash in CI coverage builds (#418) --------- Co-authored-by: Copilot <[email protected]>
1 parent ed80cc6 commit 9ace8f2

File tree

14 files changed

+311
-47
lines changed

14 files changed

+311
-47
lines changed

.github/workflows/ci.yaml

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -184,19 +184,19 @@ jobs:
184184
- name: Generate Unit Coverage Report
185185
env:
186186
LLVM_PROFILE_FILE: unit-%p-%m.profraw
187-
RUSTFLAGS: -Cinstrument-coverage
187+
RUSTFLAGS: -Cinstrument-coverage -Clink-arg=-Wl,--threads=1
188188
run: RUST_TEST_THREADS=1 cargo hack llvm-cov --locked --lcov --output-path unit-lcov.info --lib
189189

190190
# Integration tests coverage
191191
- name: Generate Integration Coverage Report
192192
env:
193193
LLVM_PROFILE_FILE: integration-%p-%m.profraw
194-
RUSTFLAGS: -Cinstrument-coverage
194+
RUSTFLAGS: -Cinstrument-coverage -Clink-arg=-Wl,--threads=1
195195
run: RUST_TEST_THREADS=1 cargo hack llvm-cov --locked --lcov --output-path integration-lcov.info --test integration
196196
- name: Generate Properties Coverage Report
197197
env:
198198
LLVM_PROFILE_FILE: properties-%p-%m.profraw
199-
RUSTFLAGS: -Cinstrument-coverage
199+
RUSTFLAGS: -Cinstrument-coverage -Clink-arg=-Wl,--threads=1
200200
run: RUST_TEST_THREADS=1 cargo hack llvm-cov --locked --lcov --output-path properties-lcov.info --test properties
201201

202202
# Upload unit coverage

docs/index.mdx

Lines changed: 41 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -625,8 +625,47 @@ A Trigger defines actions to take when monitored conditions are met. Triggers ca
625625
| `**config.secret.type**` | `String` | Secret type (**"Plain"**, **"Environment"**, or **"HashicorpCloudVault"**) |
626626
| `**config.secret.value**` | `String` | Secret value (HMAC secret, environment variable name, or vault secret name) |
627627
| `**config.headers**` | `Object` | Headers to include in the webhook request |
628-
| `**config.message.title**` | `String` | Title that appears in the webhook message |
629-
| `**config.message.body**` | `String` | Message template with variable substitution |
628+
| `**config.payload_mode**` | `String` | Payload mode: **"template"** (default) or **"raw"** |
629+
| `**config.message.title**` | `String` | Title that appears in the webhook message (required for template mode) |
630+
| `**config.message.body**` | `String` | Message template with variable substitution (required for template mode) |
631+
632+
##### Webhook Payload Modes
633+
634+
Webhooks support two payload modes that determine how data is sent to your endpoint:
635+
636+
**Template Mode (default)**
637+
638+
In template mode, the webhook sends a formatted JSON payload with `title` and `body` fields, where variables are substituted from the monitor match data:
639+
640+
```json
641+
{
642+
"title": "Monitor Alert triggered",
643+
"body": "Large transfer detected from 0x123... to 0x456..."
644+
}
645+
```
646+
647+
**Raw Mode**
648+
649+
In raw mode, the webhook sends the complete `MonitorMatch` object directly as the JSON payload. This is useful when you want to receive all blockchain event data without formatting, allowing your receiving service to process the raw data as needed.
650+
651+
```json
652+
{
653+
"raw_webhook": {
654+
"name": "Raw Payload Webhook",
655+
"trigger_type": "webhook",
656+
"config": {
657+
"url": { "type": "plain", "value": "https://api.example.com/events" },
658+
"method": "POST",
659+
"payload_mode": "raw"
660+
}
661+
}
662+
}
663+
```
664+
665+
When using raw mode:
666+
* The `message` field is ignored
667+
* The payload contains the full monitor match including: monitor configuration, transaction details, receipt, logs, matched conditions, and decoded arguments
668+
* This is particularly useful for integrations that need to process the complete event data programmatically
630669

631670
##### Discord Notifications
632671
```json

examples/config/triggers/webhook_notifications.json

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -64,5 +64,24 @@
6464
"body": "${monitor.name} triggered because of a large swap of ${functions.0.args.out_min} tokens | https://stellar.expert/explorer/public/tx/${transaction.hash}"
6565
}
6666
}
67+
},
68+
"raw_payload_webhook": {
69+
"name": "Raw Payload Webhook",
70+
"trigger_type": "webhook",
71+
"config": {
72+
"url": {
73+
"type": "plain",
74+
"value": "https://api.example.com/events"
75+
},
76+
"method": "POST",
77+
"secret": {
78+
"type": "plain",
79+
"value": "webhook-secret"
80+
},
81+
"headers": {
82+
"Content-Type": "application/json"
83+
},
84+
"payload_mode": "raw"
85+
}
6786
}
6887
}

src/models/config/trigger_config.rs

Lines changed: 79 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@ use std::{collections::HashMap, fs, path::Path};
1111
use crate::{
1212
models::{
1313
config::error::ConfigError, ConfigLoader, SecretValue, Trigger, TriggerType,
14-
TriggerTypeConfig,
14+
TriggerTypeConfig, WebhookPayloadMode,
1515
},
1616
services::trigger::validate_script_config,
1717
utils::normalize_string,
@@ -433,6 +433,7 @@ impl ConfigLoader for Trigger {
433433
url,
434434
method,
435435
message,
436+
payload_mode,
436437
..
437438
} = &self.config
438439
{
@@ -457,20 +458,23 @@ impl ConfigLoader for Trigger {
457458
}
458459
}
459460
}
460-
// Validate message
461-
if message.title.trim().is_empty() {
462-
return Err(ConfigError::validation_error(
463-
"Title cannot be empty",
464-
None,
465-
None,
466-
));
467-
}
468-
if message.body.trim().is_empty() {
469-
return Err(ConfigError::validation_error(
470-
"Body cannot be empty",
471-
None,
472-
None,
473-
));
461+
// Validate message only in template mode
462+
// In raw mode, message is not used as the MonitorMatch is sent directly
463+
if *payload_mode == WebhookPayloadMode::Template {
464+
if message.title.trim().is_empty() {
465+
return Err(ConfigError::validation_error(
466+
"Title cannot be empty",
467+
None,
468+
None,
469+
));
470+
}
471+
if message.body.trim().is_empty() {
472+
return Err(ConfigError::validation_error(
473+
"Body cannot be empty",
474+
None,
475+
None,
476+
));
477+
}
474478
}
475479
}
476480
}
@@ -908,7 +912,7 @@ mod tests {
908912

909913
#[test]
910914
fn test_webhook_trigger_validation() {
911-
// Valid trigger
915+
// Valid trigger with template mode (default)
912916
let valid_trigger = TriggerBuilder::new()
913917
.name("test_webhook")
914918
.webhook("https://api.example.com/webhook")
@@ -923,15 +927,15 @@ mod tests {
923927
.build();
924928
assert!(invalid_url.validate().is_err());
925929

926-
// Empty title
930+
// Empty title in template mode - should fail
927931
let invalid_title = TriggerBuilder::new()
928932
.name("test_webhook")
929933
.webhook("https://api.example.com/webhook")
930934
.message("", "Test message")
931935
.build();
932936
assert!(invalid_title.validate().is_err());
933937

934-
// Empty body
938+
// Empty body in template mode - should fail
935939
let invalid_body = TriggerBuilder::new()
936940
.name("test_webhook")
937941
.webhook("https://api.example.com/webhook")
@@ -940,6 +944,63 @@ mod tests {
940944
assert!(invalid_body.validate().is_err());
941945
}
942946

947+
#[test]
948+
fn test_webhook_trigger_validation_raw_mode() {
949+
use crate::models::WebhookPayloadMode;
950+
951+
// Valid trigger with raw payload mode - empty message is OK
952+
let valid_raw_trigger = TriggerBuilder::new()
953+
.name("test_webhook_raw")
954+
.webhook("https://api.example.com/webhook")
955+
.webhook_payload_mode(WebhookPayloadMode::Raw)
956+
.message("", "") // Empty message is valid in raw mode
957+
.build();
958+
assert!(valid_raw_trigger.validate().is_ok());
959+
960+
// Invalid URL in raw mode - should still fail
961+
let invalid_url_raw = TriggerBuilder::new()
962+
.name("test_webhook_raw")
963+
.webhook("invalid-url")
964+
.webhook_payload_mode(WebhookPayloadMode::Raw)
965+
.build();
966+
assert!(invalid_url_raw.validate().is_err());
967+
968+
// Valid URL with raw mode and non-empty message
969+
let valid_raw_with_message = TriggerBuilder::new()
970+
.name("test_webhook_raw")
971+
.webhook("https://api.example.com/webhook")
972+
.webhook_payload_mode(WebhookPayloadMode::Raw)
973+
.message("Alert", "Test message")
974+
.build();
975+
assert!(valid_raw_with_message.validate().is_ok());
976+
}
977+
978+
#[test]
979+
fn test_webhook_payload_mode_serialization() {
980+
use crate::models::WebhookPayloadMode;
981+
982+
// Test serialization
983+
let template_mode = WebhookPayloadMode::Template;
984+
let raw_mode = WebhookPayloadMode::Raw;
985+
986+
assert_eq!(
987+
serde_json::to_string(&template_mode).unwrap(),
988+
"\"template\""
989+
);
990+
assert_eq!(serde_json::to_string(&raw_mode).unwrap(), "\"raw\"");
991+
992+
// Test deserialization
993+
let deserialized_template: WebhookPayloadMode =
994+
serde_json::from_str("\"template\"").unwrap();
995+
let deserialized_raw: WebhookPayloadMode = serde_json::from_str("\"raw\"").unwrap();
996+
997+
assert_eq!(deserialized_template, WebhookPayloadMode::Template);
998+
assert_eq!(deserialized_raw, WebhookPayloadMode::Raw);
999+
1000+
// Test default
1001+
assert_eq!(WebhookPayloadMode::default(), WebhookPayloadMode::Template);
1002+
}
1003+
9431004
#[test]
9441005
fn test_discord_trigger_validation() {
9451006
// Valid trigger

src/models/core/mod.rs

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,4 +14,6 @@ pub use monitor::{
1414
TransactionCondition, TransactionStatus, TriggerConditions, SCRIPT_LANGUAGE_EXTENSIONS,
1515
};
1616
pub use network::{Network, RpcUrl};
17-
pub use trigger::{NotificationMessage, Trigger, TriggerType, TriggerTypeConfig};
17+
pub use trigger::{
18+
NotificationMessage, Trigger, TriggerType, TriggerTypeConfig, WebhookPayloadMode,
19+
};

src/models/core/trigger.rs

Lines changed: 16 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,17 @@ pub struct NotificationMessage {
4848
pub body: String,
4949
}
5050

51+
/// Payload mode for webhook triggers
52+
#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, Default)]
53+
#[serde(rename_all = "lowercase")]
54+
pub enum WebhookPayloadMode {
55+
/// Use title/body templates with variable substitution (default)
56+
#[default]
57+
Template,
58+
/// Send the raw MonitorMatch as the JSON payload
59+
Raw,
60+
}
61+
5162
/// Type-specific configuration for triggers
5263
#[derive(Debug, Clone, Deserialize, Serialize, PartialEq)]
5364
#[serde(deny_unknown_fields)]
@@ -93,8 +104,12 @@ pub enum TriggerTypeConfig {
93104
secret: Option<SecretValue>,
94105
/// Optional HTTP headers
95106
headers: Option<std::collections::HashMap<String, String>>,
96-
/// Notification message
107+
/// Notification message (required for template mode, optional for raw mode)
108+
#[serde(default)]
97109
message: NotificationMessage,
110+
/// Payload mode: "template" (default) or "raw"
111+
#[serde(default)]
112+
payload_mode: WebhookPayloadMode,
98113
/// Retry policy for HTTP requests
99114
#[serde(default)]
100115
retry_policy: RetryConfig,

src/models/mod.rs

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -88,7 +88,8 @@ pub use blockchain::solana::{
8888
pub use core::{
8989
AddressWithSpec, EventCondition, FunctionCondition, MatchConditions, Monitor, Network,
9090
NotificationMessage, RpcUrl, ScriptLanguage, TransactionCondition, TransactionStatus, Trigger,
91-
TriggerConditions, TriggerType, TriggerTypeConfig, SCRIPT_LANGUAGE_EXTENSIONS,
91+
TriggerConditions, TriggerType, TriggerTypeConfig, WebhookPayloadMode,
92+
SCRIPT_LANGUAGE_EXTENSIONS,
9293
};
9394

9495
// Re-export config types

src/services/filter/filters/evm/filter.rs

Lines changed: 1 addition & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -548,11 +548,7 @@ impl<T> EVMBlockFilter<T> {
548548
{
549549
Some(event) => event,
550550
None => {
551-
FilterError::internal_error(
552-
format!("No matching event found for log topic: {:?}", log.topics[0]),
553-
None,
554-
None,
555-
);
551+
tracing::debug!("No matching event found for log topic: {:?}", log.topics[0]);
556552
return None;
557553
}
558554
};

src/services/notification/mod.rs

Lines changed: 29 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ mod webhook;
1818
use crate::{
1919
models::{
2020
MonitorMatch, NotificationMessage, ScriptLanguage, Trigger, TriggerType, TriggerTypeConfig,
21+
WebhookPayloadMode,
2122
},
2223
utils::{normalize_string, RetryConfig},
2324
};
@@ -212,6 +213,15 @@ impl NotificationService {
212213
| TriggerType::Discord
213214
| TriggerType::Webhook
214215
| TriggerType::Telegram => {
216+
// Check if this is a webhook trigger with raw payload mode
217+
let is_raw_mode = matches!(
218+
&trigger.config,
219+
TriggerTypeConfig::Webhook {
220+
payload_mode: WebhookPayloadMode::Raw,
221+
..
222+
}
223+
);
224+
215225
// Use the Webhookable trait to get config, retry policy and payload builder
216226
let components = trigger.config.as_webhook_components()?;
217227

@@ -228,12 +238,24 @@ impl NotificationService {
228238
)
229239
})?;
230240

231-
// Build the payload
232-
let payload = components.builder.build_payload(
233-
&components.config.title,
234-
&components.config.body_template,
235-
variables,
236-
);
241+
// Build the payload based on the mode
242+
let payload = if is_raw_mode {
243+
// In raw mode, serialize the MonitorMatch directly
244+
serde_json::to_value(monitor_match).map_err(|e| {
245+
NotificationError::internal_error(
246+
format!("Failed to serialize MonitorMatch: {}", e),
247+
Some(e.into()),
248+
None,
249+
)
250+
})?
251+
} else {
252+
// In template mode, use the payload builder
253+
components.builder.build_payload(
254+
&components.config.title,
255+
&components.config.body_template,
256+
variables,
257+
)
258+
};
237259

238260
// Create the notifier
239261
let notifier = WebhookNotifier::new(components.config, http_client)?;
@@ -752,6 +774,7 @@ mod tests {
752774
"my-secret".to_string(),
753775
))),
754776
headers: Some([("X-Custom".to_string(), "Value".to_string())].into()),
777+
payload_mode: WebhookPayloadMode::default(),
755778
retry_policy: RetryConfig::default(),
756779
};
757780

src/services/notification/webhook.rs

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -273,7 +273,7 @@ impl WebhookNotifier {
273273
#[cfg(test)]
274274
mod tests {
275275
use crate::{
276-
models::{NotificationMessage, SecretString, SecretValue},
276+
models::{NotificationMessage, SecretString, SecretValue, WebhookPayloadMode},
277277
services::notification::{GenericWebhookPayloadBuilder, WebhookPayloadBuilder},
278278
utils::{tests::create_test_http_client, RetryConfig},
279279
};
@@ -311,6 +311,7 @@ mod tests {
311311
title: "Test Alert".to_string(),
312312
body: "Test message ${value}".to_string(),
313313
},
314+
payload_mode: WebhookPayloadMode::default(),
314315
retry_policy: RetryConfig::default(),
315316
}
316317
}

0 commit comments

Comments
 (0)