durable_workflow/telemetry/
carrier.rs

1//! Bounded portable observations, never task/receipt/lease authority.
2
3use 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/// Fixed diagnostic values contain no carrier data or baggage names/values.
17#[derive(Clone, Copy, Debug, PartialEq, Eq)]
18pub enum Diagnostic {
19    /// Optional metadata is malformed, oversized, unsupported or misbound.
20    Discarded,
21    /// Normalization removed unsupported baggage or canonicalized propagation.
22    Filtered,
23}
24
25/// Accept the exact implemented v1 capability, independent of Server versions.
26pub 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
39/// Identity bounds apply to UTF-8 bytes and reject Unicode controls.
40pub fn identity(value: &str) -> bool {
41    !value.is_empty() && value.len() <= 512 && !value.chars().any(char::is_control)
42}
43
44/// Remove only structural observations when the actual response lacks its
45/// original poll identity. Serde's business compatibility defaults are not
46/// evidence that an omitted generation was present on the wire.
47pub(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    // Match Core's conservative escaped Unicode/slash JSON budget.
115    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
134/// Normalize with the official W3C propagators from a fresh context. Baggage
135/// defaults empty and is allowed only for admission/run parents.
136pub 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    // Official baggage errors may include rejected header text when an app has
168    // enabled OTel internal logs. Silence only this synchronous parsing scope;
169    // the application's global subscriber and business logs remain untouched.
170    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        // Rust's official injector emits an empty tracestate. Omit empty
179        // optional headers to match the portable Core envelope.
180        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
212/// Capture the caller before starting an HTTP transport span. Propagators and
213/// sampling remain application-owned. Panic/failure cannot change business I/O.
214pub 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
232/// Read a task-bound queue producer link after the original claim is admitted.
233/// Baggage is forbidden; this context never replaces the run parent.
234pub 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
255/// Read bounded secondary observations only after the enclosing original claim
256/// is admitted. Nullable child producers are retained without fabrication.
257pub 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}