1use base64::prelude::{BASE64_STANDARD, Engine as _};
2use bytes::{Bytes, BytesMut};
3use futures::{FutureExt, SinkExt};
4use http::{Request, Uri};
5use hyper::Body;
6use indoc::indoc;
7use serde_json::{Value, json};
8use snafu::Snafu;
9use tokio_util::codec::Encoder as _;
10use vector_lib::configurable::configurable_component;
11
12use crate::{
13 codecs::{Encoder, EncodingConfig, Transformer},
14 config::{
15 AcknowledgementsConfig, DynValidatedSink, GenerateConfig, Input, SinkConfig, SinkContext,
16 ValidatedSink,
17 },
18 event::Event,
19 gcp::{GcpAuthConfig, GcpAuthenticator, PUBSUB_URL, Scope},
20 http::HttpClient,
21 sinks::{
22 Healthcheck, VectorSink,
23 gcs_common::config::healthcheck_response,
24 util::{
25 BatchConfig, BatchSettings, BoxedRawValue, HttpEndpoint, JsonArrayBuffer,
26 SinkBatchSettings, TowerRequestConfig,
27 http::{BatchedHttpSink, HttpEventEncoder, HttpSink},
28 },
29 },
30 tls::{TlsConfig, TlsSettings},
31};
32
33#[derive(Debug, Snafu)]
34enum HealthcheckError {
35 #[snafu(display("Configured topic not found"))]
36 TopicNotFound,
37}
38
39const MAX_BATCH_PAYLOAD_SIZE: usize = 10_000_000;
41
42#[derive(Clone, Copy, Debug, Default)]
43pub struct PubsubDefaultBatchSettings;
44
45impl SinkBatchSettings for PubsubDefaultBatchSettings {
46 const MAX_EVENTS: Option<usize> = Some(1000);
47 const MAX_BYTES: Option<usize> = Some(10_000_000);
48 const TIMEOUT_SECS: f64 = 1.0;
49}
50
51#[configurable_component(sink(
53 "gcp_pubsub",
54 "Publish observability events to GCP's Pub/Sub messaging system."
55))]
56#[derive(Clone, Debug)]
57pub struct PubsubConfig {
58 #[configurable(metadata(docs::examples = "vector-123456"))]
60 pub project: String,
61
62 #[configurable(metadata(docs::examples = "this-is-a-topic"))]
64 pub topic: String,
65
66 #[serde(default = "default_endpoint")]
75 #[configurable(metadata(docs::examples = "https://us-central1-pubsub.googleapis.com"))]
76 pub endpoint: HttpEndpoint,
77
78 #[serde(default, flatten)]
79 pub auth: GcpAuthConfig,
80
81 #[configurable(derived)]
82 #[serde(default)]
83 pub batch: BatchConfig<PubsubDefaultBatchSettings>,
84
85 #[configurable(derived)]
86 #[serde(default)]
87 pub request: TowerRequestConfig,
88
89 #[configurable(derived)]
90 encoding: EncodingConfig,
91
92 #[configurable(derived)]
93 #[serde(default)]
94 pub tls: Option<TlsConfig>,
95
96 #[configurable(derived)]
97 #[serde(
98 default,
99 deserialize_with = "crate::serde::bool_or_struct",
100 skip_serializing_if = "crate::serde::is_default"
101 )]
102 acknowledgements: AcknowledgementsConfig,
103}
104
105fn default_endpoint() -> HttpEndpoint {
106 HttpEndpoint::parse(PUBSUB_URL).expect("static default endpoint should be a valid http(s) URL")
107}
108
109impl GenerateConfig for PubsubConfig {
110 fn generate_config() -> serde_json::Value {
111 serde_yaml::from_str(indoc! {r#"
112 project: my-project
113 topic: my-topic
114 encoding:
115 codec: json
116 "#})
117 .unwrap()
118 }
119}
120
121#[async_trait::async_trait]
122#[typetag::serde(name = "gcp_pubsub")]
123impl SinkConfig for PubsubConfig {
124 fn input(&self) -> Input {
125 Input::new(self.encoding.config().input_type())
126 }
127
128 fn acknowledgements(&self) -> &AcknowledgementsConfig {
129 &self.acknowledgements
130 }
131
132 fn as_dyn_validated(&self) -> Option<&dyn DynValidatedSink> {
133 Some(self)
134 }
135}
136
137#[async_trait::async_trait]
138impl ValidatedSink for PubsubConfig {
139 type Validated = ValidatedPubsub;
140
141 fn validate(&self) -> crate::Result<ValidatedPubsub> {
142 let uri_base = self.endpoint.append_path(&format!(
143 "/v1/projects/{}/topics/{}",
144 self.project, self.topic,
145 ))?;
146
147 let batch_settings = self
148 .batch
149 .validate()?
150 .limit_max_bytes(MAX_BATCH_PAYLOAD_SIZE)?
151 .into_batch_settings()?;
152
153 let transformer = self.encoding.transformer();
154
155 Ok(ValidatedPubsub {
156 uri_base,
157 batch_settings,
158 transformer,
159 })
160 }
161
162 async fn build(
163 &self,
164 validated: &ValidatedPubsub,
165 cx: SinkContext,
166 ) -> crate::Result<(VectorSink, Healthcheck)> {
167 let ValidatedPubsub {
168 uri_base,
169 batch_settings,
170 transformer,
171 } = validated.clone();
172
173 let auth = self.auth.build(Scope::PubSub).await?;
175
176 let serializer = self.encoding.build()?;
177 let encoder = Encoder::<()>::new(serializer);
178
179 let sink = PubsubSink {
180 auth,
181 uri_base,
182 transformer,
183 encoder,
184 };
185
186 let request_settings = self.request.into_settings();
187 let tls_settings = TlsSettings::from_options(self.tls.as_ref())?;
188 let client = HttpClient::new(tls_settings, cx.proxy())?;
189
190 let healthcheck = healthcheck(client.clone(), sink.uri("")?, sink.auth.clone()).boxed();
191 sink.auth.spawn_regenerate_token();
192
193 let sink = BatchedHttpSink::new(
194 sink,
195 JsonArrayBuffer::new(batch_settings.size),
196 request_settings,
197 batch_settings.timeout,
198 client,
199 )
200 .sink_map_err(|error| error!(message = "Fatal gcp_pubsub sink error.", %error, internal_log_rate_limit = false));
201
202 #[allow(deprecated)]
203 Ok((VectorSink::from_event_sink(sink), healthcheck))
204 }
205}
206
207#[derive(Clone, Debug)]
208pub struct ValidatedPubsub {
209 uri_base: HttpEndpoint,
210 batch_settings: BatchSettings<JsonArrayBuffer>,
211 transformer: Transformer,
212}
213
214struct PubsubSink {
215 auth: GcpAuthenticator,
216 uri_base: HttpEndpoint,
217 transformer: Transformer,
218 encoder: Encoder<()>,
219}
220
221impl PubsubSink {
222 fn uri(&self, suffix: &str) -> crate::Result<Uri> {
223 let mut uri = self.uri_base.append_raw_suffix(suffix)?.into_uri();
226 self.auth.apply_uri(&mut uri);
227 Ok(uri)
228 }
229}
230
231struct PubSubSinkEventEncoder {
232 transformer: Transformer,
233 encoder: Encoder<()>,
234}
235
236impl HttpEventEncoder<Value> for PubSubSinkEventEncoder {
237 fn encode_event(&mut self, mut event: Event) -> Option<Value> {
238 self.transformer.transform(&mut event);
239 let mut bytes = BytesMut::new();
240 self.encoder.encode(event, &mut bytes).ok()?;
242 Some(json!({ "data": BASE64_STANDARD.encode(&bytes) }))
245 }
246}
247
248impl HttpSink for PubsubSink {
249 type Input = Value;
250 type Output = Vec<BoxedRawValue>;
251 type Encoder = PubSubSinkEventEncoder;
252
253 fn build_encoder(&self) -> Self::Encoder {
254 PubSubSinkEventEncoder {
255 transformer: self.transformer.clone(),
256 encoder: self.encoder.clone(),
257 }
258 }
259
260 async fn build_request(&self, events: Self::Output) -> crate::Result<Request<Bytes>> {
261 let body = json!({ "messages": events });
262 let body = crate::serde::json::to_bytes(&body).unwrap().freeze();
263
264 let uri = self.uri(":publish").unwrap();
265 let builder = Request::post(uri).header("Content-Type", "application/json");
266
267 let mut request = builder.body(body).unwrap();
268 self.auth.apply(&mut request);
269
270 Ok(request)
271 }
272}
273
274async fn healthcheck(client: HttpClient, uri: Uri, auth: GcpAuthenticator) -> crate::Result<()> {
275 let mut request = Request::get(uri).body(Body::empty()).unwrap();
276 auth.apply(&mut request);
277
278 let response = client.send(request).await?;
279 healthcheck_response(response, HealthcheckError::TopicNotFound.into())
280}
281
282#[cfg(test)]
283mod tests {
284 use indoc::indoc;
285
286 use super::*;
287
288 #[test]
289 fn generate_config() {
290 crate::test_util::test_generate_config::<PubsubConfig>();
291 }
292
293 #[test]
294 fn validate_produces_usable_values() {
295 use crate::config::ValidatedSink;
296
297 let config: PubsubConfig = serde_yaml::from_str(indoc! {r#"
298 project: project
299 topic: topic
300 encoding:
301 codec: json
302 "#})
303 .unwrap();
304
305 let validated = config.validate().expect("validation should succeed");
306 assert_eq!(
307 validated.uri_base.to_string(),
308 "https://pubsub.googleapis.com/v1/projects/project/topics/topic"
309 );
310 }
311
312 #[tokio::test]
313 async fn fails_missing_creds() {
314 let config: PubsubConfig = serde_yaml::from_str(indoc! {r#"
315 project: project
316 topic: topic
317 encoding:
318 codec: json
319 "#})
320 .unwrap();
321 if SinkConfig::build(&config, SinkContext::default())
322 .await
323 .is_ok()
324 {
325 panic!("config.build failed to error");
326 }
327 }
328}
329
330#[cfg(all(test, feature = "gcp-integration-tests"))]
331mod integration_tests {
332 use reqwest::{Client, Method, Response};
333 use serde::{Deserialize, Serialize};
334 use serde_json::{Value, json};
335 use vector_lib::{
336 codecs::JsonSerializerConfig,
337 event::{BatchNotifier, BatchStatus},
338 };
339
340 use super::*;
341 use crate::{
342 gcp,
343 test_util::{
344 components::{
345 COMPONENT_ERROR_TAGS, HTTP_SINK_TAGS, run_and_assert_sink_compliance,
346 run_and_assert_sink_error,
347 },
348 random_events_with_stream, random_metrics_with_stream, random_string, trace_init,
349 },
350 };
351
352 const PROJECT: &str = "testproject";
353
354 fn config(topic: &str) -> PubsubConfig {
355 PubsubConfig {
356 project: PROJECT.into(),
357 topic: topic.into(),
358 endpoint: HttpEndpoint::parse(&gcp::PUBSUB_ADDRESS).unwrap(),
359 auth: GcpAuthConfig {
360 skip_authentication: true,
361 ..Default::default()
362 },
363 batch: Default::default(),
364 request: Default::default(),
365 encoding: JsonSerializerConfig::default().into(),
366 tls: Default::default(),
367 acknowledgements: Default::default(),
368 }
369 }
370
371 async fn config_build(topic: &str) -> (VectorSink, crate::sinks::Healthcheck) {
372 let cx = SinkContext::default();
373 SinkConfig::build(&config(topic), cx)
374 .await
375 .expect("Building sink failed")
376 }
377
378 #[tokio::test]
379 async fn publish_metrics() {
380 trace_init();
381
382 let (topic, subscription) = create_topic_subscription().await;
383 let (sink, healthcheck) = config_build(&topic).await;
384
385 healthcheck.await.expect("Health check failed");
386
387 let (batch, mut receiver) = BatchNotifier::new_with_receiver();
388 let (input, events) = random_metrics_with_stream(100, Some(batch), None);
389 run_and_assert_sink_compliance(sink, events, &HTTP_SINK_TAGS).await;
390 assert_eq!(receiver.try_recv(), Ok(BatchStatus::Delivered));
391
392 let response = pull_messages(&subscription, 1000).await;
393 let messages = response
394 .receivedMessages
395 .as_ref()
396 .expect("Response is missing messages");
397 assert_eq!(input.len(), messages.len());
398 for i in 0..input.len() {
399 let data = messages[i].message.decode_data_as_value();
400 let data = serde_json::to_value(data).unwrap();
401 let expected = serde_json::to_value(input[i].as_metric()).unwrap();
402 assert_eq!(data, expected);
403 }
404 }
405
406 #[tokio::test]
407 async fn publish_events() {
408 trace_init();
409
410 let (topic, subscription) = create_topic_subscription().await;
411 let (sink, healthcheck) = config_build(&topic).await;
412
413 healthcheck.await.expect("Health check failed");
414
415 let (batch, mut receiver) = BatchNotifier::new_with_receiver();
416 let (input, events) = random_events_with_stream(100, 100, Some(batch));
417 run_and_assert_sink_compliance(sink, events, &HTTP_SINK_TAGS).await;
418 assert_eq!(receiver.try_recv(), Ok(BatchStatus::Delivered));
419
420 let response = pull_messages(&subscription, 1000).await;
421 let messages = response
422 .receivedMessages
423 .as_ref()
424 .expect("Response is missing messages");
425 assert_eq!(input.len(), messages.len());
426 for i in 0..input.len() {
427 let data = messages[i].message.decode_data();
428 let data = serde_json::to_value(data).unwrap();
429 let expected =
430 serde_json::to_value(input[i].as_log().all_event_fields().unwrap()).unwrap();
431 assert_eq!(data, expected);
432 }
433 }
434
435 #[tokio::test]
436 async fn publish_events_broken_topic() {
437 trace_init();
438
439 let (topic, _subscription) = create_topic_subscription().await;
440 let (sink, _healthcheck) = config_build(&format!("BREAK{topic}BREAK")).await;
441 let (batch, mut receiver) = BatchNotifier::new_with_receiver();
444 let (_input, events) = random_events_with_stream(100, 100, Some(batch));
445 run_and_assert_sink_error(sink, events, &COMPONENT_ERROR_TAGS).await;
446 assert_eq!(receiver.try_recv(), Ok(BatchStatus::Rejected));
447 }
448
449 #[tokio::test]
450 async fn checks_for_valid_topic() {
451 trace_init();
452
453 let (topic, _subscription) = create_topic_subscription().await;
454 let topic = format!("BAD{topic}");
455 let (_sink, healthcheck) = config_build(&topic).await;
456 healthcheck.await.expect_err("Health check did not fail");
457 }
458
459 async fn create_topic_subscription() -> (String, String) {
460 let topic = format!("topic-{}", random_string(10));
461 let subscription = format!("subscription-{}", random_string(10));
462 request(Method::PUT, &format!("topics/{topic}"), json!({}))
463 .await
464 .json::<Value>()
465 .await
466 .expect("Creating new topic failed");
467 request(
468 Method::PUT,
469 &format!("subscriptions/{subscription}"),
470 json!({ "topic": format!("projects/{}/topics/{}", PROJECT, topic) }),
471 )
472 .await
473 .json::<Value>()
474 .await
475 .expect("Creating new subscription failed");
476 (topic, subscription)
477 }
478
479 async fn request(method: Method, path: &str, json: Value) -> Response {
480 let url = format!("{}/v1/projects/{}/{}", *gcp::PUBSUB_ADDRESS, PROJECT, path);
481 Client::new()
482 .request(method.clone(), &url)
483 .json(&json)
484 .send()
485 .await
486 .unwrap_or_else(|_| panic!("Sending {method} request to {url} failed"))
487 }
488
489 async fn pull_messages(subscription: &str, count: usize) -> PullResponse {
490 request(
491 Method::POST,
492 &format!("subscriptions/{subscription}:pull"),
493 json!({
494 "returnImmediately": true,
495 "maxMessages": count
496 }),
497 )
498 .await
499 .json::<PullResponse>()
500 .await
501 .expect("Extracting pull data failed")
502 }
503
504 #[derive(Debug, Deserialize)]
505 #[allow(non_snake_case)]
506 struct PullResponse {
507 receivedMessages: Option<Vec<PullMessageOuter>>,
508 }
509
510 #[derive(Debug, Deserialize)]
511 #[allow(non_snake_case)]
512 #[allow(dead_code)] struct PullMessageOuter {
514 ackId: String,
515 message: PullMessage,
516 }
517
518 #[derive(Debug, Deserialize)]
519 #[allow(non_snake_case)]
520 #[allow(dead_code)] struct PullMessage {
522 data: String,
523 messageId: String,
524 publishTime: String,
525 }
526
527 impl PullMessage {
528 fn decode_data(&self) -> TestMessage {
529 let data = BASE64_STANDARD
530 .decode(&self.data)
531 .expect("Invalid base64 data");
532 let data = String::from_utf8_lossy(&data);
533 serde_json::from_str(&data).expect("Invalid message structure")
534 }
535
536 fn decode_data_as_value(&self) -> Value {
537 let data = BASE64_STANDARD
538 .decode(&self.data)
539 .expect("Invalid base64 data");
540 let data = String::from_utf8_lossy(&data);
541 serde_json::from_str(&data).expect("Invalid json")
542 }
543 }
544
545 #[derive(Debug, Deserialize, Serialize)]
546 struct TestMessage {
547 timestamp: String,
548 message: String,
549 }
550}