1use std::collections::BTreeMap;
4
5use serde::{Deserialize, Serialize, de};
6use thiserror::Error;
7
8#[derive(Error, Debug)]
10pub enum ConfigError {
11 #[error("unknown kafka config name")]
13 UnknownKafkaConfigName,
14 #[error("invalid kafka shard configuration: must have shard with index 0")]
16 InvalidShard,
17}
18
19#[derive(Clone, Copy, Debug, Eq, PartialEq, Ord, PartialOrd, Hash)]
21pub enum KafkaTopic {
22 Events,
24 Attachments,
26 Transactions,
28 Outcomes,
30 OutcomesBilling,
32 MetricsSessions,
34 Profiles,
36 ReplayRecordings,
38 Monitors,
40 Spans,
42 Feedback,
44 Items,
46}
47
48impl KafkaTopic {
49 pub fn iter() -> std::slice::Iter<'static, Self> {
52 use KafkaTopic::*;
53 static TOPICS: [KafkaTopic; 12] = [
54 Events,
55 Attachments,
56 Transactions,
57 Outcomes,
58 OutcomesBilling,
59 MetricsSessions,
60 Profiles,
61 ReplayRecordings,
62 Monitors,
63 Spans,
64 Feedback,
65 Items,
66 ];
67 TOPICS.iter()
68 }
69}
70
71macro_rules! define_topic_assignments {
72 ($($field_name:ident : ($kafka_topic:path, $default_topic:literal, $doc:literal)),* $(,)?) => {
73 #[derive(Deserialize, Serialize, Debug, Clone)]
75 #[serde(default)]
76 pub struct TopicAssignments {
77 $(
78 #[serde(alias = $default_topic)]
79 #[doc = $doc]
80 pub $field_name: TopicAssignment,
81 )*
82
83 #[serde(flatten, skip_serializing)]
85 pub unused: Unused,
86 }
87
88 impl TopicAssignments{
89 #[must_use]
91 pub fn get(&self, kafka_topic: KafkaTopic) -> &TopicAssignment {
92 match kafka_topic {
93 $(
94 $kafka_topic => &self.$field_name,
95 )*
96 }
97 }
98 }
99
100 impl KafkaTopic {
101 pub fn logical_topic_name(&self) -> &'static str {
103 match self {
104 $(
105 $kafka_topic => $default_topic,
106 )*
107 }
108 }
109 }
110
111 impl Default for TopicAssignments {
112 fn default() -> Self {
113 Self {
114 $(
115 $field_name: $default_topic.to_owned().into(),
116 )*
117 unused: Default::default()
118 }
119 }
120 }
121 };
122}
123
124define_topic_assignments! {
128 events: (KafkaTopic::Events, "ingest-events", "Simple events topic name."),
129 attachments: (KafkaTopic::Attachments, "ingest-attachments", "Events with attachments topic name."),
130 transactions: (KafkaTopic::Transactions, "ingest-transactions", "Transaction events topic name."),
131 outcomes: (KafkaTopic::Outcomes, "outcomes", "Outcomes topic name."),
132 outcomes_billing: (KafkaTopic::OutcomesBilling, "outcomes-billing", "Outcomes topic name for billing critical outcomes."),
133 metrics_sessions: (KafkaTopic::MetricsSessions, "ingest-metrics", "Topic name for metrics extracted from sessions, aka release health."),
134 profiles: (KafkaTopic::Profiles, "profiles", "Stacktrace topic name"),
135 replay_recordings: (KafkaTopic::ReplayRecordings, "ingest-replay-recordings", "Recordings topic name."),
136 monitors: (KafkaTopic::Monitors, "ingest-monitors", "Monitor check-ins."),
137 spans: (KafkaTopic::Spans, "ingest-spans", "Standalone spans without a transaction."),
138 feedback: (KafkaTopic::Feedback, "ingest-feedback-events", "Feedback events topic."),
139 items: (KafkaTopic::Items, "snuba-items", "Items topic."),
140}
141
142#[derive(Debug, Default, Clone)]
144pub struct Unused(Vec<String>);
145
146impl Unused {
147 pub fn names(&self) -> &[String] {
149 &self.0
150 }
151}
152
153impl<'de> de::Deserialize<'de> for Unused {
154 fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
155 where
156 D: de::Deserializer<'de>,
157 {
158 let topics = BTreeMap::<String, de::IgnoredAny>::deserialize(deserializer)?;
159 Ok(Self(topics.into_keys().collect()))
160 }
161}
162
163#[derive(Debug, Serialize, Clone)]
171pub struct TopicAssignment(Vec<TopicConfig>);
172
173impl<'de> de::Deserialize<'de> for TopicAssignment {
174 fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
175 where
176 D: de::Deserializer<'de>,
177 {
178 #[derive(Deserialize, Debug)]
179 #[serde(untagged)]
180 enum Inner {
181 ShardedPrimary(Vec<String>),
183 ShardedSecondary(Vec<TopicConfig>),
184 Primary(String),
185 Secondary(TopicConfig),
186 }
187
188 let configs = match Inner::deserialize(deserializer)? {
189 Inner::Primary(topic_name) => vec![topic_name.into()],
190 Inner::Secondary(config) => vec![config],
191 Inner::ShardedPrimary(topic_names) => topic_names.into_iter().map(From::from).collect(),
192 Inner::ShardedSecondary(configs) => configs,
193 };
194
195 if configs.is_empty() {
196 return Err(de::Error::custom(
197 "topic assignment must have at least one shard",
198 ));
199 }
200
201 Ok(Self(configs))
202 }
203}
204
205#[derive(Debug, Deserialize, Serialize, Clone)]
207pub struct TopicConfig {
208 #[serde(rename = "name")]
210 topic_name: String,
211 #[serde(rename = "config", skip_serializing_if = "Option::is_none")]
215 kafka_config_name: Option<String>,
216 #[serde(default, skip_serializing_if = "Option::is_none")]
218 key_rate_limit: Option<KeyRateLimit>,
219}
220
221impl From<String> for TopicConfig {
222 fn from(topic_name: String) -> Self {
223 Self {
224 topic_name,
225 kafka_config_name: None,
226 key_rate_limit: None,
227 }
228 }
229}
230
231#[derive(Debug, Deserialize, Serialize, Clone, Copy)]
233pub struct KeyRateLimit {
234 pub limit_per_window: u64,
236
237 pub window_secs: u64,
241}
242
243#[derive(Debug)]
248pub struct KafkaTopicConfig<'a>(Vec<KafkaParams<'a>>);
249
250impl<'a> KafkaTopicConfig<'a> {
251 pub fn topics(&self) -> &[KafkaParams<'a>] {
253 &self.0
254 }
255}
256
257#[derive(Debug)]
259pub struct KafkaParams<'a> {
260 pub topic_name: String,
262 pub config_name: Option<&'a str>,
264 pub params: &'a [KafkaConfigParam],
266 pub key_rate_limit: Option<KeyRateLimit>,
268}
269
270impl From<String> for TopicAssignment {
271 fn from(topic_name: String) -> Self {
272 Self(vec![topic_name.into()])
273 }
274}
275
276impl TopicAssignment {
277 pub fn kafka_configs<'a>(
282 &'a self,
283 default_config: &'a Vec<KafkaConfigParam>,
284 secondary_configs: &'a BTreeMap<String, Vec<KafkaConfigParam>>,
285 ) -> Result<KafkaTopicConfig<'a>, ConfigError> {
286 let configs = self
287 .0
288 .iter()
289 .map(|tc| {
290 Ok(KafkaParams {
291 topic_name: tc.topic_name.clone(),
292 config_name: tc.kafka_config_name.as_deref(),
293 params: match &tc.kafka_config_name {
294 Some(config) => secondary_configs
295 .get(config)
296 .ok_or(ConfigError::UnknownKafkaConfigName)?,
297 None => default_config.as_slice(),
298 },
299 key_rate_limit: tc.key_rate_limit,
300 })
301 })
302 .collect::<Result<_, _>>()?;
303
304 Ok(KafkaTopicConfig(configs))
305 }
306}
307
308#[derive(Debug, Deserialize, Serialize, Clone)]
310pub struct KafkaConfigParam {
311 pub name: String,
313 pub value: String,
315}
316
317#[cfg(test)]
318mod tests {
319
320 use super::*;
321
322 #[test]
323 fn test_kafka_config() {
324 let yaml = r#"
325ingest-events: "ingest-events-kafka-topic"
326profiles:
327 name: "ingest-profiles"
328 config: "profiles"
329ingest-metrics: "ingest-metrics-3"
330transactions: "ingest-transactions-kafka-topic"
331"#;
332
333 let mut second_config = BTreeMap::new();
334 second_config.insert(
335 "profiles".to_owned(),
336 vec![KafkaConfigParam {
337 name: "test".to_owned(),
338 value: "test-value".to_owned(),
339 }],
340 );
341
342 let topics: TopicAssignments = serde_yaml::from_str(yaml).unwrap();
343 insta::assert_debug_snapshot!(topics, @r#"
344 TopicAssignments {
345 events: TopicAssignment(
346 [
347 TopicConfig {
348 topic_name: "ingest-events-kafka-topic",
349 kafka_config_name: None,
350 key_rate_limit: None,
351 },
352 ],
353 ),
354 attachments: TopicAssignment(
355 [
356 TopicConfig {
357 topic_name: "ingest-attachments",
358 kafka_config_name: None,
359 key_rate_limit: None,
360 },
361 ],
362 ),
363 transactions: TopicAssignment(
364 [
365 TopicConfig {
366 topic_name: "ingest-transactions-kafka-topic",
367 kafka_config_name: None,
368 key_rate_limit: None,
369 },
370 ],
371 ),
372 outcomes: TopicAssignment(
373 [
374 TopicConfig {
375 topic_name: "outcomes",
376 kafka_config_name: None,
377 key_rate_limit: None,
378 },
379 ],
380 ),
381 outcomes_billing: TopicAssignment(
382 [
383 TopicConfig {
384 topic_name: "outcomes-billing",
385 kafka_config_name: None,
386 key_rate_limit: None,
387 },
388 ],
389 ),
390 metrics_sessions: TopicAssignment(
391 [
392 TopicConfig {
393 topic_name: "ingest-metrics-3",
394 kafka_config_name: None,
395 key_rate_limit: None,
396 },
397 ],
398 ),
399 profiles: TopicAssignment(
400 [
401 TopicConfig {
402 topic_name: "ingest-profiles",
403 kafka_config_name: Some(
404 "profiles",
405 ),
406 key_rate_limit: None,
407 },
408 ],
409 ),
410 replay_recordings: TopicAssignment(
411 [
412 TopicConfig {
413 topic_name: "ingest-replay-recordings",
414 kafka_config_name: None,
415 key_rate_limit: None,
416 },
417 ],
418 ),
419 monitors: TopicAssignment(
420 [
421 TopicConfig {
422 topic_name: "ingest-monitors",
423 kafka_config_name: None,
424 key_rate_limit: None,
425 },
426 ],
427 ),
428 spans: TopicAssignment(
429 [
430 TopicConfig {
431 topic_name: "ingest-spans",
432 kafka_config_name: None,
433 key_rate_limit: None,
434 },
435 ],
436 ),
437 feedback: TopicAssignment(
438 [
439 TopicConfig {
440 topic_name: "ingest-feedback-events",
441 kafka_config_name: None,
442 key_rate_limit: None,
443 },
444 ],
445 ),
446 items: TopicAssignment(
447 [
448 TopicConfig {
449 topic_name: "snuba-items",
450 kafka_config_name: None,
451 key_rate_limit: None,
452 },
453 ],
454 ),
455 unused: Unused(
456 [],
457 ),
458 }
459 "#);
460 }
461
462 #[test]
463 fn test_default_topic_is_valid() {
464 for topic in KafkaTopic::iter() {
465 let name = topic.logical_topic_name();
466 assert!(sentry_kafka_schemas::get_schema(name, None).is_ok());
467 }
468 }
469
470 #[test]
471 fn test_sharded_kafka_config() {
472 let yaml = r#"
473events: ["ingest-events-1", "ingest-events-2"]
474profiles:
475 - name: "ingest-profiles-1"
476 config: "profiles"
477 - name: "ingest-profiles-2"
478 config: "profiles"
479"#;
480 let topics: TopicAssignments = serde_yaml::from_str(yaml).unwrap();
481
482 let def_config = vec![KafkaConfigParam {
483 name: "test".to_owned(),
484 value: "test-value".to_owned(),
485 }];
486 let mut second_config = BTreeMap::new();
487 second_config.insert(
488 "profiles".to_owned(),
489 vec![KafkaConfigParam {
490 name: "test".to_owned(),
491 value: "test-value".to_owned(),
492 }],
493 );
494
495 let events_configs = topics
496 .events
497 .kafka_configs(&def_config, &second_config)
498 .expect("Kafka config for sharded events topic");
499
500 insta::assert_debug_snapshot!(events_configs, @r###"
501 KafkaTopicConfig(
502 [
503 KafkaParams {
504 topic_name: "ingest-events-1",
505 config_name: None,
506 params: [
507 KafkaConfigParam {
508 name: "test",
509 value: "test-value",
510 },
511 ],
512 key_rate_limit: None,
513 },
514 KafkaParams {
515 topic_name: "ingest-events-2",
516 config_name: None,
517 params: [
518 KafkaConfigParam {
519 name: "test",
520 value: "test-value",
521 },
522 ],
523 key_rate_limit: None,
524 },
525 ],
526 )
527 "###);
528
529 let profiles_configs = topics
530 .profiles
531 .kafka_configs(&def_config, &second_config)
532 .expect("Kafka config for sharded profiles topic");
533
534 insta::assert_debug_snapshot!(profiles_configs, @r###"
535 KafkaTopicConfig(
536 [
537 KafkaParams {
538 topic_name: "ingest-profiles-1",
539 config_name: Some(
540 "profiles",
541 ),
542 params: [
543 KafkaConfigParam {
544 name: "test",
545 value: "test-value",
546 },
547 ],
548 key_rate_limit: None,
549 },
550 KafkaParams {
551 topic_name: "ingest-profiles-2",
552 config_name: Some(
553 "profiles",
554 ),
555 params: [
556 KafkaConfigParam {
557 name: "test",
558 value: "test-value",
559 },
560 ],
561 key_rate_limit: None,
562 },
563 ],
564 )
565 "###);
566 }
567
568 #[test]
569 fn test_per_shard_rate_limits() {
570 let yaml = r#"
571events:
572 - name: "shard-0"
573 config: "cluster1"
574 key_rate_limit:
575 limit_per_window: 100
576 window_secs: 60
577 - name: "shard-1"
578 config: "cluster2"
579 key_rate_limit:
580 limit_per_window: 200
581 window_secs: 120
582 - name: "shard-2" # No rate limit (Primary variant)
583"#;
584
585 let def_config = vec![KafkaConfigParam {
586 name: "bootstrap.servers".to_owned(),
587 value: "primary:9092".to_owned(),
588 }];
589 let mut second_config = BTreeMap::new();
590 second_config.insert(
591 "cluster1".to_owned(),
592 vec![KafkaConfigParam {
593 name: "bootstrap.servers".to_owned(),
594 value: "cluster1:9092".to_owned(),
595 }],
596 );
597 second_config.insert(
598 "cluster2".to_owned(),
599 vec![KafkaConfigParam {
600 name: "bootstrap.servers".to_owned(),
601 value: "cluster2:9092".to_owned(),
602 }],
603 );
604
605 let topics: TopicAssignments = serde_yaml::from_str(yaml).unwrap();
606
607 let events_configs = topics
608 .events
609 .kafka_configs(&def_config, &second_config)
610 .expect("Kafka config for per-shard rate limits");
611
612 insta::assert_debug_snapshot!(events_configs, @r###"
613 KafkaTopicConfig(
614 [
615 KafkaParams {
616 topic_name: "shard-0",
617 config_name: Some(
618 "cluster1",
619 ),
620 params: [
621 KafkaConfigParam {
622 name: "bootstrap.servers",
623 value: "cluster1:9092",
624 },
625 ],
626 key_rate_limit: Some(
627 KeyRateLimit {
628 limit_per_window: 100,
629 window_secs: 60,
630 },
631 ),
632 },
633 KafkaParams {
634 topic_name: "shard-1",
635 config_name: Some(
636 "cluster2",
637 ),
638 params: [
639 KafkaConfigParam {
640 name: "bootstrap.servers",
641 value: "cluster2:9092",
642 },
643 ],
644 key_rate_limit: Some(
645 KeyRateLimit {
646 limit_per_window: 200,
647 window_secs: 120,
648 },
649 ),
650 },
651 KafkaParams {
652 topic_name: "shard-2",
653 config_name: None,
654 params: [
655 KafkaConfigParam {
656 name: "bootstrap.servers",
657 value: "primary:9092",
658 },
659 ],
660 key_rate_limit: None,
661 },
662 ],
663 )
664 "###);
665 }
666}