Skip to content

Commit 553bbbd

Browse files
committed
feat(gateway): eliminate the Sentry ingestion gap
Signed-off-by: Yordis Prieto <yordis.prieto@gmail.com>
1 parent 85f57e0 commit 553bbbd

15 files changed

Lines changed: 907 additions & 2 deletions

File tree

devops/docker/compose/.env.example

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,13 @@
4141
# TROGON_SOURCE_NOTION_STREAM_MAX_AGE_SECS=604800
4242
# TROGON_SOURCE_NOTION_NATS_ACK_TIMEOUT_SECS=10
4343

44+
# --- Sentry Source ---
45+
# TROGON_SOURCE_SENTRY_CLIENT_SECRET=
46+
# TROGON_SOURCE_SENTRY_SUBJECT_PREFIX=sentry
47+
# TROGON_SOURCE_SENTRY_STREAM_NAME=SENTRY
48+
# TROGON_SOURCE_SENTRY_STREAM_MAX_AGE_SECS=604800
49+
# TROGON_SOURCE_SENTRY_NATS_ACK_TIMEOUT_SECS=10
50+
4451
# --- Discord Source ---
4552
# TROGON_SOURCE_DISCORD_BOT_TOKEN=
4653
# TROGON_SOURCE_DISCORD_GATEWAY_INTENTS=guilds,guild_members,guild_messages,guild_message_reactions,direct_messages,message_content,guild_voice_states

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

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ prefix:
1616
| incident.io | `/incidentio/webhook` | `TROGON_SOURCE_INCIDENTIO_SIGNING_SECRET` |
1717
| Linear | `/linear/webhook` | `TROGON_SOURCE_LINEAR_WEBHOOK_SECRET` |
1818
| Notion | `/notion/webhook` | `TROGON_SOURCE_NOTION_VERIFICATION_TOKEN` |
19+
| Sentry | `/sentry/webhook` | `TROGON_SOURCE_SENTRY_CLIENT_SECRET` |
1920

2021
The gateway port is configured via `TROGON_GATEWAY_PORT` (default `8080`).
2122
Liveness and readiness probes are available at `GET /-/liveness` and `GET /-/readiness`.
@@ -67,6 +68,13 @@ then point the Notion webhook endpoint at `/notion/webhook`. Verified events
6768
are forwarded to NATS on `{subject_prefix}.{type}` subjects such as
6869
`notion.page.created`.
6970

71+
## Sentry webhooks
72+
73+
Sentry integration-platform webhooks sign the raw JSON body with the app client
74+
secret. Configure `TROGON_SOURCE_SENTRY_CLIENT_SECRET`, point the webhook URL
75+
at `/sentry/webhook`, and the gateway will forward verified payloads to NATS on
76+
`{subject_prefix}.{resource}.{action}` subjects such as `sentry.issue.created`.
77+
7078
## Exposing webhooks with ngrok
7179

