1use std::collections::BTreeMap;
2use std::future::Future;
3use std::panic::{AssertUnwindSafe, catch_unwind};
4use std::sync::{Arc, Mutex};
5
6#[derive(Debug, Clone, PartialEq)]
7pub enum RuntimeAttributeValue {
8 String(String),
9 Integer(i64),
10 Float(f64),
11 Boolean(bool),
12}
13
14impl From<&str> for RuntimeAttributeValue {
15 fn from(value: &str) -> Self {
16 Self::String(value.to_owned())
17 }
18}
19
20impl From<String> for RuntimeAttributeValue {
21 fn from(value: String) -> Self {
22 Self::String(value)
23 }
24}
25
26impl From<i64> for RuntimeAttributeValue {
27 fn from(value: i64) -> Self {
28 Self::Integer(value)
29 }
30}
31
32impl From<usize> for RuntimeAttributeValue {
33 fn from(value: usize) -> Self {
34 Self::Integer(value as i64)
35 }
36}
37
38impl From<bool> for RuntimeAttributeValue {
39 fn from(value: bool) -> Self {
40 Self::Boolean(value)
41 }
42}
43
44#[derive(Debug, Clone, PartialEq)]
45pub struct RuntimeOperation {
46 pub family: String,
47 pub name: String,
48 pub attributes: BTreeMap<String, RuntimeAttributeValue>,
49}
50
51impl RuntimeOperation {
52 pub fn new(family: impl Into<String>, name: impl Into<String>) -> Self {
53 let family = family.into();
54 let name = name.into();
55 let mut attributes = BTreeMap::new();
56 attributes.insert("teaql.operation.family".into(), family.clone().into());
57 attributes.insert("teaql.operation.name".into(), name.clone().into());
58 Self {
59 family,
60 name,
61 attributes,
62 }
63 }
64
65 pub fn attribute(
66 mut self,
67 key: impl Into<String>,
68 value: impl Into<RuntimeAttributeValue>,
69 ) -> Self {
70 let key = key.into();
71 if !is_forbidden_attribute(&key) {
72 self.attributes.insert(key, value.into());
73 }
74 self
75 }
76}
77
78fn is_forbidden_attribute(key: &str) -> bool {
79 matches!(
80 key,
81 "teaql.entity.id"
82 | "teaql.user.id"
83 | "teaql.tenant.id"
84 | "teaql.query.parameters"
85 | "teaql.field.values"
86 | "teaql.audit.reason"
87 | "db.query.parameter_values"
88 | "http.request.body"
89 | "url.full"
90 )
91}
92
93pub fn runtime_error_category(error_type: &str) -> &'static str {
95 let error_type = error_type.to_ascii_lowercase();
96 for (category, terms) in [
97 ("timeout", &["timeout", "deadline"][..]),
98 (
99 "authorization",
100 &[
101 "authentication",
102 "authorization",
103 "unauthorized",
104 "forbidden",
105 "permission",
106 ],
107 ),
108 (
109 "validation",
110 &[
111 "validation",
112 "invalidargument",
113 "valueerror",
114 "parse",
115 "format",
116 ],
117 ),
118 (
119 "conflict",
120 &[
121 "conflict",
122 "optimistic",
123 "version",
124 "duplicate",
125 "alreadyexists",
126 ],
127 ),
128 (
129 "transport",
130 &[
131 "transport",
132 "network",
133 "connection",
134 "socket",
135 "http",
136 "ioerror",
137 ],
138 ),
139 ("provider", &["provider", "sql", "database", "jdbc"]),
140 ] {
141 if terms.iter().any(|term| error_type.contains(term)) {
142 return category;
143 }
144 }
145 "internal"
146}
147
148pub trait RuntimeTelemetryScope: Send {
149 fn with_context(&self, callback: &mut dyn FnMut()) {
150 callback();
151 }
152 fn success(&mut self, attributes: BTreeMap<String, RuntimeAttributeValue>);
153 fn failure(&mut self, error_type: &str);
154}
155
156pub trait RuntimeTelemetry: Send + Sync {
157 fn start(&self, operation: RuntimeOperation) -> Box<dyn RuntimeTelemetryScope>;
158 fn is_noop(&self) -> bool {
159 false
160 }
161 fn extract_context(
162 &self,
163 _carrier: &BTreeMap<String, String>,
164 ) -> Box<dyn RuntimeTelemetryPropagationContext> {
165 Box::new(NoopRuntimeTelemetryPropagationContext)
166 }
167 fn flush(&self) {}
168 fn shutdown(&self) {}
169}
170
171pub trait RuntimeTelemetryPropagationContext: Send + Sync {
172 fn with_context(&self, callback: &mut dyn FnMut()) {
173 callback();
174 }
175}
176
177struct NoopRuntimeTelemetryPropagationContext;
178impl RuntimeTelemetryPropagationContext for NoopRuntimeTelemetryPropagationContext {}
179
180pub struct FailOpenRuntimeTelemetryPropagationContext {
181 delegate: Option<Box<dyn RuntimeTelemetryPropagationContext>>,
182}
183
184impl FailOpenRuntimeTelemetryPropagationContext {
185 pub async fn run<F: Future>(&self, future: F) -> F::Output {
186 futures_util::pin_mut!(future);
187 futures_util::future::poll_fn(|task_context| {
188 let Some(delegate) = self.delegate.as_ref() else {
189 return future.as_mut().poll(task_context);
190 };
191 let mut result = None;
192 let mut invoked = false;
193 let mut callback = || {
194 invoked = true;
195 result = Some(future.as_mut().poll(task_context));
196 };
197 let context_result = catch_unwind(AssertUnwindSafe(|| {
198 delegate.with_context(&mut callback);
199 }));
200 match (context_result, result) {
201 (_, Some(result)) => result,
202 (Err(payload), None) if invoked => std::panic::resume_unwind(payload),
203 _ => future.as_mut().poll(task_context),
204 }
205 })
206 .await
207 }
208}
209
210pub fn extract_runtime_context(
211 telemetry: &Arc<dyn RuntimeTelemetry>,
212 carrier: &BTreeMap<String, String>,
213) -> FailOpenRuntimeTelemetryPropagationContext {
214 let delegate = catch_unwind(AssertUnwindSafe(|| telemetry.extract_context(carrier))).ok();
215 FailOpenRuntimeTelemetryPropagationContext { delegate }
216}
217
218#[derive(Default)]
219pub struct NoopRuntimeTelemetry;
220
221impl RuntimeTelemetry for NoopRuntimeTelemetry {
222 fn start(&self, _operation: RuntimeOperation) -> Box<dyn RuntimeTelemetryScope> {
223 Box::new(NoopRuntimeTelemetryScope)
224 }
225
226 fn is_noop(&self) -> bool {
227 true
228 }
229}
230
231struct NoopRuntimeTelemetryScope;
232
233impl RuntimeTelemetryScope for NoopRuntimeTelemetryScope {
234 fn success(&mut self, _attributes: BTreeMap<String, RuntimeAttributeValue>) {}
235 fn failure(&mut self, _error_type: &str) {}
236}
237
238pub struct FailOpenRuntimeTelemetryScope {
239 delegate: Mutex<Option<Box<dyn RuntimeTelemetryScope>>>,
240}
241
242impl FailOpenRuntimeTelemetryScope {
243 pub async fn run<F: Future>(&self, future: F) -> F::Output {
244 futures_util::pin_mut!(future);
245 futures_util::future::poll_fn(|task_context| {
246 let mut result = None;
247 let Ok(delegate) = self.delegate.lock() else {
248 return future.as_mut().poll(task_context);
249 };
250 let Some(scope) = delegate.as_ref() else {
251 return future.as_mut().poll(task_context);
252 };
253 let mut invoked = false;
254 let mut callback = || {
255 invoked = true;
256 result = Some(future.as_mut().poll(task_context));
257 };
258 let context_result = catch_unwind(AssertUnwindSafe(|| {
259 scope.with_context(&mut callback);
260 }));
261 match (context_result, result) {
262 (_, Some(result)) => result,
263 (Err(payload), None) if invoked => std::panic::resume_unwind(payload),
264 _ => future.as_mut().poll(task_context),
265 }
266 })
267 .await
268 }
269
270 pub fn success(&self, attributes: BTreeMap<String, RuntimeAttributeValue>) {
271 self.finish(|scope| scope.success(attributes));
272 }
273
274 pub fn failure(&self, error_type: &str) {
275 self.finish(|scope| scope.failure(error_type));
276 }
277
278 fn finish(&self, action: impl FnOnce(&mut dyn RuntimeTelemetryScope)) {
279 let Ok(mut delegate) = self.delegate.lock() else {
280 return;
281 };
282 let Some(mut scope) = delegate.take() else {
283 return;
284 };
285 let _ = catch_unwind(AssertUnwindSafe(|| action(scope.as_mut())));
286 }
287}
288
289pub fn start_runtime_operation(
290 telemetry: &Arc<dyn RuntimeTelemetry>,
291 operation: RuntimeOperation,
292) -> FailOpenRuntimeTelemetryScope {
293 let delegate = catch_unwind(AssertUnwindSafe(|| telemetry.start(operation))).ok();
294 FailOpenRuntimeTelemetryScope {
295 delegate: Mutex::new(delegate),
296 }
297}
298
299#[cfg(test)]
300mod tests {
301 use super::*;
302
303 #[test]
304 fn classifies_native_error_types_without_messages() {
305 assert_eq!(runtime_error_category("DatabaseTimeoutError"), "timeout");
306 assert_eq!(runtime_error_category("PermissionError"), "authorization");
307 assert_eq!(runtime_error_category("UnknownTeaQLError"), "internal");
308 }
309
310 struct BrokenTelemetry;
311 impl RuntimeTelemetry for BrokenTelemetry {
312 fn start(&self, _operation: RuntimeOperation) -> Box<dyn RuntimeTelemetryScope> {
313 panic!("adapter failed")
314 }
315 }
316
317 struct BrokenContextTelemetry;
318 impl RuntimeTelemetry for BrokenContextTelemetry {
319 fn start(&self, _operation: RuntimeOperation) -> Box<dyn RuntimeTelemetryScope> {
320 Box::new(BrokenContextScope)
321 }
322 }
323 struct BrokenContextScope;
324 impl RuntimeTelemetryScope for BrokenContextScope {
325 fn with_context(&self, _callback: &mut dyn FnMut()) {
326 panic!("context adapter failed")
327 }
328 fn success(&mut self, _attributes: BTreeMap<String, RuntimeAttributeValue>) {}
329 fn failure(&mut self, _error_type: &str) {}
330 }
331
332 #[test]
333 fn strips_forbidden_attributes_and_is_fail_open() {
334 let operation = RuntimeOperation::new("query", "School.list")
335 .attribute("teaql.entity.type", "School")
336 .attribute("teaql.entity.id", 42_i64);
337 assert_eq!(
338 operation.attributes.get("teaql.entity.type"),
339 Some(&"School".into())
340 );
341 assert!(!operation.attributes.contains_key("teaql.entity.id"));
342
343 let telemetry: Arc<dyn RuntimeTelemetry> = Arc::new(BrokenTelemetry);
344 let scope = start_runtime_operation(&telemetry, operation);
345 scope.success(BTreeMap::new());
346 scope.failure("late");
347 }
348
349 #[tokio::test]
350 async fn context_activation_is_fail_open() {
351 let telemetry: Arc<dyn RuntimeTelemetry> = Arc::new(BrokenContextTelemetry);
352 let scope =
353 start_runtime_operation(&telemetry, RuntimeOperation::new("query", "School.list"));
354 assert_eq!(scope.run(async { 42 }).await, 42);
355 }
356}