1use std::{collections::HashMap, convert::TryFrom, io};
2
3use bytes::Bytes;
4use chrono::{FixedOffset, Utc};
5use http::{
6 Uri,
7 header::{HeaderName, HeaderValue},
8};
9use indoc::indoc;
10use snafu::{ResultExt, Snafu};
11use tower::ServiceBuilder;
12use uuid::Uuid;
13use vector_lib::{
14 TimeZone,
15 codecs::encoding::Framer,
16 configurable::configurable_component,
17 event::{EventFinalizers, Finalizable},
18 request_metadata::RequestMetadata,
19};
20
21use crate::{
22 codecs::{Encoder, EncodingConfigWithFraming, SinkType, Transformer},
23 config::{AcknowledgementsConfig, DataType, GenerateConfig, Input, SinkConfig, SinkContext},
24 event::Event,
25 gcp::{GcpAuthConfig, GcpAuthenticator, Scope},
26 http::{HttpClient, get_http_scheme_from_uri},
27 serde::json::to_string,
28 sinks::{
29 Healthcheck, VectorSink,
30 gcs_common::{
31 config::{
32 GcsPredefinedAcl, GcsRetryLogic, GcsStorageClass, build_healthcheck,
33 default_endpoint,
34 },
35 service::{GcsRequest, GcsRequestSettings, GcsService},
36 sink::GcsSink,
37 },
38 util::{
39 BulkSizeBasedDefaultBatchSettings, Compression, RequestBuilder, ServiceBuilderExt,
40 TowerRequestConfig, batch::BatchConfig, metadata::RequestMetadataBuilder,
41 partitioner::KeyPartitioner, request_builder::EncodeResult,
42 service::TowerRequestConfigDefaults, timezone_to_offset,
43 },
44 },
45 template::{ConfinementConfig, Template, TemplateParseError},
46 tls::{TlsConfig, TlsSettings},
47};
48
49#[derive(Debug, Snafu)]
50#[snafu(visibility(pub))]
51pub enum GcsHealthcheckError {
52 #[snafu(display("key_prefix template parse error: {}", source))]
53 KeyPrefixTemplate { source: TemplateParseError },
54}
55
56#[derive(Clone, Copy, Debug)]
57pub struct GcsTowerRequestConfigDefaults;
58
59impl TowerRequestConfigDefaults for GcsTowerRequestConfigDefaults {
60 const RATE_LIMIT_NUM: u64 = 1_000;
61}
62
63#[configurable_component(sink(
65 "gcp_cloud_storage",
66 "Store observability events in GCP Cloud Storage."
67))]
68#[derive(Clone, Debug)]
69#[serde(deny_unknown_fields)]
70pub struct GcsSinkConfig {
71 #[configurable(metadata(docs::examples = "my-bucket"))]
73 bucket: String,
74
75 acl: Option<GcsPredefinedAcl>,
81
82 storage_class: Option<GcsStorageClass>,
88
89 #[configurable(metadata(docs::additional_props_description = "A key/value pair."))]
95 #[configurable(metadata(docs::advanced))]
96 metadata: Option<HashMap<String, String>>,
97
98 #[configurable(metadata(docs::templateable))]
104 #[configurable(metadata(
105 docs::examples = "date=%F/",
106 docs::examples = "date=%F/hour=%H/",
107 docs::examples = "year=%Y/month=%m/day=%d/",
108 docs::examples = "application_id={{ application_id }}/date=%F/"
109 ))]
110 #[configurable(metadata(docs::advanced))]
111 key_prefix: Option<String>,
112
113 #[serde(default = "default_time_format")]
130 #[configurable(metadata(docs::advanced))]
131 filename_time_format: String,
132
133 #[serde(default = "crate::serde::default_true")]
142 #[configurable(metadata(docs::advanced))]
143 filename_append_uuid: bool,
144
145 #[configurable(metadata(docs::advanced))]
149 filename_extension: Option<String>,
150
151 #[serde(flatten)]
152 encoding: EncodingConfigWithFraming,
153
154 #[configurable(derived)]
161 #[serde(default)]
162 compression: Compression,
163
164 #[configurable(metadata(
170 docs::examples = "text/plain; charset=utf-8",
171 docs::examples = "application/gzip"
172 ))]
173 content_type: Option<String>,
174
175 #[configurable(metadata(docs::examples = "gzip", docs::examples = "zstd"))]
181 content_encoding: Option<String>,
182
183 #[configurable(metadata(docs::examples = "no-transform"))]
187 cache_control: Option<String>,
188
189 #[configurable(derived)]
190 #[serde(default)]
191 batch: BatchConfig<BulkSizeBasedDefaultBatchSettings>,
192
193 #[configurable(metadata(docs::examples = "http://localhost:9000"))]
195 #[configurable(validation(format = "uri"))]
196 #[serde(default = "default_endpoint")]
197 endpoint: String,
198
199 #[configurable(derived)]
200 #[serde(default)]
201 request: TowerRequestConfig<GcsTowerRequestConfigDefaults>,
202
203 #[serde(flatten)]
204 auth: GcpAuthConfig,
205
206 #[configurable(derived)]
207 tls: Option<TlsConfig>,
208
209 #[configurable(derived)]
210 #[serde(
211 default,
212 deserialize_with = "crate::serde::bool_or_struct",
213 skip_serializing_if = "crate::serde::is_default"
214 )]
215 acknowledgements: AcknowledgementsConfig,
216
217 #[configurable(derived)]
218 #[serde(default)]
219 pub timezone: Option<TimeZone>,
220
221 #[serde(flatten)]
222 pub confinement: ConfinementConfig,
223}
224
225fn default_time_format() -> String {
226 "%s".to_string()
227}
228
229#[cfg(test)]
230fn default_config(encoding: EncodingConfigWithFraming) -> GcsSinkConfig {
231 GcsSinkConfig {
232 bucket: Default::default(),
233 acl: Default::default(),
234 storage_class: Default::default(),
235 metadata: Default::default(),
236 key_prefix: Default::default(),
237 filename_time_format: default_time_format(),
238 filename_append_uuid: true,
239 filename_extension: Default::default(),
240 content_type: Default::default(),
241 content_encoding: Default::default(),
242 cache_control: Default::default(),
243 encoding,
244 compression: Compression::gzip_default(),
245 batch: Default::default(),
246 endpoint: Default::default(),
247 request: Default::default(),
248 auth: Default::default(),
249 tls: Default::default(),
250 acknowledgements: Default::default(),
251 timezone: Default::default(),
252 confinement: ConfinementConfig::default(),
253 }
254}
255
256impl GenerateConfig for GcsSinkConfig {
257 fn generate_config() -> toml::Value {
258 toml::from_str(indoc! {r#"
259 bucket = "my-bucket"
260 credentials_path = "/path/to/credentials.json"
261 framing.method = "newline_delimited"
262 encoding.codec = "json"
263 "#})
264 .unwrap()
265 }
266}
267
268#[async_trait::async_trait]
269#[typetag::serde(name = "gcp_cloud_storage")]
270impl SinkConfig for GcsSinkConfig {
271 async fn build(&self, cx: SinkContext) -> crate::Result<(VectorSink, Healthcheck)> {
272 let auth = self.auth.build(Scope::DevStorageReadWrite).await?;
273 let base_url = format!("{}/{}/", self.endpoint, self.bucket);
274 let tls = TlsSettings::from_options(self.tls.as_ref())?;
275 let client = HttpClient::new(tls, cx.proxy())?;
276 let healthcheck = build_healthcheck(
277 self.bucket.clone(),
278 client.clone(),
279 base_url.clone(),
280 auth.clone(),
281 )?;
282 auth.spawn_regenerate_token();
283 let sink = self.build_sink(client, base_url, auth, cx)?;
284
285 self.confinement.set_confinement_gauge("sink", Self::NAME);
286 Ok((sink, healthcheck))
287 }
288
289 fn input(&self) -> Input {
290 Input::new(self.encoding.config().1.input_type() & DataType::Log)
291 }
292
293 fn acknowledgements(&self) -> &AcknowledgementsConfig {
294 &self.acknowledgements
295 }
296}
297
298impl GcsSinkConfig {
299 fn build_sink(
300 &self,
301 client: HttpClient,
302 base_url: String,
303 auth: GcpAuthenticator,
304 cx: SinkContext,
305 ) -> crate::Result<VectorSink> {
306 let request = self.request.into_settings();
307
308 let batch_settings = self.batch.into_batcher_settings()?;
309
310 let partitioner = self.key_partitioner()?;
311
312 let protocol = get_http_scheme_from_uri(&base_url.parse::<Uri>().unwrap());
313
314 let svc = ServiceBuilder::new()
315 .settings(request, GcsRetryLogic::default())
316 .service(GcsService::new(client, base_url, auth));
317
318 let request_settings = RequestSettings::new(self, cx)?;
319
320 let sink = GcsSink::new(svc, request_settings, partitioner, batch_settings, protocol);
321
322 Ok(VectorSink::from_event_streamsink(sink))
323 }
324
325 fn key_partitioner(&self) -> crate::Result<KeyPartitioner> {
326 let tpl = Template::try_from(self.key_prefix.as_deref().unwrap_or("date=%F/"))
327 .context(KeyPrefixTemplateSnafu)?;
328 let tpl = tpl.confine(&self.confinement, Self::NAME, "key_prefix")?;
329 Ok(KeyPartitioner::new(tpl, None))
330 }
331}
332
333#[derive(Clone, Debug)]
337struct RequestSettings {
338 acl: Option<HeaderValue>,
339 content_type: HeaderValue,
340 content_encoding: Option<HeaderValue>,
341 storage_class: HeaderValue,
342 cache_control: Option<HeaderValue>,
343 headers: Vec<(HeaderName, HeaderValue)>,
344 extension: String,
345 time_format: String,
346 append_uuid: bool,
347 encoder: (Transformer, Encoder<Framer>),
348 compression: Compression,
349 tz_offset: Option<FixedOffset>,
350}
351
352impl RequestBuilder<(String, Vec<Event>)> for RequestSettings {
353 type Metadata = (String, EventFinalizers);
354 type Events = Vec<Event>;
355 type Encoder = (Transformer, Encoder<Framer>);
356 type Payload = Bytes;
357 type Request = GcsRequest;
358 type Error = io::Error;
359
360 fn compression(&self) -> Compression {
361 self.compression
362 }
363
364 fn encoder(&self) -> &Self::Encoder {
365 &self.encoder
366 }
367
368 fn split_input(
369 &self,
370 input: (String, Vec<Event>),
371 ) -> (Self::Metadata, RequestMetadataBuilder, Self::Events) {
372 let (partition_key, mut events) = input;
373 let finalizers = events.take_finalizers();
374 let builder = RequestMetadataBuilder::from_events(&events);
375
376 ((partition_key, finalizers), builder, events)
377 }
378
379 fn build_request(
380 &self,
381 gcp_metadata: Self::Metadata,
382 metadata: RequestMetadata,
383 payload: EncodeResult<Self::Payload>,
384 ) -> Self::Request {
385 let (key, finalizers) = gcp_metadata;
386 let filename = {
388 let seconds = match self.tz_offset {
389 Some(offset) => Utc::now().with_timezone(&offset).format(&self.time_format),
390 None => Utc::now()
391 .with_timezone(&chrono::Utc)
392 .format(&self.time_format),
393 };
394
395 if self.append_uuid {
396 let uuid = Uuid::new_v4();
397 format!("{}-{}", seconds, uuid.hyphenated())
398 } else {
399 seconds.to_string()
400 }
401 };
402
403 let key = format!("{}{}.{}", key, filename, self.extension);
404 let body = payload.into_payload();
405
406 GcsRequest {
407 key,
408 body,
409 finalizers,
410 settings: GcsRequestSettings {
411 acl: self.acl.clone(),
412 content_type: self.content_type.clone(),
413 content_encoding: self.content_encoding.clone(),
414 storage_class: self.storage_class.clone(),
415 cache_control: self.cache_control.clone(),
416 headers: self.headers.clone(),
417 },
418 metadata,
419 }
420 }
421}
422
423impl RequestSettings {
424 fn new(config: &GcsSinkConfig, cx: SinkContext) -> crate::Result<Self> {
425 let transformer = config.encoding.transformer();
426 let (framer, serializer) = config.encoding.build(SinkType::MessageBased)?;
427 let encoder = Encoder::<Framer>::new(framer, serializer);
428 let acl = config
429 .acl
430 .map(|acl| HeaderValue::from_str(&to_string(acl)).unwrap());
431 let content_type_str = config
432 .content_type
433 .as_deref()
434 .unwrap_or_else(|| encoder.content_type());
435 let content_type = HeaderValue::from_str(content_type_str)?;
436 let content_encoding = match &config.content_encoding {
437 Some(ce) => Some(HeaderValue::from_str(ce)?),
438 None => config
439 .compression
440 .content_encoding()
441 .map(|ce| HeaderValue::from_str(&to_string(ce)).unwrap()),
442 };
443 let storage_class = config.storage_class.unwrap_or_default();
444 let storage_class = HeaderValue::from_str(&to_string(storage_class)).unwrap();
445 let cache_control = config
446 .cache_control
447 .as_ref()
448 .map(|cc| HeaderValue::from_str(cc))
449 .transpose()?;
450 let metadata = config
451 .metadata
452 .as_ref()
453 .map(|metadata| {
454 metadata
455 .iter()
456 .map(make_header)
457 .collect::<Result<Vec<_>, _>>()
458 })
459 .unwrap_or_else(|| Ok(vec![]))?;
460 let extension = config
461 .filename_extension
462 .clone()
463 .unwrap_or_else(|| config.compression.extension().into());
464 let time_format = config.filename_time_format.clone();
465 let append_uuid = config.filename_append_uuid;
466 let offset = config
467 .timezone
468 .or(cx.globals.timezone)
469 .and_then(timezone_to_offset);
470
471 Ok(Self {
472 acl,
473 content_type,
474 content_encoding,
475 storage_class,
476 cache_control,
477 headers: metadata,
478 extension,
479 time_format,
480 append_uuid,
481 compression: config.compression,
482 encoder: (transformer, encoder),
483 tz_offset: offset,
484 })
485 }
486}
487
488fn make_header((name, value): (&String, &String)) -> crate::Result<(HeaderName, HeaderValue)> {
490 Ok((
491 HeaderName::from_bytes(name.as_bytes())?,
492 HeaderValue::from_str(value)?,
493 ))
494}
495
496#[cfg(test)]
497mod tests {
498 use futures_util::{future::ready, stream};
499 use vector_lib::{
500 EstimatedJsonEncodedSizeOf,
501 codecs::{
502 JsonSerializerConfig, NewlineDelimitedEncoderConfig, TextSerializerConfig,
503 encoding::FramingConfig,
504 },
505 partition::Partitioner,
506 request_metadata::GroupedCountByteSize,
507 };
508 use vrl::event_path;
509
510 use super::*;
511 use crate::{
512 event::LogEvent,
513 template::{ConfinementConfig, Template},
514 test_util::{
515 components::{SINK_TAGS, run_and_assert_sink_compliance},
516 http::{always_200_response, spawn_blackhole_http_server},
517 },
518 };
519
520 #[test]
521 fn generate_config() {
522 crate::test_util::test_generate_config::<GcsSinkConfig>();
523 }
524
525 #[tokio::test]
526 async fn component_spec_compliance() {
527 let mock_endpoint = spawn_blackhole_http_server(always_200_response).await;
528
529 let context = SinkContext::default();
530
531 let tls = TlsSettings::default();
532 let client =
533 HttpClient::new(tls, context.proxy()).expect("should not fail to create HTTP client");
534
535 let config =
536 default_config((None::<FramingConfig>, JsonSerializerConfig::default()).into());
537 let sink = config
538 .build_sink(
539 client,
540 mock_endpoint.to_string(),
541 GcpAuthenticator::None,
542 context,
543 )
544 .expect("failed to build sink");
545
546 let event = Event::Log(LogEvent::from("simple message"));
547 run_and_assert_sink_compliance(sink, stream::once(ready(event)), &SINK_TAGS).await;
548 }
549
550 #[test]
551 fn gcs_encode_event_apply_rules() {
552 crate::test_util::trace_init();
553
554 let message = "hello world".to_string();
555 let mut event = LogEvent::from(message);
556 event.insert(event_path!("key"), "value");
557
558 let sink_config = GcsSinkConfig {
559 key_prefix: Some("key: {{ key }}".into()),
560 ..default_config((None::<FramingConfig>, TextSerializerConfig::default()).into())
561 };
562 let key = sink_config
563 .key_partitioner()
564 .unwrap()
565 .partition(&Event::Log(event))
566 .expect("key wasn't provided");
567
568 assert_eq!(key, "key: value");
569 }
570
571 fn request_settings(sink_config: &GcsSinkConfig, context: SinkContext) -> RequestSettings {
572 RequestSettings::new(sink_config, context).expect("Could not create request settings")
573 }
574
575 fn build_request(extension: Option<&str>, uuid: bool, compression: Compression) -> GcsRequest {
576 let context = SinkContext::default();
577 let sink_config = GcsSinkConfig {
578 key_prefix: Some("key/".into()),
579 filename_time_format: "date".into(),
580 filename_extension: extension.map(Into::into),
581 filename_append_uuid: uuid,
582 compression,
583 ..default_config(
584 (
585 Some(NewlineDelimitedEncoderConfig::new()),
586 JsonSerializerConfig::default(),
587 )
588 .into(),
589 )
590 };
591 let log = LogEvent::default().into();
592 let key = sink_config
593 .key_partitioner()
594 .unwrap()
595 .partition(&log)
596 .expect("key wasn't provided");
597
598 let mut byte_size = GroupedCountByteSize::new_untagged();
599 byte_size.add_event(&log, log.estimated_json_encoded_size_of());
600
601 let request_settings = request_settings(&sink_config, context);
602 let (metadata, metadata_request_builder, _events) =
603 request_settings.split_input((key, vec![log]));
604 let payload = EncodeResult::uncompressed(Bytes::new(), byte_size);
605 let request_metadata = metadata_request_builder.build(&payload);
606
607 request_settings.build_request(metadata, request_metadata, payload)
608 }
609
610 #[test]
611 fn gcs_build_request() {
612 let req = build_request(Some("ext"), false, Compression::None);
613 assert_eq!(req.key, "key/date.ext".to_string());
614
615 let req = build_request(None, false, Compression::None);
616 assert_eq!(req.key, "key/date.log".to_string());
617
618 let req = build_request(None, false, Compression::gzip_default());
619 assert_eq!(req.key, "key/date.log.gz".to_string());
620
621 let req = build_request(None, true, Compression::gzip_default());
622 assert_ne!(req.key, "key/date.log.gz".to_string());
623 }
624
625 #[test]
626 fn gcs_content_type_default() {
627 let context = SinkContext::default();
628 let sink_config = GcsSinkConfig {
629 content_type: None,
630 ..default_config((None::<FramingConfig>, TextSerializerConfig::default()).into())
631 };
632
633 let request_settings = request_settings(&sink_config, context);
634 assert_eq!(
636 request_settings.content_type.to_str().unwrap(),
637 "text/plain"
638 );
639 }
640
641 #[test]
642 fn gcs_content_type_custom() {
643 let context = SinkContext::default();
644 let sink_config = GcsSinkConfig {
645 content_type: Some("text/plain; charset=utf-8".to_string()),
646 ..default_config((None::<FramingConfig>, TextSerializerConfig::default()).into())
647 };
648
649 let request_settings = request_settings(&sink_config, context);
650 assert_eq!(
652 request_settings.content_type.to_str().unwrap(),
653 "text/plain; charset=utf-8"
654 );
655 }
656
657 #[test]
658 fn gcs_content_type_invalid() {
659 let context = SinkContext::default();
660 let sink_config = GcsSinkConfig {
661 content_type: Some("text/plain\nInvalid".to_string()),
663 ..default_config((None::<FramingConfig>, TextSerializerConfig::default()).into())
664 };
665
666 let result = RequestSettings::new(&sink_config, context);
667 assert!(result.is_err());
669 }
670
671 #[test]
672 fn gcs_content_encoding_default() {
673 let context = SinkContext::default();
674 let sink_config = GcsSinkConfig {
675 content_encoding: None,
676 compression: Compression::gzip_default(),
677 ..default_config((None::<FramingConfig>, TextSerializerConfig::default()).into())
678 };
679
680 let request_settings = request_settings(&sink_config, context);
681 assert_eq!(
683 request_settings.content_encoding.unwrap().to_str().unwrap(),
684 "gzip"
685 );
686 }
687
688 #[test]
689 fn gcs_content_encoding_none_when_no_compression() {
690 let context = SinkContext::default();
691 let sink_config = GcsSinkConfig {
692 content_encoding: None,
693 compression: Compression::None,
694 ..default_config((None::<FramingConfig>, TextSerializerConfig::default()).into())
695 };
696
697 let request_settings = request_settings(&sink_config, context);
698 assert!(request_settings.content_encoding.is_none());
700 }
701
702 #[test]
703 fn gcs_content_encoding_custom() {
704 let context = SinkContext::default();
705 let sink_config = GcsSinkConfig {
706 content_encoding: Some("gzip".to_string()),
707 compression: Compression::None,
708 ..default_config((None::<FramingConfig>, TextSerializerConfig::default()).into())
709 };
710
711 let request_settings = request_settings(&sink_config, context);
712 assert_eq!(
714 request_settings.content_encoding.unwrap().to_str().unwrap(),
715 "gzip"
716 );
717 }
718
719 #[test]
720 fn gcs_content_encoding_invalid() {
721 let context = SinkContext::default();
722 let sink_config = GcsSinkConfig {
723 content_encoding: Some("gzip\nInvalid".to_string()),
725 ..default_config((None::<FramingConfig>, TextSerializerConfig::default()).into())
726 };
727
728 let result = RequestSettings::new(&sink_config, context);
729 assert!(result.is_err());
731 }
732
733 #[test]
734 fn gcs_content_encoding_empty() {
735 let context = SinkContext::default();
736 let sink_config = GcsSinkConfig {
737 content_encoding: Some("".to_string()),
739 compression: Compression::gzip_default(),
740 ..default_config((None::<FramingConfig>, TextSerializerConfig::default()).into())
741 };
742
743 let request_settings = request_settings(&sink_config, context);
744 assert_eq!(
746 request_settings.content_encoding.unwrap().to_str().unwrap(),
747 ""
748 );
749 }
750
751 #[test]
752 fn gcs_cache_control_default() {
753 let context = SinkContext::default();
754 let sink_config = GcsSinkConfig {
755 cache_control: None,
756 ..default_config((None::<FramingConfig>, TextSerializerConfig::default()).into())
757 };
758
759 let request_settings = request_settings(&sink_config, context);
760 assert!(request_settings.cache_control.is_none());
762 }
763
764 #[test]
765 fn gcs_cache_control_custom() {
766 let context = SinkContext::default();
767 let sink_config = GcsSinkConfig {
768 cache_control: Some("no-transform".to_string()),
769 ..default_config((None::<FramingConfig>, TextSerializerConfig::default()).into())
770 };
771
772 let request_settings = request_settings(&sink_config, context);
773 assert_eq!(
774 request_settings.cache_control.unwrap().to_str().unwrap(),
775 "no-transform"
776 );
777 }
778
779 #[test]
780 fn gcs_cache_control_invalid() {
781 let context = SinkContext::default();
782 let sink_config = GcsSinkConfig {
783 cache_control: Some("no-cache\nInvalid".to_string()),
785 ..default_config((None::<FramingConfig>, TextSerializerConfig::default()).into())
786 };
787
788 let result = RequestSettings::new(&sink_config, context);
789 assert!(result.is_err());
791 }
792
793 #[test]
794 fn confinement_rejects_unconfined_key_prefix() {
795 let config = GcsSinkConfig {
796 key_prefix: Some("{{ tenant }}".into()),
797 ..default_config((None::<FramingConfig>, TextSerializerConfig::default()).into())
798 };
799 match config.key_partitioner() {
800 Err(err) => assert!(
801 err.to_string().contains("no literal string prefix"),
802 "unexpected error: {err}"
803 ),
804 Ok(_) => panic!("expected confinement error"),
805 }
806 }
807
808 #[test]
809 fn confinement_opt_out_allows_unconfined_key_prefix() {
810 let config = GcsSinkConfig {
811 key_prefix: Some("{{ tenant }}".into()),
812 confinement: ConfinementConfig {
813 dangerously_allow_unconfined_template_resolution: true,
814 },
815 ..default_config((None::<FramingConfig>, TextSerializerConfig::default()).into())
816 };
817 assert!(config.key_partitioner().is_ok());
818 }
819
820 #[test]
821 fn confinement_blocks_dotdot_escape_at_render() {
822 use crate::event::Event;
823
824 let template: Template = "safe/{{ tenant }}/".try_into().unwrap();
825 let template = template
826 .confine(
827 &ConfinementConfig::default(),
828 "gcp_cloud_storage",
829 "key_prefix",
830 )
831 .unwrap();
832 let mut event = Event::Log(LogEvent::from("x"));
833 event
834 .as_mut_log()
835 .insert(event_path!("tenant"), "../../escape");
836 assert!(template.render_string(&event).is_err());
837 }
838}