Skip to main content

agent_engine/runtime/trace/
emit.rs

1//! Trace emission seam (Task 8, spec §6.2/§6.4).
2//!
3//! Transports hand fully-built, schema-valid [`RequestTrace`] records to a
4//! [`TraceSink`]. The seam is deliberately tiny:
5//!
6//! - **Record rule (documented invariant):** one record is emitted per
7//!   *actual transport attempt* (one HTTP send). A request that is retried
8//!   N times therefore yields N+1 records sharing the same `request_id`,
9//!   with strictly increasing `attempt` ordinals; record `attempt = k`
10//!   carries the k−1 prior failed tries in `outcome.retries`, so the Task 7
11//!   invariant `attempt == retries.len() + 1` holds for every record.
12//!   Non-final attempts carry a typed `ProviderFailed` terminal describing
13//!   that attempt's own failure; the final record carries the request's
14//!   terminal outcome (`Completed`, `Canceled`, or `ProviderFailed`).
15//!   A cancellation observed *between* attempts (during backoff sleep, when
16//!   no send is in flight) emits no extra record — the preceding failed
17//!   attempt was already recorded.
18//! - **Correctness firewall:** nothing in this module can fail a request.
19//!   Key I/O failure degrades the record (digest-bearing sections omitted)
20//!   and bumps a metadata-only counter + one warning; it never propagates.
21//! - **No persistence here:** persistence lives in the Task 11 bounded
22//!   background writer (`runtime::telemetry::writer`); production installs
23//!   its `WriterTraceSink` when telemetry is Basic/Full. The default sink
24//!   is a no-op; tests install [`CollectingTraceSink`].
25//!
26//! No hidden global mutable state: all shared state (lazy key cell,
27//! counters, sequence) lives inside the [`TraceContext`] handle the caller
28//! owns and clones.
29
30use super::key::{
31    keyed_digest, load_or_create_digest_key, load_or_create_digest_key_at, DigestDomain,
32    TraceDigestKey,
33};
34use super::types::{
35    CacheMeta, EndpointMeta, MessageMeta, RequestAnatomy, RequestTrace, RetryClass, RetryMeta,
36    StopReason, SystemSegmentMeta, TimingStages, ToolMeta, TraceId, TraceSchemaVersion,
37    TransportKind, TransportOutcome, UsageMeta, WireMeta,
38};
39use agent_core::prompt::QualifiedModelId;
40use agent_core::TurnOutcome;
41use std::path::PathBuf;
42use std::sync::atomic::{AtomicBool, AtomicU64, Ordering};
43use std::sync::Arc;
44use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH};
45
46// --- Sink seam ---
47
48/// Receives finished trace records. Implementations must be cheap and
49/// non-blocking: they run inline on the request path.
50pub trait TraceSink: Send + Sync + std::fmt::Debug {
51    fn emit(&self, record: RequestTrace);
52    /// Update a previously emitted request record with execution metadata.
53    /// Default sinks cannot mutate and therefore ignore enrichment; collecting
54    /// and writer sinks replace in place so one logical transport attempt
55    /// remains exactly one trace record.
56    fn enrich_execution_events(
57        &self,
58        _request_id: &TraceId,
59        _events: Vec<super::types::ToolExecutionEvent>,
60    ) {
61    }
62    /// Optional latest record snapshot used to append execution-enriched
63    /// copies after the provider request has completed.
64    fn snapshot_for_request(&self, _request_id: &TraceId) -> Option<RequestTrace> {
65        None
66    }
67    /// When `false`, transports skip record construction entirely.
68    fn enabled(&self) -> bool {
69        true
70    }
71}
72
73/// Disabled sink: accepts and discards. `enabled() == false` lets
74/// transports skip all trace work. Production installs the Task 11
75/// writer-backed sink (`runtime::telemetry::WriterTraceSink`) instead when
76/// the telemetry level warrants persistence.
77#[derive(Debug, Default)]
78pub struct NoopTraceSink;
79
80impl TraceSink for NoopTraceSink {
81    fn emit(&self, _record: RequestTrace) {}
82    fn enabled(&self) -> bool {
83        false
84    }
85}
86
87/// In-memory collector for deterministic tests.
88#[derive(Debug, Default)]
89pub struct CollectingTraceSink {
90    records: std::sync::Mutex<Vec<RequestTrace>>,
91}
92
93impl CollectingTraceSink {
94    pub fn new() -> Arc<Self> {
95        Arc::new(Self::default())
96    }
97
98    /// Snapshot of everything emitted so far, in emission order.
99    pub fn records(&self) -> Vec<RequestTrace> {
100        self.records.lock().expect("trace sink poisoned").clone()
101    }
102}
103
104impl TraceSink for CollectingTraceSink {
105    fn emit(&self, record: RequestTrace) {
106        self.records
107            .lock()
108            .expect("trace sink poisoned")
109            .push(record);
110    }
111
112    fn enrich_execution_events(
113        &self,
114        request_id: &TraceId,
115        events: Vec<super::types::ToolExecutionEvent>,
116    ) {
117        if let Some(record) = self
118            .records
119            .lock()
120            .expect("trace sink poisoned")
121            .iter_mut()
122            .rev()
123            .find(|record| &record.request_id == request_id)
124        {
125            record.execution_events = events;
126        }
127    }
128
129    fn snapshot_for_request(&self, request_id: &TraceId) -> Option<RequestTrace> {
130        self.records
131            .lock()
132            .expect("trace sink poisoned")
133            .iter()
134            .rev()
135            .find(|record| &record.request_id == request_id)
136            .cloned()
137    }
138}
139
140// --- Context handle ---
141
142/// Cloneable handle carrying the sink, the lazily-loaded digest key, the
143/// session identity, and metadata-only degradation counters.
144#[derive(Clone)]
145pub struct TraceContext {
146    sink: Arc<dyn TraceSink>,
147    session_id: TraceId,
148    /// `None` → the installation default key path.
149    key_path: Option<Arc<PathBuf>>,
150    /// Lazily initialized once per context; `None` inside means the key was
151    /// unavailable (I/O failure) — records degrade, requests are unaffected.
152    key: Arc<tokio::sync::OnceCell<Option<Arc<TraceDigestKey>>>>,
153    key_warned: Arc<AtomicBool>,
154    /// Count of records that were degraded or dropped for internal reasons
155    /// (key unavailable, unrepresentable structural identity). Metadata only.
156    degraded: Arc<AtomicU64>,
157    request_seq: Arc<AtomicU64>,
158    /// Session-scoped previous-component cache snapshot (Task 12, §6.6).
159    /// Bounded metadata only; shared across clones of this context.
160    cache_snapshots: Arc<super::diagnostics::CacheSnapshotStore>,
161    /// One-request redacted content capture (`/trace next content`);
162    /// `None` in every default/session context.
163    content_capture: Option<Arc<super::controls::ContentCapture>>,
164    /// One-shot request gate (Task 12 `/trace next`): `Some` only on an
165    /// armed ephemeral context. The FIRST logical request (`RequestTracer::
166    /// begin`) through this context consumes the gate; every retry attempt
167    /// of that request still emits (the gate is per logical request, not
168    /// per attempt), and every subsequent request through the same context
169    /// (tool-loop continuation sharing the `ApiOptions`) is disabled.
170    /// `None` (Basic/Full session contexts) never gates anything.
171    one_shot_gate: Option<Arc<AtomicBool>>,
172    /// Request-correlated execution events keyed by the request ID. The
173    /// transport may emit before tool execution; the collecting/writer sinks
174    /// receive an updated envelope snapshot when the stream records events.
175    execution_events: Arc<
176        std::sync::Mutex<std::collections::BTreeMap<String, Vec<super::types::ToolExecutionEvent>>>,
177    >,
178}
179
180impl std::fmt::Debug for TraceContext {
181    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
182        f.debug_struct("TraceContext")
183            .field("enabled", &self.sink.enabled())
184            .field("session_id", &self.session_id)
185            .finish_non_exhaustive()
186    }
187}
188
189impl Default for TraceContext {
190    fn default() -> Self {
191        Self::disabled()
192    }
193}
194
195impl TraceContext {
196    /// Replace the first matching request record with an execution-enriched
197    /// snapshot. Sinks that persist append-only records may retain both; the
198    /// latest snapshot is authoritative for the request ID.
199    pub fn emit_execution_enriched(&self, request_id: &TraceId) {
200        self.sink
201            .enrich_execution_events(request_id, self.execution_events(request_id));
202    }
203
204    /// Session identity shared by all request/tool events in this context.
205    pub fn session_id(&self) -> &TraceId {
206        &self.session_id
207    }
208
209    /// Append bounded execution metadata for a request. This never fails the
210    /// turn; malformed/unrepresentable fields are rejected before this seam.
211    pub fn record_execution_event(&self, event: super::types::ToolExecutionEvent) {
212        self.execution_events
213            .lock()
214            .unwrap_or_else(std::sync::PoisonError::into_inner)
215            .entry(event.request_id.as_str().to_string())
216            .or_default()
217            .push(event);
218    }
219
220    pub fn execution_events(&self, request_id: &TraceId) -> Vec<super::types::ToolExecutionEvent> {
221        self.execution_events
222            .lock()
223            .unwrap_or_else(std::sync::PoisonError::into_inner)
224            .get(request_id.as_str())
225            .cloned()
226            .unwrap_or_default()
227    }
228
229    /// Test/stream seam: correlated IDs for the most recently begun request.
230    pub fn latest_request_correlation(&self) -> Option<(TraceId, TraceId, TraceId)> {
231        let seq = self.request_seq.load(Ordering::Relaxed);
232        if seq == 0 {
233            return None;
234        }
235        let pid = std::process::id();
236        Some((
237            self.session_id.clone(),
238            TraceId::new(format!("turn-{pid}-{seq}")).ok()?,
239            TraceId::new(format!("req-{pid}-{seq}")).ok()?,
240        ))
241    }
242
243    pub fn reserve_request_correlation(&self) -> Option<RequestCorrelation> {
244        if !self.enabled() {
245            return None;
246        }
247        let seq = self.next_request_seq();
248        let pid = std::process::id();
249        Some(RequestCorrelation {
250            session_id: self.session_id.clone(),
251            turn_id: TraceId::new(format!("turn-{pid}-{seq}")).ok()?,
252            request_id: TraceId::new(format!("req-{pid}-{seq}")).ok()?,
253        })
254    }
255
256    /// Disabled context: no-op sink, no key I/O, zero request-path work.
257    pub fn disabled() -> Self {
258        Self::with_sink(Arc::new(NoopTraceSink))
259    }
260
261    /// Context with the given sink and the installation default key path.
262    pub fn with_sink(sink: Arc<dyn TraceSink>) -> Self {
263        let session_id = TraceId::new(format!("session-{}-{}", std::process::id(), unix_ms_now()))
264            .expect("generated session id is valid");
265        Self {
266            sink,
267            session_id,
268            key_path: None,
269            key: Arc::new(tokio::sync::OnceCell::new()),
270            key_warned: Arc::new(AtomicBool::new(false)),
271            degraded: Arc::new(AtomicU64::new(0)),
272            request_seq: Arc::new(AtomicU64::new(0)),
273            cache_snapshots: Arc::new(super::diagnostics::CacheSnapshotStore::new()),
274            content_capture: None,
275            one_shot_gate: None,
276            execution_events: Arc::new(std::sync::Mutex::new(std::collections::BTreeMap::new())),
277        }
278    }
279
280    /// Fork this context with a replacement sink, preserving the session
281    /// identity, digest-key state, degradation counters, request sequence,
282    /// and — crucially — the shared session [`CacheSnapshotStore`], so an
283    /// armed one-shot request feeds the same §6.6 diagnostics that
284    /// `/context` reads. The one-shot gate and content capture are NOT
285    /// inherited: the caller attaches them explicitly.
286    pub fn fork_with_sink(&self, sink: Arc<dyn TraceSink>) -> Self {
287        Self {
288            sink,
289            session_id: self.session_id.clone(),
290            key_path: self.key_path.clone(),
291            key: Arc::clone(&self.key),
292            key_warned: Arc::clone(&self.key_warned),
293            degraded: Arc::clone(&self.degraded),
294            request_seq: Arc::clone(&self.request_seq),
295            cache_snapshots: Arc::clone(&self.cache_snapshots),
296            content_capture: None,
297            one_shot_gate: None,
298            execution_events: Arc::clone(&self.execution_events),
299        }
300    }
301
302    /// Attach a one-request content capture arm (see `trace::controls`).
303    pub fn with_content_capture(mut self, capture: Arc<super::controls::ContentCapture>) -> Self {
304        self.content_capture = Some(capture);
305        self
306    }
307
308    /// Arm the one-shot request gate: exactly one logical request
309    /// (`RequestTracer::begin`) traces through this context, all its retry
310    /// attempts included; subsequent requests are disabled. Only armed
311    /// ephemeral contexts carry this — normal Basic/Full contexts never do.
312    pub fn with_one_shot_request_gate(mut self) -> Self {
313        self.one_shot_gate = Some(Arc::new(AtomicBool::new(false)));
314        self
315    }
316
317    /// The logical request just failed pre-inference and is being resent
318    /// with a different body (Codex dropping a rejected hosted tool). Let the
319    /// resend's `begin` through an armed one-shot gate so `/trace next` still
320    /// records it. Call only when this request got a tracer (held the
321    /// gate); an untraced continuation must stay untraced. No-op on ungated
322    /// contexts.
323    pub fn reopen_one_shot_for_resend(&self) {
324        if let Some(gate) = &self.one_shot_gate {
325            gate.store(false, Ordering::SeqCst);
326        }
327    }
328
329    /// Pass (consume) the one-shot gate. `true` for ungated contexts and
330    /// for the first caller on a gated context; `false` afterwards.
331    fn pass_one_shot_gate(&self) -> bool {
332        match &self.one_shot_gate {
333            None => true,
334            Some(gate) => !gate.swap(true, Ordering::SeqCst),
335        }
336    }
337
338    /// The session cache snapshot store (spec §6.6 previous-turn compare).
339    pub fn cache_snapshots(&self) -> &Arc<super::diagnostics::CacheSnapshotStore> {
340        &self.cache_snapshots
341    }
342
343    /// Capture the request body for an armed one-shot content capture
344    /// (no-op in every other context). `body_bytes` must be the request
345    /// body only — never headers or credentials.
346    pub fn capture_request_content(&self, request_id: &TraceId, body_bytes: &[u8]) {
347        if let Some(capture) = &self.content_capture {
348            capture.capture(request_id, body_bytes);
349        }
350    }
351
352    /// Explicitly fail an armed content capture on a provider path that has
353    /// no capturable request body (e.g. the extension sidecar, which owns
354    /// framing/serialization out of process). Fix for silent consumption:
355    /// the arm is consumed *visibly* — one metadata-only warning plus a
356    /// bump of the degraded-records counter that `/trace status` reports —
357    /// instead of vanishing with no artifact and no signal. No-op in every
358    /// context without an armed capture.
359    pub fn capture_unsupported(&self, reason: &str) {
360        if let Some(capture) = &self.content_capture {
361            if capture.mark_unsupported() {
362                self.note_degraded();
363                tracing::warn!(
364                    reason,
365                    "trace content capture unsupported on this provider path; \
366                     no capture bundle was written (see /trace status degraded counter)"
367                );
368            }
369        }
370    }
371
372    /// Override the digest-key path (tests / non-default installs).
373    pub fn with_key_path(mut self, path: PathBuf) -> Self {
374        self.key_path = Some(Arc::new(path));
375        self
376    }
377
378    pub fn enabled(&self) -> bool {
379        // A gated context whose one shot was already taken behaves as
380        // disabled: transports skip record construction for the second and
381        // later logical requests of the tool loop.
382        self.sink.enabled()
383            && self
384                .one_shot_gate
385                .as_ref()
386                .map(|gate| !gate.load(Ordering::SeqCst))
387                .unwrap_or(true)
388    }
389
390    /// Records degraded/dropped for internal reasons since context creation.
391    pub fn degraded_records(&self) -> u64 {
392        self.degraded.load(Ordering::Relaxed)
393    }
394
395    /// Load (or create) the installation digest key, once per context, off
396    /// the async threads. Failure is remembered: it warns once (metadata
397    /// only), bumps the degradation counter, and yields `None` forever —
398    /// it can never fail or delay the request itself.
399    pub async fn digest_key(&self) -> Option<Arc<TraceDigestKey>> {
400        self.key
401            .get_or_init(|| async {
402                let path = self.key_path.clone();
403                let loaded = tokio::task::spawn_blocking(move || match path {
404                    Some(p) => load_or_create_digest_key_at(&p),
405                    None => load_or_create_digest_key(),
406                })
407                .await;
408                match loaded {
409                    Ok(Ok(key)) => Some(Arc::new(key)),
410                    Ok(Err(err)) => {
411                        self.note_key_failure(&err.to_string());
412                        None
413                    }
414                    Err(join_err) => {
415                        self.note_key_failure(&join_err.to_string());
416                        None
417                    }
418                }
419            })
420            .await
421            .clone()
422    }
423
424    fn note_key_failure(&self, reason: &str) {
425        self.degraded.fetch_add(1, Ordering::Relaxed);
426        if !self.key_warned.swap(true, Ordering::Relaxed) {
427            // Metadata-only: the reason describes our own key file I/O,
428            // never provider or request content.
429            tracing::warn!(
430                reason,
431                "trace digest key unavailable — traces degrade to digest-free records"
432            );
433        }
434    }
435
436    fn note_degraded(&self) {
437        self.degraded.fetch_add(1, Ordering::Relaxed);
438    }
439
440    fn next_request_seq(&self) -> u64 {
441        self.request_seq.fetch_add(1, Ordering::Relaxed)
442    }
443}
444
445fn unix_ms_now() -> u64 {
446    SystemTime::now()
447        .duration_since(UNIX_EPOCH)
448        .map(|d| d.as_millis() as u64)
449        .unwrap_or(0)
450}
451
452// --- Per-attempt monotonic clock ---
453
454/// Monotonic ([`Instant`]-based) timing capture for one transport attempt.
455/// Every mark is set-once; unobserved stages stay `None` (never zero).
456#[derive(Debug, Clone, Copy)]
457pub struct AttemptClock {
458    start: Instant,
459    send_start_unix_ms: u64,
460    headers_ms: Option<u64>,
461    first_byte_ms: Option<u64>,
462    first_model_event_ms: Option<u64>,
463    stream_end_ms: Option<u64>,
464}
465
466impl AttemptClock {
467    /// Start the clock immediately before handing bytes to the transport.
468    pub fn start() -> Self {
469        Self {
470            start: Instant::now(),
471            send_start_unix_ms: unix_ms_now(),
472            headers_ms: None,
473            first_byte_ms: None,
474            first_model_event_ms: None,
475            stream_end_ms: None,
476        }
477    }
478
479    fn elapsed_ms(&self) -> u64 {
480        self.start.elapsed().as_millis() as u64
481    }
482
483    /// Response headers observed (set-once).
484    pub fn mark_headers(&mut self) {
485        if self.headers_ms.is_none() {
486            self.headers_ms = Some(self.elapsed_ms());
487        }
488    }
489
490    /// First response body byte / SSE frame observed (set-once).
491    pub fn mark_first_byte(&mut self) {
492        if self.first_byte_ms.is_none() {
493            self.first_byte_ms = Some(self.elapsed_ms());
494        }
495    }
496
497    /// First parsed model event, expressed as a monotonic offset from the
498    /// headers mark (the stream parser measures from post-headers).
499    pub fn set_first_model_event_after_headers(&mut self, offset_ms: u64) {
500        if self.first_model_event_ms.is_none() {
501            if let Some(headers) = self.headers_ms {
502                self.first_model_event_ms = Some(headers.saturating_add(offset_ms));
503            }
504        }
505    }
506
507    /// First parsed model event, measured directly on this attempt's own
508    /// monotonic clock (set-once). Used by transports that observe the
509    /// event on the same task that owns the clock (OpenAI-compatible
510    /// paths); the Anthropic stream parser instead reports a post-headers
511    /// offset via [`Self::set_first_model_event_after_headers`].
512    pub fn mark_first_model_event(&mut self) {
513        if self.first_model_event_ms.is_none() {
514            self.first_model_event_ms = Some(self.elapsed_ms());
515        }
516    }
517
518    /// First parsed model event at a caller-measured monotonic offset from
519    /// this attempt's send start (set-once). Used by transports whose stream
520    /// events are observed on a task that does not own the clock (the
521    /// extension-provider forwarder records the offset through an atomic and
522    /// the owning task applies it here before finishing).
523    pub fn set_first_model_event_offset(&mut self, offset_ms: u64) {
524        if self.first_model_event_ms.is_none() {
525            self.first_model_event_ms = Some(offset_ms);
526        }
527    }
528
529    /// Stream fully consumed / request finished (set-once).
530    pub fn mark_stream_end(&mut self) {
531        if self.stream_end_ms.is_none() {
532            self.stream_end_ms = Some(self.elapsed_ms());
533        }
534    }
535
536    fn stages(&self) -> TimingStages {
537        TimingStages {
538            send_start_unix_ms: Some(self.send_start_unix_ms),
539            headers_ms: self.headers_ms,
540            first_byte_ms: self.first_byte_ms,
541            first_model_event_ms: self.first_model_event_ms,
542            stream_end_ms: self.stream_end_ms,
543        }
544    }
545}
546
547// --- Request structure (built once per request from trusted inputs) ---
548
549/// Structural, metadata-only description of one outgoing request, built by a
550/// transport-specific helper (see `trace::anthropic`) from trusted inputs —
551/// including [`WireMeta`] computed from the exact bytes handed to reqwest.
552#[derive(Debug, Clone, Default)]
553pub struct RequestStructure {
554    pub anatomy: RequestAnatomy,
555    /// `None` when the digest key is unavailable — never re-serialized.
556    pub wire: Option<WireMeta>,
557    pub system_segments: Vec<SystemSegmentMeta>,
558    pub messages: Vec<MessageMeta>,
559    pub tools: Vec<ToolMeta>,
560    pub cache: CacheMeta,
561    /// Provider-adapter translation report entries (Task 9): every semantic
562    /// loss/rewrite, positional IDs only. Populates `translation_losses`.
563    pub translation: Vec<super::types::TranslationLoss>,
564}
565
566/// Compute exact-wire metadata from the very byte buffer handed to the
567/// transport. This is the ONLY constructor for [`WireMeta`] used by
568/// transports — there is no re-serialization path.
569pub fn wire_meta_from_sent_bytes(key: &TraceDigestKey, sent_bytes: &[u8]) -> WireMeta {
570    WireMeta {
571        byte_len: sent_bytes.len() as u64,
572        digest: keyed_digest(key, DigestDomain::Wire, sent_bytes),
573    }
574}
575
576#[derive(Debug, Clone, PartialEq, Eq)]
577pub struct RequestCorrelation {
578    pub session_id: TraceId,
579    pub turn_id: TraceId,
580    pub request_id: TraceId,
581}
582
583// --- Per-request tracer ---
584
585/// Builds and emits one record per actual transport attempt (see module
586/// docs for the record rule). Owned by a single request; not shared.
587pub struct RequestTracer {
588    ctx: TraceContext,
589    session_id: TraceId,
590    turn_id: TraceId,
591    request_id: TraceId,
592    model: QualifiedModelId,
593    transport: TransportKind,
594    endpoint: EndpointMeta,
595    structure: RequestStructure,
596    retries: Vec<RetryMeta>,
597    attempt: u32,
598}
599
600impl RequestTracer {
601    /// Begin tracing one request. Returns `None` when the context is
602    /// disabled or the structural identity is unrepresentable (counted as a
603    /// degraded record — never an error).
604    pub fn begin(
605        ctx: &TraceContext,
606        correlation: Option<RequestCorrelation>,
607        model: QualifiedModelId,
608        transport: TransportKind,
609        endpoint: EndpointMeta,
610        structure: RequestStructure,
611    ) -> Option<Self> {
612        if !ctx.enabled() {
613            return None;
614        }
615        let correlation = match correlation.or_else(|| ctx.reserve_request_correlation()) {
616            Some(correlation) => correlation,
617            None => {
618                ctx.note_degraded();
619                return None;
620            }
621        };
622        let request_id = correlation.request_id;
623        let turn_id = correlation.turn_id;
624        let session_id = correlation.session_id;
625        // One-shot gate LAST (Task 12 `/trace next`): consumed only when a
626        // tracer can actually start — an identity-validation failure above
627        // returns without spending the arm, so the arm still covers the
628        // next viable request. First logical request wins; its retry
629        // attempts all emit through this tracer; later begins observe the
630        // consumed gate and return `None`.
631        if !ctx.pass_one_shot_gate() {
632            return None;
633        }
634        Some(Self {
635            ctx: ctx.clone(),
636            session_id,
637            turn_id,
638            request_id,
639            model,
640            transport,
641            endpoint,
642            structure,
643            retries: Vec::new(),
644            attempt: 1,
645        })
646    }
647
648    /// The shared request ID for correlating this request's records.
649    pub fn request_id(&self) -> &TraceId {
650        &self.request_id
651    }
652
653    pub fn correlation_ids(&self) -> (&TraceId, &TraceId, &TraceId) {
654        (&self.session_id, &self.turn_id, &self.request_id)
655    }
656
657    /// Record a failed attempt that WILL be retried: emits this attempt's
658    /// record (typed `ProviderFailed` terminal), then appends the retry to
659    /// the shared history and advances the attempt ordinal.
660    #[allow(clippy::too_many_arguments)]
661    pub fn attempt_failed(
662        &mut self,
663        clock: AttemptClock,
664        class: RetryClass,
665        delay: Duration,
666        http_status: Option<u16>,
667        provider_request_id: Option<TraceId>,
668        code: &str,
669    ) {
670        let outcome = TransportOutcome {
671            timings: clock.stages(),
672            retries: self.retries.clone(),
673            provider_request_id,
674            http_status,
675            stop_reason: None,
676            usage: None,
677            terminal: TurnOutcome::ProviderFailed {
678                code: code.to_string(),
679                correlation_id: self.request_id.as_str().to_string(),
680            },
681        };
682        self.emit_record(outcome);
683        // The verdict belongs to this attempt: the next one starts without
684        // it, even if it fails before streaming.
685        self.structure.cache.server_diagnosis = None;
686        self.retries.push(RetryMeta {
687            attempt: self.attempt,
688            class,
689            delay_ms: delay.as_millis() as u64,
690        });
691        self.attempt += 1;
692    }
693
694    /// Emit the final record for this request's last attempt.
695    #[allow(clippy::too_many_arguments)]
696    pub fn finish(
697        self,
698        clock: AttemptClock,
699        http_status: Option<u16>,
700        provider_request_id: Option<TraceId>,
701        stop_reason: Option<StopReason>,
702        usage: Option<UsageMeta>,
703        terminal: TurnOutcome,
704    ) {
705        let outcome = TransportOutcome {
706            timings: clock.stages(),
707            retries: self.retries.clone(),
708            provider_request_id,
709            http_status,
710            stop_reason,
711            usage,
712            terminal,
713        };
714        self.emit_record(outcome);
715    }
716
717    /// A typed terminal for an attempt that failed: the correlation ID is
718    /// the trace request ID — never a raw error string.
719    pub fn failed_terminal(&self, code: &str) -> TurnOutcome {
720        TurnOutcome::ProviderFailed {
721            code: code.to_string(),
722            correlation_id: self.request_id.as_str().to_string(),
723        }
724    }
725
726    /// Attach (or clear) the provider's cache diagnosis for the attempt
727    /// that is about to finish. Set per attempt so a retry never inherits
728    /// the previous attempt's verdict.
729    pub fn set_server_diagnosis(&mut self, diagnosis: Option<super::ServerCacheDiagnosis>) {
730        self.structure.cache.server_diagnosis = diagnosis;
731    }
732
733    fn emit_record(&self, outcome: TransportOutcome) {
734        let record = RequestTrace {
735            schema: TraceSchemaVersion,
736            session_id: self.session_id.clone(),
737            turn_id: self.turn_id.clone(),
738            request_id: self.request_id.clone(),
739            execution_events: self
740                .ctx
741                .execution_events
742                .lock()
743                .unwrap_or_else(std::sync::PoisonError::into_inner)
744                .get(self.request_id.as_str())
745                .cloned()
746                .unwrap_or_default(),
747            attempt: self.attempt,
748            model: self.model.clone(),
749            transport: self.transport,
750            endpoint: self.endpoint.clone(),
751            anatomy: self.structure.anatomy,
752            wire: self.structure.wire.clone(),
753            system_segments: self.structure.system_segments.clone(),
754            messages: self.structure.messages.clone(),
755            tools: self.structure.tools.clone(),
756            cache: self.structure.cache.clone(),
757            translation_losses: self.structure.translation.clone(),
758            outcome,
759        };
760        self.ctx.sink.emit(record);
761    }
762}