vector/sinks/loki/
config.rs1use std::collections::HashMap;
2
3use vrl::value::Kind;
4
5use super::{healthcheck::healthcheck, sink::LokiSink};
6use crate::{
7 http::{Auth, HttpClient, MaybeAuth},
8 schema,
9 sinks::{prelude::*, util::UriSerde},
10 template::ConfinementConfig,
11};
12
13const fn default_compression() -> Compression {
14 Compression::Snappy
15}
16
17fn default_loki_path() -> String {
18 "/loki/api/v1/push".to_string()
19}
20
21#[configurable_component(sink("loki", "Deliver log event data to the Loki aggregation system."))]
23#[derive(Clone, Debug)]
24#[serde(deny_unknown_fields)]
25pub struct LokiConfig {
26 #[configurable(metadata(docs::examples = "http://localhost:3100"))]
30 pub endpoint: UriSerde,
31
32 #[serde(default = "default_loki_path")]
34 pub path: String,
35
36 #[configurable(derived)]
37 pub encoding: EncodingConfig,
38
39 #[configurable(metadata(
45 docs::examples = "some_tenant_id",
46 docs::examples = "{{ event_field }}",
47 ))]
48 pub tenant_id: Option<Template>,
49
50 #[configurable(metadata(docs::examples = "loki_labels_examples()"))]
63 #[configurable(metadata(docs::additional_props_description = "A Loki label."))]
64 pub labels: HashMap<Template, Template>,
65
66 #[serde(default = "crate::serde::default_false")]
68 pub remove_label_fields: bool,
69
70 #[configurable(metadata(docs::examples = "loki_structured_metadata_examples()"))]
79 #[configurable(metadata(docs::additional_props_description = "Loki structured metadata."))]
80 #[serde(default)]
81 pub structured_metadata: HashMap<Template, Template>,
82
83 #[serde(default = "crate::serde::default_false")]
85 pub remove_structured_metadata_fields: bool,
86
87 #[serde(default = "crate::serde::default_true")]
91 pub remove_timestamp: bool,
92
93 #[serde(default = "default_compression")]
96 pub compression: Compression,
97
98 #[configurable(derived)]
99 #[serde(default)]
100 pub out_of_order_action: OutOfOrderAction,
101
102 #[configurable(derived)]
103 pub auth: Option<Auth>,
104
105 #[configurable(derived)]
106 #[serde(default)]
107 pub request: TowerRequestConfig,
108
109 #[configurable(derived)]
110 #[serde(default)]
111 pub batch: BatchConfig<LokiDefaultBatchSettings>,
112
113 #[configurable(derived)]
114 pub tls: Option<TlsConfig>,
115
116 #[configurable(derived)]
117 #[serde(
118 default,
119 deserialize_with = "crate::serde::bool_or_struct",
120 skip_serializing_if = "crate::serde::is_default"
121 )]
122 acknowledgements: AcknowledgementsConfig,
123
124 #[configurable(derived)]
125 #[serde(flatten)]
126 pub confinement: ConfinementConfig,
127}
128
129fn loki_labels_examples() -> HashMap<String, String> {
130 let mut examples = HashMap::new();
131 examples.insert("source".to_string(), "vector".to_string());
132 examples.insert(
133 "\"pod_labels_*\"".to_string(),
134 "{{ kubernetes.pod_labels }}".to_string(),
135 );
136 examples.insert("\"*\"".to_string(), "{{ metadata }}".to_string());
137 examples.insert(
138 "{{ event_field }}".to_string(),
139 "{{ some_other_event_field }}".to_string(),
140 );
141 examples
142}
143
144fn loki_structured_metadata_examples() -> HashMap<String, String> {
145 let mut examples = HashMap::new();
146 examples.insert("source".to_string(), "vector".to_string());
147 examples.insert(
148 "\"pod_labels_*\"".to_string(),
149 "{{ kubernetes.pod_labels }}".to_string(),
150 );
151 examples.insert("\"*\"".to_string(), "{{ metadata }}".to_string());
152 examples.insert(
153 "{{ event_field }}".to_string(),
154 "{{ some_other_event_field }}".to_string(),
155 );
156 examples
157}
158
159#[derive(Clone, Copy, Debug, Default)]
160pub struct LokiDefaultBatchSettings;
161
162impl SinkBatchSettings for LokiDefaultBatchSettings {
163 const MAX_EVENTS: Option<usize> = Some(100_000);
164 const MAX_BYTES: Option<usize> = Some(1_000_000);
165 const TIMEOUT_SECS: f64 = 1.0;
166}
167
168#[configurable_component]
179#[derive(Copy, Clone, Debug, Default)]
180#[serde(rename_all = "snake_case")]
181pub enum OutOfOrderAction {
182 #[default]
188 Accept,
189
190 RewriteTimestamp,
192
193 Drop,
195}
196
197impl GenerateConfig for LokiConfig {
198 fn generate_config() -> toml::Value {
199 toml::from_str(
200 r#"endpoint = "http://localhost:3100"
201 encoding.codec = "json"
202 labels = {}"#,
203 )
204 .unwrap()
205 }
206}
207
208impl LokiConfig {
209 pub(super) fn build_client(&self, cx: SinkContext) -> crate::Result<HttpClient> {
210 let tls = TlsSettings::from_options(self.tls.as_ref())?;
211 let client = HttpClient::new(tls, cx.proxy())?;
212 Ok(client)
213 }
214}
215
216#[async_trait::async_trait]
217#[typetag::serde(name = "loki")]
218impl SinkConfig for LokiConfig {
219 async fn build(
220 &self,
221 cx: SinkContext,
222 ) -> crate::Result<(VectorSink, crate::sinks::Healthcheck)> {
223 if self.labels.is_empty() {
224 return Err("`labels` must include at least one label.".into());
225 }
226
227 for label in self.labels.keys() {
228 if !valid_label_name(label) {
229 return Err(format!("Invalid label name {:?}", label.get_ref()).into());
230 }
231 }
232
233 let client = self.build_client(cx)?;
234
235 let config = LokiConfig {
236 auth: self.auth.choose_one(&self.endpoint.auth)?,
237 ..self.clone()
238 };
239
240 let sink = LokiSink::new(config.clone(), client.clone())?;
241
242 let healthcheck = healthcheck(config, client).boxed();
243
244 self.confinement.set_confinement_gauge("sink", Self::NAME);
245 Ok((VectorSink::from_event_streamsink(sink), healthcheck))
246 }
247
248 fn input(&self) -> Input {
249 let requirement =
250 schema::Requirement::empty().optional_meaning("timestamp", Kind::timestamp());
251
252 Input::new(self.encoding.config().input_type() & DataType::Log)
253 .with_schema_requirement(requirement)
254 }
255
256 fn acknowledgements(&self) -> &AcknowledgementsConfig {
257 &self.acknowledgements
258 }
259}
260
261pub fn valid_label_name(label: &Template) -> bool {
262 label.is_dynamic() || {
263 let mut label_trim = label.get_ref().trim();
272 if let Some(without_opening_end) = label_trim.strip_suffix('*') {
273 label_trim = without_opening_end
274 }
275
276 let mut label_chars = label_trim.chars();
277 if let Some(ch) = label_chars.next() {
278 (ch.is_ascii_alphabetic() || ch == '_')
279 && label_chars.all(|ch| ch.is_ascii_alphanumeric() || ch == '_')
280 } else {
281 label.get_ref().trim() == "*"
282 }
283 }
284}
285
286#[cfg(test)]
287mod tests {
288 use std::convert::TryInto;
289
290 use super::valid_label_name;
291 use crate::template::{ConfinementConfig, Template};
292
293 #[test]
294 fn valid_label_names() {
295 assert!(valid_label_name(&"name".try_into().unwrap()));
296 assert!(valid_label_name(&" name ".try_into().unwrap()));
297 assert!(valid_label_name(&"bee_bop".try_into().unwrap()));
298 assert!(valid_label_name(&"a09b".try_into().unwrap()));
299 assert!(valid_label_name(&"abc_*".try_into().unwrap()));
300 assert!(valid_label_name(&"_*".try_into().unwrap()));
301 assert!(valid_label_name(&"*".try_into().unwrap()));
302
303 assert!(!valid_label_name(&"0ab".try_into().unwrap()));
304 assert!(!valid_label_name(&"".try_into().unwrap()));
305 assert!(!valid_label_name(&" ".try_into().unwrap()));
306
307 assert!(valid_label_name(&"{{field}}".try_into().unwrap()));
308 }
309
310 #[test]
311 fn confinement_rejects_unconfined_tenant_id() {
312 let template = Template::try_from("{{ tenant }}").unwrap();
313 let config = ConfinementConfig::default();
314 let result = template.confine(&config, "loki", "tenant_id");
315 assert!(
316 result.is_err(),
317 "bare tenant_id template with no literal prefix must be rejected"
318 );
319 }
320
321 #[test]
322 fn confinement_opt_out_allows_unconfined_tenant_id() {
323 let template = Template::try_from("{{ tenant }}").unwrap();
324 let config = ConfinementConfig {
325 dangerously_allow_unconfined_template_resolution: true,
326 };
327 let result = template.confine(&config, "loki", "tenant_id");
328 assert!(result.is_ok(), "opt-out must allow bare tenant_id template");
329 }
330
331 #[test]
332 fn confinement_prefixed_tenant_id_locks_org_prefix() {
333 use crate::event::{Event, LogEvent};
334 use vrl::event_path;
335 let template = Template::try_from("team-{{ org }}").unwrap();
338 let config = ConfinementConfig::default();
339 let confined = template.confine(&config, "loki", "tenant_id").unwrap();
340 let mut event = LogEvent::default();
341 event.insert(event_path!("org"), "other-tenant-entirely");
342 let rendered = confined.render_string(&Event::Log(event)).unwrap();
343 assert!(
344 rendered.starts_with("team-"),
345 "operator-controlled prefix must be preserved in rendered tenant_id"
346 );
347 }
348}