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        // `-<seq>`: contexts created in the same millisecond (workers
264        // spawned in one tool batch) still get distinct session ids.
265        static SESSION_SEQ: AtomicU64 = AtomicU64::new(0);
266        let seq = SESSION_SEQ.fetch_add(1, Ordering::Relaxed);
267        let session_id =
268            TraceId::new(format!("session-{}-{}-{seq}", std::process::id(), unix_ms_now()))
269                .expect("generated session id is valid");
270        Self {
271            sink,
272            session_id,
273            key_path: None,
274            key: Arc::new(tokio::sync::OnceCell::new()),
275            key_warned: Arc::new(AtomicBool::new(false)),
276            degraded: Arc::new(AtomicU64::new(0)),
277            request_seq: Arc::new(AtomicU64::new(0)),
278            cache_snapshots: Arc::new(super::diagnostics::CacheSnapshotStore::new()),
279            content_capture: None,
280            one_shot_gate: None,
281            execution_events: Arc::new(std::sync::Mutex::new(std::collections::BTreeMap::new())),
282        }
283    }
284
285    /// Fork this context with a replacement sink, preserving the session
286    /// identity, digest-key state, degradation counters, request sequence,
287    /// and — crucially — the shared session [`CacheSnapshotStore`], so an
288    /// armed one-shot request feeds the same §6.6 diagnostics that
289    /// `/context` reads. The one-shot gate and content capture are NOT
290    /// inherited: the caller attaches them explicitly.
291    pub fn fork_with_sink(&self, sink: Arc<dyn TraceSink>) -> Self {
292        Self {
293            sink,
294            session_id: self.session_id.clone(),
295            key_path: self.key_path.clone(),
296            key: Arc::clone(&self.key),
297            key_warned: Arc::clone(&self.key_warned),
298            degraded: Arc::clone(&self.degraded),
299            request_seq: Arc::clone(&self.request_seq),
300            cache_snapshots: Arc::clone(&self.cache_snapshots),
301            content_capture: None,
302            one_shot_gate: None,
303            execution_events: Arc::clone(&self.execution_events),
304        }
305    }
306
307    /// Attach a one-request content capture arm (see `trace::controls`).
308    pub fn with_content_capture(mut self, capture: Arc<super::controls::ContentCapture>) -> Self {
309        self.content_capture = Some(capture);
310        self
311    }
312
313    /// Arm the one-shot request gate: exactly one logical request
314    /// (`RequestTracer::begin`) traces through this context, all its retry
315    /// attempts included; subsequent requests are disabled. Only armed
316    /// ephemeral contexts carry this — normal Basic/Full contexts never do.
317    pub fn with_one_shot_request_gate(mut self) -> Self {
318        self.one_shot_gate = Some(Arc::new(AtomicBool::new(false)));
319        self
320    }
321
322    /// The logical request just failed pre-inference and is being resent
323    /// with a different body (Codex dropping a rejected hosted tool). Let the
324    /// resend's `begin` through an armed one-shot gate so `/trace next` still
325    /// records it. Call only when this request got a tracer (held the
326    /// gate); an untraced continuation must stay untraced. No-op on ungated
327    /// contexts.
328    pub fn reopen_one_shot_for_resend(&self) {
329        if let Some(gate) = &self.one_shot_gate {
330            gate.store(false, Ordering::SeqCst);
331        }
332    }
333
334    /// Pass (consume) the one-shot gate. `true` for ungated contexts and
335    /// for the first caller on a gated context; `false` afterwards.
336    fn pass_one_shot_gate(&self) -> bool {
337        match &self.one_shot_gate {
338            None => true,
339            Some(gate) => !gate.swap(true, Ordering::SeqCst),
340        }
341    }
342
343    /// The session cache snapshot store (spec §6.6 previous-turn compare).
344    pub fn cache_snapshots(&self) -> &Arc<super::diagnostics::CacheSnapshotStore> {
345        &self.cache_snapshots
346    }
347
348    /// Capture the request body for an armed one-shot content capture
349    /// (no-op in every other context). `body_bytes` must be the request
350    /// body only — never headers or credentials.
351    pub fn capture_request_content(&self, request_id: &TraceId, body_bytes: &[u8]) {
352        if let Some(capture) = &self.content_capture {
353            capture.capture(request_id, body_bytes);
354        }
355    }
356
357    /// Explicitly fail an armed content capture on a provider path that has
358    /// no capturable request body (e.g. the extension sidecar, which owns
359    /// framing/serialization out of process). Fix for silent consumption:
360    /// the arm is consumed *visibly* — one metadata-only warning plus a
361    /// bump of the degraded-records counter that `/trace status` reports —
362    /// instead of vanishing with no artifact and no signal. No-op in every
363    /// context without an armed capture.
364    pub fn capture_unsupported(&self, reason: &str) {
365        if let Some(capture) = &self.content_capture {
366            if capture.mark_unsupported() {
367                self.note_degraded();
368                tracing::warn!(
369                    reason,
370                    "trace content capture unsupported on this provider path; \
371                     no capture bundle was written (see /trace status degraded counter)"
372                );
373            }
374        }
375    }
376
377    /// Override the digest-key path (tests / non-default installs).
378    pub fn with_key_path(mut self, path: PathBuf) -> Self {
379        self.key_path = Some(Arc::new(path));
380        self
381    }
382
383    pub fn enabled(&self) -> bool {
384        // A gated context whose one shot was already taken behaves as
385        // disabled: transports skip record construction for the second and
386        // later logical requests of the tool loop.
387        self.sink.enabled()
388            && self
389                .one_shot_gate
390                .as_ref()
391                .map(|gate| !gate.load(Ordering::SeqCst))
392                .unwrap_or(true)
393    }
394
395    /// Records degraded/dropped for internal reasons since context creation.
396    pub fn degraded_records(&self) -> u64 {
397        self.degraded.load(Ordering::Relaxed)
398    }
399
400    /// Load (or create) the installation digest key, once per context, off
401    /// the async threads. Failure is remembered: it warns once (metadata
402    /// only), bumps the degradation counter, and yields `None` forever —
403    /// it can never fail or delay the request itself.
404    pub async fn digest_key(&self) -> Option<Arc<TraceDigestKey>> {
405        self.key
406            .get_or_init(|| async {
407                let path = self.key_path.clone();
408                let loaded = tokio::task::spawn_blocking(move || match path {
409                    Some(p) => load_or_create_digest_key_at(&p),
410                    None => load_or_create_digest_key(),
411                })
412                .await;
413                match loaded {
414                    Ok(Ok(key)) => Some(Arc::new(key)),
415                    Ok(Err(err)) => {
416                        self.note_key_failure(&err.to_string());
417                        None
418                    }
419                    Err(join_err) => {
420                        self.note_key_failure(&join_err.to_string());
421                        None
422                    }
423                }
424            })
425            .await
426            .clone()
427    }
428
429    fn note_key_failure(&self, reason: &str) {
430        self.degraded.fetch_add(1, Ordering::Relaxed);
431        if !self.key_warned.swap(true, Ordering::Relaxed) {
432            // Metadata-only: the reason describes our own key file I/O,
433            // never provider or request content.
434            tracing::warn!(
435                reason,
436                "trace digest key unavailable — traces degrade to digest-free records"
437            );
438        }
439    }
440
441    fn note_degraded(&self) {
442        self.degraded.fetch_add(1, Ordering::Relaxed);
443    }
444
445    fn next_request_seq(&self) -> u64 {
446        self.request_seq.fetch_add(1, Ordering::Relaxed)
447    }
448}
449
450fn unix_ms_now() -> u64 {
451    SystemTime::now()
452        .duration_since(UNIX_EPOCH)
453        .map(|d| d.as_millis() as u64)
454        .unwrap_or(0)
455}
456
457// --- Per-attempt monotonic clock ---
458
459/// Monotonic ([`Instant`]-based) timing capture for one transport attempt.
460/// Every mark is set-once; unobserved stages stay `None` (never zero).
461#[derive(Debug, Clone, Copy)]
462pub struct AttemptClock {
463    start: Instant,
464    send_start_unix_ms: u64,
465    headers_ms: Option<u64>,
466    first_byte_ms: Option<u64>,
467    first_model_event_ms: Option<u64>,
468    stream_end_ms: Option<u64>,
469}
470
471impl AttemptClock {
472    /// Start the clock immediately before handing bytes to the transport.
473    pub fn start() -> Self {
474        Self {
475            start: Instant::now(),
476            send_start_unix_ms: unix_ms_now(),
477            headers_ms: None,
478            first_byte_ms: None,
479            first_model_event_ms: None,
480            stream_end_ms: None,
481        }
482    }
483
484    fn elapsed_ms(&self) -> u64 {
485        self.start.elapsed().as_millis() as u64
486    }
487
488    /// Response headers observed (set-once).
489    pub fn mark_headers(&mut self) {
490        if self.headers_ms.is_none() {
491            self.headers_ms = Some(self.elapsed_ms());
492        }
493    }
494
495    /// First response body byte / SSE frame observed (set-once).
496    pub fn mark_first_byte(&mut self) {
497        if self.first_byte_ms.is_none() {
498            self.first_byte_ms = Some(self.elapsed_ms());
499        }
500    }
501
502    /// First parsed model event, expressed as a monotonic offset from the
503    /// headers mark (the stream parser measures from post-headers).
504    pub fn set_first_model_event_after_headers(&mut self, offset_ms: u64) {
505        if self.first_model_event_ms.is_none() {
506            if let Some(headers) = self.headers_ms {
507                self.first_model_event_ms = Some(headers.saturating_add(offset_ms));
508            }
509        }
510    }
511
512    /// First parsed model event, measured directly on this attempt's own
513    /// monotonic clock (set-once). Used by transports that observe the
514    /// event on the same task that owns the clock (OpenAI-compatible
515    /// paths); the Anthropic stream parser instead reports a post-headers
516    /// offset via [`Self::set_first_model_event_after_headers`].
517    pub fn mark_first_model_event(&mut self) {
518        if self.first_model_event_ms.is_none() {
519            self.first_model_event_ms = Some(self.elapsed_ms());
520        }
521    }
522
523    /// First parsed model event at a caller-measured monotonic offset from
524    /// this attempt's send start (set-once). Used by transports whose stream
525    /// events are observed on a task that does not own the clock (the
526    /// extension-provider forwarder records the offset through an atomic and
527    /// the owning task applies it here before finishing).
528    pub fn set_first_model_event_offset(&mut self, offset_ms: u64) {
529        if self.first_model_event_ms.is_none() {
530            self.first_model_event_ms = Some(offset_ms);
531        }
532    }
533
534    /// Stream fully consumed / request finished (set-once).
535    pub fn mark_stream_end(&mut self) {
536        if self.stream_end_ms.is_none() {
537            self.stream_end_ms = Some(self.elapsed_ms());
538        }
539    }
540
541    /// First parsed model event, ms after send start (`None` if unobserved).
542    pub fn first_model_event_ms(&self) -> Option<u64> {
543        self.first_model_event_ms
544    }
545
546    /// Stream end, ms after send start; until marked, the time elapsed so far.
547    pub fn stream_end_or_now_ms(&self) -> u64 {
548        self.stream_end_ms.unwrap_or_else(|| self.elapsed_ms())
549    }
550
551    fn stages(&self) -> TimingStages {
552        TimingStages {
553            send_start_unix_ms: Some(self.send_start_unix_ms),
554            headers_ms: self.headers_ms,
555            first_byte_ms: self.first_byte_ms,
556            first_model_event_ms: self.first_model_event_ms,
557            stream_end_ms: self.stream_end_ms,
558        }
559    }
560}
561
562// --- Request structure (built once per request from trusted inputs) ---
563
564/// Structural, metadata-only description of one outgoing request, built by a
565/// transport-specific helper (see `trace::anthropic`) from trusted inputs —
566/// including [`WireMeta`] computed from the exact bytes handed to reqwest.
567#[derive(Debug, Clone, Default)]
568pub struct RequestStructure {
569    pub anatomy: RequestAnatomy,
570    /// `None` when the digest key is unavailable — never re-serialized.
571    pub wire: Option<WireMeta>,
572    pub system_segments: Vec<SystemSegmentMeta>,
573    pub messages: Vec<MessageMeta>,
574    pub tools: Vec<ToolMeta>,
575    pub cache: CacheMeta,
576    /// Provider-adapter translation report entries (Task 9): every semantic
577    /// loss/rewrite, positional IDs only. Populates `translation_losses`.
578    pub translation: Vec<super::types::TranslationLoss>,
579}
580
581/// Compute exact-wire metadata from the very byte buffer handed to the
582/// transport. This is the ONLY constructor for [`WireMeta`] used by
583/// transports — there is no re-serialization path.
584pub fn wire_meta_from_sent_bytes(key: &TraceDigestKey, sent_bytes: &[u8]) -> WireMeta {
585    WireMeta {
586        byte_len: sent_bytes.len() as u64,
587        digest: keyed_digest(key, DigestDomain::Wire, sent_bytes),
588    }
589}
590
591#[derive(Debug, Clone, PartialEq, Eq)]
592pub struct RequestCorrelation {
593    pub session_id: TraceId,
594    pub turn_id: TraceId,
595    pub request_id: TraceId,
596}
597
598// --- Per-request tracer ---
599
600/// Builds and emits one record per actual transport attempt (see module
601/// docs for the record rule). Owned by a single request; not shared.
602pub struct RequestTracer {
603    ctx: TraceContext,
604    session_id: TraceId,
605    turn_id: TraceId,
606    request_id: TraceId,
607    model: QualifiedModelId,
608    transport: TransportKind,
609    endpoint: EndpointMeta,
610    structure: RequestStructure,
611    retries: Vec<RetryMeta>,
612    attempt: u32,
613}
614
615impl RequestTracer {
616    /// Begin tracing one request. Returns `None` when the context is
617    /// disabled or the structural identity is unrepresentable (counted as a
618    /// degraded record — never an error).
619    pub fn begin(
620        ctx: &TraceContext,
621        correlation: Option<RequestCorrelation>,
622        model: QualifiedModelId,
623        transport: TransportKind,
624        endpoint: EndpointMeta,
625        structure: RequestStructure,
626    ) -> Option<Self> {
627        if !ctx.enabled() {
628            return None;
629        }
630        let correlation = match correlation.or_else(|| ctx.reserve_request_correlation()) {
631            Some(correlation) => correlation,
632            None => {
633                ctx.note_degraded();
634                return None;
635            }
636        };
637        let request_id = correlation.request_id;
638        let turn_id = correlation.turn_id;
639        let session_id = correlation.session_id;
640        // One-shot gate LAST (Task 12 `/trace next`): consumed only when a
641        // tracer can actually start — an identity-validation failure above
642        // returns without spending the arm, so the arm still covers the
643        // next viable request. First logical request wins; its retry
644        // attempts all emit through this tracer; later begins observe the
645        // consumed gate and return `None`.
646        if !ctx.pass_one_shot_gate() {
647            return None;
648        }
649        Some(Self {
650            ctx: ctx.clone(),
651            session_id,
652            turn_id,
653            request_id,
654            model,
655            transport,
656            endpoint,
657            structure,
658            retries: Vec::new(),
659            attempt: 1,
660        })
661    }
662
663    /// The shared request ID for correlating this request's records.
664    pub fn request_id(&self) -> &TraceId {
665        &self.request_id
666    }
667
668    pub fn correlation_ids(&self) -> (&TraceId, &TraceId, &TraceId) {
669        (&self.session_id, &self.turn_id, &self.request_id)
670    }
671
672    /// Record a failed attempt that WILL be retried: emits this attempt's
673    /// record (typed `ProviderFailed` terminal), then appends the retry to
674    /// the shared history and advances the attempt ordinal.
675    #[allow(clippy::too_many_arguments)]
676    pub fn attempt_failed(
677        &mut self,
678        clock: AttemptClock,
679        class: RetryClass,
680        delay: Duration,
681        http_status: Option<u16>,
682        provider_request_id: Option<TraceId>,
683        code: &str,
684    ) {
685        let outcome = TransportOutcome {
686            timings: clock.stages(),
687            retries: self.retries.clone(),
688            provider_request_id,
689            http_status,
690            stop_reason: None,
691            usage: None,
692            terminal: TurnOutcome::ProviderFailed {
693                code: code.to_string(),
694                correlation_id: self.request_id.as_str().to_string(),
695            },
696        };
697        self.emit_record(outcome);
698        // The verdict belongs to this attempt: the next one starts without
699        // it, even if it fails before streaming.
700        self.structure.cache.server_diagnosis = None;
701        self.retries.push(RetryMeta {
702            attempt: self.attempt,
703            class,
704            delay_ms: delay.as_millis() as u64,
705        });
706        self.attempt += 1;
707    }
708
709    /// Emit the final record for this request's last attempt.
710    #[allow(clippy::too_many_arguments)]
711    pub fn finish(
712        self,
713        clock: AttemptClock,
714        http_status: Option<u16>,
715        provider_request_id: Option<TraceId>,
716        stop_reason: Option<StopReason>,
717        usage: Option<UsageMeta>,
718        terminal: TurnOutcome,
719    ) {
720        let outcome = TransportOutcome {
721            timings: clock.stages(),
722            retries: self.retries.clone(),
723            provider_request_id,
724            http_status,
725            stop_reason,
726            usage,
727            terminal,
728        };
729        self.emit_record(outcome);
730    }
731
732    /// A typed terminal for an attempt that failed: the correlation ID is
733    /// the trace request ID — never a raw error string.
734    pub fn failed_terminal(&self, code: &str) -> TurnOutcome {
735        TurnOutcome::ProviderFailed {
736            code: code.to_string(),
737            correlation_id: self.request_id.as_str().to_string(),
738        }
739    }
740
741    /// Attach (or clear) the provider's cache diagnosis for the attempt
742    /// that is about to finish. Set per attempt so a retry never inherits
743    /// the previous attempt's verdict.
744    pub fn set_server_diagnosis(&mut self, diagnosis: Option<super::ServerCacheDiagnosis>) {
745        self.structure.cache.server_diagnosis = diagnosis;
746    }
747
748    fn emit_record(&self, outcome: TransportOutcome) {
749        let record = RequestTrace {
750            schema: TraceSchemaVersion,
751            session_id: self.session_id.clone(),
752            turn_id: self.turn_id.clone(),
753            request_id: self.request_id.clone(),
754            execution_events: self
755                .ctx
756                .execution_events
757                .lock()
758                .unwrap_or_else(std::sync::PoisonError::into_inner)
759                .get(self.request_id.as_str())
760                .cloned()
761                .unwrap_or_default(),
762            attempt: self.attempt,
763            model: self.model.clone(),
764            transport: self.transport,
765            endpoint: self.endpoint.clone(),
766            anatomy: self.structure.anatomy,
767            wire: self.structure.wire.clone(),
768            system_segments: self.structure.system_segments.clone(),
769            messages: self.structure.messages.clone(),
770            tools: self.structure.tools.clone(),
771            cache: self.structure.cache.clone(),
772            translation_losses: self.structure.translation.clone(),
773            outcome,
774        };
775        self.ctx.sink.emit(record);
776    }
777}