7280
```bash

rsworkspace/Cargo.lock

Lines changed: 20 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

rsworkspace/Cargo.toml

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,9 @@ unexpected_cfgs = { level = "deny", check-cfg = ['cfg(coverage)'] }
88

99
[workspace.lints.clippy]
1010
all = "deny"
11+
expect_used = "deny"
12+
panic = "deny"
13+
unwrap_used = "deny"
1114

1215
[workspace.dependencies]
1316
# Internal crates
@@ -21,6 +24,7 @@ trogon-source-gitlab = { path = "crates/trogon-source-gitlab" }
2124
trogon-source-incidentio = { path = "crates/trogon-source-incidentio" }
2225
trogon-source-linear = { path = "crates/trogon-source-linear" }
2326
trogon-source-notion = { path = "crates/trogon-source-notion" }
27+
trogon-source-sentry = { path = "crates/trogon-source-sentry" }
2428
trogon-source-slack = { path = "crates/trogon-source-slack" }
2529
trogon-source-telegram = { path = "crates/trogon-source-telegram" }
2630
trogon-std = { path = "crates/trogon-std" }

rsworkspace/crates/trogon-gateway/Cargo.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@ trogon-source-gitlab = { workspace = true }
2727
trogon-source-incidentio = { workspace = true }
2828
trogon-source-linear = { workspace = true }
2929
trogon-source-notion = { workspace = true }
30+
trogon-source-sentry = { workspace = true }
3031
trogon-source-slack = { workspace = true }
3132
trogon-source-telegram = { workspace = true }
3233
trogon-std = { workspace = true, features = ["clap", "telemetry-http"] }

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

Lines changed: 170 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ use trogon_source_incidentio::config::IncidentioConfig as IncidentioSourceConfig
1515
use trogon_source_incidentio::incidentio_signing_secret::IncidentioSigningSecret;
1616
use trogon_source_linear::config::LinearWebhookSecret;
1717
use trogon_source_notion::NotionVerificationToken;
18+
use trogon_source_sentry::SentryClientSecret;
1819
use trogon_source_slack::config::SlackSigningSecret;
1920
use trogon_source_telegram::config::TelegramWebhookSecret;
2021
use trogon_std::{NonZeroDuration, ZeroDuration};
@@ -169,6 +170,8 @@ struct SourcesConfig {
169170
linear: LinearConfig,
170171
#[config(nested)]
171172
notion: NotionConfig,
173+
#[config(nested)]
174+
sentry: SentryConfig,
172175
}
173176

174177
#[derive(Config)]
@@ -316,6 +319,22 @@ struct NotionConfig {
316319
nats_ack_timeout_secs: u64,
317320
}
318321

322+
#[derive(Config)]
323+
struct SentryConfig {
324+
#[config(env = "TROGON_SOURCE_SENTRY_STATUS")]
325+
status: Option<String>,
326+
#[config(env = "TROGON_SOURCE_SENTRY_CLIENT_SECRET")]
327+
client_secret: Option<String>,
328+
#[config(env = "TROGON_SOURCE_SENTRY_SUBJECT_PREFIX", default = "sentry")]
329+
subject_prefix: String,
330+
#[config(env = "TROGON_SOURCE_SENTRY_STREAM_NAME", default = "SENTRY")]
331+
stream_name: String,
332+
#[config(env = "TROGON_SOURCE_SENTRY_STREAM_MAX_AGE_SECS", default = 604_800)]
333+
stream_max_age_secs: u64,
334+
#[config(env = "TROGON_SOURCE_SENTRY_NATS_ACK_TIMEOUT_SECS", default = 10)]
335+
nats_ack_timeout_secs: u64,
336+
}
337+
319338
pub struct ResolvedHttpServerConfig {
320339
pub port: u16,
321340
}
@@ -332,6 +351,7 @@ pub struct ResolvedConfig {
332351
pub incidentio: Option<trogon_source_incidentio::IncidentioConfig>,
333352
pub linear: Option<trogon_source_linear::LinearConfig>,
334353
pub notion: Option<trogon_source_notion::NotionConfig>,
354+
pub sentry: Option<trogon_source_sentry::SentryConfig>,
335355
}
336356

337357
impl ResolvedConfig {
@@ -344,6 +364,7 @@ impl ResolvedConfig {
344364
|| self.incidentio.is_some()
345365
|| self.linear.is_some()
346366
|| self.notion.is_some()
367+
|| self.sentry.is_some()
347368
}
348369
}
349370

@@ -372,6 +393,7 @@ fn resolve(cfg: GatewayConfig, nats_overrides: &NatsArgs) -> Result<ResolvedConf
372393
let incidentio = resolve_incidentio(cfg.sources.incidentio, &mut errors);
373394
let linear = resolve_linear(cfg.sources.linear, &mut errors);
374395
let notion = resolve_notion(cfg.sources.notion, &mut errors);
396+
let sentry = resolve_sentry(cfg.sources.sentry, &mut errors);
375397

376398
let nats_max_payload_bytes = match NonZeroUsize::new(cfg.nats_max_payload_bytes) {
377399
Some(v) => v,
@@ -403,6 +425,7 @@ fn resolve(cfg: GatewayConfig, nats_overrides: &NatsArgs) -> Result<ResolvedConf
403425
incidentio,
404426
linear,
405427
notion,
428+
sentry,
406429
})
407430
}
408431

@@ -1072,6 +1095,88 @@ fn resolve_notion(
10721095
})
10731096
}
10741097

1098+
fn resolve_sentry(
1099+
section: SentryConfig,
1100+
errors: &mut Vec<ConfigValidationError>,
1101+
) -> Option<trogon_source_sentry::SentryConfig> {
1102+
if !resolve_source_status("sentry", section.status.as_deref(), errors) {
1103+
return None;
1104+
}
1105+
1106+
let client_secret = match section.client_secret {
1107+
Some(secret) => match SentryClientSecret::new(secret) {
1108+
Ok(secret) => secret,
1109+
Err(error) => {
1110+
errors.push(ConfigValidationError::invalid(
1111+
"sentry",
1112+
"client_secret",
1113+
error,
1114+
));
1115+
return None;
1116+
}
1117+
},
1118+
None => {
1119+
return None;
1120+
}
1121+
};
1122+
1123+
let subject_prefix = match NatsToken::new(section.subject_prefix) {
1124+
Ok(token) => token,
1125+
Err(error) => {
1126+
errors.push(ConfigValidationError::invalid_subject_token(
1127+
"sentry",
1128+
"subject_prefix",
1129+
error,
1130+
));
1131+
return None;
1132+
}
1133+
};
1134+
1135+
let stream_name = match NatsToken::new(section.stream_name) {
1136+
Ok(token) => token,
1137+
Err(error) => {
1138+
errors.push(ConfigValidationError::invalid_subject_token(
1139+
"sentry",
1140+
"stream_name",
1141+
error,
1142+
));
1143+
return None;
1144+
}
1145+
};
1146+
1147+
let nats_ack_timeout = match NonZeroDuration::from_secs(section.nats_ack_timeout_secs) {
1148+
Ok(duration) => duration,
1149+
Err(error) => {
1150+
errors.push(ConfigValidationError::invalid(
1151+
"sentry",
1152+
"nats_ack_timeout_secs",
1153+
error,
1154+
));
1155+
return None;
1156+
}
1157+
};
1158+
1159+
let stream_max_age = match StreamMaxAge::from_secs(section.stream_max_age_secs) {
1160+
Ok(age) => age,
1161+
Err(error) => {
1162+
errors.push(ConfigValidationError::invalid(
1163+
"sentry",
1164+
"stream_max_age_secs",
1165+
error,
1166+
));
1167+
return None;
1168+
}
1169+
};
1170+
1171+
Some(trogon_source_sentry::SentryConfig {
1172+
client_secret,
1173+
subject_prefix,
1174+
stream_name,
1175+
stream_max_age,
1176+
nats_ack_timeout,
1177+
})
1178+
}
1179+
10751180
fn resolve_source_status(
10761181
source: &'static str,
10771182
status: Option<&str>,
@@ -1185,6 +1290,15 @@ verification_token = "{token}"
11851290
)
11861291
}
11871292

1293+
fn sentry_toml(secret: &str) -> String {
1294+
format!(
1295+
r#"
1296+
[sources.sentry]
1297+
client_secret = "{secret}"
1298+
"#
1299+
)
1300+
}
1301+
11881302
fn incidentio_valid_test_secret() -> String {
11891303
["whsec_", "dGVzdC1zZWNyZXQ="].concat()
11901304
}
@@ -1450,6 +1564,25 @@ verification_token = "notion-verification-token-example"
14501564
assert!(cfg.notion.is_none());
14511565
}
14521566

1567+
#[test]
1568+
fn sentry_resolves_with_valid_secret() {
1569+
let f = write_toml(&sentry_toml("sentry-client-secret"));
1570+
let cfg = load(Some(f.path())).expect("load failed");
1571+
assert!(cfg.sentry.is_some());
1572+
}
1573+
1574+
#[test]
1575+
fn sentry_disabled_returns_none() {
1576+
let toml = r#"
1577+
[sources.sentry]
1578+
status = "disabled"
1579+
client_secret = "sentry-client-secret"
1580+
"#;
1581+
let f = write_toml(toml);
1582+
let cfg = load(Some(f.path())).expect("load failed");
1583+
assert!(cfg.sentry.is_none());
1584+
}
1585+
14531586
#[test]
14541587
fn notion_missing_token_returns_none() {
14551588
let toml = r#"
@@ -1992,6 +2125,15 @@ port = 9090
19922125
);
19932126
}
19942127

2128+
#[test]
2129+
fn sentry_empty_client_secret_is_invalid() {
2130+
let f = write_toml(&sentry_toml(""));
2131+
let result = load(Some(f.path()));
2132+
assert!(
2133+
matches!(result, Err(ConfigError::Validation(ref errs)) if errs.iter().any(|e| e.contains("sentry: invalid client_secret")))
2134+
);
2135+
}
2136+
19952137
#[test]
19962138
fn incidentio_invalid_secret_is_invalid() {
19972139
let f = write_toml(&incidentio_toml("whsec_not-base64!"));
@@ -2274,6 +2416,34 @@ stream_max_age_secs = 0
22742416
);
22752417
}
22762418

2419+
#[test]
2420+
fn sentry_zero_nats_ack_timeout_is_error() {
2421+
let toml = r#"
2422+
[sources.sentry]
2423+
client_secret = "sentry-client-secret"
2424+
nats_ack_timeout_secs = 0
2425+
"#;
2426+
let f = write_toml(toml);
2427+
let result = load(Some(f.path()));
2428+
assert!(
2429+
matches!(result, Err(ConfigError::Validation(ref errs)) if errs.iter().any(|e| e.contains("sentry: nats_ack_timeout_secs must not be zero")))
2430+
);
2431+
}
2432+
2433+
#[test]
2434+
fn sentry_zero_stream_max_age_is_error() {
2435+
let toml = r#"
2436+
[sources.sentry]
2437+
client_secret = "sentry-client-secret"
2438+
stream_max_age_secs = 0
2439+
"#;
2440+
let f = write_toml(toml);
2441+
let result = load(Some(f.path()));
2442+
assert!(
2443+
matches!(result, Err(ConfigError::Validation(ref errs)) if errs.iter().any(|e| e.contains("sentry: stream_max_age_secs must not be zero")))
2444+
);
2445+
}
2446+
22772447
#[test]
22782448
fn incidentio_zero_timestamp_tolerance_is_error() {
22792449
let toml = format!(

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

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -78,6 +78,14 @@ where
7878
info!(source = "notion", "mounted at /notion");
7979
}
8080

81+
if let Some(ref cfg) = config.sentry {
82+
app = app.nest(
83+
"/sentry",
84+
trogon_source_sentry::router(publisher.clone(), cfg),
85+
);
86+
info!(source = "sentry", "mounted at /sentry");
87+
}
88+
8189
app
8290
}
8391

@@ -137,6 +145,9 @@ webhook_secret = "linear-secret"
137145
138146
[sources.notion]
139147
verification_token = "notion-verification-token-example"
148+
149+
[sources.sentry]
150+
client_secret = "sentry-client-secret"
140151
"#
141152
.to_string()
142153
}

0 commit comments

Comments
 (0)