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}
18
19/// Define the topics over which Relay communicates with Sentry.
20#[derive(Clone, Copy, Debug, Eq, PartialEq, Ord, PartialOrd, Hash)]
21pub enum KafkaTopic {
22    /// Simple events (without attachments) topic.
23    Events,
24    /// Complex events (with attachments) topic.
25    Attachments,
26    /// Transaction events topic.
27    Transactions,
28    /// Shared outcomes topic for Relay and Sentry.
29    Outcomes,
30    /// Override for billing critical outcomes.
31    OutcomesBilling,
32    /// Any metric that is extracted from sessions.
33    MetricsSessions,
34    /// Profiles
35    Profiles,
36    /// ReplayRecordings, large blobs sent by the replay sdk
37    ReplayRecordings,
38    /// Monitor check-ins.
39    Monitors,
40    /// Standalone spans without a transaction.
41    Spans,
42    /// Feedback events topic.
43    Feedback,
44    /// Items topic
45    Items,
46}
47
48impl KafkaTopic {
49    /// Returns iterator over the variants of [`KafkaTopic`].
50    /// It will have to be adjusted if the new variants are added.
51    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        /// Configuration for topics.
74        #[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            /// Additional topic assignments configured but currently unused by this Relay instance.
84            #[serde(flatten, skip_serializing)]
85            pub unused: Unused,
86        }
87
88        impl TopicAssignments{
89            /// Get a topic assignment by [`KafkaTopic`] value
90            #[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            /// Map this KafkaTopic to the "logical topic", i.e. the default topic name.
102            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
124// WARNING: When adding a topic here, make sure that the kafka topic exists or can be auto-created.
125// Failure to do so will result in Relay crashing (if the `kafka_validate_topics` config flag is enabled),
126// or event loss in the store service.
127define_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/// A list of all currently, by this Relay, unused topic configurations.
143#[derive(Debug, Default, Clone)]
144pub struct Unused(Vec<String>);
145
146impl Unused {
147    /// Returns all unused topic names.
148    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/// Configuration for a "logical" topic/datasink that Relay should forward data into.
164///
165/// Can be either a string containing the kafka topic name to produce into (using the default
166/// `kafka_config`), an object containing keys `topic_name` and `kafka_config_name` for using a
167/// custom kafka cluster, or an array of topic names/configs for sharded topics.
168///
169/// See documentation for `secondary_kafka_configs` for more information.
170#[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            // order matters. structs can be deserialized from arrays.
182            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/// Configuration for topic
206#[derive(Debug, Deserialize, Serialize, Clone)]
207pub struct TopicConfig {
208    /// The topic name to use.
209    #[serde(rename = "name")]
210    topic_name: String,
211    /// The Kafka config name will be used to produce data to the given topic.
212    ///
213    /// If the config is missing, the default config will be used.
214    #[serde(rename = "config", skip_serializing_if = "Option::is_none")]
215    kafka_config_name: Option<String>,
216    /// Optionally, a rate limit per partition key to protect against partition imbalance.
217    #[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/// Produce rate limit configuration for a topic.
232#[derive(Debug, Deserialize, Serialize, Clone, Copy)]
233pub struct KeyRateLimit {
234    /// Limit each partition key to N messages per `window_secs`.
235    pub limit_per_window: u64,
236
237    /// The size of the window to record counters for.
238    ///
239    /// Larger windows imply higher memory usage.
240    pub window_secs: u64,
241}
242
243/// A Kafka config for a topic.
244///
245/// This internally includes configuration for multiple 'physical' Kafka topics,
246/// as Relay can shard to multiple topics at once.
247#[derive(Debug)]
248pub struct KafkaTopicConfig<'a>(Vec<KafkaParams<'a>>);
249
250impl<'a> KafkaTopicConfig<'a> {
251    /// Kafka params for each psysical shard.
252    pub fn topics(&self) -> &[KafkaParams<'a>] {
253        &self.0
254    }
255}
256
257/// Config for creating a Kafka producer.
258#[derive(Debug)]
259pub struct KafkaParams<'a> {
260    /// The topic names to use. Can be a single topic or multiple topics for sharding.
261    pub topic_name: String,
262    /// The Kafka config name will be used to produce data.
263    pub config_name: Option<&'a str>,
264    /// Parameters for the Kafka producer configuration.
265    pub params: &'a [KafkaConfigParam],
266    /// Optionally, a rate limit per partition key to protect against partition imbalance.
267    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    /// Get the Kafka configs for the current topic assignment.
278    ///
279    /// # Errors
280    /// Returns [`ConfigError`] if the configuration for the current topic assignment is invalid.
281    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/// A name value pair of Kafka config parameter.
309#[derive(Debug, Deserialize, Serialize, Clone)]
310pub struct KafkaConfigParam {
311    /// Name of the Kafka config parameter.
312    pub name: String,
313    /// Value of the Kafka config parameter.
314    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}