Skip to main content

relay_kafka/
config.rs

1//! Configuration primitives to configure the kafka producer and properly set up the connection.
2
3use std::collections::BTreeMap;
4
5use serde::{Deserialize, Serialize, de};
6use thiserror::Error;
7
8/// Kafka configuration errors.
9#[derive(Error, Debug)]
10pub enum ConfigError {
11    /// The user referenced a kafka config name that does not exist.
12    #[error("unknown kafka config name")]
13    UnknownKafkaConfigName,
14    /// The user did not configure 0 shard
15    #[error("invalid kafka shard configuration: must have shard with index 0")]
16    InvalidShard,
17    /// Two topic assignments produce to the same Kafka topic name on different clusters.
18    #[error("kafka topic {0:?} is configured on more than one kafka config")]
19    AmbiguousTopic(String),
20}
21
22/// Define the topics over which Relay communicates with Sentry.
23#[derive(Clone, Copy, Debug, Eq, PartialEq, Ord, PartialOrd, Hash)]
24pub enum KafkaTopic {
25    /// Simple events (without attachments) topic.
26    Events,
27    /// Complex events (with attachments) topic.
28    Attachments,
29    /// Transaction events topic.
30    Transactions,
31    /// Shared outcomes topic for Relay and Sentry.
32    Outcomes,
33    /// Override for billing critical outcomes.
34    OutcomesBilling,
35    /// Any metric that is extracted from sessions.
36    MetricsSessions,
37    /// Profiles
38    Profiles,
39    /// ReplayRecordings, large blobs sent by the replay sdk
40    ReplayRecordings,
41    /// Monitor check-ins.
42    Monitors,
43    /// Standalone spans without a transaction.
44    Spans,
45    /// Feedback events topic.
46    Feedback,
47    /// Items topic
48    Items,
49}
50
51impl KafkaTopic {
52    /// Returns iterator over the variants of [`KafkaTopic`].
53    /// It will have to be adjusted if the new variants are added.
54    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        /// Configuration for topics.
77        #[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            /// Additional topic assignments configured but currently unused by this Relay instance.
87            #[serde(flatten, skip_serializing)]
88            pub unused: Unused,
89        }
90
91        impl TopicAssignments{
92            /// Get a topic assignment by [`KafkaTopic`] value
93            #[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            /// Map this KafkaTopic to the "logical topic", i.e. the default topic name.
105            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
127// WARNING: When adding a topic here, make sure that the kafka topic exists or can be auto-created.
128// Failure to do so will result in Relay crashing (if the `kafka_validate_topics` config flag is enabled),
129// or event loss in the store service.
130define_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/// A list of all currently, by this Relay, unused topic configurations.
146#[derive(Debug, Default, Clone)]
147pub struct Unused(Vec<String>);
148
149impl Unused {
150    /// Returns all unused topic names.
151    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/// Configuration for a "logical" topic/datasink that Relay should forward data into.
167///
168/// Can be either a string containing the kafka topic name to produce into (using the default
169/// `kafka_config`), an object containing keys `topic_name` and `kafka_config_name` for using a
170/// custom kafka cluster, or an array of topic names/configs for sharded topics.
171///
172/// See documentation for `secondary_kafka_configs` for more information.
173#[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            // order matters. structs can be deserialized from arrays.
185            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/// Configuration for topic
209#[derive(Debug, Deserialize, Serialize, Clone)]
210pub struct TopicConfig {
211    /// The topic name to use.
212    #[serde(rename = "name")]
213    topic_name: String,
214    /// The Kafka config name will be used to produce data to the given topic.
215    ///
216    /// If the config is missing, the default config will be used.
217    #[serde(rename = "config", skip_serializing_if = "Option::is_none")]
218    kafka_config_name: Option<String>,
219    /// Optionally, a rate limit per partition key to protect against partition imbalance.
220    #[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/// Produce rate limit configuration for a topic.
235#[derive(Debug, Deserialize, Serialize, Clone, Copy)]
236pub struct KeyRateLimit {
237    /// Limit each partition key to N messages per `window_secs`.
238    pub limit_per_window: u64,
239
240    /// The size of the window to record counters for.
241    ///
242    /// Larger windows imply higher memory usage.
243    pub window_secs: u64,
244}
245
246/// A Kafka config for a topic.
247///
248/// This internally includes configuration for multiple 'physical' Kafka topics,
249/// as Relay can shard to multiple topics at once.
250#[derive(Debug)]
251pub struct KafkaTopicConfig<'a>(Vec<KafkaParams<'a>>);
252
253impl<'a> KafkaTopicConfig<'a> {
254    /// Kafka params for each psysical shard.
255    pub fn topics(&self) -> &[KafkaParams<'a>] {
256        &self.0
257    }
258}
259
260/// Config for creating a Kafka producer.
261#[derive(Debug)]
262pub struct KafkaParams<'a> {
263    /// The topic names to use. Can be a single topic or multiple topics for sharding.
264    pub topic_name: String,
265    /// The Kafka config name will be used to produce data.
266    pub config_name: Option<&'a str>,
267    /// Parameters for the Kafka producer configuration.
268    pub params: &'a [KafkaConfigParam],
269    /// Optionally, a rate limit per partition key to protect against partition imbalance.
270    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    /// Get the Kafka configs for the current topic assignment.
281    ///
282    /// # Errors
283    /// Returns [`ConfigError`] if the configuration for the current topic assignment is invalid.
284    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/// A name value pair of Kafka config parameter.
312#[derive(Debug, Deserialize, Serialize, Clone, PartialEq, Eq)]
313pub struct KafkaConfigParam {
314    /// Name of the Kafka config parameter.
315    pub name: String,
316    /// Value of the Kafka config parameter.
317    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}