agent_block_types/obs.rs
1use std::borrow::Cow;
2use std::sync::OnceLock;
3
4use reqwest::Url;
5use uuid::Uuid;
6
7const REDACTED: &str = "[REDACTED]";
8
9/// Returns a process-scoped agent ID that is generated once and reused for the
10/// lifetime of the process. The semantic scope of `agent_id` is
11/// "one agent-block execution", which is coarser than `run_id` (per-call).
12/// Both currently collapse to the same generated value in single-run
13/// invocations, but the conceptual distinction is preserved so that future
14/// deployments can evolve the two scopes independently (e.g. long-running
15/// daemon vs. per-request).
16///
17/// Also used as the last-resort correlation ID for process-scoped artifacts
18/// when neither `AGENT_BLOCK_RUN_ID` nor `AGENT_BLOCK_TRACE_ID` is set.
19pub fn process_agent_id() -> &'static str {
20 static AGENT_ID: OnceLock<String> = OnceLock::new();
21 AGENT_ID.get_or_init(|| Uuid::new_v4().to_string())
22}
23
24/// Build the observability context tuple `(trace_id, run_id, agent_id, agent_name)`.
25///
26/// Resolution order for `agent_id`:
27/// 1. `AGENT_BLOCK_AGENT_ID` environment variable (non-empty)
28/// 2. `fallback_agent_id` argument (non-None)
29/// 3. Process-scoped auto-generated UUID v4 (generated once per process lifetime).
30/// Scope: one agent-block execution = one `agent_id`. Conceptually coarser than
31/// `run_id` (per-call), though both may share the same value in simple invocations.
32pub fn obs_context(fallback_agent_id: Option<&str>) -> (String, String, String, String) {
33 let trace_id = std::env::var("AGENT_BLOCK_TRACE_ID").unwrap_or_default();
34 let run_id = std::env::var("AGENT_BLOCK_RUN_ID").unwrap_or_default();
35 let agent_id = std::env::var("AGENT_BLOCK_AGENT_ID")
36 .ok()
37 .filter(|v| !v.is_empty())
38 .or_else(|| fallback_agent_id.map(ToString::to_string))
39 .unwrap_or_else(|| process_agent_id().to_string());
40 let agent_name = std::env::var("AGENT_BLOCK_AGENT_NAME").unwrap_or_default();
41 (trace_id, run_id, agent_id, agent_name)
42}
43
44/// Render a single structured observability log line in the fixed
45/// `key=value` format used by the `ab.obs` logging convention.
46///
47/// The line always begins with `prefix=ab.obs`, followed by `event`,
48/// `component`, and the four context fields carried in `ctx`
49/// (`trace_id`, `run_id`, `agent_id`, `agent_name`), then appends each
50/// caller-supplied `extra` pair in order. Every value is passed through
51/// `kv_escape`, which redacts sensitive keys, sanitizes `url` values, and
52/// JSON-quotes any value containing whitespace or `=` so the line stays
53/// parseable.
54pub fn obs_line(
55 component: &str,
56 event: &str,
57 ctx: &(String, String, String, String),
58 extra: &[(&str, &str)],
59) -> String {
60 let mut parts = vec![
61 "prefix=ab.obs".to_string(),
62 format!("event={}", event),
63 format!("component={}", component),
64 format!("trace_id={}", kv_escape("trace_id", &ctx.0)),
65 format!("run_id={}", kv_escape("run_id", &ctx.1)),
66 format!("agent_id={}", kv_escape("agent_id", &ctx.2)),
67 format!("agent_name={}", kv_escape("agent_name", &ctx.3)),
68 ];
69 for (k, v) in extra {
70 parts.push(format!("{}={}", k, kv_escape(k, v)));
71 }
72 parts.join(" ")
73}
74
75fn kv_escape(key: &str, value: &str) -> String {
76 let safe = sanitize_value(key, value);
77 if safe.is_empty() {
78 "\"\"".to_string()
79 } else if safe.chars().any(|c| c.is_whitespace() || c == '=') {
80 serde_json::Value::String(safe.into_owned()).to_string()
81 } else {
82 safe.into_owned()
83 }
84}
85
86fn sanitize_value<'a>(key: &str, value: &'a str) -> Cow<'a, str> {
87 if is_sensitive_key(key) {
88 return Cow::Borrowed(REDACTED);
89 }
90 if key.eq_ignore_ascii_case("url") {
91 return Cow::Owned(sanitize_url(value));
92 }
93 Cow::Borrowed(value)
94}
95
96/// True when a key name looks credential-bearing under the `ab.obs` policy.
97///
98/// Substring match on the lowercased key, so `x-auth-token` / `apikey_v2` and
99/// similar variants are covered.
100pub fn is_sensitive_key(key: &str) -> bool {
101 let k = key.to_ascii_lowercase();
102 [
103 "authorization",
104 "cookie",
105 "set-cookie",
106 "token",
107 "secret",
108 "password",
109 "passwd",
110 "api_key",
111 "apikey",
112 "access_key",
113 "private_key",
114 ]
115 .iter()
116 .any(|needle| k.contains(needle))
117}
118
119/// Strip credentials and volatile components from a URL so it is safe to log.
120///
121/// Removes any `user:pass@` userinfo, the query string, and the fragment.
122/// Parseable URLs are rebuilt via the `url` crate (username / password cleared,
123/// query / fragment dropped). Unparseable inputs have their `user:pass@`
124/// userinfo and any `?query` / `#fragment` stripped heuristically, then are
125/// truncated to the first 16 characters (suffixed with `...`) so a human can
126/// still recognise the target from logs without leaking secrets.
127pub fn sanitize_url(raw: &str) -> String {
128 match Url::parse(raw) {
129 Ok(mut u) => {
130 let _ = u.set_username("");
131 let _ = u.set_password(None);
132 u.set_query(None);
133 u.set_fragment(None);
134 u.to_string()
135 }
136 Err(_) => {
137 // URL is not parseable: preserve the first 16 characters so a human
138 // can identify the target from logs (e.g. a typo like "htps://...").
139 // Strip both `user:pass@` userinfo AND `?query` / `#fragment` so
140 // credentials or tokens embedded anywhere in the authority / query /
141 // fragment cannot leak even in the truncated form. Path remains
142 // (truncated) so the host/path prefix is still identifiable.
143 let sanitized = redact_userinfo(raw);
144 let cut_end = sanitized.find(['?', '#']).unwrap_or(sanitized.len());
145 let trimmed = &sanitized[..cut_end];
146 if trimmed.len() <= 16 {
147 trimmed.to_string()
148 } else {
149 format!("{}...", &trimmed[..16])
150 }
151 }
152 }
153}
154
155/// Remove `user:pass@` userinfo from an unparseable URL string.
156///
157/// Uses simple substring heuristics: looks for `://` (scheme separator) then
158/// `@` within the authority. If found, replaces the `user:pass@` span with
159/// an empty string. If not found, returns the input unchanged.
160fn redact_userinfo(raw: &str) -> String {
161 if let Some(scheme_end) = raw.find("://") {
162 let after_scheme = scheme_end + 3;
163 let authority = &raw[after_scheme..];
164 if let Some(at_pos) = authority.find('@') {
165 // Reconstruct: scheme + "://" + everything after "@"
166 let scheme_and_sep = &raw[..after_scheme];
167 let host_and_rest = &authority[at_pos + 1..];
168 return format!("{}{}", scheme_and_sep, host_and_rest);
169 }
170 }
171 raw.to_string()
172}
173
174#[cfg(test)]
175mod tests {
176 use super::*;
177
178 #[test]
179 fn process_agent_id_is_non_empty_and_stable() {
180 // process_agent_id() must return a non-empty value and the same
181 // value on every call (OnceLock semantics within this process).
182 let id1 = process_agent_id();
183 let id2 = process_agent_id();
184 assert!(!id1.is_empty(), "process_agent_id must not be empty");
185 assert_eq!(
186 id1, id2,
187 "process_agent_id must be stable within the process"
188 );
189 }
190
191 #[test]
192 fn obs_context_fallback_agent_id_wins_over_auto() {
193 // When ENV is absent and fallback_agent_id is provided, it takes priority.
194 // This test avoids mutating global ENV to prevent parallelism flakiness.
195 // We temporarily unset via a guard-free approach: only valid if ENV is absent.
196 // Use a distinctive value that cannot collide with a real env setting.
197 let fallback = "test-fallback-agent-xxx";
198 // Ensure ENV is not set to this value (it may be set to something else).
199 // If ENV IS set, skip assertion on fallback path (env wins per spec).
200 if std::env::var("AGENT_BLOCK_AGENT_ID")
201 .unwrap_or_default()
202 .is_empty()
203 {
204 let (_, _, id, _) = obs_context(Some(fallback));
205 assert_eq!(id, fallback);
206 }
207 }
208
209 #[test]
210 fn sanitize_url_strips_credentials_and_query() {
211 let raw = "https://user:pass@example.com/path?q=1&r=2#frag";
212 let got = sanitize_url(raw);
213 assert_eq!(got, "https://example.com/path");
214 }
215
216 #[test]
217 fn sanitize_url_malformed_truncates_to_16_chars() {
218 // Previously returned "[UNPARSEABLE]"; now returns the first 16 chars
219 // of the input so log readers can identify the target.
220 let raw = "not a valid url ://::garbage";
221 let got = sanitize_url(raw);
222 // First 16 chars of "not a valid url " are "not a valid url " — truncated with "..."
223 assert_eq!(got, "not a valid url ...");
224 }
225
226 #[test]
227 fn sanitize_url_empty_string_returns_empty() {
228 let got = sanitize_url("");
229 assert_eq!(got, "");
230 }
231
232 #[test]
233 fn sanitize_url_short_unparseable_returns_as_is() {
234 // A short but unparseable URL (≤16 chars) is returned without truncation.
235 let raw = "htps://x.com";
236 let got = sanitize_url(raw);
237 assert_eq!(got, "htps://x.com");
238 }
239
240 #[test]
241 fn sanitize_url_unparseable_strips_userinfo() {
242 // Even for unparseable URLs, obvious user:pass@ patterns are stripped.
243 let raw = "htps://user:pass@example.com/path";
244 let got = sanitize_url(raw);
245 // After stripping: "htps://example.com/path" → first 16: "htps://example.c" + "..."
246 assert!(
247 !got.contains("pass"),
248 "password must be stripped from unparseable URL: {got}"
249 );
250 assert!(
251 !got.contains("user"),
252 "username must be stripped from unparseable URL: {got}"
253 );
254 }
255
256 #[test]
257 fn sanitize_url_unparseable_strips_query_and_fragment() {
258 // Even for unparseable URLs, secrets embedded in ?query or #fragment
259 // must be stripped before truncation. Previously the 16-char truncate
260 // could leak a partial token when the secret sat within the first 16
261 // bytes of the input.
262 let raw = "htps://api.x/?token=SUPER_SECRET_VALUE_XYZ";
263 let got = sanitize_url(raw);
264 assert!(
265 !got.contains("SECRET"),
266 "query secret must be stripped: {got}"
267 );
268 assert!(
269 !got.contains("token"),
270 "query key must also be stripped: {got}"
271 );
272
273 let raw2 = "htps://api.x/#token=SECRET";
274 let got2 = sanitize_url(raw2);
275 assert!(
276 !got2.contains("SECRET"),
277 "fragment secret must be stripped: {got2}"
278 );
279 }
280}