bge_m3_embedding_server/logging.rs
1// Copyright (c) 2026 J. Patrick Fulton
2//
3// Licensed under the Apache License, Version 2.0 (the "License");
4// you may not use this file except in compliance with the License.
5// You may obtain a copy of the License at
6//
7// http://www.apache.org/licenses/LICENSE-2.0
8//
9// Unless required by applicable law or agreed to in writing, software
10// distributed under the License is distributed on an "AS IS" BASIS,
11// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12// See the License for the specific language governing permissions and
13// limitations under the License.
14
15//! Tracing initialization with module and build-variant tags on every JSON log line.
16//!
17//! In `CloudWatch` / non-TTY environments the server emits one JSON object per
18//! log event. Every JSON event begins with two compile-time attributes:
19//!
20//! 1. `"bge_module"` — always `"server"`. Lets operators distinguish this
21//! service from the router or other BGE-family processes in shared log groups.
22//! 2. `"build"` — `"cpu"` for the default MLAS image, `"cuda"` whenever the
23//! `cuda` or `tensorrt` features are enabled (both are turned on by
24//! `Dockerfile.cuda`). Use this in `CloudWatch` Insights to filter a mixed
25//! CPU/CUDA fleet.
26//!
27//! The formatter chain is `PrependModule → PrependBuild → JSON formatter`,
28//! so the rendered line always starts with
29//! `{"bge_module":"server","build":"<variant>",…}`.
30//!
31//! The human-readable `text` / `pretty` formats used during local dev are
32//! left unchanged.
33
34use std::fmt;
35
36use tracing::{Event, Subscriber};
37use tracing_subscriber::fmt::FmtContext;
38use tracing_subscriber::fmt::format::{FormatEvent, FormatFields, Writer};
39use tracing_subscriber::registry::LookupSpan;
40
41/// Compile-time service name included as the first key on every JSON log line.
42///
43/// Distinguishes this binary from the BGE router and other services that may
44/// share a `CloudWatch` log group or Logs Insights workspace.
45pub const BGE_MODULE: &str = "server";
46
47/// Compile-time identifier for the build of the server that is running.
48///
49/// Resolves to `"cuda"` when either the `cuda` or `tensorrt` Cargo feature is
50/// enabled — the production CUDA image (`Dockerfile.cuda`) turns on both, and
51/// operators care about "is this the GPU image?" not "which EP variant did it
52/// register". Otherwise resolves to `"cpu"` (the default MLAS build).
53pub const BUILD_VARIANT: &str = if cfg!(any(feature = "cuda", feature = "tensorrt")) {
54 "cuda"
55} else {
56 "cpu"
57};
58
59/// Wraps a JSON [`FormatEvent`] so the rendered object always begins with a
60/// `"build"` key.
61///
62/// The wrapper renders the inner formatter into a temporary `String`, then
63/// rewrites the leading `{` to `{"build":"<variant>",`. All other keys, span
64/// context, and trailing newline produced by the inner formatter are
65/// preserved verbatim, so existing `CloudWatch` Insights queries keep working.
66pub struct PrependBuild<F> {
67 inner: F,
68}
69
70impl<F> PrependBuild<F> {
71 /// Wrap `inner` so its rendered events are prefixed with the build key.
72 pub fn new(inner: F) -> Self {
73 Self { inner }
74 }
75}
76
77impl<S, N, F> FormatEvent<S, N> for PrependBuild<F>
78where
79 S: Subscriber + for<'a> LookupSpan<'a>,
80 N: for<'a> FormatFields<'a> + 'static,
81 F: FormatEvent<S, N>,
82{
83 fn format_event(
84 &self,
85 ctx: &FmtContext<'_, S, N>,
86 mut writer: Writer<'_>,
87 event: &Event<'_>,
88 ) -> fmt::Result {
89 let mut buf = String::new();
90 self.inner.format_event(ctx, Writer::new(&mut buf), event)?;
91
92 let trailing_newline = buf.ends_with('\n');
93 let body = if trailing_newline {
94 &buf[..buf.len() - 1]
95 } else {
96 &buf[..]
97 };
98
99 if let Some(rest) = body.strip_prefix('{') {
100 if let Some(rest) = rest.strip_prefix('}') {
101 // Inner produced an empty object `{}` — emit `{"build":"…"}`
102 // followed by whatever (if anything) trailed the close brace.
103 write!(writer, "{{\"build\":\"{BUILD_VARIANT}\"}}{rest}")?;
104 } else {
105 write!(writer, "{{\"build\":\"{BUILD_VARIANT}\",{rest}")?;
106 }
107 } else {
108 // Inner formatter did not produce a JSON object (e.g. someone
109 // wrapped the wrong formatter). Pass the body through unchanged
110 // rather than corrupting it.
111 writer.write_str(body)?;
112 }
113
114 if trailing_newline {
115 writer.write_char('\n')?;
116 }
117 Ok(())
118 }
119}
120
121/// Wraps a JSON [`FormatEvent`] so the rendered object always begins with a
122/// `"bge_module"` key (value: the compile-time [`BGE_MODULE`] constant).
123///
124/// Intended to be the outermost wrapper in the formatter chain so that
125/// `"bge_module"` appears as the very first key, before `"build"` and before
126/// all standard `tracing-subscriber` fields.
127pub struct PrependModule<F> {
128 inner: F,
129}
130
131impl<F> PrependModule<F> {
132 /// Wrap `inner` so its rendered events are prefixed with the module key.
133 pub fn new(inner: F) -> Self {
134 Self { inner }
135 }
136}
137
138impl<S, N, F> FormatEvent<S, N> for PrependModule<F>
139where
140 S: Subscriber + for<'a> LookupSpan<'a>,
141 N: for<'a> FormatFields<'a> + 'static,
142 F: FormatEvent<S, N>,
143{
144 fn format_event(
145 &self,
146 ctx: &FmtContext<'_, S, N>,
147 mut writer: Writer<'_>,
148 event: &Event<'_>,
149 ) -> fmt::Result {
150 let mut buf = String::new();
151 self.inner.format_event(ctx, Writer::new(&mut buf), event)?;
152
153 let trailing_newline = buf.ends_with('\n');
154 let body = if trailing_newline {
155 &buf[..buf.len() - 1]
156 } else {
157 &buf[..]
158 };
159
160 if let Some(rest) = body.strip_prefix('{') {
161 if let Some(rest) = rest.strip_prefix('}') {
162 // Inner produced `{}` — emit `{"bge_module":"…"}` plus any tail.
163 write!(writer, "{{\"bge_module\":\"{BGE_MODULE}\"}}{rest}")?;
164 } else {
165 write!(writer, "{{\"bge_module\":\"{BGE_MODULE}\",{rest}")?;
166 }
167 } else {
168 // Inner formatter did not produce a JSON object. Pass through unchanged.
169 writer.write_str(body)?;
170 }
171
172 if trailing_newline {
173 writer.write_char('\n')?;
174 }
175 Ok(())
176 }
177}
178
179/// Initialize the global tracing subscriber.
180///
181/// Reads `RUST_LOG` for the filter directive (defaulting to `info`) and
182/// `BGE_M3_LOG_FORMAT` for the format selection: `json`, `text`/`pretty`, or
183/// auto-detect via the stdout TTY check. JSON events begin with
184/// `"bge_module":"server"` then `"build":"<variant>"` as the first two keys;
185/// the human formats are unchanged.
186///
187/// Calling this twice is harmless — the second call no-ops via `try_init`.
188pub fn init() {
189 let env_filter =
190 tracing_subscriber::EnvFilter::try_from_default_env().unwrap_or_else(|_| "info".into());
191 let log_format = std::env::var("BGE_M3_LOG_FORMAT").ok();
192 // JSON by default in non-TTY environments (Docker / Fargate / CloudWatch).
193 // Force pretty with BGE_M3_LOG_FORMAT=text or BGE_M3_LOG_FORMAT=pretty.
194 // Force JSON with BGE_M3_LOG_FORMAT=json.
195 let want_json = match log_format.as_deref() {
196 Some("text" | "pretty") => false,
197 Some("json") => true,
198 _ => !std::io::IsTerminal::is_terminal(&std::io::stdout()),
199 };
200 if want_json {
201 let inner = tracing_subscriber::fmt::format()
202 .json()
203 .with_current_span(true);
204 tracing_subscriber::fmt()
205 .event_format(PrependModule::new(PrependBuild::new(inner)))
206 .fmt_fields(tracing_subscriber::fmt::format::JsonFields::new())
207 .with_env_filter(env_filter)
208 .try_init()
209 .ok();
210 } else {
211 tracing_subscriber::fmt()
212 .with_env_filter(env_filter)
213 .try_init()
214 .ok();
215 }
216}
217
218#[cfg(test)]
219mod tests;