1use 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
56pub 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#[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
74struct 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 refresh_at: Instant,
87}
88
89struct 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 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
199fn 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 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
256fn 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
277fn 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
288fn 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 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 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 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
375fn 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
384fn 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, ×tamp);
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 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}