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, LogFormat};
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<LogFormat>,
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 -> "template"` entry (`{{namespace.path}}` references plus legacy `$var` interpolation) 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    fn reads_response_body(&self) -> bool {
458        crate::plugins::util::log_entry::reads_response_body(
459            self.log_format.as_ref(),
460            self.include_resp_body,
461        )
462    }
463
464    async fn execute(&self, ctx: Context) -> PluginResult {
465        let entry = build_entry(
466            &ctx,
467            self.log_format.as_ref(),
468            self.include_req_body,
469            self.include_resp_body,
470        );
471        self.sink.push(entry);
472
473        Ok(PluginOutput::success(ctx))
474    }
475}
476
477#[cfg(test)]
478mod tests {
479    use super::*;
480    use base64::Engine;
481
482    // Test RSA private key (PKCS#8), reused from the openid_connect tests.
483    const PRIV_PEM: &str = "-----BEGIN PRIVATE KEY-----\n\
484MIIEvAIBADANBgkqhkiG9w0BAQEFAASCBKYwggSiAgEAAoIBAQCvciOuri5uG88q\n\
485rZ3T6qUhTYl7nWDHvVGBBsA8ku3xUfOW97PGpWbTe/Yq/3jovVxAQsAe/QoIMyUU\n\
486HKCdDKAsIBO9j9OEPs3Le6cThFx+/9Z1U9cw4wCIa4TNtGBhyDgqbqKpOLNnXLI6\n\
487WEcrykkoV5nUUH/47aS2i9BiqZn6H9eEL1VH82IX/x4fWNIEyXQAxKZtyULgznR4\n\
488oUz2QPaY/cWtpK85B12scs1IpLnzEdjy69t28ZQnYZ7Nrvl+aFjSkvnqxhoNJ9Ut\n\
489Lw2/3vld8t3Lh6B4vTM4vdJsue1dum6WnyEKEx/SDuCSDxWONfmdhu/B4XUghaQS\n\
4901wBNiEhvAgMBAAECggEASXDcee8ktWfDsShK9F35MLcd0VaAICxiFUInr1OL8ePt\n\
491tSjMIt+y6t0tnzMgwEAgATBP7sjabbNHFqOjIgqac84bpVKy5l1J1R9WQWe7NlhO\n\
492w/9MCYVEgFaNmXQjklr3E+ALDA4VnzNg0eaJKE39kLsWxBbMcv27YMSm/t3i/B2s\n\
493rwZbzBgxXXR5r7j/Tt+hRJmGHXe0zZvsNLzFNj4CsyngBiY9CIcexroGxd3yGEf7\n\
4940PKHwbZKkH0CPr6QAc4f+tPgIfHB+8+29QPrUTR9e60Sc6dZNUjTr1EWIxyvFxVK\n\
495dI3ekR5W26a81+yxc2MpRK8wZsv+mJ6okaeVs2+3jQKBgQDr0b3YX4RC9trW+RsE\n\
4969wUXeLr3o9Vb0FTHf/8ALAZ9EWywEmF+sdA8fKs8+H+IyIzX6KGw/UbzqIi2aDuJ\n\
497q63IPxKyyXr7nfVSUz8qWIGT/WoG/1d4rpFN2sbR/r/oue7uJnaXMIPswVT+zO8q\n\
4985YieEPDwhteJ8bJUC16NWwddBQKBgQC+dcEmNm7MzxI/cuwubkojhayXw1ouACu4\n\
499giGp3lJywzIAnV1CsJTGTpvHk31j+/L9oB2U/586+65MGklGJ2TGs0IQZs0iAy1H\n\
500Oq3zzsLp0KiVizyqchgkIWP6KVpx5aPkpJSgPJGyJzuwofZwRzPK7IZr8c4MOtsy\n\
501M8j8up8p4wKBgGbUxTYvIJuazX7kjXWyydOcX9tQ497vj6iXFflbOVEcYgq9WSpI\n\
502G4fkzT7/FY3t9gzIcomdSG1D1qnD9gJojJU/e8XeufQywyEtD+RFR+vim3OFsPz9\n\
503EnuipQQ5VDIFsjzDJP90tnJtM8UQVFKeWN6kgIxCIIcUkDC57HczdJiJAoGASPG4\n\
504g/YdAXvdNUfChRXgdzJfI9DB3RRbqlLMqc5oLWPs5qdebIhMspawuwMV5xE7wz9r\n\
505lQFB7sktvB/lKGU2B5PoHXgB4KDu2nTy4omxxPMRXhTxqyX/cPcI32qvJSgaWRtf\n\
506gO8xrdWw2rltNRtQDsv/v5/glnaENPn4ZDLlepkCgYAqag5Uxj0ps6WNE/D6IEWA\n\
507eTGicEEJPJQB9bGrElna7WyOjntnO5miRmpM1jH39R417czBURmvZHO2oTnqghZF\n\
508c/7P2kweQNU7vtM/iLcm8EyFRw2lVB3J/XVTEcPU6ZeZHlVbGtiKx3gukkMBc4Ct\n\
509CQTyrvDSz5J6MQhLtbNHnQ==\n\
510-----END PRIVATE KEY-----\n";
511
512    fn cfg(pairs: &[(&str, Value)]) -> HashMap<String, Value> {
513        pairs
514            .iter()
515            .map(|(k, v)| (k.to_string(), v.clone()))
516            .collect()
517    }
518
519    fn auth_config_value() -> Value {
520        json!({
521            "client_email": "logger@proj.iam.gserviceaccount.com",
522            "private_key": PRIV_PEM,
523            "project_id": "my-project",
524        })
525    }
526
527    #[test]
528    fn from_config_requires_auth() {
529        let res = GoogleCloudLoggingPlugin::from_config(&HashMap::new(), &PluginResources::empty());
530        let Err(e) = res else {
531            panic!("expected error")
532        };
533        assert!(e.contains("auth_config"));
534    }
535
536    #[test]
537    fn from_config_requires_core_auth_fields() {
538        for missing in ["client_email", "private_key", "project_id"] {
539            let mut auth = auth_config_value();
540            auth.as_object_mut().unwrap().remove(missing);
541            let c = cfg(&[("auth_config", auth)]);
542            let res = GoogleCloudLoggingPlugin::from_config(&c, &PluginResources::empty());
543            let Err(e) = res else {
544                panic!("expected error when `{missing}` missing")
545            };
546            assert!(e.contains(missing));
547        }
548    }
549
550    #[tokio::test]
551    async fn from_config_ok_and_defaults() {
552        let c = cfg(&[("auth_config", auth_config_value())]);
553        let auth = resolve_auth(&c).unwrap();
554        assert_eq!(auth.token_uri, DEFAULT_TOKEN_URI);
555        assert_eq!(auth.scopes, DEFAULT_SCOPES);
556        assert!(GoogleCloudLoggingPlugin::from_config(&c, &PluginResources::empty()).is_ok());
557    }
558
559    #[test]
560    fn jwt_is_signed_from_private_key() {
561        let jwt = build_jwt(
562            "logger@proj.iam.gserviceaccount.com",
563            "scope-a scope-b",
564            DEFAULT_TOKEN_URI,
565            1_700_000_000,
566            PRIV_PEM,
567        )
568        .unwrap();
569
570        let parts: Vec<&str> = jwt.split('.').collect();
571        assert_eq!(parts.len(), 3, "a JWT has three dot-separated segments");
572
573        let engine = base64::engine::general_purpose::URL_SAFE_NO_PAD;
574        let header: Value = serde_json::from_slice(&engine.decode(parts[0]).unwrap()).unwrap();
575        assert_eq!(header["alg"], "RS256");
576        assert_eq!(header["typ"], "JWT");
577
578        let claims: Value = serde_json::from_slice(&engine.decode(parts[1]).unwrap()).unwrap();
579        assert_eq!(claims["iss"], "logger@proj.iam.gserviceaccount.com");
580        assert_eq!(claims["scope"], "scope-a scope-b");
581        assert_eq!(claims["aud"], DEFAULT_TOKEN_URI);
582        assert_eq!(claims["iat"], 1_700_000_000u64);
583        assert_eq!(claims["exp"], 1_700_003_600u64);
584    }
585
586    #[test]
587    fn write_payload_shape() {
588        let entries = vec![
589            json!({ "request": { "method": "GET" } }),
590            json!({ "request": { "method": "POST" } }),
591        ];
592        let resource = json!({ "type": "global" });
593        let payload = build_write_payload(
594            &entries,
595            "projects/my-project/logs/featherbit%2Flogs",
596            &resource,
597            "2023-11-14T22:13:20Z",
598        );
599        assert_eq!(payload["partialSuccess"], false);
600        let arr = payload["entries"].as_array().unwrap();
601        assert_eq!(arr.len(), 2);
602        assert_eq!(
603            arr[0]["logName"],
604            "projects/my-project/logs/featherbit%2Flogs"
605        );
606        assert_eq!(arr[0]["resource"]["type"], "global");
607        assert_eq!(arr[0]["jsonPayload"]["request"]["method"], "GET");
608        assert_eq!(arr[0]["timestamp"], "2023-11-14T22:13:20Z");
609        assert_eq!(
610            arr[0]["labels"]["source"],
611            "featherbit-google-cloud-logging"
612        );
613        assert_eq!(arr[1]["jsonPayload"]["request"]["method"], "POST");
614    }
615
616    #[test]
617    fn rfc3339_conversion() {
618        assert_eq!(rfc3339_zulu(1_700_000_000), "2023-11-14T22:13:20Z");
619        assert_eq!(rfc3339_zulu(0), "1970-01-01T00:00:00Z");
620    }
621}