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