1use std::borrow::Cow;
2use std::sync::OnceLock;
3
4use reqwest::Url;
5use uuid::Uuid;
6
7const REDACTED: &str = "[REDACTED]";
8
9fn process_agent_id() -> &'static str {
17 static AGENT_ID: OnceLock<String> = OnceLock::new();
18 AGENT_ID.get_or_init(|| Uuid::new_v4().to_string())
19}
20
21pub fn obs_context(fallback_agent_id: Option<&str>) -> (String, String, String, String) {
30 let trace_id = std::env::var("AGENT_BLOCK_TRACE_ID").unwrap_or_default();
31 let run_id = std::env::var("AGENT_BLOCK_RUN_ID").unwrap_or_default();
32 let agent_id = std::env::var("AGENT_BLOCK_AGENT_ID")
33 .ok()
34 .filter(|v| !v.is_empty())
35 .or_else(|| fallback_agent_id.map(ToString::to_string))
36 .unwrap_or_else(|| process_agent_id().to_string());
37 let agent_name = std::env::var("AGENT_BLOCK_AGENT_NAME").unwrap_or_default();
38 (trace_id, run_id, agent_id, agent_name)
39}
40
41pub fn obs_line(
52 component: &str,
53 event: &str,
54 ctx: &(String, String, String, String),
55 extra: &[(&str, &str)],
56) -> String {
57 let mut parts = vec![
58 "prefix=ab.obs".to_string(),
59 format!("event={}", event),
60 format!("component={}", component),
61 format!("trace_id={}", kv_escape("trace_id", &ctx.0)),
62 format!("run_id={}", kv_escape("run_id", &ctx.1)),
63 format!("agent_id={}", kv_escape("agent_id", &ctx.2)),
64 format!("agent_name={}", kv_escape("agent_name", &ctx.3)),
65 ];
66 for (k, v) in extra {
67 parts.push(format!("{}={}", k, kv_escape(k, v)));
68 }
69 parts.join(" ")
70}
71
72fn kv_escape(key: &str, value: &str) -> String {
73 let safe = sanitize_value(key, value);
74 if safe.is_empty() {
75 "\"\"".to_string()
76 } else if safe.chars().any(|c| c.is_whitespace() || c == '=') {
77 serde_json::Value::String(safe.into_owned()).to_string()
78 } else {
79 safe.into_owned()
80 }
81}
82
83fn sanitize_value<'a>(key: &str, value: &'a str) -> Cow<'a, str> {
84 if is_sensitive_key(key) {
85 return Cow::Borrowed(REDACTED);
86 }
87 if key.eq_ignore_ascii_case("url") {
88 return Cow::Owned(sanitize_url(value));
89 }
90 Cow::Borrowed(value)
91}
92
93fn is_sensitive_key(key: &str) -> bool {
94 let k = key.to_ascii_lowercase();
95 [
96 "authorization",
97 "cookie",
98 "set-cookie",
99 "token",
100 "secret",
101 "password",
102 "passwd",
103 "api_key",
104 "apikey",
105 "access_key",
106 "private_key",
107 ]
108 .iter()
109 .any(|needle| k.contains(needle))
110}
111
112pub fn sanitize_url(raw: &str) -> String {
121 match Url::parse(raw) {
122 Ok(mut u) => {
123 let _ = u.set_username("");
124 let _ = u.set_password(None);
125 u.set_query(None);
126 u.set_fragment(None);
127 u.to_string()
128 }
129 Err(_) => {
130 let sanitized = redact_userinfo(raw);
137 let cut_end = sanitized.find(['?', '#']).unwrap_or(sanitized.len());
138 let trimmed = &sanitized[..cut_end];
139 if trimmed.len() <= 16 {
140 trimmed.to_string()
141 } else {
142 format!("{}...", &trimmed[..16])
143 }
144 }
145 }
146}
147
148fn redact_userinfo(raw: &str) -> String {
154 if let Some(scheme_end) = raw.find("://") {
155 let after_scheme = scheme_end + 3;
156 let authority = &raw[after_scheme..];
157 if let Some(at_pos) = authority.find('@') {
158 let scheme_and_sep = &raw[..after_scheme];
160 let host_and_rest = &authority[at_pos + 1..];
161 return format!("{}{}", scheme_and_sep, host_and_rest);
162 }
163 }
164 raw.to_string()
165}
166
167#[cfg(test)]
168mod tests {
169 use super::*;
170
171 #[test]
172 fn process_agent_id_is_non_empty_and_stable() {
173 let id1 = process_agent_id();
176 let id2 = process_agent_id();
177 assert!(!id1.is_empty(), "process_agent_id must not be empty");
178 assert_eq!(
179 id1, id2,
180 "process_agent_id must be stable within the process"
181 );
182 }
183
184 #[test]
185 fn obs_context_fallback_agent_id_wins_over_auto() {
186 let fallback = "test-fallback-agent-xxx";
191 if std::env::var("AGENT_BLOCK_AGENT_ID")
194 .unwrap_or_default()
195 .is_empty()
196 {
197 let (_, _, id, _) = obs_context(Some(fallback));
198 assert_eq!(id, fallback);
199 }
200 }
201
202 #[test]
203 fn sanitize_url_strips_credentials_and_query() {
204 let raw = "https://user:pass@example.com/path?q=1&r=2#frag";
205 let got = sanitize_url(raw);
206 assert_eq!(got, "https://example.com/path");
207 }
208
209 #[test]
210 fn sanitize_url_malformed_truncates_to_16_chars() {
211 let raw = "not a valid url ://::garbage";
214 let got = sanitize_url(raw);
215 assert_eq!(got, "not a valid url ...");
217 }
218
219 #[test]
220 fn sanitize_url_empty_string_returns_empty() {
221 let got = sanitize_url("");
222 assert_eq!(got, "");
223 }
224
225 #[test]
226 fn sanitize_url_short_unparseable_returns_as_is() {
227 let raw = "htps://x.com";
229 let got = sanitize_url(raw);
230 assert_eq!(got, "htps://x.com");
231 }
232
233 #[test]
234 fn sanitize_url_unparseable_strips_userinfo() {
235 let raw = "htps://user:pass@example.com/path";
237 let got = sanitize_url(raw);
238 assert!(
240 !got.contains("pass"),
241 "password must be stripped from unparseable URL: {got}"
242 );
243 assert!(
244 !got.contains("user"),
245 "username must be stripped from unparseable URL: {got}"
246 );
247 }
248
249 #[test]
250 fn sanitize_url_unparseable_strips_query_and_fragment() {
251 let raw = "htps://api.x/?token=SUPER_SECRET_VALUE_XYZ";
256 let got = sanitize_url(raw);
257 assert!(
258 !got.contains("SECRET"),
259 "query secret must be stripped: {got}"
260 );
261 assert!(
262 !got.contains("token"),
263 "query key must also be stripped: {got}"
264 );
265
266 let raw2 = "htps://api.x/#token=SECRET";
267 let got2 = sanitize_url(raw2);
268 assert!(
269 !got2.contains("SECRET"),
270 "fragment secret must be stripped: {got2}"
271 );
272 }
273}