gcplog_rs/
lib.rs

1use chrono::{SecondsFormat, Utc};
2use serde::Serialize;
3use serde_json::to_string;
4use std::env;
5use tracing::field::{Field, Visit};
6use tracing::span::{Attributes, Id};
7use tracing::{Event, Subscriber};
8use tracing_subscriber::filter::LevelFilter;
9use tracing_subscriber::layer::Context;
10use tracing_subscriber::prelude::*;
11use tracing_subscriber::registry::LookupSpan;
12use tracing_subscriber::{registry, Layer};
13
14struct TraceId(String);
15
16#[derive(Default)]
17struct TraceIdVisitor {
18    trace_id: Option<String>,
19}
20
21impl Visit for TraceIdVisitor {
22    fn record_debug(&mut self, field: &Field, value: &dyn std::fmt::Debug) {
23        if field.name() == "trace_id" {
24            self.trace_id = Some(format!("{value:?}"))
25        }
26    }
27}
28
29#[derive(Default)]
30struct EventVisitor {
31    message: Option<String>,
32}
33
34impl Visit for EventVisitor {
35    fn record_debug(&mut self, field: &Field, value: &dyn std::fmt::Debug) {
36        if field.name() == "message" {
37            self.message = Some(format!("{value:?}"))
38        }
39    }
40}
41
42struct GcpLayer {
43    gcp_project_id: String,
44}
45
46#[derive(Serialize)]
47struct SourceLocation {
48    file: String,
49    line: String,
50    function: String,
51}
52
53#[derive(Serialize)]
54struct LogEntry<'a> {
55    severity: &'a str,
56    message: String,
57    time: String,
58    #[serde(rename = "logging.googleapis.com/trace")]
59    #[serde(skip_serializing_if = "Option::is_none")]
60    trace: Option<String>,
61    #[serde(rename = "logging.googleapis.com/sourceLocation")]
62    #[serde(skip_serializing_if = "Option::is_none")]
63    source_location: Option<SourceLocation>,
64}
65
66impl<S> Layer<S> for GcpLayer
67where
68    S: Subscriber + for<'lookup> LookupSpan<'lookup>,
69{
70    fn on_new_span(&self, attrs: &Attributes<'_>, id: &Id, ctx: Context<'_, S>) {
71        if let Some(span) = ctx.span(id) {
72            let mut visitor = TraceIdVisitor::default();
73            attrs.record(&mut visitor);
74            if let Some(trace_id) = visitor.trace_id {
75                span.extensions_mut().insert(TraceId(trace_id));
76            }
77        };
78    }
79
80    fn on_event(&self, event: &Event<'_>, ctx: Context<'_, S>) {
81        let mut trace = None;
82        if let Some(scope) = ctx.event_scope(event) {
83            for span in scope.from_root() {
84                let extensions = span.extensions();
85                let Some(trace_id) = extensions.get::<TraceId>() else {
86                    continue;
87                };
88                let t = &trace_id.0;
89                trace = Some(format!("projects/{}/traces/{t}", self.gcp_project_id));
90            }
91        }
92        let mut visitor = EventVisitor::default();
93        event.record(&mut visitor);
94
95        let metadata = event.metadata();
96        let source_location = metadata.file().map(|file| SourceLocation {
97            file: file.to_string(),
98            line: metadata.line().unwrap_or(0).to_string(),
99            function: metadata.target().to_string(),
100        });
101
102        let entry = LogEntry {
103            severity: event.metadata().level().as_str(),
104            message: visitor.message.unwrap_or_default(),
105            time: Utc::now().to_rfc3339_opts(SecondsFormat::Millis, true),
106            trace,
107            source_location,
108        };
109        eprintln!("{}", to_string(&entry).unwrap());
110    }
111}
112
113/// Fetch the GCP project ID from the metadata service.
114///
115/// This queries the GCP metadata service at http://169.254.169.254/computeMetadata/v1/project/project-id
116/// The metadata host can be overridden via the GCE_METADATA_HOST environment variable.
117fn fetch_project_id() -> Result<String, Box<dyn std::error::Error>> {
118    let host = env::var("GCE_METADATA_HOST").unwrap_or_else(|_| "169.254.169.254".to_string());
119    let url = format!("http://{}/computeMetadata/v1/project/project-id", host);
120
121    let response = ureq::get(&url)
122        .set("Metadata-Flavor", "Google")
123        .timeout(std::time::Duration::from_secs(2))
124        .call()?;
125
126    let project_id = response.into_string()?.trim().to_string();
127    Ok(project_id)
128}
129
130/// Configuration for the GCP structured logging subscriber.
131pub struct Config {
132    /// Your GCP project ID used to construct the full trace path.
133    /// If None, will attempt to fetch from the GCP metadata service.
134    pub gcp_project_id: Option<String>,
135    /// The minimum log level to emit (defaults to INFO if not specified)
136    pub level_filter: Option<LevelFilter>,
137}
138
139impl Config {
140    /// Create a new config that will auto-detect the GCP project ID from the metadata service.
141    /// The log level will default to INFO.
142    pub fn new() -> Self {
143        Self {
144            gcp_project_id: None,
145            level_filter: None,
146        }
147    }
148
149    /// Create a new config with the specified GCP project ID.
150    /// The log level will default to INFO.
151    pub fn with_project_id(gcp_project_id: impl Into<String>) -> Self {
152        Self {
153            gcp_project_id: Some(gcp_project_id.into()),
154            level_filter: None,
155        }
156    }
157
158    /// Set the log level filter.
159    pub fn with_level(mut self, level: LevelFilter) -> Self {
160        self.level_filter = Some(level);
161        self
162    }
163}
164
165impl Default for Config {
166    fn default() -> Self {
167        Self::new()
168    }
169}
170
171/// Initialize the GCP structured logging subscriber.
172///
173/// This sets up a tracing subscriber that outputs logs to stderr in the JSON format
174/// expected by Google Cloud Run and Cloud Logging. To associate logs with traces,
175/// create spans with a `trace_id` field using `info_span!("trace_id", trace_id = %"your-trace-id")`.
176///
177/// If the config does not specify a project ID, this will attempt to fetch it from the
178/// GCP metadata service. If that fails, it will use "unknown" as the project ID.
179///
180/// # Arguments
181///
182/// * `config` - Configuration for the subscriber
183///
184/// # Examples
185///
186/// ```no_run
187/// use tracing::info;
188///
189/// // Auto-detect project ID from metadata service
190/// gcplog_rs::init(gcplog_rs::Config::new());
191/// info!("Application started");
192/// ```
193///
194/// ```no_run
195/// use tracing::info;
196///
197/// // Use explicit project ID
198/// gcplog_rs::init(gcplog_rs::Config::with_project_id("my-project-123"));
199/// info!("Application started");
200/// ```
201///
202/// ```no_run
203/// use tracing::info;
204/// use tracing_subscriber::filter::LevelFilter;
205///
206/// // Use custom level
207/// let config = gcplog_rs::Config::with_project_id("my-project-123")
208///     .with_level(LevelFilter::DEBUG);
209/// gcplog_rs::init(config);
210/// info!("Application started");
211/// ```
212pub fn init(config: Config) {
213    let gcp_project_id = config
214        .gcp_project_id
215        .or_else(|| fetch_project_id().ok())
216        .unwrap_or_else(|| "unknown".to_string());
217
218    let layer = GcpLayer { gcp_project_id };
219    let level_filter = config.level_filter.unwrap_or(LevelFilter::INFO);
220    registry().with(layer.with_filter(level_filter)).init();
221}
222
223#[cfg(test)]
224mod tests {
225    use super::*;
226    use tracing::{info, info_span, warn};
227
228    #[test]
229    fn test_gcp_log_format() {
230        init(Config::with_project_id("test-project-123"));
231
232        // Test basic log without trace
233        info!("Application started");
234
235        // Test log with trace_id
236        let span = info_span!("trace_id", trace_id = %"abc123");
237        let _guard = span.enter();
238        info!("Processing request");
239        warn!("Potential issue detected");
240    }
241}