1use std::collections::{BTreeMap, HashMap};
2
3use aws_sdk_cloudwatchlogs::Client as CloudwatchLogsClient;
4use futures::FutureExt;
5use http::HeaderValue;
6use serde::{Deserialize, Deserializer, de};
7use tower::ServiceBuilder;
8use vector_lib::{
9 codecs::JsonSerializerConfig, configurable::configurable_component, schema,
10 stream::BatcherSettings,
11};
12use vrl::value::Kind;
13
14use crate::{
15 aws::{AwsAuthentication, ClientBuilder, RegionOrEndpoint, create_client},
16 codecs::{Encoder, EncodingConfig},
17 config::{
18 AcknowledgementsConfig, DataType, DynValidatedSink, GenerateConfig, Input, ProxyConfig,
19 SinkConfig, SinkContext, ValidatedSink,
20 },
21 sinks::{
22 Healthcheck, VectorSink,
23 aws_cloudwatch_logs::{
24 healthcheck::healthcheck, request_builder::CloudwatchRequestBuilder,
25 retry::CloudwatchRetryLogic, service::CloudwatchLogsPartitionSvc, sink::CloudwatchSink,
26 },
27 util::{
28 BatchConfig, Compression, ServiceBuilderExt, SinkBatchSettings,
29 http::{OrderedHeaderName, RequestConfig, validate_headers},
30 },
31 },
32 template::{ConfinedTemplate, ConfinementConfig, Template, UnconfinedTemplate},
33 tls::TlsConfig,
34};
35
36pub struct CloudwatchLogsClientBuilder;
37
38impl ClientBuilder for CloudwatchLogsClientBuilder {
39 type Client = aws_sdk_cloudwatchlogs::client::Client;
40
41 fn build(&self, config: &aws_types::SdkConfig) -> Self::Client {
42 aws_sdk_cloudwatchlogs::client::Client::new(config)
43 }
44}
45
46#[configurable_component]
47#[derive(Clone, Debug, Default)]
48pub struct Retention {
50 #[serde(default)]
52 pub enabled: bool,
53
54 #[serde(
56 default,
57 deserialize_with = "retention_days",
58 skip_serializing_if = "crate::serde::is_default"
59 )]
60 pub days: u32,
61}
62
63fn retention_days<'de, D>(deserializer: D) -> Result<u32, D::Error>
64where
65 D: Deserializer<'de>,
66{
67 let days: u32 = Deserialize::deserialize(deserializer)?;
68 const ALLOWED_VALUES: &[u32] = &[
69 1, 3, 5, 7, 14, 30, 60, 90, 120, 150, 180, 365, 400, 545, 731, 1096, 1827, 2192, 2557,
70 2922, 3288, 3653,
71 ];
72 if ALLOWED_VALUES.contains(&days) {
73 Ok(days)
74 } else {
75 let msg = format!("one of allowed values: {ALLOWED_VALUES:?}").to_owned();
76 let expected: &str = msg.as_str();
77 Err(de::Error::invalid_value(
78 de::Unexpected::Signed(days.into()),
79 &expected,
80 ))
81 }
82}
83
84#[configurable_component(sink(
86 "aws_cloudwatch_logs",
87 "Publish log events to AWS CloudWatch Logs."
88))]
89#[derive(Clone, Debug)]
90#[serde(deny_unknown_fields)]
91pub struct CloudwatchLogsSinkConfig {
92 #[configurable(metadata(docs::examples = "group-name"))]
96 #[configurable(metadata(docs::examples = "group-{{ file }}"))]
97 pub group_name: Template,
98
99 #[configurable(metadata(docs::examples = "stream-{{ host }}"))]
107 #[configurable(metadata(docs::examples = "%Y-%m-%d"))]
108 #[configurable(metadata(docs::examples = "stream-name"))]
109 pub stream_name: UnconfinedTemplate,
110
111 #[serde(flatten)]
115 pub region: RegionOrEndpoint,
116
117 #[serde(default = "crate::serde::default_true")]
124 pub create_missing_group: bool,
125
126 #[serde(default = "crate::serde::default_true")]
130 pub create_missing_stream: bool,
131
132 #[configurable(derived)]
133 #[serde(default)]
134 pub retention: Retention,
135
136 #[configurable(derived)]
137 pub encoding: EncodingConfig,
138
139 #[configurable(derived)]
140 #[serde(default)]
141 pub compression: Compression,
142
143 #[configurable(derived)]
144 #[serde(default)]
145 pub batch: BatchConfig<CloudwatchLogsDefaultBatchSettings>,
146
147 #[configurable(derived)]
148 #[serde(default)]
149 pub request: RequestConfig,
150
151 #[configurable(derived)]
152 pub tls: Option<TlsConfig>,
153
154 #[configurable(deprecated)]
158 #[configurable(metadata(docs::hidden))]
159 pub assume_role: Option<String>,
160
161 #[configurable(derived)]
162 #[serde(default)]
163 pub auth: AwsAuthentication,
164
165 #[configurable(derived)]
166 #[serde(
167 default,
168 deserialize_with = "crate::serde::bool_or_struct",
169 skip_serializing_if = "crate::serde::is_default"
170 )]
171 pub acknowledgements: AcknowledgementsConfig,
172
173 #[configurable(derived)]
178 #[serde(default)]
179 pub kms_key: Option<String>,
180
181 #[configurable(derived)]
185 #[serde(default)]
186 #[configurable(metadata(
187 docs::additional_props_description = "A tag represented as a key-value pair"
188 ))]
189 pub tags: Option<HashMap<String, String>>,
190
191 #[configurable(derived)]
192 #[serde(flatten)]
193 pub confinement: ConfinementConfig,
194}
195
196impl CloudwatchLogsSinkConfig {
197 pub async fn create_client(&self, proxy: &ProxyConfig) -> crate::Result<CloudwatchLogsClient> {
198 create_client::<CloudwatchLogsClientBuilder>(
199 &CloudwatchLogsClientBuilder {},
200 &self.auth,
201 self.region.region(),
202 self.region.endpoint(),
203 proxy,
204 self.tls.as_ref(),
205 None,
206 )
207 .await
208 }
209}
210
211#[async_trait::async_trait]
212#[typetag::serde(name = "aws_cloudwatch_logs")]
213impl SinkConfig for CloudwatchLogsSinkConfig {
214 fn confinement_config(&self) -> Option<&crate::template::ConfinementConfig> {
215 Some(&self.confinement)
216 }
217
218 fn input(&self) -> Input {
219 let requirement =
220 schema::Requirement::empty().optional_meaning("timestamp", Kind::timestamp());
221
222 Input::new(self.encoding.config().input_type() & DataType::Log)
223 .with_schema_requirement(requirement)
224 }
225
226 fn acknowledgements(&self) -> &AcknowledgementsConfig {
227 &self.acknowledgements
228 }
229
230 fn as_dyn_validated(&self) -> Option<&dyn DynValidatedSink> {
231 Some(self)
232 }
233}
234
235#[derive(Clone, Debug)]
236pub struct ValidatedCloudwatchLogs {
237 group_template: ConfinedTemplate,
238 batcher_settings: BatcherSettings,
239 headers: BTreeMap<OrderedHeaderName, HeaderValue>,
240}
241
242#[async_trait::async_trait]
243impl ValidatedSink for CloudwatchLogsSinkConfig {
244 type Validated = ValidatedCloudwatchLogs;
245
246 fn validate(&self) -> crate::Result<ValidatedCloudwatchLogs> {
247 let group_template =
248 self.group_name
249 .clone()
250 .confine(&self.confinement, Self::NAME, "group_name")?;
251 let batcher_settings = self.batch.into_batcher_settings()?;
252 let headers = validate_headers(&self.request.headers)?;
253
254 Ok(ValidatedCloudwatchLogs {
255 group_template,
256 batcher_settings,
257 headers,
258 })
259 }
260
261 async fn build(
262 &self,
263 validated: &ValidatedCloudwatchLogs,
264 cx: SinkContext,
265 ) -> crate::Result<(VectorSink, Healthcheck)> {
266 let ValidatedCloudwatchLogs {
267 group_template,
268 batcher_settings,
269 headers,
270 } = validated.clone();
271 let request_settings = self.request.tower.into_settings();
272 let client = self.create_client(cx.proxy()).await?;
273 let svc = ServiceBuilder::new()
274 .settings(request_settings, CloudwatchRetryLogic::new())
275 .service(CloudwatchLogsPartitionSvc::new(
276 self.clone(),
277 client.clone(),
278 headers.clone(),
279 ));
280 let transformer = self.encoding.transformer();
281 let serializer = self.encoding.build()?;
282 let encoder = Encoder::<()>::new(serializer);
283 let healthcheck = healthcheck(self.clone(), client).boxed();
284 let sink = CloudwatchSink {
285 batcher_settings,
286 request_builder: CloudwatchRequestBuilder {
287 group_template,
288 stream_template: self.stream_name.clone(),
289 transformer,
290 encoder,
291 },
292
293 service: svc,
294 };
295 Ok((VectorSink::from_event_streamsink(sink), healthcheck))
296 }
297}
298
299impl GenerateConfig for CloudwatchLogsSinkConfig {
300 fn generate_config() -> serde_json::Value {
301 serde_json::to_value(default_config(JsonSerializerConfig::default().into())).unwrap()
302 }
303}
304
305fn default_config(encoding: EncodingConfig) -> CloudwatchLogsSinkConfig {
306 CloudwatchLogsSinkConfig {
307 encoding,
308 group_name: Default::default(),
309 stream_name: Default::default(),
310 region: Default::default(),
311 create_missing_group: true,
312 create_missing_stream: true,
313 retention: Default::default(),
314 compression: Default::default(),
315 batch: Default::default(),
316 request: Default::default(),
317 tls: Default::default(),
318 assume_role: Default::default(),
319 auth: Default::default(),
320 acknowledgements: Default::default(),
321 kms_key: Default::default(),
322 tags: Default::default(),
323 confinement: Default::default(),
324 }
325}
326
327#[derive(Clone, Copy, Debug, Default)]
328pub struct CloudwatchLogsDefaultBatchSettings;
329
330impl SinkBatchSettings for CloudwatchLogsDefaultBatchSettings {
331 const MAX_EVENTS: Option<usize> = Some(10_000);
332 const MAX_BYTES: Option<usize> = Some(1_048_576);
333 const TIMEOUT_SECS: f64 = 1.0;
334}
335
336#[cfg(test)]
337mod tests {
338 use crate::config::ValidatedSink;
339 use crate::sinks::aws_cloudwatch_logs::config::CloudwatchLogsSinkConfig;
340 use crate::template::{ConfinementConfig, Template};
341 use vector_lib::codecs::JsonSerializerConfig;
342
343 #[test]
344 fn prepares_valid_config() {
345 let mut config = super::default_config(JsonSerializerConfig::default().into());
346 config.group_name = "group-{{ file }}".try_into().unwrap();
347 config.stream_name = "stream".try_into().unwrap();
348
349 let validated = config.validate().expect("preparation should succeed");
350 assert_eq!(validated.group_template.to_string(), "group-{{ file }}");
351 assert_eq!(validated.batcher_settings.item_limit, 10_000);
352 }
353
354 #[test]
355 fn test_generate_config() {
356 crate::test_util::test_generate_config::<CloudwatchLogsSinkConfig>();
357 }
358
359 #[test]
360 fn validate_rejects_invalid_header_name() {
361 let mut config = super::default_config(JsonSerializerConfig::default().into());
362 config.group_name = "group".try_into().unwrap();
363 config.stream_name = "stream".try_into().unwrap();
364 config
365 .request
366 .headers
367 .insert("invalid header name".to_string(), "value".to_string());
368
369 assert!(config.validate().is_err());
370 }
371
372 #[test]
373 fn validate_rejects_invalid_header_value() {
374 let mut config = super::default_config(JsonSerializerConfig::default().into());
375 config.group_name = "group".try_into().unwrap();
376 config.stream_name = "stream".try_into().unwrap();
377 config.request.headers.insert(
378 "valid-header".to_string(),
379 "value\nwith newline".to_string(),
380 );
381
382 assert!(config.validate().is_err());
383 }
384
385 #[test]
386 fn validate_retains_valid_headers() {
387 let mut config = super::default_config(JsonSerializerConfig::default().into());
388 config.group_name = "group".try_into().unwrap();
389 config.stream_name = "stream".try_into().unwrap();
390 config
391 .request
392 .headers
393 .insert("x-custom-header".to_string(), "custom-value".to_string());
394
395 let validated = config.validate().expect("preparation should succeed");
396 assert_eq!(validated.headers.len(), 1);
397 let (name, value) = validated.headers.iter().next().unwrap();
398 assert_eq!(name.inner(), "x-custom-header");
399 assert_eq!(value, "custom-value");
400 }
401
402 #[test]
403 fn confinement_rejects_unconfined_group_name() {
404 let template = Template::try_from("{{ group }}").unwrap();
405 let config = ConfinementConfig::default();
406 let result = template.confine(&config, "aws_cloudwatch_logs", "group_name");
407 assert!(result.is_err());
408 }
409
410 #[test]
411 fn confinement_opt_out_allows_unconfined_group_name() {
412 let template = Template::try_from("{{ group }}").unwrap();
413 let config = ConfinementConfig {
414 dangerously_allow_unconfined_template_resolution: true,
415 };
416 let result = template.confine(&config, "aws_cloudwatch_logs", "group_name");
417 assert!(result.is_ok());
418 }
419
420 #[test]
421 fn confinement_allows_prefixed_group_name() {
422 let template = Template::try_from("events-{{ env }}").unwrap();
423 let config = ConfinementConfig::default();
424 let result = template.confine(&config, "aws_cloudwatch_logs", "group_name");
425 assert!(result.is_ok());
426 }
427}