Skip to main content

vector/sinks/gcp/
pubsub.rs

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
39// 10MB maximum message size: https://cloud.google.com/pubsub/quotas#resource_limits
40const 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/// Configuration for the `gcp_pubsub` sink.
52#[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    /// The project name to which to publish events.
59    #[configurable(metadata(docs::examples = "vector-123456"))]
60    pub project: String,
61
62    /// The topic within the project to which to publish events.
63    #[configurable(metadata(docs::examples = "this-is-a-topic"))]
64    pub topic: String,
65
66    /// The endpoint to which to publish events.
67    ///
68    /// The scheme (`http` or `https`) must be specified. No path should be included since the paths defined
69    /// by the [`GCP Pub/Sub`][pubsub_api] API are used.
70    ///
71    /// The trailing slash `/` must not be included.
72    ///
73    /// [pubsub_api]: https://cloud.google.com/pubsub/docs/reference/rest
74    #[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        // We only need to load the credentials if we are not targeting an emulator.
174        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        // The suffix is a Google API method (for example `:publish`) that
224        // attaches directly to the topic path without a separator.
225        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        // Errors are handled by `Encoder`.
241        self.encoder.encode(event, &mut bytes).ok()?;
242        // Each event needs to be base64 encoded, and put into a JSON object
243        // as the `data` item.
244        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        // Explicitly skip healthcheck
442
443        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)] // deserialize all fields
513    struct PullMessageOuter {
514        ackId: String,
515        message: PullMessage,
516    }
517
518    #[derive(Debug, Deserialize)]
519    #[allow(non_snake_case)]
520    #[allow(dead_code)] // deserialize all fields
521    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}