Skip to main content

vector/sinks/aws_s3/
config.rs

1use aws_sdk_s3::Client as S3Client;
2use tower::ServiceBuilder;
3#[cfg(feature = "codecs-parquet")]
4use vector_lib::codecs::BatchEncoder;
5#[cfg(feature = "codecs-parquet")]
6use vector_lib::codecs::encoding::{BatchSerializerConfig, format::ParquetSerializerConfig};
7use vector_lib::{
8    TimeZone,
9    codecs::{
10        EncoderKind, TextSerializerConfig,
11        encoding::{Framer, FramingConfig},
12    },
13    configurable::configurable_component,
14    sink::VectorSink,
15};
16
17use super::sink::S3RequestOptions;
18use crate::{
19    aws::{AwsAuthentication, RegionOrEndpoint},
20    codecs::{Encoder, EncodingConfigWithFraming, SinkType},
21    config::{AcknowledgementsConfig, GenerateConfig, Input, ProxyConfig, SinkConfig, SinkContext},
22    sinks::{
23        Healthcheck,
24        s3_common::{
25            self,
26            config::{RetryStrategy, S3Options},
27            partitioner::S3KeyPartitioner,
28            service::S3Service,
29            sink::S3Sink,
30        },
31        util::{
32            BatchConfig, BulkSizeBasedDefaultBatchSettings, Compression, ServiceBuilderExt,
33            TowerRequestConfig, timezone_to_offset,
34        },
35    },
36    template::{ConfinementConfig, Template},
37    tls::TlsConfig,
38};
39
40/// Batch encoding configuration for the `aws_s3` sink.
41#[cfg(feature = "codecs-parquet")]
42#[configurable_component]
43#[derive(Clone, Debug)]
44#[serde(tag = "codec", rename_all = "snake_case")]
45#[configurable(metadata(
46    docs::enum_tag_description = "The codec to use for batch encoding events."
47))]
48pub enum S3BatchEncoding {
49    /// Encodes events in [Apache Parquet][apache_parquet] columnar format.
50    ///
51    /// [apache_parquet]: https://parquet.apache.org/
52    Parquet(ParquetSerializerConfig),
53}
54
55/// Configuration for the `aws_s3` sink.
56#[configurable_component(sink(
57    "aws_s3",
58    "Store observability events in the AWS S3 object storage system."
59))]
60#[derive(Clone, Debug)]
61#[serde(deny_unknown_fields)]
62pub struct S3SinkConfig {
63    /// The S3 bucket name.
64    ///
65    /// This must not include a leading `s3://` or a trailing `/`.
66    #[configurable(metadata(docs::examples = "my-bucket"))]
67    pub bucket: String,
68
69    /// A prefix to apply to all object keys.
70    ///
71    /// Prefixes are useful for partitioning objects, such as by creating an object key that
72    /// stores objects under a particular directory. If using a prefix for this purpose, it must end
73    /// in `/` to act as a directory path. A trailing `/` is **not** automatically added.
74    #[serde(default = "default_key_prefix")]
75    #[configurable(metadata(docs::templateable))]
76    #[configurable(metadata(docs::examples = "date=%F/hour=%H"))]
77    #[configurable(metadata(docs::examples = "year=%Y/month=%m/day=%d"))]
78    #[configurable(metadata(docs::examples = "application_id={{ application_id }}/date=%F"))]
79    pub key_prefix: String,
80
81    /// The timestamp format for the time component of the object key.
82    ///
83    /// By default, object keys are appended with a timestamp that reflects when the objects are
84    /// sent to S3, such that the resulting object key is functionally equivalent to joining the key
85    /// prefix with the formatted timestamp, such as `date=2022-07-18/1658176486`.
86    ///
87    /// This would represent a `key_prefix` set to `date=%F/` and the timestamp of Mon Jul 18 2022
88    /// 20:34:44 GMT+0000, with the `filename_time_format` being set to `%s`, which renders
89    /// timestamps in seconds since the Unix epoch.
90    ///
91    /// Supports the common [`strftime`][chrono_strftime_specifiers] specifiers found in most
92    /// languages.
93    ///
94    /// When set to an empty string, no timestamp is appended to the key prefix.
95    ///
96    /// [chrono_strftime_specifiers]: https://docs.rs/chrono/latest/chrono/format/strftime/index.html#specifiers
97    #[serde(default = "default_filename_time_format")]
98    pub filename_time_format: String,
99
100    /// Whether or not to append a UUID v4 token to the end of the object key.
101    ///
102    /// The UUID is appended to the timestamp portion of the object key, such that if the object key
103    /// generated is `date=2022-07-18/1658176486`, setting this field to `true` results
104    /// in an object key that looks like `date=2022-07-18/1658176486-30f6652c-71da-4f9f-800d-a1189c47c547`.
105    ///
106    /// This ensures there are no name collisions, and can be useful in high-volume workloads where
107    /// object keys must be unique.
108    #[serde(default = "crate::serde::default_true")]
109    #[configurable(metadata(docs::human_name = "Append UUID to Filename"))]
110    pub filename_append_uuid: bool,
111
112    /// The filename extension to use in the object key.
113    ///
114    /// This overrides setting the extension based on the configured `compression`.
115    #[configurable(metadata(docs::examples = "json"))]
116    pub filename_extension: Option<String>,
117
118    #[serde(flatten)]
119    pub options: S3Options,
120
121    #[serde(flatten)]
122    pub region: RegionOrEndpoint,
123
124    #[serde(flatten)]
125    pub encoding: EncodingConfigWithFraming,
126
127    /// Batch encoding configuration for columnar formats.
128    ///
129    /// When set, events are encoded together as a batch in a columnar format (Parquet)
130    /// instead of the standard per-event framing-based encoding. The columnar format handles
131    /// its own internal compression, so the top-level `compression` setting is bypassed.
132    #[cfg(feature = "codecs-parquet")]
133    #[configurable(derived)]
134    #[serde(default)]
135    pub batch_encoding: Option<S3BatchEncoding>,
136
137    /// Compression configuration.
138    ///
139    /// All compression algorithms use the default compression level unless otherwise specified.
140    ///
141    /// Some cloud storage API clients and browsers handle decompression transparently, so
142    /// depending on how they are accessed, files may not always appear to be compressed.
143    #[configurable(derived)]
144    #[serde(default = "Compression::gzip_default")]
145    pub compression: Compression,
146
147    #[configurable(derived)]
148    #[serde(default)]
149    pub batch: BatchConfig<BulkSizeBasedDefaultBatchSettings>,
150
151    #[configurable(derived)]
152    #[serde(default)]
153    pub request: TowerRequestConfig,
154
155    #[configurable(derived)]
156    pub tls: Option<TlsConfig>,
157
158    #[configurable(derived)]
159    #[serde(default)]
160    pub auth: AwsAuthentication,
161
162    #[configurable(derived)]
163    #[serde(
164        default,
165        deserialize_with = "crate::serde::bool_or_struct",
166        skip_serializing_if = "crate::serde::is_default"
167    )]
168    pub acknowledgements: AcknowledgementsConfig,
169
170    #[configurable(derived)]
171    #[serde(default)]
172    pub timezone: Option<TimeZone>,
173
174    /// Specifies which addressing style to use.
175    ///
176    /// This controls if the bucket name is in the hostname or part of the URL.
177    #[serde(default = "crate::serde::default_true")]
178    pub force_path_style: bool,
179
180    /// Specifies retry strategy for failed requests.
181    ///
182    /// By default, the sink only retries attempts it deems possible to retry.
183    /// These settings extend the default behavior.
184    #[configurable(derived)]
185    #[serde(default, skip_serializing_if = "vector_lib::serde::is_default")]
186    pub retry_strategy: RetryStrategy,
187
188    #[serde(flatten)]
189    pub confinement: ConfinementConfig,
190}
191
192pub(super) fn default_key_prefix() -> String {
193    "date=%F".to_string()
194}
195
196pub(super) fn default_filename_time_format() -> String {
197    "%s".to_string()
198}
199
200impl GenerateConfig for S3SinkConfig {
201    fn generate_config() -> toml::Value {
202        toml::Value::try_from(Self {
203            bucket: "".to_owned(),
204            key_prefix: default_key_prefix(),
205            filename_time_format: default_filename_time_format(),
206            filename_append_uuid: true,
207            filename_extension: None,
208            options: S3Options::default(),
209            region: RegionOrEndpoint::default(),
210            encoding: (None::<FramingConfig>, TextSerializerConfig::default()).into(),
211            #[cfg(feature = "codecs-parquet")]
212            batch_encoding: None,
213            compression: Compression::gzip_default(),
214            batch: BatchConfig::default(),
215            request: TowerRequestConfig::default(),
216            tls: Some(TlsConfig::default()),
217            auth: AwsAuthentication::default(),
218            acknowledgements: Default::default(),
219            timezone: Default::default(),
220            force_path_style: Default::default(),
221            retry_strategy: Default::default(),
222            confinement: ConfinementConfig::default(),
223        })
224        .unwrap()
225    }
226}
227
228#[async_trait::async_trait]
229#[typetag::serde(name = "aws_s3")]
230impl SinkConfig for S3SinkConfig {
231    async fn build(&self, cx: SinkContext) -> crate::Result<(VectorSink, Healthcheck)> {
232        let service = self.create_service(&cx.proxy).await?;
233        let healthcheck = self.build_healthcheck(service.client())?;
234        let sink = self.build_processor(service, cx)?;
235        self.confinement.set_confinement_gauge("sink", Self::NAME);
236        Ok((sink, healthcheck))
237    }
238
239    fn input(&self) -> Input {
240        #[cfg(feature = "codecs-parquet")]
241        if let Some(batch_encoding) = &self.batch_encoding {
242            let S3BatchEncoding::Parquet(parquet_config) = batch_encoding;
243            let resolved = BatchSerializerConfig::Parquet(parquet_config.clone());
244            return Input::new(resolved.input_type());
245        }
246        Input::new(self.encoding.config().1.input_type())
247    }
248
249    fn acknowledgements(&self) -> &AcknowledgementsConfig {
250        &self.acknowledgements
251    }
252}
253
254impl S3SinkConfig {
255    pub fn build_processor(
256        &self,
257        service: S3Service,
258        cx: SinkContext,
259    ) -> crate::Result<VectorSink> {
260        // Build our S3 client/service, which is what we'll ultimately feed
261        // requests into in order to ship files to S3.  We build this here in
262        // order to configure the client/service with retries, concurrency
263        // limits, rate limits, and whatever else the client should have.
264        let request_limits = self.request.into_settings();
265        let retry_strategy = self.retry_strategy.clone();
266        let service = ServiceBuilder::new()
267            .settings(request_limits, retry_strategy)
268            .service(service);
269
270        let offset = self
271            .timezone
272            .or(cx.globals.timezone)
273            .and_then(timezone_to_offset);
274
275        // Configure our partitioning/batching.
276        let batch_settings = self.batch.into_batcher_settings()?;
277
278        let key_prefix = Template::try_from(self.key_prefix.clone())?.with_tz_offset(offset);
279        let key_prefix = key_prefix.confine(&self.confinement, Self::NAME, "key_prefix")?;
280
281        let ssekms_key_id = self
282            .options
283            .ssekms_key_id
284            .as_ref()
285            .cloned()
286            .map(|ssekms_key_id| Template::try_from(ssekms_key_id.as_str()))
287            .transpose()?
288            .map(|t| t.confine(&self.confinement, Self::NAME, "ssekms_key_id"))
289            .transpose()?;
290
291        let partitioner = S3KeyPartitioner::new(key_prefix, ssekms_key_id, None);
292
293        let transformer = self.encoding.transformer();
294
295        // When batch_encoding is configured (e.g., Parquet), use batch mode
296        // with internal compression and appropriate file extension.
297        #[cfg(feature = "codecs-parquet")]
298        if let Some(batch_encoding) = &self.batch_encoding {
299            let S3BatchEncoding::Parquet(parquet_config) = batch_encoding;
300            let resolved_batch_config = BatchSerializerConfig::Parquet(parquet_config.clone());
301
302            let batch_serializer = resolved_batch_config.build_batch_serializer()?;
303            let batch_encoder = BatchEncoder::new(batch_serializer);
304
305            // Auto-detect Content-Type from batch format. Users can still
306            // override via `options.content_type`; we only set it when unset.
307            let mut api_options = self.options.clone();
308            if api_options.content_type.is_none() {
309                api_options.content_type = batch_encoder.content_type().map(|s| s.to_string());
310            }
311
312            let encoder = EncoderKind::Batch(batch_encoder);
313
314            let filename_extension = self.filename_extension.clone().or_else(|| {
315                Some(
316                    match batch_encoding {
317                        S3BatchEncoding::Parquet(_) => "parquet",
318                    }
319                    .to_string(),
320                )
321            });
322
323            if self.compression != Compression::None {
324                warn!("Top level compression setting ignored when batch_encoding set to parquet.")
325            }
326
327            let request_options = S3RequestOptions {
328                bucket: self.bucket.clone(),
329                api_options,
330                filename_extension,
331                filename_time_format: self.filename_time_format.clone(),
332                filename_append_uuid: self.filename_append_uuid,
333                encoder: (transformer, encoder),
334                // Batch formats handle their own compression internally
335                compression: Compression::None,
336                filename_tz_offset: offset,
337            };
338
339            let sink = S3Sink::new(service, request_options, partitioner, batch_settings);
340            return Ok(VectorSink::from_event_streamsink(sink));
341        }
342
343        let (framer, serializer) = self.encoding.build(SinkType::MessageBased)?;
344        let encoder = EncoderKind::Framed(Box::new(Encoder::<Framer>::new(framer, serializer)));
345
346        let request_options = S3RequestOptions {
347            bucket: self.bucket.clone(),
348            api_options: self.options.clone(),
349            filename_extension: self.filename_extension.clone(),
350            filename_time_format: self.filename_time_format.clone(),
351            filename_append_uuid: self.filename_append_uuid,
352            encoder: (transformer, encoder),
353            compression: self.compression,
354            filename_tz_offset: offset,
355        };
356
357        let sink = S3Sink::new(service, request_options, partitioner, batch_settings);
358
359        Ok(VectorSink::from_event_streamsink(sink))
360    }
361
362    pub fn build_healthcheck(&self, client: S3Client) -> crate::Result<Healthcheck> {
363        s3_common::config::build_healthcheck(self.bucket.clone(), client)
364    }
365
366    pub async fn create_service(&self, proxy: &ProxyConfig) -> crate::Result<S3Service> {
367        s3_common::config::create_service(
368            &self.region,
369            &self.auth,
370            proxy,
371            self.tls.as_ref(),
372            self.force_path_style,
373        )
374        .await
375    }
376}
377
378#[cfg(test)]
379mod tests {
380    use super::S3SinkConfig;
381    use crate::template::{ConfinementConfig, Template};
382
383    #[test]
384    fn generate_config() {
385        crate::test_util::test_generate_config::<S3SinkConfig>();
386    }
387
388    /// Correct TOML shape: `batch_encoding.codec = "parquet"` with `schema_mode = "auto_infer"`.
389    #[cfg(feature = "codecs-parquet")]
390    #[test]
391    fn parquet_batch_encoding_correct_toml_shape() {
392        let config: S3SinkConfig = serde_yaml::from_str(indoc::indoc! {r#"
393            bucket: test-bucket
394            compression: none
395            encoding:
396              codec: text
397            batch_encoding:
398              schema_mode: auto_infer
399              codec: parquet
400              compression:
401                algorithm: snappy
402            "#})
403        .expect("correct batch_encoding shape should parse");
404
405        let batch_enc = config
406            .batch_encoding
407            .expect("batch_encoding should be Some");
408        let super::S3BatchEncoding::Parquet(ref p) = batch_enc;
409        use vector_lib::codecs::encoding::format::{ParquetCompression, ParquetSchemaMode};
410        assert_eq!(p.schema_mode, ParquetSchemaMode::AutoInfer);
411        assert_eq!(p.compression, ParquetCompression::Snappy);
412    }
413
414    /// Content-Type must be auto-detected as `application/vnd.apache.parquet`
415    /// when `batch_encoding` is set and `content_type` is not explicitly provided.
416    #[cfg(feature = "codecs-parquet")]
417    #[test]
418    fn parquet_content_type_auto_detected() {
419        use vector_lib::codecs::encoding::format::{
420            ParquetCompression, ParquetSchemaMode, ParquetSerializerConfig,
421        };
422
423        use crate::sinks::s3_common::config::S3Options;
424        use crate::sinks::util::{BatchConfig, BulkSizeBasedDefaultBatchSettings, Compression};
425        use vector_lib::codecs::TextSerializerConfig;
426        use vector_lib::codecs::encoding::{BatchSerializerConfig, FramingConfig};
427
428        let parquet_config = ParquetSerializerConfig {
429            schema_mode: ParquetSchemaMode::AutoInfer,
430            compression: ParquetCompression::Snappy,
431            ..Default::default()
432        };
433
434        let config = S3SinkConfig {
435            bucket: "test".to_string(),
436            key_prefix: super::default_key_prefix(),
437            filename_time_format: super::default_filename_time_format(),
438            filename_append_uuid: true,
439            filename_extension: None,
440            options: S3Options::default(),
441            region: crate::aws::RegionOrEndpoint::with_both("us-east-1", "http://localhost:4566"),
442            encoding: (None::<FramingConfig>, TextSerializerConfig::default()).into(),
443            batch_encoding: Some(super::S3BatchEncoding::Parquet(parquet_config)),
444            compression: Compression::None,
445            batch: BatchConfig::<BulkSizeBasedDefaultBatchSettings>::default(),
446            request: Default::default(),
447            tls: Default::default(),
448            auth: Default::default(),
449            acknowledgements: Default::default(),
450            timezone: Default::default(),
451            force_path_style: true,
452            retry_strategy: Default::default(),
453            confinement: ConfinementConfig::default(),
454        };
455
456        let super::S3BatchEncoding::Parquet(p) = config.batch_encoding.as_ref().unwrap();
457        let batch_config = BatchSerializerConfig::Parquet(p.clone());
458        let batch_serializer = batch_config.build_batch_serializer().unwrap();
459        let batch_encoder = vector_lib::codecs::BatchEncoder::new(batch_serializer);
460
461        let mut api_options = config.options.clone();
462        if api_options.content_type.is_none() {
463            api_options.content_type = batch_encoder.content_type().map(|s| s.to_string());
464        }
465
466        assert_eq!(
467            api_options.content_type.as_deref(),
468            Some("application/vnd.apache.parquet"),
469            "Content-Type must be auto-detected for Parquet"
470        );
471    }
472
473    /// When user explicitly sets `content_type`, the auto-detection must not override it.
474    #[cfg(feature = "codecs-parquet")]
475    #[test]
476    fn parquet_content_type_user_override_preserved() {
477        let config: S3SinkConfig = serde_yaml::from_str(indoc::indoc! {r#"
478            bucket: test-bucket
479            compression: none
480            content_type: "application/octet-stream"
481            encoding:
482              codec: text
483            batch_encoding:
484              codec: parquet
485              schema_mode: auto_infer
486              compression:
487                algorithm: gzip
488                level: 9
489            "#})
490        .unwrap();
491
492        let super::S3BatchEncoding::Parquet(p) = config.batch_encoding.as_ref().unwrap();
493        let batch_config = vector_lib::codecs::encoding::BatchSerializerConfig::Parquet(p.clone());
494        let batch_serializer = batch_config.build_batch_serializer().unwrap();
495        let batch_encoder = vector_lib::codecs::BatchEncoder::new(batch_serializer);
496
497        let mut api_options = config.options.clone();
498        if api_options.content_type.is_none() {
499            api_options.content_type = batch_encoder.content_type().map(|s| s.to_string());
500        }
501
502        assert_eq!(
503            api_options.content_type.as_deref(),
504            Some("application/octet-stream"),
505            "User-specified Content-Type must not be overridden"
506        );
507    }
508
509    /// Codecs other than `parquet` must be rejected at parse time, since
510    /// `S3BatchEncoding` only exposes the `parquet` variant.
511    #[cfg(feature = "codecs-parquet")]
512    #[test]
513    fn parquet_batch_encoding_rejects_unsupported_codec() {
514        let err = serde_yaml::from_str::<S3SinkConfig>(
515            r#"
516            bucket: test-bucket
517            compression: none
518            encoding:
519              codec: text
520            batch_encoding:
521              codec: arrow_stream
522            "#,
523        )
524        .unwrap_err();
525
526        assert!(
527            err.to_string().contains("arrow_stream"),
528            "expected error to mention the offending codec, got: {err}"
529        );
530    }
531
532    /// Explicit filename_extension overrides the `.parquet` default.
533    #[cfg(feature = "codecs-parquet")]
534    #[test]
535    fn parquet_filename_extension_user_override() {
536        let config: S3SinkConfig = serde_yaml::from_str(indoc::indoc! {r#"
537            bucket: test-bucket
538            compression: none
539            filename_extension: pq
540            encoding:
541              codec: text
542            batch_encoding:
543              codec: parquet
544              schema_mode: auto_infer
545            "#})
546        .unwrap();
547
548        assert_eq!(config.filename_extension.as_deref(), Some("pq"));
549    }
550
551    /// `schema_mode` defaults to `relaxed` when not specified.
552    #[cfg(feature = "codecs-parquet")]
553    #[test]
554    fn parquet_schema_mode_defaults_to_relaxed() {
555        use vector_lib::codecs::encoding::format::ParquetSchemaMode;
556
557        let config: S3SinkConfig = serde_yaml::from_str(indoc::indoc! {r#"
558            bucket: test-bucket
559            compression: none
560            encoding:
561              codec: text
562            batch_encoding:
563              codec: parquet
564            "#})
565        .unwrap();
566
567        let super::S3BatchEncoding::Parquet(p) = config.batch_encoding.unwrap();
568        assert_eq!(p.schema_mode, ParquetSchemaMode::Relaxed);
569    }
570
571    /// Explicit `schema_mode = "strict"` is correctly parsed.
572    #[cfg(feature = "codecs-parquet")]
573    #[test]
574    fn parquet_schema_mode_strict_parsed() {
575        use vector_lib::codecs::encoding::format::ParquetSchemaMode;
576
577        let config: S3SinkConfig = serde_yaml::from_str(indoc::indoc! {r#"
578            bucket: test-bucket
579            compression: none
580            encoding:
581              codec: text
582            batch_encoding:
583              codec: parquet
584              schema_mode: strict
585              schema_file: tmp/something.schema
586            "#})
587        .unwrap();
588
589        let super::S3BatchEncoding::Parquet(p) = config.batch_encoding.unwrap();
590        assert_eq!(p.schema_mode, ParquetSchemaMode::Strict);
591    }
592
593    #[test]
594    fn confinement_rejects_unconfined_key_prefix() {
595        let template: Template = "{{ tenant }}".try_into().unwrap();
596        let err = template
597            .confine(&ConfinementConfig::default(), "aws_s3", "key_prefix")
598            .unwrap_err();
599        assert!(
600            err.to_string().contains("no literal string prefix"),
601            "unexpected error: {err}"
602        );
603    }
604
605    #[test]
606    fn confinement_opt_out_allows_unconfined_key_prefix() {
607        let cfg = ConfinementConfig {
608            dangerously_allow_unconfined_template_resolution: true,
609        };
610        let template: Template = "{{ tenant }}".try_into().unwrap();
611        assert!(template.confine(&cfg, "aws_s3", "key_prefix").is_ok());
612    }
613
614    #[test]
615    fn confinement_blocks_dotdot_escape_at_render() {
616        use crate::event::Event;
617        use vector_lib::event::LogEvent;
618        use vrl::event_path;
619
620        let template: Template = "safe/{{ tenant }}/".try_into().unwrap();
621        let template = template
622            .confine(&ConfinementConfig::default(), "aws_s3", "key_prefix")
623            .unwrap();
624        let mut event = Event::Log(LogEvent::from("x"));
625        event
626            .as_mut_log()
627            .insert(event_path!("tenant"), "../../escape");
628        assert!(template.render_string(&event).is_err());
629    }
630}