Skip to main content

featherbit/plugins/native/
google_cloud_logging.rs

1//! The `google-cloud-logging` node — ships access-log entries to
2//! [Google Cloud Logging](https://cloud.google.com/logging) in batches.
3//!
4//! Ported from APISIX's `google-cloud-logging.lua`. On the request path the
5//! node builds a log entry (via the shared
6//! [`build_entry`](crate::plugins::util::log_entry::build_entry)) and hands it
7//! to a [`BatchSink`]. A background task wraps the buffered entries into the
8//! Cloud Logging `entries:write` payload and POSTs them to
9//! `https://logging.googleapis.com/v2/entries:write` with an OAuth2 bearer
10//! token. The node passes the context through unchanged.
11//!
12//! ## Authentication (service-account JWT → OAuth2 access token)
13//! Cloud Logging is called with a short-lived OAuth2 access token obtained via
14//! the service-account JWT-bearer grant:
15//! 1. A JWT is assembled and RS256-signed with the service account's
16//!    `private_key` — claims `iss` = `client_email`, `scope` = space-joined
17//!    scopes, `aud` = `token_uri`, `iat`/`exp` (1h lifetime).
18//! 2. The JWT is POSTed to `token_uri` as
19//!    `grant_type=urn:ietf:params:oauth:grant-type:jwt-bearer&assertion=<jwt>`.
20//! 3. The returned `access_token` is **cached** ([`tokio::sync::Mutex`]) and
21//!    reused until ~60s before its `expires_in`, then refreshed.
22//!
23//! ## Deviations from APISIX
24//! - APISIX derives per-entry `httpRequest`/`insertId` fields and merges
25//!   `log_format_extra`. featherbit uses the shared log entry as the
26//!   `jsonPayload` and omits `httpRequest`/`insertId`; use `log_format` to
27//!   shape the payload.
28//! - `log_id` defaults to `featherbit%2Flogs` (APISIX: `apisix.apache.org%2Flogs`).
29//! - `resource` defaults to `{"type":"global"}`.
30
31use std::collections::HashMap;
32use std::sync::Arc;
33use std::time::Duration;
34
35use async_trait::async_trait;
36use jsonwebtoken::{encode, Algorithm, EncodingKey, Header};
37use serde::Deserialize;
38use serde_json::{json, Value};
39use tokio::sync::Mutex;
40use tokio::time::Instant;
41
42use crate::batch::{BatchConfig, BatchFlusher, BatchSink, FlushError};
43use crate::context::Context;
44use crate::outbound::{OutboundClient, OutboundRequest};
45use crate::plugins::resources::PluginResources;
46use crate::plugins::util::log_entry::{build_entry, parse_log_format};
47use crate::plugins::{Plugin, PluginOutput, PluginResult};
48
49const DEFAULT_TOKEN_URI: &str = "https://oauth2.googleapis.com/token";
50const DEFAULT_ENTRIES_URI: &str = "https://logging.googleapis.com/v2/entries:write";
51const DEFAULT_SCOPES: &[&str] = &[
52    "https://www.googleapis.com/auth/logging.write",
53    "https://www.googleapis.com/auth/cloud-platform",
54];
55
56/// Ships log entries to Google Cloud Logging in batches.
57pub struct GoogleCloudLoggingPlugin {
58    sink: BatchSink,
59    log_format: Option<HashMap<String, Value>>,
60    include_req_body: bool,
61    include_resp_body: bool,
62}
63
64/// Service-account credentials resolved from `auth_config` or `auth_file`.
65#[derive(Clone)]
66struct AuthConfig {
67    client_email: String,
68    private_key: String,
69    project_id: String,
70    token_uri: String,
71    scopes: Vec<String>,
72}
73
74/// Caches the OAuth2 access token and refreshes it near expiry.
75struct TokenManager {
76    auth: AuthConfig,
77    client: Arc<OutboundClient>,
78    ssl_verify: bool,
79    timeout: Duration,
80    cached: Mutex<Option<CachedToken>>,
81}
82
83struct CachedToken {
84    token: String,
85    /// When the token should be considered stale (already minus a safety margin).
86    refresh_at: Instant,
87}
88
89/// Delivers batched log entries to the Cloud Logging `entries:write` endpoint.
90struct GoogleCloudFlusher {
91    client: Arc<OutboundClient>,
92    tokens: TokenManager,
93    entries_uri: String,
94    log_name: String,
95    resource: Value,
96    ssl_verify: bool,
97    timeout: Duration,
98}
99
100impl GoogleCloudLoggingPlugin {
101    /// Builds the plugin from node config.
102    ///
103    /// Config keys:
104    ///
105    /// | Key | Type | Default | Description |
106    /// |---|---|---|---|
107    /// | `auth_config` | object | — | Inline service account: `client_email`, `private_key`, `project_id` (all required), optional `token_uri`, `scopes`. |
108    /// | `auth_file` | string | — | Path to a service-account JSON file (used when `auth_config` is absent). |
109    /// | `resource` | object | `{"type":"global"}` | [MonitoredResource](https://cloud.google.com/logging/docs/reference/v2/rest/v2/MonitoredResource) attached to each entry. |
110    /// | `log_id` | string | `featherbit%2Flogs` | Log id; the `logName` becomes `projects/<project_id>/logs/<log_id>`. |
111    /// | `ssl_verify` | bool | `true` | Verify Google TLS certificates. |
112    /// | `timeout` | int (seconds) | `10` | Per-call HTTP timeout (token fetch and write). |
113    /// | `log_format` | object | — | Custom `name -> "$var template"` entry used as the `jsonPayload`. |
114    /// | `include_req_body` / `include_resp_body` | bool | `false` | Include bodies in the default entry. |
115    ///
116    /// Either `auth_config` (with `client_email` + `private_key` + `project_id`)
117    /// or `auth_file` is required. Batch keys follow
118    /// [`BatchConfig::from_config`].
119    ///
120    /// ```yaml
121    /// type: google-cloud-logging
122    /// config:
123    ///   auth_config:
124    ///     client_email: logger@my-project.iam.gserviceaccount.com
125    ///     private_key: |
126    ///       -----BEGIN PRIVATE KEY-----
127    ///       ...
128    ///       -----END PRIVATE KEY-----
129    ///     project_id: my-project
130    ///   log_id: featherbit%2Flogs
131    /// ```
132    pub fn from_config(
133        config: &HashMap<String, Value>,
134        resources: &Arc<PluginResources>,
135    ) -> Result<Self, String> {
136        let auth = resolve_auth(config)?;
137
138        let ssl_verify = config
139            .get("ssl_verify")
140            .and_then(|v| v.as_bool())
141            .unwrap_or(true);
142        let timeout =
143            Duration::from_secs(config.get("timeout").and_then(|v| v.as_u64()).unwrap_or(10));
144        let log_id = config
145            .get("log_id")
146            .and_then(|v| v.as_str())
147            .unwrap_or("featherbit%2Flogs");
148        let resource = config
149            .get("resource")
150            .cloned()
151            .unwrap_or_else(|| json!({ "type": "global" }));
152        let entries_uri = config
153            .get("entries_uri")
154            .and_then(|v| v.as_str())
155            .unwrap_or(DEFAULT_ENTRIES_URI)
156            .to_string();
157
158        let log_name = format!("projects/{}/logs/{}", auth.project_id, log_id);
159
160        let log_format = parse_log_format(config)?;
161        let include_req_body = config
162            .get("include_req_body")
163            .and_then(|v| v.as_bool())
164            .unwrap_or(false);
165        let include_resp_body = config
166            .get("include_resp_body")
167            .and_then(|v| v.as_bool())
168            .unwrap_or(false);
169
170        let batch_cfg =
171            BatchConfig::from_config(config).map_err(|e| format!("google-cloud-logging: {e}"))?;
172
173        let flusher = Arc::new(GoogleCloudFlusher {
174            client: resources.outbound.clone(),
175            tokens: TokenManager {
176                auth,
177                client: resources.outbound.clone(),
178                ssl_verify,
179                timeout,
180                cached: Mutex::new(None),
181            },
182            entries_uri,
183            log_name,
184            resource,
185            ssl_verify,
186            timeout,
187        });
188        let sink = BatchSink::spawn("google-cloud-logging", batch_cfg, flusher);
189
190        Ok(Self {
191            sink,
192            log_format,
193            include_req_body,
194            include_resp_body,
195        })
196    }
197}
198
199/// Resolves the service-account credentials from `auth_config` (inline) or
200/// `auth_file` (path to a JSON file). `client_email`, `private_key`, and
201/// `project_id` are required.
202fn resolve_auth(config: &HashMap<String, Value>) -> Result<AuthConfig, String> {
203    let obj: serde_json::Map<String, Value> = match config.get("auth_config") {
204        Some(Value::Object(m)) => m.clone(),
205        _ => {
206            let path = config
207                .get("auth_file")
208                .and_then(|v| v.as_str())
209                .ok_or("google-cloud-logging: `auth_config` or `auth_file` is required")?;
210            let content = std::fs::read_to_string(path).map_err(|e| {
211                format!("google-cloud-logging: failed to read auth_file `{path}`: {e}")
212            })?;
213            serde_json::from_str::<serde_json::Map<String, Value>>(&content).map_err(|e| {
214                format!("google-cloud-logging: auth_file `{path}` is not a JSON object: {e}")
215            })?
216        }
217    };
218
219    let get_str = |key: &str| obj.get(key).and_then(|v| v.as_str()).map(str::to_string);
220
221    let client_email = get_str("client_email")
222        .filter(|s| !s.is_empty())
223        .ok_or("google-cloud-logging: `client_email` is required")?;
224    let private_key = get_str("private_key")
225        .filter(|s| !s.is_empty())
226        .ok_or("google-cloud-logging: `private_key` is required")?;
227    let project_id = get_str("project_id")
228        .filter(|s| !s.is_empty())
229        .ok_or("google-cloud-logging: `project_id` is required")?;
230    let token_uri = get_str("token_uri")
231        .filter(|s| !s.is_empty())
232        .unwrap_or_else(|| DEFAULT_TOKEN_URI.to_string());
233
234    // Accept `scopes` (preferred) or `scope`, as an array of strings.
235    let scopes = obj
236        .get("scopes")
237        .or_else(|| obj.get("scope"))
238        .and_then(|v| v.as_array())
239        .map(|arr| {
240            arr.iter()
241                .filter_map(|v| v.as_str().map(str::to_string))
242                .collect::<Vec<_>>()
243        })
244        .filter(|v| !v.is_empty())
245        .unwrap_or_else(|| DEFAULT_SCOPES.iter().map(|s| s.to_string()).collect());
246
247    Ok(AuthConfig {
248        client_email,
249        private_key,
250        project_id,
251        token_uri,
252        scopes,
253    })
254}
255
256/// Assembles and RS256-signs the service-account JWT assertion.
257fn build_jwt(
258    client_email: &str,
259    scope: &str,
260    token_uri: &str,
261    now_secs: u64,
262    private_key_pem: &str,
263) -> Result<String, String> {
264    let claims = json!({
265        "iss": client_email,
266        "scope": scope,
267        "aud": token_uri,
268        "iat": now_secs,
269        "exp": now_secs + 3600,
270    });
271    let key = EncodingKey::from_rsa_pem(private_key_pem.as_bytes())
272        .map_err(|e| format!("invalid service-account private_key: {e}"))?;
273    encode(&Header::new(Algorithm::RS256), &claims, &key)
274        .map_err(|e| format!("failed to sign service-account JWT: {e}"))
275}
276
277/// Wraps one log entry into a Cloud Logging `LogEntry`.
278fn build_log_entry(entry: &Value, log_name: &str, resource: &Value, timestamp: &str) -> Value {
279    json!({
280        "logName": log_name,
281        "resource": resource,
282        "jsonPayload": entry,
283        "timestamp": timestamp,
284        "labels": { "source": "featherbit-google-cloud-logging" },
285    })
286}
287
288/// Builds the full `entries:write` request payload.
289fn build_write_payload(
290    entries: &[Value],
291    log_name: &str,
292    resource: &Value,
293    timestamp: &str,
294) -> Value {
295    let wrapped: Vec<Value> = entries
296        .iter()
297        .map(|e| build_log_entry(e, log_name, resource, timestamp))
298        .collect();
299    json!({ "entries": wrapped, "partialSuccess": false })
300}
301
302#[derive(Deserialize)]
303struct TokenResponse {
304    access_token: String,
305    #[serde(default)]
306    expires_in: Option<u64>,
307}
308
309impl TokenManager {
310    /// Returns a valid access token, refreshing via the JWT-bearer grant when
311    /// the cached one is absent or near expiry.
312    async fn access_token(&self) -> Result<String, String> {
313        let mut guard = self.cached.lock().await;
314        if let Some(cached) = guard.as_ref() {
315            if Instant::now() < cached.refresh_at {
316                return Ok(cached.token.clone());
317            }
318        }
319
320        let now = std::time::SystemTime::now()
321            .duration_since(std::time::UNIX_EPOCH)
322            .map(|d| d.as_secs())
323            .unwrap_or(0);
324        let scope = self.auth.scopes.join(" ");
325        let assertion = build_jwt(
326            &self.auth.client_email,
327            &scope,
328            &self.auth.token_uri,
329            now,
330            &self.auth.private_key,
331        )?;
332
333        // grant_type value percent-encoded; the JWT assertion is base64url-safe.
334        let body = format!(
335            "grant_type=urn%3Aietf%3Aparams%3Aoauth%3Agrant-type%3Ajwt-bearer&assertion={assertion}"
336        );
337        let req = OutboundRequest {
338            method: http::Method::POST,
339            url: self.auth.token_uri.clone(),
340            headers: vec![(
341                "Content-Type".to_string(),
342                "application/x-www-form-urlencoded".to_string(),
343            )],
344            body: body.into_bytes().into(),
345            timeout: self.timeout,
346            ssl_verify: self.ssl_verify,
347            tls: None,
348        };
349
350        let resp = self
351            .client
352            .request(req)
353            .await
354            .map_err(|e| format!("token request failed: {e}"))?;
355        if resp.status != 200 {
356            return Err(format!(
357                "token endpoint returned status {}: {}",
358                resp.status,
359                String::from_utf8_lossy(&resp.body)
360            ));
361        }
362        let parsed: TokenResponse = serde_json::from_slice(&resp.body)
363            .map_err(|e| format!("failed to parse token response: {e}"))?;
364
365        // Refresh ~60s before expiry; default lifetime 3600s.
366        let ttl = parsed.expires_in.unwrap_or(3600).saturating_sub(60).max(1);
367        *guard = Some(CachedToken {
368            token: parsed.access_token.clone(),
369            refresh_at: Instant::now() + Duration::from_secs(ttl),
370        });
371        Ok(parsed.access_token)
372    }
373}
374
375/// RFC3339 UTC timestamp (`YYYY-MM-DDTHH:MM:SSZ`) from a unix second count.
376fn rfc3339_zulu(unix_secs: u64) -> String {
377    let days = (unix_secs / 86400) as i64;
378    let rem = unix_secs % 86400;
379    let (hh, mm, ss) = (rem / 3600, (rem % 3600) / 60, rem % 60);
380    let (y, m, d) = civil_from_days(days);
381    format!("{y:04}-{m:02}-{d:02}T{hh:02}:{mm:02}:{ss:02}Z")
382}
383
384/// Howard Hinnant's civil-from-days: days since the unix epoch → (year, month, day).
385fn civil_from_days(z: i64) -> (i64, u32, u32) {
386    let z = z + 719_468;
387    let era = if z >= 0 { z } else { z - 146_096 } / 146_097;
388    let doe = z - era * 146_097;
389    let yoe = (doe - doe / 1460 + doe / 36_524 - doe / 146_096) / 365;
390    let y = yoe + era * 400;
391    let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
392    let mp = (5 * doy + 2) / 153;
393    let d = (doy - (153 * mp + 2) / 5 + 1) as u32;
394    let m = if mp < 10 { mp + 3 } else { mp - 9 } as u32;
395    (if m <= 2 { y + 1 } else { y }, m, d)
396}
397
398fn now_secs() -> u64 {
399    std::time::SystemTime::now()
400        .duration_since(std::time::UNIX_EPOCH)
401        .map(|d| d.as_secs())
402        .unwrap_or(0)
403}
404
405#[async_trait]
406impl BatchFlusher for GoogleCloudFlusher {
407    async fn flush(&self, entries: &[Value]) -> Result<(), FlushError> {
408        let token = self.tokens.access_token().await.map_err(|e| FlushError {
409            message: format!("failed to obtain access token: {e}"),
410            first_fail: None,
411        })?;
412
413        let timestamp = rfc3339_zulu(now_secs());
414        let payload = build_write_payload(entries, &self.log_name, &self.resource, &timestamp);
415        let body = serde_json::to_vec(&payload).map_err(|e| FlushError {
416            message: format!("failed to encode entries:write payload: {e}"),
417            first_fail: None,
418        })?;
419
420        let req = OutboundRequest {
421            method: http::Method::POST,
422            url: self.entries_uri.clone(),
423            headers: vec![
424                ("Content-Type".to_string(), "application/json".to_string()),
425                ("Authorization".to_string(), format!("Bearer {token}")),
426            ],
427            body: body.into(),
428            timeout: self.timeout,
429            ssl_verify: self.ssl_verify,
430            tls: None,
431        };
432
433        match self.client.request(req).await {
434            Ok(resp) if resp.status == 200 => Ok(()),
435            Ok(resp) => Err(FlushError {
436                message: format!(
437                    "google cloud logging returned status {}: {}",
438                    resp.status,
439                    String::from_utf8_lossy(&resp.body)
440                ),
441                first_fail: None,
442            }),
443            Err(e) => Err(FlushError {
444                message: e.to_string(),
445                first_fail: None,
446            }),
447        }
448    }
449}
450
451#[async_trait]
452impl Plugin for GoogleCloudLoggingPlugin {
453    fn plugin_type(&self) -> &str {
454        "google-cloud-logging"
455    }
456
457    async fn execute(&self, ctx: Context, _named_inputs: &HashMap<String, Value>) -> PluginResult {
458        let entry = build_entry(
459            &ctx,
460            self.log_format.as_ref(),
461            self.include_req_body,
462            self.include_resp_body,
463        );
464        self.sink.push(entry);
465
466        Ok(PluginOutput {
467            context: ctx,
468            named_outputs: HashMap::new(),
469        })
470    }
471}
472
473#[cfg(test)]
474mod tests {
475    use super::*;
476    use base64::Engine;
477
478    // Test RSA private key (PKCS#8), reused from the openid_connect tests.
479    const PRIV_PEM: &str = "-----BEGIN PRIVATE KEY-----\n\
480MIIEvAIBADANBgkqhkiG9w0BAQEFAASCBKYwggSiAgEAAoIBAQCvciOuri5uG88q\n\
481rZ3T6qUhTYl7nWDHvVGBBsA8ku3xUfOW97PGpWbTe/Yq/3jovVxAQsAe/QoIMyUU\n\
482HKCdDKAsIBO9j9OEPs3Le6cThFx+/9Z1U9cw4wCIa4TNtGBhyDgqbqKpOLNnXLI6\n\
483WEcrykkoV5nUUH/47aS2i9BiqZn6H9eEL1VH82IX/x4fWNIEyXQAxKZtyULgznR4\n\
484oUz2QPaY/cWtpK85B12scs1IpLnzEdjy69t28ZQnYZ7Nrvl+aFjSkvnqxhoNJ9Ut\n\
485Lw2/3vld8t3Lh6B4vTM4vdJsue1dum6WnyEKEx/SDuCSDxWONfmdhu/B4XUghaQS\n\
4861wBNiEhvAgMBAAECggEASXDcee8ktWfDsShK9F35MLcd0VaAICxiFUInr1OL8ePt\n\
487tSjMIt+y6t0tnzMgwEAgATBP7sjabbNHFqOjIgqac84bpVKy5l1J1R9WQWe7NlhO\n\
488w/9MCYVEgFaNmXQjklr3E+ALDA4VnzNg0eaJKE39kLsWxBbMcv27YMSm/t3i/B2s\n\
489rwZbzBgxXXR5r7j/Tt+hRJmGHXe0zZvsNLzFNj4CsyngBiY9CIcexroGxd3yGEf7\n\
4900PKHwbZKkH0CPr6QAc4f+tPgIfHB+8+29QPrUTR9e60Sc6dZNUjTr1EWIxyvFxVK\n\
491dI3ekR5W26a81+yxc2MpRK8wZsv+mJ6okaeVs2+3jQKBgQDr0b3YX4RC9trW+RsE\n\
4929wUXeLr3o9Vb0FTHf/8ALAZ9EWywEmF+sdA8fKs8+H+IyIzX6KGw/UbzqIi2aDuJ\n\
493q63IPxKyyXr7nfVSUz8qWIGT/WoG/1d4rpFN2sbR/r/oue7uJnaXMIPswVT+zO8q\n\
4945YieEPDwhteJ8bJUC16NWwddBQKBgQC+dcEmNm7MzxI/cuwubkojhayXw1ouACu4\n\
495giGp3lJywzIAnV1CsJTGTpvHk31j+/L9oB2U/586+65MGklGJ2TGs0IQZs0iAy1H\n\
496Oq3zzsLp0KiVizyqchgkIWP6KVpx5aPkpJSgPJGyJzuwofZwRzPK7IZr8c4MOtsy\n\
497M8j8up8p4wKBgGbUxTYvIJuazX7kjXWyydOcX9tQ497vj6iXFflbOVEcYgq9WSpI\n\
498G4fkzT7/FY3t9gzIcomdSG1D1qnD9gJojJU/e8XeufQywyEtD+RFR+vim3OFsPz9\n\
499EnuipQQ5VDIFsjzDJP90tnJtM8UQVFKeWN6kgIxCIIcUkDC57HczdJiJAoGASPG4\n\
500g/YdAXvdNUfChRXgdzJfI9DB3RRbqlLMqc5oLWPs5qdebIhMspawuwMV5xE7wz9r\n\
501lQFB7sktvB/lKGU2B5PoHXgB4KDu2nTy4omxxPMRXhTxqyX/cPcI32qvJSgaWRtf\n\
502gO8xrdWw2rltNRtQDsv/v5/glnaENPn4ZDLlepkCgYAqag5Uxj0ps6WNE/D6IEWA\n\
503eTGicEEJPJQB9bGrElna7WyOjntnO5miRmpM1jH39R417czBURmvZHO2oTnqghZF\n\
504c/7P2kweQNU7vtM/iLcm8EyFRw2lVB3J/XVTEcPU6ZeZHlVbGtiKx3gukkMBc4Ct\n\
505CQTyrvDSz5J6MQhLtbNHnQ==\n\
506-----END PRIVATE KEY-----\n";
507
508    fn cfg(pairs: &[(&str, Value)]) -> HashMap<String, Value> {
509        pairs
510            .iter()
511            .map(|(k, v)| (k.to_string(), v.clone()))
512            .collect()
513    }
514
515    fn auth_config_value() -> Value {
516        json!({
517            "client_email": "logger@proj.iam.gserviceaccount.com",
518            "private_key": PRIV_PEM,
519            "project_id": "my-project",
520        })
521    }
522
523    #[test]
524    fn from_config_requires_auth() {
525        let res = GoogleCloudLoggingPlugin::from_config(&HashMap::new(), &PluginResources::empty());
526        let Err(e) = res else {
527            panic!("expected error")
528        };
529        assert!(e.contains("auth_config"));
530    }
531
532    #[test]
533    fn from_config_requires_core_auth_fields() {
534        for missing in ["client_email", "private_key", "project_id"] {
535            let mut auth = auth_config_value();
536            auth.as_object_mut().unwrap().remove(missing);
537            let c = cfg(&[("auth_config", auth)]);
538            let res = GoogleCloudLoggingPlugin::from_config(&c, &PluginResources::empty());
539            let Err(e) = res else {
540                panic!("expected error when `{missing}` missing")
541            };
542            assert!(e.contains(missing));
543        }
544    }
545
546    #[tokio::test]
547    async fn from_config_ok_and_defaults() {
548        let c = cfg(&[("auth_config", auth_config_value())]);
549        let auth = resolve_auth(&c).unwrap();
550        assert_eq!(auth.token_uri, DEFAULT_TOKEN_URI);
551        assert_eq!(auth.scopes, DEFAULT_SCOPES);
552        assert!(GoogleCloudLoggingPlugin::from_config(&c, &PluginResources::empty()).is_ok());
553    }
554
555    #[test]
556    fn jwt_is_signed_from_private_key() {
557        let jwt = build_jwt(
558            "logger@proj.iam.gserviceaccount.com",
559            "scope-a scope-b",
560            DEFAULT_TOKEN_URI,
561            1_700_000_000,
562            PRIV_PEM,
563        )
564        .unwrap();
565
566        let parts: Vec<&str> = jwt.split('.').collect();
567        assert_eq!(parts.len(), 3, "a JWT has three dot-separated segments");
568
569        let engine = base64::engine::general_purpose::URL_SAFE_NO_PAD;
570        let header: Value = serde_json::from_slice(&engine.decode(parts[0]).unwrap()).unwrap();
571        assert_eq!(header["alg"], "RS256");
572        assert_eq!(header["typ"], "JWT");
573
574        let claims: Value = serde_json::from_slice(&engine.decode(parts[1]).unwrap()).unwrap();
575        assert_eq!(claims["iss"], "logger@proj.iam.gserviceaccount.com");
576        assert_eq!(claims["scope"], "scope-a scope-b");
577        assert_eq!(claims["aud"], DEFAULT_TOKEN_URI);
578        assert_eq!(claims["iat"], 1_700_000_000u64);
579        assert_eq!(claims["exp"], 1_700_003_600u64);
580    }
581
582    #[test]
583    fn write_payload_shape() {
584        let entries = vec![
585            json!({ "request": { "method": "GET" } }),
586            json!({ "request": { "method": "POST" } }),
587        ];
588        let resource = json!({ "type": "global" });
589        let payload = build_write_payload(
590            &entries,
591            "projects/my-project/logs/featherbit%2Flogs",
592            &resource,
593            "2023-11-14T22:13:20Z",
594        );
595        assert_eq!(payload["partialSuccess"], false);
596        let arr = payload["entries"].as_array().unwrap();
597        assert_eq!(arr.len(), 2);
598        assert_eq!(
599            arr[0]["logName"],
600            "projects/my-project/logs/featherbit%2Flogs"
601        );
602        assert_eq!(arr[0]["resource"]["type"], "global");
603        assert_eq!(arr[0]["jsonPayload"]["request"]["method"], "GET");
604        assert_eq!(arr[0]["timestamp"], "2023-11-14T22:13:20Z");
605        assert_eq!(
606            arr[0]["labels"]["source"],
607            "featherbit-google-cloud-logging"
608        );
609        assert_eq!(arr[1]["jsonPayload"]["request"]["method"], "POST");
610    }
611
612    #[test]
613    fn rfc3339_conversion() {
614        assert_eq!(rfc3339_zulu(1_700_000_000), "2023-11-14T22:13:20Z");
615        assert_eq!(rfc3339_zulu(0), "1970-01-01T00:00:00Z");
616    }
617}