1use std::collections::HashMap;
4
5use opentelemetry::{
6 baggage::{BaggageExt, KeyValueMetadata},
7 propagation::TextMapPropagator,
8 trace::TraceContextExt,
9 Context,
10};
11use opentelemetry_sdk::propagation::{BaggagePropagator, TraceContextPropagator};
12use serde_json::{json, Value};
13
14const SCHEMA: &str = "durable-workflow.trace-context";
15
16#[derive(Clone, Copy, Debug, PartialEq, Eq)]
18pub enum Diagnostic {
19 Discarded,
21 Filtered,
23}
24
25pub fn supported(info: &Value) -> bool {
27 let value = &info["worker_protocol"]["server_capabilities"]["observability_context"];
28 value.is_object()
29 && value["supported"] == true
30 && value["schema"] == SCHEMA
31 && value["version"].as_u64() == Some(1)
32 && value["dispatch_schema"] == "durable-workflow.queue-trace"
33 && value["links_schema"] == "durable-workflow.trace-links"
34 && value["max_context_bytes"].as_u64() == Some(4096)
35 && value["max_links"].as_u64() == Some(32)
36 && value["max_links_bytes"].as_u64() == Some(4096)
37}
38
39pub fn identity(value: &str) -> bool {
41 !value.is_empty() && value.len() <= 512 && !value.chars().any(char::is_control)
42}
43
44pub(crate) fn retain_poll_observations(
48 value: &mut Value,
49 request: &Value,
50 path: &str,
51 namespace: &str,
52) {
53 let Some(task) = value.get_mut("task").filter(|task| task.is_object()) else {
54 return;
55 };
56 let query = path == "/worker/query-tasks/poll";
57 let activity = path == "/worker/activity-tasks/poll";
58 let task_id = if query { "query_task_id" } else { "task_id" };
59 let generation = if query {
60 "query_task_attempt"
61 } else if activity {
62 "attempt_number"
63 } else {
64 "workflow_task_attempt"
65 };
66 let now = std::time::SystemTime::now()
67 .duration_since(std::time::UNIX_EPOCH)
68 .ok()
69 .and_then(|elapsed| {
70 chrono::DateTime::<chrono::Utc>::from_timestamp(
71 i64::try_from(elapsed.as_secs()).ok()?,
72 elapsed.subsec_nanos(),
73 )
74 });
75 let valid = [task_id, "workflow_id", "run_id", "lease_owner"]
76 .into_iter()
77 .all(|field| task[field].as_str().is_some_and(identity))
78 && request["worker_id"].as_str().is_some_and(identity)
79 && task["lease_owner"] == request["worker_id"]
80 && task[generation]
81 .as_i64()
82 .is_some_and(|generation| generation > 0)
83 && task.get("namespace").is_none_or(|value| value == namespace)
84 && task
85 .get("task_queue")
86 .is_none_or(|value| value == &request["task_queue"])
87 && task
88 .get("build_id")
89 .is_none_or(|value| value == &request["build_id"])
90 && (!activity
91 || task["activity_attempt_id"]
92 .as_str()
93 .or(task["attempt_id"].as_str())
94 .is_some_and(identity))
95 && task.get("lease_expires_at").is_none_or(|deadline| {
96 deadline
97 .as_str()
98 .and_then(|deadline| chrono::DateTime::parse_from_rfc3339(deadline).ok())
99 .is_some_and(|deadline| now.is_some_and(|now| deadline > now))
100 });
101 if !valid {
102 let task = task.as_object_mut().expect("checked object");
103 for field in [
104 "observability_context",
105 "dispatch_observability_context",
106 "observability_links",
107 ] {
108 task.remove(field);
109 }
110 }
111}
112
113fn bounded_json(value: &Value) -> bool {
114 serde_json::to_string(value).is_ok_and(|json| {
116 json.chars()
117 .map(|character| match character {
118 '/' => 2,
119 character if character.is_ascii() => 1,
120 character if character as u32 <= 0xffff => 6,
121 _ => 12,
122 })
123 .sum::<usize>()
124 <= 4096
125 })
126}
127
128fn keys(value: &Value, allowed: &[&str]) -> bool {
129 value.as_object().is_some_and(|value| {
130 value.len() <= allowed.len() && value.keys().all(|key| allowed.contains(&key.as_str()))
131 })
132}
133
134pub fn normalize(value: &Value, allowlist: &[String]) -> (Option<Value>, Option<Diagnostic>) {
137 if value.is_null() {
138 return (None, None);
139 }
140 let discarded = (None, Some(Diagnostic::Discarded));
141 if !keys(value, &["schema", "version", "carrier"])
142 || value["schema"] != SCHEMA
143 || value["version"].as_u64() != Some(1)
144 || !keys(&value["carrier"], &["traceparent", "tracestate", "baggage"])
145 || !value["carrier"]["traceparent"].is_string()
146 {
147 return discarded;
148 }
149 let mut headers = HashMap::new();
150 for (key, value) in value["carrier"].as_object().expect("checked object") {
151 let Some(header) = value.as_str() else {
152 return discarded;
153 };
154 let limit = match key.as_str() {
155 "traceparent" => 256,
156 "tracestate" => 512,
157 _ => 2048,
158 };
159 if header.len() > limit {
160 return discarded;
161 }
162 headers.insert(key.clone(), header.to_owned());
163 }
164 if !bounded_json(value) {
165 return discarded;
166 }
167 tracing::subscriber::with_default(tracing::subscriber::NoSubscriber::default(), || {
171 let propagator = TraceContextPropagator::new();
172 let context = propagator.extract_with_context(&Context::new(), &headers);
173 if !context.span().span_context().is_valid() {
174 return discarded;
175 }
176 let mut normalized = HashMap::new();
177 propagator.inject_context(&context, &mut normalized);
178 normalized.retain(|key, value: &mut String| key != "tracestate" || !value.is_empty());
181 if !allowlist.is_empty() && headers.contains_key("baggage") {
182 let baggage = BaggagePropagator::new();
183 let context = baggage.extract_with_context(&Context::new(), &headers);
184 let retained: Vec<_> = context
185 .baggage()
186 .iter()
187 .filter(|(key, _)| allowlist.iter().any(|allowed| allowed == key.as_str()))
188 .map(|(key, (value, metadata))| {
189 KeyValueMetadata::new(key.clone(), value.clone(), metadata.clone())
190 })
191 .collect();
192 if retained.len() > 32 {
193 return discarded;
194 }
195 baggage.inject_context(&Context::new().with_baggage(retained), &mut normalized);
196 }
197 if normalized
198 .get("baggage")
199 .is_some_and(|header: &String| header.len() > 2048)
200 {
201 return discarded;
202 }
203 let result = json!({"schema": SCHEMA, "version": 1, "carrier": normalized});
204 if !bounded_json(&result) {
205 return discarded;
206 }
207 let diagnostic = (result["carrier"] != value["carrier"]).then_some(Diagnostic::Filtered);
208 (Some(result), diagnostic)
209 })
210}
211
212pub fn capture(allowlist: &[String]) -> (Option<Value>, Option<Diagnostic>) {
215 std::panic::catch_unwind(|| {
216 let mut headers = HashMap::new();
217 opentelemetry::global::get_text_map_propagator(|propagator| {
218 propagator.inject_context(&Context::current(), &mut headers);
219 });
220 headers.retain(|key, _| matches!(key.as_str(), "traceparent" | "tracestate" | "baggage"));
221 if headers.is_empty() {
222 return (None, None);
223 }
224 normalize(
225 &json!({"schema": SCHEMA, "version": 1, "carrier": headers}),
226 allowlist,
227 )
228 })
229 .unwrap_or((None, Some(Diagnostic::Discarded)))
230}
231
232pub fn dispatch(value: &Value, task_id: &str) -> (Option<Value>, Option<Diagnostic>) {
235 if value.is_null() {
236 return (None, None);
237 }
238 let discarded = (None, Some(Diagnostic::Discarded));
239 if !identity(task_id)
240 || !keys(value, &["schema", "version", "task_id", "context"])
241 || value["schema"] != "durable-workflow.queue-trace"
242 || value["version"].as_u64() != Some(1)
243 || value["task_id"] != task_id
244 || !keys(&value["context"]["carrier"], &["traceparent", "tracestate"])
245 {
246 return discarded;
247 }
248 let normalized = normalize(&value["context"], &[]);
249 if normalized.0.is_none() || !bounded_json(value) {
250 return discarded;
251 }
252 normalized
253}
254
255pub fn links(
258 value: &Value,
259 task_id: &str,
260 run_id: &str,
261 namespace: &str,
262) -> (Vec<Value>, Option<Diagnostic>) {
263 if value.is_null() {
264 return (Vec::new(), None);
265 }
266 let discarded = || (Vec::new(), Some(Diagnostic::Discarded));
267 if ![task_id, run_id, namespace].into_iter().all(identity)
268 || !keys(
269 value,
270 &[
271 "schema",
272 "version",
273 "task_id",
274 "run_id",
275 "namespace",
276 "links",
277 ],
278 )
279 || value["schema"] != "durable-workflow.trace-links"
280 || value["version"].as_u64() != Some(1)
281 || value["task_id"] != task_id
282 || value["run_id"] != run_id
283 || value["namespace"] != namespace
284 {
285 return discarded();
286 }
287 let Some(entries) = value["links"]
288 .as_array()
289 .filter(|entries| entries.len() <= 32)
290 else {
291 return discarded();
292 };
293 let mut result = Vec::with_capacity(entries.len());
294 let mut filtered = false;
295 for entry in entries {
296 let Some((normalized, diagnostic)) = link_entry(entry) else {
297 return discarded();
298 };
299 result.push(normalized);
300 filtered |= diagnostic.is_some();
301 }
302 if !bounded_json(value) {
303 return discarded();
304 }
305 (result, filtered.then_some(Diagnostic::Filtered))
306}
307
308fn link_entry(value: &Value) -> Option<(Value, Option<Diagnostic>)> {
309 let (required, optional): (&[&str], &[&str]) = match value["kind"].as_str()? {
310 "command" => (&["command_id"], &[]),
311 "task_outcome" => (
312 &["task_id", "event_id", "sequence", "event_type"],
313 &["activity_execution_id", "activity_attempt_id", "timer_id"],
314 ),
315 "child_outcome" => (
316 &[
317 "workflow_link_id",
318 "child_run_id",
319 "event_id",
320 "sequence",
321 "event_type",
322 ],
323 &["child_task_id"],
324 ),
325 _ => return None,
326 };
327 let allowed: Vec<_> = required
328 .iter()
329 .chain(optional)
330 .copied()
331 .chain(["kind", "context"])
332 .collect();
333 if !keys(value, &allowed) || required.iter().any(|key| value.get(key).is_none()) {
334 return None;
335 }
336 for key in required.iter().chain(optional) {
337 let Some(field) = value.get(key) else {
338 continue;
339 };
340 if *key == "sequence" {
341 if !field.as_i64().is_some_and(|sequence| sequence > 0) {
342 return None;
343 }
344 } else if !(*key == "child_task_id" && field.is_null())
345 && !field.as_str().is_some_and(identity)
346 {
347 return None;
348 }
349 }
350 if !keys(&value["context"]["carrier"], &["traceparent", "tracestate"]) {
351 return None;
352 }
353 let (context, diagnostic) = normalize(&value["context"], &[]);
354 let mut result = value.clone();
355 result["context"] = context?;
356 Some((result, diagnostic))
357}
358
359#[cfg(test)]
360mod tests {
361 use super::*;
362 const PARENT: &str = "00-12345678901234567890123456789012-1234567890123456-01";
363
364 fn envelope() -> Value {
365 json!({"schema": SCHEMA, "version": 1, "carrier": {"traceparent": PARENT}})
366 }
367
368 #[test]
369 fn missing_original_generation_cannot_become_trace_authority_through_serde_defaults() {
370 let request = json!({"worker_id": "worker", "task_queue": "orders"});
371 let original = json!({"task": {"task_id": "task", "workflow_id": "workflow", "run_id": "run",
372 "lease_owner": "worker", "workflow_task_attempt": 1, "observability_context": envelope(),
373 "arguments": {"observability_context": {"business": "preserved"}}}});
374 let mut valid = original.clone();
375 retain_poll_observations(
376 &mut valid,
377 &request,
378 "/worker/workflow-tasks/poll",
379 "tenant",
380 );
381 assert_eq!(valid, original);
382 for field in [
383 "task_id",
384 "workflow_id",
385 "run_id",
386 "lease_owner",
387 "workflow_task_attempt",
388 ] {
389 let mut invalid = original.clone();
390 invalid["task"].as_object_mut().unwrap().remove(field);
391 retain_poll_observations(
392 &mut invalid,
393 &request,
394 "/worker/workflow-tasks/poll",
395 "tenant",
396 );
397 assert!(invalid["task"].get("observability_context").is_none());
398 assert_eq!(invalid["task"]["arguments"], original["task"]["arguments"]);
399 }
400 let mut expired = original.clone();
401 expired["task"]["lease_expires_at"] = json!("2000-01-01T00:00:00Z");
402 retain_poll_observations(
403 &mut expired,
404 &request,
405 "/worker/workflow-tasks/poll",
406 "tenant",
407 );
408 assert!(expired["task"].get("observability_context").is_none());
409 }
410
411 #[test]
412 fn secondary_links_require_real_event_provenance_and_never_fabricate_child_producers() {
413 let value = json!({"schema": "durable-workflow.trace-links", "version": 1, "task_id": "task",
414 "run_id": "run", "namespace": "tenant", "links": [
415 {"kind": "command", "command_id": "command", "context": envelope()},
416 {"kind": "task_outcome", "task_id": "producer", "event_id": "event", "sequence": 1,
417 "event_type": "ActivityCompleted", "activity_execution_id": "activity", "context": envelope()},
418 {"kind": "child_outcome", "workflow_link_id": "link", "child_run_id": "child", "child_task_id": null,
419 "event_id": "terminal", "sequence": i64::MAX, "event_type": "WorkflowCancelled", "context": envelope()}
420 ]});
421 assert_eq!(
422 links(&value, "task", "run", "tenant"),
423 (value["links"].as_array().unwrap().clone(), None)
424 );
425 assert_eq!(
426 links(&value, "wrong", "run", "tenant"),
427 (Vec::new(), Some(Diagnostic::Discarded))
428 );
429 for sequence in [
430 json!(true),
431 json!(0),
432 json!(-1),
433 json!(1.0),
434 json!("1"),
435 json!(u64::MAX),
436 ] {
437 let mut invalid = value.clone();
438 invalid["links"][1]["sequence"] = sequence;
439 assert_eq!(
440 links(&invalid, "task", "run", "tenant"),
441 (Vec::new(), Some(Diagnostic::Discarded))
442 );
443 }
444 for key in ["event_id", "sequence", "event_type"] {
445 let mut invalid = value.clone();
446 invalid["links"][2].as_object_mut().unwrap().remove(key);
447 assert_eq!(
448 links(&invalid, "task", "run", "tenant"),
449 (Vec::new(), Some(Diagnostic::Discarded))
450 );
451 }
452 let mut invalid = value.clone();
453 invalid["links"][1]["activity_id"] = json!("ambiguous");
454 assert_eq!(
455 links(&invalid, "task", "run", "tenant"),
456 (Vec::new(), Some(Diagnostic::Discarded))
457 );
458 invalid = value.clone();
459 invalid["links"][0]["context"]["carrier"]["baggage"] = json!("tenant=private");
460 assert_eq!(
461 links(&invalid, "task", "run", "tenant"),
462 (Vec::new(), Some(Diagnostic::Discarded))
463 );
464 invalid["links"] = json!(vec![value["links"][0].clone(); 33]);
465 assert_eq!(
466 links(&invalid, "task", "run", "tenant"),
467 (Vec::new(), Some(Diagnostic::Discarded))
468 );
469 }
470
471 #[test]
472 fn exact_capability_rejects_missing_unknown_and_coerced_values() {
473 let info = json!({"worker_protocol": {"server_capabilities": {"observability_context": {
474 "supported": true, "schema": SCHEMA, "version": 1, "dispatch_schema": "durable-workflow.queue-trace",
475 "links_schema": "durable-workflow.trace-links", "max_context_bytes": 4096, "max_links": 32, "max_links_bytes": 4096,
476 }}}});
477 assert!(supported(&info));
478 for key in info["worker_protocol"]["server_capabilities"]["observability_context"]
479 .as_object()
480 .unwrap()
481 .keys()
482 {
483 for wrong in [
484 Value::Null,
485 json!(false),
486 json!("1"),
487 json!(1.0),
488 json!([]),
489 json!("unknown"),
490 ] {
491 let mut candidate = info.clone();
492 candidate["worker_protocol"]["server_capabilities"]["observability_context"][key] =
493 wrong;
494 assert!(!supported(&candidate));
495 }
496 }
497 assert!(!supported(&json!({"version": "999.0.0"})));
498 }
499
500 #[test]
501 fn official_validation_refuses_invalid_context_without_ambient_fallback() {
502 assert_eq!(normalize(&envelope(), &[]), (Some(envelope()), None));
503 for parent in [
504 "00-00000000000000000000000000000000-1234567890123456-01",
505 "00-12345678901234567890123456789012-0000000000000000-01",
506 "ff-12345678901234567890123456789012-1234567890123456-01",
507 "00-ABCDEF78901234567890123456789012-1234567890123456-01",
508 "private-header",
509 ] {
510 let mut value = envelope();
511 value["carrier"]["traceparent"] = json!(parent);
512 assert_eq!(normalize(&value, &[]), (None, Some(Diagnostic::Discarded)));
513 }
514 assert_eq!(normalize(&Value::Null, &[]), (None, None));
515 for value in [json!(true), json!([]), json!({})] {
516 assert_eq!(normalize(&value, &[]), (None, Some(Diagnostic::Discarded)));
517 }
518 }
519
520 #[test]
521 fn baggage_is_empty_by_default_and_allowlist_is_bounded() {
522 let mut value = envelope();
523 value["carrier"]["baggage"] = json!("tenant=public,credential=private");
524 let original = value.clone();
525 assert_eq!(
526 normalize(&value, &[]),
527 (Some(envelope()), Some(Diagnostic::Filtered))
528 );
529 let mut expected = envelope();
530 expected["carrier"]["baggage"] = json!("tenant=public");
531 assert_eq!(
532 normalize(&value, &["tenant".into()]),
533 (Some(expected), Some(Diagnostic::Filtered))
534 );
535 assert_eq!(value, original);
536 let keys: Vec<_> = (0..33).map(|number| format!("k{number}")).collect();
537 value["carrier"]["baggage"] = json!(keys
538 .iter()
539 .map(|key| format!("{key}=v"))
540 .collect::<Vec<_>>()
541 .join(","));
542 assert_eq!(
543 normalize(&value, &keys),
544 (None, Some(Diagnostic::Discarded))
545 );
546 value["carrier"]["baggage"] = json!(keys[..32]
547 .iter()
548 .map(|key| format!("{key}=v"))
549 .collect::<Vec<_>>()
550 .join(","));
551 assert!(normalize(&value, &keys).0.is_some());
552 }
553
554 #[test]
555 fn byte_bounds_unknown_fields_and_wrong_dispatch_identity_are_discarded() {
556 for (key, size) in [("traceparent", 257), ("tracestate", 513), ("baggage", 2049)] {
557 let mut value = envelope();
558 value["carrier"][key] = json!("x".repeat(size));
559 assert_eq!(normalize(&value, &[]), (None, Some(Diagnostic::Discarded)));
560 }
561 let mut value = json!({"schema": "durable-workflow.queue-trace", "version": 1, "task_id": "task", "context": envelope()});
562 assert_eq!(dispatch(&value, "task"), (Some(envelope()), None));
563 assert_eq!(
564 dispatch(&value, "other"),
565 (None, Some(Diagnostic::Discarded))
566 );
567 value["context"]["carrier"]["baggage"] = json!("tenant=public");
568 assert_eq!(
569 dispatch(&value, "task"),
570 (None, Some(Diagnostic::Discarded))
571 );
572 value["context"]["unknown"] = json!("private");
573 assert_eq!(
574 normalize(&value["context"], &[]),
575 (None, Some(Diagnostic::Discarded))
576 );
577 for invalid in ["", "task\n", "task\u{0085}"] {
578 assert!(!identity(invalid));
579 }
580 assert!(!identity(&"x".repeat(513)));
581 assert!(identity(&"x".repeat(512)));
582 assert!(identity("任务"));
583 }
584}