Skip to content

Commit f16ce7f

Browse files
authored
feat(gateway): add Datadog webhook source (#456)
Signed-off-by: Yordis Prieto <yordis.prieto@gmail.com>
1 parent f39859d commit f16ce7f

24 files changed

Lines changed: 1291 additions & 5 deletions

File tree

devops/docker/compose/.env.example

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,9 @@ TROGON_GATEWAY_LOCAL_GITHUB_WEBHOOK_SECRET=local-dev-secret
3838
# --- Sentry Source ---
3939
# SENTRY_PRIMARY_CLIENT_SECRET=
4040

41+
# --- Datadog Source ---
42+
# DATADOG_PRIMARY_WEBHOOK_TOKEN=
43+
4144
# --- Discord Source ---
4245
# DISCORD_BOT_TOKEN=
4346

devops/docker/compose/services/trogon-gateway/README.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ named TOML integrations, each with its own literal or env-backed secret.
1919
| Microsoft Graph change notifications | `/sources/microsoft-graph/{integration}/webhook` | `client_state` |
2020
| Notion | `/sources/notion/{integration}/webhook` | `verification_token` |
2121
| Sentry | `/sources/sentry/{integration}/webhook` | `client_secret` |
22+
| Datadog | `/sources/datadog/{integration}/webhook` | `webhook_token` |
2223

2324
The gateway port is configured via `TROGON_GATEWAY_PORT` (default `8080`).
2425
Liveness and readiness probes are available at `GET /-/liveness` and `GET /-/readiness`.

devops/docker/compose/services/trogon-gateway/gateway.toml

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -81,6 +81,17 @@ webhook_secret = { env = "TROGON_GATEWAY_LOCAL_GITHUB_WEBHOOK_SECRET" }
8181
# [sources.sentry.integrations.primary.webhook]
8282
# client_secret = { env = "SENTRY_PRIMARY_CLIENT_SECRET" }
8383
#
84+
# [sources.datadog.integrations.primary]
85+
# subject_prefix = "datadog-primary"
86+
# stream_name = "DATADOG_PRIMARY"
87+
# stream_max_age_secs = 604800
88+
# nats_ack_timeout_secs = 10
89+
#
90+
# [sources.datadog.integrations.primary.webhook]
91+
# webhook_token = { env = "DATADOG_PRIMARY_WEBHOOK_TOKEN" }
92+
# webhook_token_header = "X-Datadog-Webhook-Token" # optional; override the custom header name
93+
# timestamp_tolerance_secs = 300 # optional anti-replay window; requires "timestamp": "$DATE_POSIX" in the payload
94+
#
8495
# [sources.discord]
8596
# bot_token = { env = "DISCORD_BOT_TOKEN" }
8697
# gateway_intents = "guilds,guild_members,guild_messages,guild_message_reactions,direct_messages,message_content,guild_voice_states"

otel/semconv/registry/gateway.yaml

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,7 @@ groups:
2121
- id: event_id
2222
type: string
2323
stability: development
24-
brief: Webhook event identifier (Slack, Notion).
24+
brief: Webhook event identifier (Slack, Notion, Datadog).
2525
- id: event
2626
type: string
2727
stability: development
@@ -157,3 +157,12 @@ groups:
157157
attributes:
158158
- ref: notification_count
159159
- ref: subject
160+
- id: span.datadog.webhook
161+
type: span
162+
span_kind: server
163+
stability: development
164+
brief: Datadog webhook ingestion.
165+
attributes:
166+
- ref: event_type
167+
- ref: event_id
168+
- ref: subject

rsworkspace/crates/trogon-gateway/README.md

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,7 @@ configured as a named integration and mounted at its integration path.
4444
| Microsoft Graph change notifications | `/sources/microsoft-graph/{integration}/webhook` |
4545
| Notion | `/sources/notion/{integration}/webhook` |
4646
| Sentry | `/sources/sentry/{integration}/webhook` |
47+
| Datadog | `/sources/datadog/{integration}/webhook` |
4748

4849
Integration IDs must contain only ASCII letters, numbers, `_`, or `-`. They cannot be empty, contain path
4950
separators, or exceed 64 characters.
@@ -70,6 +71,7 @@ or explicit environment reference shape.
7071
| Microsoft Graph change notifications | `client_state` |
7172
| Notion | `verification_token` |
7273
| Sentry | `client_secret` |
74+
| Datadog | `webhook_token` |
7375

7476
## Core configuration
7577

@@ -103,6 +105,8 @@ Source-specific extras:
103105
- Telegram `webhook_registration_mode = "startup"` attempts registration on startup and requires `bot_token` plus `public_webhook_url`
104106
- Linear `timestamp_tolerance_secs` (default: `60`, `0` disables tolerance)
105107
- Twitter/X `consumer_secret` is used for both CRC responses and `x-twitter-webhooks-signature` validation
108+
- Datadog webhooks are not signed. `webhook_token` is a shared secret the operator sends as a custom header (configured in the Datadog webhook's *Custom Headers*), verified in constant time. The header name defaults to `X-Datadog-Webhook-Token` and can be overridden per integration with `webhook_token_header`. Routing keys off an `event_type` field in the payload template, falling back to `.unroutable` when it is absent or invalid; an optional `id` field is used as the NATS message ID for deduplication
109+
- Datadog `timestamp_tolerance_secs` (optional, disabled by default) enables a freshness/anti-replay check. Datadog sends no timestamp header, so the operator must template a `timestamp` field carrying `$DATE_POSIX` (POSIX seconds) into the payload. When enabled, requests whose `timestamp` is outside the window are rejected (`401`), and requests missing/with an unparseable `timestamp` are rejected (`400`). `0` or unset disables the check
106110

107111
## Config file shape
108112

@@ -163,8 +167,34 @@ verification_token = "notion-verification-token-example"
163167

164168
[sources.sentry.integrations.primary.webhook]
165169
client_secret = "sentry-client-secret"
170+
171+
[sources.datadog.integrations.primary.webhook]
172+
webhook_token = { env = "DATADOG_PRIMARY_WEBHOOK_TOKEN" }
173+
# webhook_token_header = "X-Datadog-Webhook-Token" # optional; override the custom header name
174+
# timestamp_tolerance_secs = 300 # optional anti-replay window; requires "timestamp": "$DATE_POSIX" in the payload
175+
```
176+
177+
Configure the matching Datadog webhook (Integrations > Webhooks) with a custom header and a payload template, for example:
178+
179+
```json
180+
{
181+
"X-Datadog-Webhook-Token": "$WEBHOOK_TOKEN"
182+
}
166183
```
167184

185+
```json
186+
{
187+
"id": "$ID",
188+
"event_type": "$EVENT_TYPE",
189+
"timestamp": "$DATE_POSIX",
190+
"title": "$EVENT_TITLE",
191+
"body": "$EVENT_MSG",
192+
"alert_transition": "$ALERT_TRANSITION"
193+
}
194+
```
195+
196+
`timestamp` is only required when `timestamp_tolerance_secs` is set; `$DATE_POSIX` is POSIX epoch seconds (`$DATE` is epoch milliseconds, which this source does not accept).
197+
168198
## Microsoft Graph change notifications
169199

170200
This source receives Microsoft Graph change notifications. It does not implement

rsworkspace/crates/trogon-gateway/src/config.rs

Lines changed: 115 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
use std::collections::BTreeMap;
22
use std::path::Path;
33

4+
use crate::source::datadog::DatadogWebhookToken;
45
use crate::source::discord::config::DiscordBotToken;
56
use crate::source::github::config::GitHubWebhookSecret;
67
use crate::source::gitlab::GitLabSigningToken;
@@ -19,6 +20,7 @@ use crate::source::telegram::config::{
1920
};
2021
use crate::source::twitter::config::TwitterConsumerSecret;
2122
use crate::source_integration_id::{SourceIntegrationId, SourceIntegrationIdError};
23+
use axum::http::HeaderName;
2224
use confique::Config;
2325
#[cfg(test)]
2426
use trogon_nats::NatsAuth;
@@ -350,6 +352,8 @@ struct SourcesConfig {
350352
notion: NotionConfig,
351353
#[config(nested)]
352354
sentry: SentryConfig,
355+
#[config(nested)]
356+
datadog: DatadogConfig,
353357
}
354358

355359
#[derive(Config)]
@@ -448,6 +452,14 @@ struct SentryConfig {
448452
integrations: BTreeMap<String, SourceIntegrationInput<SentryWebhookConfig>>,
449453
}
450454

455+
#[derive(Config)]
456+
#[config(layer_attr(serde(deny_unknown_fields)))]
457+
struct DatadogConfig {
458+
status: Option<String>,
459+
#[config(default = {})]
460+
integrations: BTreeMap<String, SourceIntegrationInput<DatadogWebhookConfig>>,
461+
}
462+
451463
#[derive(serde::Deserialize)]
452464
#[serde(deny_unknown_fields)]
453465
struct SourceIntegrationInput<T> {
@@ -544,6 +556,14 @@ struct SentryWebhookConfig {
544556
client_secret: Option<SecretInput>,
545557
}
546558

559+
#[derive(serde::Deserialize)]
560+
#[serde(deny_unknown_fields)]
561+
struct DatadogWebhookConfig {
562+
webhook_token: Option<SecretInput>,
563+
webhook_token_header: Option<String>,
564+
timestamp_tolerance_secs: Option<u64>,
565+
}
566+
547567
pub struct ResolvedHttpServerConfig {
548568
pub port: u16,
549569
}
@@ -573,6 +593,7 @@ pub struct ResolvedConfig {
573593
pub microsoft_graph: Vec<SourceIntegration<crate::source::microsoft_graph::MicrosoftGraphConfig>>,
574594
pub notion: Vec<SourceIntegration<crate::source::notion::NotionConfig>>,
575595
pub sentry: Vec<SourceIntegration<crate::source::sentry::SentryConfig>>,
596+
pub datadog: Vec<SourceIntegration<crate::source::datadog::DatadogConfig>>,
576597
}
577598

578599
impl ResolvedConfig {
@@ -588,6 +609,7 @@ impl ResolvedConfig {
588609
|| !self.microsoft_graph.is_empty()
589610
|| !self.notion.is_empty()
590611
|| !self.sentry.is_empty()
612+
|| !self.datadog.is_empty()
591613
}
592614
}
593615

@@ -620,6 +642,7 @@ fn resolve(cfg: GatewayConfig, nats_overrides: &NatsArgs) -> Result<ResolvedConf
620642
let microsoft_graph = resolve_microsoft_graph_integrations(cfg.sources.microsoft_graph, &env, &mut errors);
621643
let notion = resolve_notion_integrations(cfg.sources.notion, &env, &mut errors);
622644
let sentry = resolve_sentry_integrations(cfg.sources.sentry, &env, &mut errors);
645+
let datadog = resolve_datadog_integrations(cfg.sources.datadog, &env, &mut errors);
623646

624647
if !errors.is_empty() {
625648
return Err(ConfigError::Validation(ValidationErrors(errors)));
@@ -641,6 +664,7 @@ fn resolve(cfg: GatewayConfig, nats_overrides: &NatsArgs) -> Result<ResolvedConf
641664
microsoft_graph,
642665
notion,
643666
sentry,
667+
datadog,
644668
})
645669
}
646670

@@ -1703,6 +1727,97 @@ fn resolve_sentry_integrations(
17031727
integrations
17041728
}
17051729

1730+
fn resolve_datadog_integrations(
1731+
section: DatadogConfig,
1732+
env: &impl ReadEnv,
1733+
errors: &mut Vec<ConfigValidationError>,
1734+
) -> Vec<SourceIntegration<crate::source::datadog::DatadogConfig>> {
1735+
let DatadogConfig {
1736+
status,
1737+
integrations: configured_integrations,
1738+
} = section;
1739+
let mut integrations = Vec::new();
1740+
if !resolve_source_status("datadog", status.as_deref(), errors) {
1741+
return integrations;
1742+
}
1743+
for (raw_id, integration) in configured_integrations {
1744+
let Some(id) = resolve_integration_id("datadog", raw_id, errors) else {
1745+
continue;
1746+
};
1747+
if !resolve_integration_source_status("datadog", &id, integration.status.as_deref(), errors) {
1748+
continue;
1749+
}
1750+
let Some(webhook) = integration.webhook else {
1751+
errors.push(ConfigValidationError::missing_integration("datadog", &id, "webhook"));
1752+
continue;
1753+
};
1754+
let Some(token) =
1755+
require_integration_value("datadog", &id, "webhook_token", webhook.webhook_token, env, errors)
1756+
else {
1757+
continue;
1758+
};
1759+
let webhook_token = match DatadogWebhookToken::new(token) {
1760+
Ok(token) => token,
1761+
Err(error) => {
1762+
errors.push(ConfigValidationError::invalid_integration(
1763+
"datadog",
1764+
&id,
1765+
"webhook_token",
1766+
error,
1767+
));
1768+
continue;
1769+
}
1770+
};
1771+
let Some((subject_prefix, stream_name, stream_max_age, nats_ack_timeout)) = resolve_common_integration_fields(
1772+
CommonIntegrationFieldsInput {
1773+
source: "datadog",
1774+
id: &id,
1775+
subject_source_prefix: "datadog",
1776+
stream_source_prefix: "DATADOG",
1777+
subject_prefix: integration.subject_prefix,
1778+
stream_name: integration.stream_name,
1779+
stream_max_age_secs: integration.stream_max_age_secs,
1780+
nats_ack_timeout_secs: integration.nats_ack_timeout_secs,
1781+
default_nats_ack_timeout_secs: DEFAULT_NATS_ACK_TIMEOUT_SECS,
1782+
},
1783+
errors,
1784+
) else {
1785+
continue;
1786+
};
1787+
let webhook_token_header = match webhook.webhook_token_header {
1788+
Some(raw) => match HeaderName::try_from(raw.trim()) {
1789+
Ok(header) => header,
1790+
Err(error) => {
1791+
errors.push(ConfigValidationError::invalid_integration(
1792+
"datadog",
1793+
&id,
1794+
"webhook_token_header",
1795+
error,
1796+
));
1797+
continue;
1798+
}
1799+
},
1800+
None => HeaderName::from_static(crate::source::datadog::constants::DEFAULT_HEADER_WEBHOOK_TOKEN),
1801+
};
1802+
let timestamp_tolerance = webhook
1803+
.timestamp_tolerance_secs
1804+
.and_then(|secs| NonZeroDuration::from_secs(secs).ok());
1805+
integrations.push(SourceIntegration::new(
1806+
id,
1807+
crate::source::datadog::DatadogConfig {
1808+
webhook_token,
1809+
webhook_token_header,
1810+
subject_prefix,
1811+
stream_name,
1812+
stream_max_age,
1813+
nats_ack_timeout,
1814+
timestamp_tolerance,
1815+
},
1816+
));
1817+
}
1818+
integrations
1819+
}
1820+
17061821
fn resolve_integration_id(
17071822
source: &'static str,
17081823
raw_id: String,

0 commit comments

Comments
 (0)