Skip to main content

graphforge_observability/
lib.rs

1//! Rust-owned, disabled-by-default telemetry runtime.
2//!
3//! The runtime never installs a process-global provider or subscriber. Enabled
4//! runtimes own one bounded worker and export only the finite semantic contract
5//! declared in this module.
6#![forbid(unsafe_code)]
7
8use serde::Serialize;
9use serde_json::{Value, json};
10use std::collections::BTreeMap;
11use std::fmt;
12use std::sync::atomic::{AtomicU8, AtomicU64, Ordering};
13use std::sync::mpsc::{self, Receiver, SyncSender, TrySendError};
14use std::sync::{Arc, Mutex};
15use std::thread;
16use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH};
17use url::Url;
18
19/// Version of the GraphForge telemetry semantic contract.
20pub const SEMANTIC_CONTRACT_VERSION: u16 = 1;
21/// Instrumentation scope used by every GraphForge signal.
22pub const INSTRUMENTATION_SCOPE: &str = "io.graphforge.engine";
23/// Instrumentation scope version.
24pub const INSTRUMENTATION_SCOPE_VERSION: &str = "1";
25
26/// Stable configuration error codes projected by every binding.
27#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize)]
28#[serde(rename_all = "snake_case")]
29pub enum TelemetryConfigErrorCode {
30    /// A numeric bound is outside the supported range.
31    InvalidBound,
32    /// The batch size is larger than its queue.
33    BatchExceedsQueue,
34    /// An OTLP endpoint is absent or unsafe to retain.
35    InvalidEndpoint,
36    /// An OTLP header name or value is invalid.
37    InvalidHeader,
38    /// OTLP settings were supplied for a different mode.
39    InvalidMode,
40    /// The bounded exporter worker could not be created.
41    ExporterUnavailable,
42}
43
44impl TelemetryConfigErrorCode {
45    /// Stable cross-language code.
46    #[must_use]
47    pub const fn as_str(self) -> &'static str {
48        match self {
49            Self::InvalidBound => "GF_TELEMETRY_INVALID_BOUND",
50            Self::BatchExceedsQueue => "GF_TELEMETRY_BATCH_EXCEEDS_QUEUE",
51            Self::InvalidEndpoint => "GF_TELEMETRY_INVALID_ENDPOINT",
52            Self::InvalidHeader => "GF_TELEMETRY_INVALID_HEADER",
53            Self::InvalidMode => "GF_TELEMETRY_INVALID_MODE",
54            Self::ExporterUnavailable => "GF_TELEMETRY_EXPORTER_UNAVAILABLE",
55        }
56    }
57}
58
59/// Sanitized configuration failure.
60#[derive(Clone, Debug, Eq, PartialEq, thiserror::Error)]
61#[error("GraphForge telemetry configuration rejected ({code:?})")]
62pub struct TelemetryConfigError {
63    /// Stable, credential-free classification.
64    pub code: TelemetryConfigErrorCode,
65}
66
67/// Runtime mode. Disabled is the default and owns no heap state.
68#[derive(Clone, Copy, Debug, Default, Eq, PartialEq, Serialize)]
69#[serde(rename_all = "snake_case")]
70pub enum TelemetryMode {
71    /// No worker, timer, DNS, socket, or record allocation.
72    #[default]
73    Disabled,
74    /// Deterministic snapshots for tests and embedding validation.
75    InMemory,
76    /// OTLP/HTTP JSON export to an explicitly supplied endpoint.
77    OtlpHttpJson,
78}
79
80/// OTLP transport configuration. Debug output deliberately redacts headers.
81#[derive(Clone, Eq, PartialEq)]
82pub struct OtlpConfig {
83    /// Collector base URL; `/v1/{signal}` is appended.
84    pub endpoint: String,
85    /// Explicit request headers, commonly authorization material.
86    pub headers: BTreeMap<String, String>,
87}
88
89impl fmt::Debug for OtlpConfig {
90    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
91        formatter
92            .debug_struct("OtlpConfig")
93            .field("endpoint", &redacted_endpoint(&self.endpoint))
94            .field(
95                "headers",
96                &format_args!("<redacted:{}>", self.headers.len()),
97            )
98            .finish()
99    }
100}
101
102/// Fully bounded runtime policy. No environment variables are read.
103#[derive(Clone, Debug, Eq, PartialEq)]
104pub struct TelemetryConfig {
105    /// Explicit runtime mode.
106    pub mode: TelemetryMode,
107    /// Maximum queued records.
108    pub queue_capacity: usize,
109    /// Maximum records exported together.
110    pub batch_size: usize,
111    /// Maximum delay before a partial batch is exported.
112    pub scheduled_delay: Duration,
113    /// Per-attempt network bound.
114    pub export_timeout: Duration,
115    /// Caller-visible flush/shutdown bound.
116    pub lifecycle_timeout: Duration,
117    /// Maximum additional export attempts.
118    pub max_retries: u8,
119    /// Initial retry delay; doubles up to the lifecycle bound.
120    pub retry_backoff: Duration,
121    /// OTLP settings, required only for OTLP mode.
122    pub otlp: Option<OtlpConfig>,
123}
124
125impl Default for TelemetryConfig {
126    fn default() -> Self {
127        Self {
128            mode: TelemetryMode::Disabled,
129            queue_capacity: 256,
130            batch_size: 64,
131            scheduled_delay: Duration::from_secs(5),
132            export_timeout: Duration::from_secs(3),
133            lifecycle_timeout: Duration::from_secs(5),
134            max_retries: 2,
135            retry_backoff: Duration::from_millis(100),
136            otlp: None,
137        }
138    }
139}
140
141impl TelemetryConfig {
142    fn validate(&self) -> Result<Option<ValidatedOtlp>, TelemetryConfigError> {
143        if self.queue_capacity == 0
144            || self.queue_capacity > 65_536
145            || self.batch_size == 0
146            || self.scheduled_delay.is_zero()
147            || self.scheduled_delay > Duration::from_mins(1)
148            || self.export_timeout.is_zero()
149            || self.export_timeout > Duration::from_secs(30)
150            || self.lifecycle_timeout.is_zero()
151            || self.lifecycle_timeout > Duration::from_mins(1)
152            || self.max_retries > 5
153            || self.retry_backoff > Duration::from_secs(5)
154        {
155            return Err(config_error(TelemetryConfigErrorCode::InvalidBound));
156        }
157        if self.batch_size > self.queue_capacity {
158            return Err(config_error(TelemetryConfigErrorCode::BatchExceedsQueue));
159        }
160        match (self.mode, &self.otlp) {
161            (TelemetryMode::OtlpHttpJson, Some(config)) => validate_otlp(config).map(Some),
162            (TelemetryMode::OtlpHttpJson, None) => {
163                Err(config_error(TelemetryConfigErrorCode::InvalidEndpoint))
164            }
165            (_, Some(_)) => Err(config_error(TelemetryConfigErrorCode::InvalidMode)),
166            (_, None) => Ok(None),
167        }
168    }
169}
170
171fn config_error(code: TelemetryConfigErrorCode) -> TelemetryConfigError {
172    TelemetryConfigError { code }
173}
174
175#[derive(Clone)]
176struct ValidatedOtlp {
177    endpoint: Url,
178    headers: BTreeMap<String, String>,
179}
180
181fn validate_otlp(config: &OtlpConfig) -> Result<ValidatedOtlp, TelemetryConfigError> {
182    let endpoint = Url::parse(&config.endpoint)
183        .map_err(|_| config_error(TelemetryConfigErrorCode::InvalidEndpoint))?;
184    if !matches!(endpoint.scheme(), "http" | "https")
185        || endpoint.host_str().is_none()
186        || !endpoint.username().is_empty()
187        || endpoint.password().is_some()
188        || endpoint.query().is_some()
189        || endpoint.fragment().is_some()
190    {
191        return Err(config_error(TelemetryConfigErrorCode::InvalidEndpoint));
192    }
193    for (name, value) in &config.headers {
194        if name.is_empty()
195            || name.len() > 128
196            || !name
197                .bytes()
198                .all(|byte| byte.is_ascii_lowercase() || byte.is_ascii_digit() || byte == b'-')
199            || value.is_empty()
200            || value.len() > 4096
201            || value.bytes().any(|byte| matches!(byte, b'\r' | b'\n'))
202        {
203            return Err(config_error(TelemetryConfigErrorCode::InvalidHeader));
204        }
205    }
206    Ok(ValidatedOtlp {
207        endpoint,
208        headers: config.headers.clone(),
209    })
210}
211
212fn redacted_endpoint(raw: &str) -> String {
213    Url::parse(raw).map_or_else(
214        |_| "<invalid-endpoint>".to_owned(),
215        |mut endpoint| {
216            let _ = endpoint.set_username("");
217            let _ = endpoint.set_password(None);
218            endpoint.set_query(None);
219            endpoint.set_fragment(None);
220            endpoint.to_string()
221        },
222    )
223}
224
225macro_rules! semantic_enum {
226    ($name:ident { $($variant:ident => $value:literal),+ $(,)? }) => {
227        #[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize)]
228        #[serde(rename_all = "snake_case")]
229        #[allow(missing_docs)]
230        pub enum $name { $($variant),+ }
231        impl $name { #[must_use] #[allow(missing_docs)] pub const fn as_str(self) -> &'static str { match self { $(Self::$variant => $value),+ } } }
232    };
233}
234
235semantic_enum!(Operation {
236    Query => "query", Mutation => "mutation", Persist => "persist", Import => "import",
237    Export => "export", Clone => "clone", Inspect => "inspect", Maintenance => "maintenance"
238});
239semantic_enum!(Stage {
240    Parse => "parse", Bind => "bind", Plan => "plan", Execute => "execute",
241    Commit => "commit", Transfer => "transfer", Recover => "recover",
242    IdentityValidation => "identity_validation", RefsDiscovery => "refs_discovery",
243    ManifestDiscovery => "manifest_discovery", Download => "download",
244    PortableVerification => "portable_verification", AtomicImport => "atomic_import",
245    Reopen => "reopen", Cleanup => "cleanup", Orchestration => "orchestration"
246});
247semantic_enum!(Outcome { Ok => "ok", Denied => "denied", Cancelled => "cancelled", Failed => "failed" });
248semantic_enum!(Failure {
249    InvalidInput => "invalid_input", ResourceLimit => "resource_limit", Storage => "storage",
250    Network => "network", Provider => "provider", Internal => "internal"
251});
252semantic_enum!(Limit { Time => "time", Memory => "memory", Rows => "rows", Bytes => "bytes", Concurrency => "concurrency" });
253semantic_enum!(JobFamily {
254    Query => "query", Import => "import", Export => "export", Clone => "clone",
255    Checkpoint => "checkpoint", Recovery => "recovery", Publication => "publication", Maintenance => "maintenance"
256});
257semantic_enum!(WaitReason {
258    Queue => "queue", WorkspaceLock => "workspace_lock", Storage => "storage",
259    Network => "network", Provider => "provider", Backoff => "backoff"
260});
261semantic_enum!(ComponentKind {
262    Cli => "cli", Api => "api", Discovery => "discovery", CypherParser => "cypher_parser",
263    Ir => "ir", RelationalPlanner => "relational_planner", Executor => "executor",
264    Adjacency => "adjacency", Storage => "storage", TextSearch => "text_search",
265    VectorSearch => "vector_search", Provider => "provider", PortableVerify => "portable_verify",
266    PortableImport => "portable_import", PortableExport => "portable_export", Checkpoint => "checkpoint",
267    Recovery => "recovery", Publication => "publication", NetworkTransport => "network_transport"
268});
269semantic_enum!(ComponentRole {
270    Facade => "facade", Parser => "parser", Planner => "planner", Compute => "compute",
271    Index => "index", Persistence => "persistence", Verification => "verification",
272    Transfer => "transfer", Coordination => "coordination"
273});
274semantic_enum!(HandoffKind { Call => "call", Read => "read", Write => "write", Transfer => "transfer", Return => "return" });
275
276/// One non-overlapping active interval in a local workspace job.
277#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize)]
278pub struct JobStage {
279    /// Finite stage class.
280    pub stage: Stage,
281    /// Component that actually ran.
282    pub component: ComponentKind,
283    /// Finite role played by that component.
284    pub component_role: ComponentRole,
285    /// Offset from job start, in nanoseconds.
286    pub start_offset_ns: u64,
287    /// Wall duration, in nanoseconds.
288    pub duration_ns: u64,
289    /// Time within the interval spent waiting.
290    pub wait_duration_ns: u64,
291    /// Finite reason when wait duration is nonzero.
292    pub wait_reason: Option<WaitReason>,
293    /// One-based attempt number.
294    pub attempt: u32,
295    /// Exact byte count, when the operation already knows it.
296    pub bytes: Option<u64>,
297    /// Bytes already present before this attempt, when the operation knows it.
298    pub resumed_bytes: Option<u64>,
299    /// Exact record count, when the operation already knows it.
300    pub records: Option<u64>,
301    /// Normalized stage outcome.
302    pub outcome: Outcome,
303}
304
305/// One ordered handoff between components actually used by a job.
306#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize)]
307pub struct ComponentHandoff {
308    /// Offset from job start where the boundary was observed.
309    pub start_offset_ns: u64,
310    /// Calling component.
311    pub from: ComponentKind,
312    /// Receiving component.
313    pub to: ComponentKind,
314    /// Finite handoff class.
315    pub kind: HandoffKind,
316    /// Handoff wall duration, in nanoseconds.
317    pub duration_ns: u64,
318    /// Explicit wait within the handoff.
319    pub wait_duration_ns: u64,
320    /// Exact bytes, when known.
321    pub bytes: Option<u64>,
322    /// Exact records, when known.
323    pub records: Option<u64>,
324}
325
326/// Complete, deterministic timing record for one local workspace job.
327#[derive(Clone, Debug, Eq, PartialEq, Serialize)]
328pub struct JobSnapshot {
329    /// Finite job family.
330    pub family: JobFamily,
331    /// Enqueue boundary on the job's monotonic timeline.
332    pub enqueued_ns: u64,
333    /// Start boundary on the same timeline.
334    pub started_ns: u64,
335    /// Finish boundary on the same timeline.
336    pub finished_ns: u64,
337    /// Normalized job outcome.
338    pub outcome: Outcome,
339    /// Normalized failure class; raw provider or engine errors are never retained.
340    pub failure: Option<Failure>,
341    /// Non-overlapping active intervals in chronological order.
342    pub stages: Vec<JobStage>,
343    /// Ordered component transfers actually observed.
344    pub handoffs: Vec<ComponentHandoff>,
345}
346
347/// Invalid local-job timing or component accounting.
348#[derive(Clone, Copy, Debug, Eq, PartialEq, thiserror::Error)]
349#[error("invalid GraphForge local-job telemetry")]
350pub struct InvalidJobSnapshot;
351
352impl JobSnapshot {
353    /// Validate timing math and deny ambiguous or overlapping attribution.
354    pub fn validate(&self) -> Result<(), InvalidJobSnapshot> {
355        if self.enqueued_ns > self.started_ns || self.started_ns > self.finished_ns {
356            return Err(InvalidJobSnapshot);
357        }
358        let active = self.finished_ns - self.started_ns;
359        let mut cursor = 0_u64;
360        for stage in &self.stages {
361            if stage.attempt == 0
362                || stage.duration_ns == 0
363                || stage.wait_duration_ns > stage.duration_ns
364                || (stage.wait_duration_ns == 0) != stage.wait_reason.is_none()
365                || stage.start_offset_ns != cursor
366            {
367                return Err(InvalidJobSnapshot);
368            }
369            cursor = cursor
370                .checked_add(stage.duration_ns)
371                .ok_or(InvalidJobSnapshot)?;
372        }
373        if cursor != active {
374            return Err(InvalidJobSnapshot);
375        }
376        if self.handoffs.iter().any(|handoff| {
377            handoff.from == handoff.to
378                || handoff.start_offset_ns > active
379                || handoff.wait_duration_ns > handoff.duration_ns
380        }) || self
381            .handoffs
382            .windows(2)
383            .any(|pair| pair[0].start_offset_ns > pair[1].start_offset_ns)
384        {
385            return Err(InvalidJobSnapshot);
386        }
387        Ok(())
388    }
389
390    /// Queue delay in nanoseconds.
391    #[must_use]
392    pub const fn queue_delay_ns(&self) -> u64 {
393        self.started_ns - self.enqueued_ns
394    }
395
396    /// Active wall time in nanoseconds.
397    #[must_use]
398    pub const fn active_duration_ns(&self) -> u64 {
399        self.finished_ns - self.started_ns
400    }
401
402    /// Total elapsed wall time in nanoseconds.
403    #[must_use]
404    pub const fn total_duration_ns(&self) -> u64 {
405        self.finished_ns - self.enqueued_ns
406    }
407}
408
409/// Closed attribute set. Operation code cannot add arbitrary keys or values.
410#[derive(Clone, Copy, Debug, Default, Eq, PartialEq, Serialize)]
411pub struct Attributes {
412    /// Finite operation class.
413    #[serde(skip_serializing_if = "Option::is_none")]
414    pub operation: Option<Operation>,
415    /// Finite lifecycle stage.
416    #[serde(skip_serializing_if = "Option::is_none")]
417    pub stage: Option<Stage>,
418    /// Finite outcome class.
419    #[serde(skip_serializing_if = "Option::is_none")]
420    pub outcome: Option<Outcome>,
421    /// Finite failure class.
422    #[serde(skip_serializing_if = "Option::is_none")]
423    pub failure: Option<Failure>,
424    /// Finite resource-limit class.
425    #[serde(skip_serializing_if = "Option::is_none")]
426    pub limit: Option<Limit>,
427}
428
429impl Attributes {
430    /// Start an allowlisted attribute set for one operation.
431    #[must_use]
432    pub const fn for_operation(operation: Operation) -> Self {
433        Self {
434            operation: Some(operation),
435            stage: None,
436            outcome: None,
437            failure: None,
438            limit: None,
439        }
440    }
441    /// Add a registered stage.
442    #[must_use]
443    pub const fn stage(mut self, stage: Stage) -> Self {
444        self.stage = Some(stage);
445        self
446    }
447    /// Add a registered outcome.
448    #[must_use]
449    pub const fn outcome(mut self, outcome: Outcome) -> Self {
450        self.outcome = Some(outcome);
451        self
452    }
453    /// Add a registered failure class.
454    #[must_use]
455    pub const fn failure(mut self, failure: Failure) -> Self {
456        self.failure = Some(failure);
457        self
458    }
459    /// Add a registered limit class.
460    #[must_use]
461    pub const fn limit(mut self, limit: Limit) -> Self {
462        self.limit = Some(limit);
463        self
464    }
465}
466
467/// OpenTelemetry signal family.
468#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize)]
469#[serde(rename_all = "snake_case")]
470pub enum SignalKind {
471    /// Span/trace signal.
472    Trace,
473    /// Numeric metric signal.
474    Metric,
475    /// Structured log event.
476    Event,
477}
478
479/// Versioned signal registry used by later instrumentation stories.
480#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize)]
481pub enum Signal {
482    /// One complete local workspace-job timing trace.
483    LocalJobTrace,
484    /// One bounded operation trace.
485    OperationTrace,
486    /// Monotonic operation counter.
487    OperationCount,
488    /// Structured lifecycle diagnostic.
489    LifecycleEvent,
490}
491
492/// Static metadata for one registered signal.
493#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize)]
494pub struct SignalDescriptor {
495    /// Stable signal name.
496    pub name: &'static str,
497    /// OpenTelemetry signal family.
498    pub kind: SignalKind,
499    /// UCUM unit.
500    pub unit: &'static str,
501}
502
503impl Signal {
504    /// Registered name, signal kind, and UCUM unit.
505    #[must_use]
506    pub const fn descriptor(self) -> SignalDescriptor {
507        match self {
508            Self::LocalJobTrace => SignalDescriptor {
509                name: "graphforge.workspace.job",
510                kind: SignalKind::Trace,
511                unit: "ns",
512            },
513            Self::OperationTrace => SignalDescriptor {
514                name: "graphforge.operation",
515                kind: SignalKind::Trace,
516                unit: "ns",
517            },
518            Self::OperationCount => SignalDescriptor {
519                name: "graphforge.operation.count",
520                kind: SignalKind::Metric,
521                unit: "{operation}",
522            },
523            Self::LifecycleEvent => SignalDescriptor {
524                name: "graphforge.lifecycle",
525                kind: SignalKind::Event,
526                unit: "1",
527            },
528        }
529    }
530}
531
532/// Immutable deterministic in-memory record.
533#[derive(Clone, Debug, Eq, PartialEq, Serialize)]
534pub struct Snapshot {
535    /// Semantic contract version.
536    pub contract_version: u16,
537    /// Instrumentation scope.
538    pub scope: &'static str,
539    /// Instrumentation scope version.
540    pub scope_version: &'static str,
541    /// Registered descriptor.
542    pub signal: SignalDescriptor,
543    /// Allowlisted attributes.
544    pub attributes: Attributes,
545    /// Typed local-job detail, present only for the local-job trace signal.
546    #[serde(skip_serializing_if = "Option::is_none")]
547    pub job: Option<JobSnapshot>,
548}
549
550/// Non-failing recording result.
551#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize)]
552#[serde(rename_all = "snake_case")]
553pub enum RecordStatus {
554    /// Runtime is disabled.
555    Disabled,
556    /// Record entered the bounded queue.
557    Accepted,
558    /// Queue was full; the graph operation remains unaffected.
559    DroppedQueueFull,
560    /// Runtime was already stopped.
561    Shutdown,
562}
563
564/// Stable lifecycle result. Failures are diagnostics, never graph errors.
565#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize)]
566#[serde(rename_all = "snake_case")]
567pub enum LifecycleStatus {
568    /// Runtime is disabled.
569    Disabled,
570    /// Lifecycle request completed.
571    Complete,
572    /// Configured caller bound elapsed.
573    TimedOut,
574    /// One or more exports failed.
575    ExportFailed,
576    /// Runtime was already stopped.
577    AlreadyShutdown,
578}
579
580impl LifecycleStatus {
581    /// Stable cross-language status.
582    #[must_use]
583    pub const fn as_str(self) -> &'static str {
584        match self {
585            Self::Disabled => "disabled",
586            Self::Complete => "complete",
587            Self::TimedOut => "timed_out",
588            Self::ExportFailed => "export_failed",
589            Self::AlreadyShutdown => "already_shutdown",
590        }
591    }
592}
593
594/// Sanitized bounded runtime counters.
595#[derive(Clone, Copy, Debug, Default, Eq, PartialEq, Serialize)]
596pub struct TelemetryStats {
597    /// Records accepted into the queue.
598    pub accepted: u64,
599    /// Records dropped on saturation.
600    pub dropped: u64,
601    /// Failed batches.
602    pub export_failures: u64,
603}
604
605/// Cloneable explicit GraphForge telemetry provider handle.
606#[derive(Clone, Default)]
607pub struct TelemetryRuntime {
608    active: Option<Arc<ActiveRuntime>>,
609}
610
611impl fmt::Debug for TelemetryRuntime {
612    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
613        formatter.write_str(if self.active.is_some() {
614            "TelemetryRuntime::Active"
615        } else {
616            "TelemetryRuntime::Disabled"
617        })
618    }
619}
620
621/// Alias emphasizing that clones share one explicit provider lifetime.
622pub type TelemetryGuard = TelemetryRuntime;
623
624struct ActiveRuntime {
625    sender: SyncSender<Command>,
626    lifecycle_timeout: Duration,
627    state: AtomicU8,
628    accepted: AtomicU64,
629    dropped: AtomicU64,
630    export_failures: Arc<AtomicU64>,
631    snapshots: Option<Arc<Mutex<Vec<Snapshot>>>>,
632}
633
634enum Command {
635    Record(Snapshot),
636    Flush(SyncSender<LifecycleStatus>),
637    Shutdown(SyncSender<LifecycleStatus>),
638}
639
640impl TelemetryRuntime {
641    /// Construct one explicit provider. No global state is read or changed.
642    pub fn new(config: TelemetryConfig) -> Result<Self, TelemetryConfigError> {
643        let validated = config.validate()?;
644        if config.mode == TelemetryMode::Disabled {
645            return Ok(Self::default());
646        }
647        let (sender, receiver) = mpsc::sync_channel(config.queue_capacity);
648        let snapshots =
649            (config.mode == TelemetryMode::InMemory).then(|| Arc::new(Mutex::new(Vec::new())));
650        let failures = Arc::new(AtomicU64::new(0));
651        let backend = match (config.mode, validated) {
652            (TelemetryMode::InMemory, None) => {
653                Backend::Memory(Arc::clone(snapshots.as_ref().expect("memory sink")))
654            }
655            (TelemetryMode::OtlpHttpJson, Some(otlp)) => {
656                Backend::Otlp(OtlpExporter::new(otlp, &config))
657            }
658            _ => return Err(config_error(TelemetryConfigErrorCode::InvalidMode)),
659        };
660        let worker_failures = Arc::clone(&failures);
661        let lifecycle_timeout = config.lifecycle_timeout;
662        thread::Builder::new()
663            .name("graphforge-otel".to_owned())
664            .spawn(move || worker(&receiver, &backend, &config, worker_failures.as_ref()))
665            .map_err(|_| config_error(TelemetryConfigErrorCode::ExporterUnavailable))?;
666        Ok(Self {
667            active: Some(Arc::new(ActiveRuntime {
668                sender,
669                lifecycle_timeout,
670                state: AtomicU8::new(0),
671                accepted: AtomicU64::new(0),
672                dropped: AtomicU64::new(0),
673                export_failures: failures,
674                snapshots,
675            })),
676        })
677    }
678
679    /// Whether this handle owns an enabled provider.
680    #[must_use]
681    pub const fn is_enabled(&self) -> bool {
682        self.active.is_some()
683    }
684
685    /// Record one registered signal without blocking or surfacing exporter failure.
686    #[must_use]
687    pub fn record(&self, signal: Signal, attributes: Attributes) -> RecordStatus {
688        let Some(active) = &self.active else {
689            return RecordStatus::Disabled;
690        };
691        if active.state.load(Ordering::Acquire) != 0 {
692            return RecordStatus::Shutdown;
693        }
694        let snapshot = Snapshot {
695            contract_version: SEMANTIC_CONTRACT_VERSION,
696            scope: INSTRUMENTATION_SCOPE,
697            scope_version: INSTRUMENTATION_SCOPE_VERSION,
698            signal: signal.descriptor(),
699            attributes,
700            job: None,
701        };
702        Self::enqueue(active, snapshot)
703    }
704
705    /// Record one validated local workspace-job trace without blocking the job.
706    pub fn record_job(&self, job: JobSnapshot) -> Result<RecordStatus, InvalidJobSnapshot> {
707        job.validate()?;
708        let Some(active) = &self.active else {
709            return Ok(RecordStatus::Disabled);
710        };
711        if active.state.load(Ordering::Acquire) != 0 {
712            return Ok(RecordStatus::Shutdown);
713        }
714        let snapshot = Snapshot {
715            contract_version: SEMANTIC_CONTRACT_VERSION,
716            scope: INSTRUMENTATION_SCOPE,
717            scope_version: INSTRUMENTATION_SCOPE_VERSION,
718            signal: Signal::LocalJobTrace.descriptor(),
719            attributes: Attributes::default(),
720            job: Some(job),
721        };
722        Ok(Self::enqueue(active, snapshot))
723    }
724
725    fn enqueue(active: &ActiveRuntime, snapshot: Snapshot) -> RecordStatus {
726        match active.sender.try_send(Command::Record(snapshot)) {
727            Ok(()) => {
728                active.accepted.fetch_add(1, Ordering::Relaxed);
729                RecordStatus::Accepted
730            }
731            Err(TrySendError::Full(_)) => {
732                active.dropped.fetch_add(1, Ordering::Relaxed);
733                RecordStatus::DroppedQueueFull
734            }
735            Err(TrySendError::Disconnected(_)) => RecordStatus::Shutdown,
736        }
737    }
738
739    /// Flush records queued before this call within the configured bound.
740    #[must_use]
741    pub fn force_flush(&self) -> LifecycleStatus {
742        self.lifecycle(false)
743    }
744
745    /// Idempotently stop this explicit provider within the configured bound.
746    #[must_use]
747    pub fn shutdown(&self) -> LifecycleStatus {
748        self.lifecycle(true)
749    }
750
751    fn lifecycle(&self, shutdown: bool) -> LifecycleStatus {
752        let Some(active) = &self.active else {
753            return LifecycleStatus::Disabled;
754        };
755        if shutdown && active.state.swap(1, Ordering::AcqRel) != 0 {
756            return LifecycleStatus::AlreadyShutdown;
757        }
758        if !shutdown && active.state.load(Ordering::Acquire) != 0 {
759            return LifecycleStatus::AlreadyShutdown;
760        }
761        let (reply, response) = mpsc::sync_channel(1);
762        let command = if shutdown {
763            Command::Shutdown(reply)
764        } else {
765            Command::Flush(reply)
766        };
767        let deadline = Instant::now() + active.lifecycle_timeout;
768        let mut pending = command;
769        loop {
770            match active.sender.try_send(pending) {
771                Ok(()) => break,
772                Err(TrySendError::Disconnected(_)) => return LifecycleStatus::AlreadyShutdown,
773                Err(TrySendError::Full(command)) => {
774                    if Instant::now() >= deadline {
775                        return LifecycleStatus::TimedOut;
776                    }
777                    pending = command;
778                    thread::yield_now();
779                }
780            }
781        }
782        response
783            .recv_timeout(deadline.saturating_duration_since(Instant::now()))
784            .unwrap_or(LifecycleStatus::TimedOut)
785    }
786
787    /// Deterministic memory snapshot; empty for disabled/OTLP modes.
788    #[must_use]
789    pub fn snapshots(&self) -> Vec<Snapshot> {
790        let Some(active) = &self.active else {
791            return Vec::new();
792        };
793        active.snapshots.as_ref().map_or_else(Vec::new, |records| {
794            records
795                .lock()
796                .unwrap_or_else(std::sync::PoisonError::into_inner)
797                .clone()
798        })
799    }
800
801    /// Sanitized counters suitable for diagnostics.
802    #[must_use]
803    pub fn stats(&self) -> TelemetryStats {
804        let Some(active) = &self.active else {
805            return TelemetryStats::default();
806        };
807        TelemetryStats {
808            accepted: active.accepted.load(Ordering::Relaxed),
809            dropped: active.dropped.load(Ordering::Relaxed),
810            export_failures: active.export_failures.load(Ordering::Relaxed),
811        }
812    }
813}
814
815impl Drop for ActiveRuntime {
816    fn drop(&mut self) {
817        if self.state.swap(1, Ordering::AcqRel) == 0 {
818            let (reply, _) = mpsc::sync_channel(1);
819            let _ = self.sender.try_send(Command::Shutdown(reply));
820        }
821    }
822}
823
824enum Backend {
825    Memory(Arc<Mutex<Vec<Snapshot>>>),
826    Otlp(OtlpExporter),
827}
828
829impl Backend {
830    fn export(&self, batch: &[Snapshot]) -> bool {
831        match self {
832            Self::Memory(records) => {
833                records
834                    .lock()
835                    .unwrap_or_else(std::sync::PoisonError::into_inner)
836                    .extend_from_slice(batch);
837                true
838            }
839            Self::Otlp(exporter) => exporter.export(batch),
840        }
841    }
842}
843
844fn worker(
845    receiver: &Receiver<Command>,
846    backend: &Backend,
847    config: &TelemetryConfig,
848    failures: &AtomicU64,
849) {
850    let mut batch = Vec::with_capacity(config.batch_size);
851    loop {
852        match receiver.recv_timeout(config.scheduled_delay) {
853            Ok(Command::Record(record)) => {
854                batch.push(record);
855                if batch.len() >= config.batch_size {
856                    export_batch(backend, &mut batch, failures);
857                }
858            }
859            Ok(Command::Flush(reply)) => {
860                export_batch(backend, &mut batch, failures);
861                let status = if failures.load(Ordering::Relaxed) == 0 {
862                    LifecycleStatus::Complete
863                } else {
864                    LifecycleStatus::ExportFailed
865                };
866                let _ = reply.try_send(status);
867            }
868            Ok(Command::Shutdown(reply)) => {
869                export_batch(backend, &mut batch, failures);
870                let status = if failures.load(Ordering::Relaxed) == 0 {
871                    LifecycleStatus::Complete
872                } else {
873                    LifecycleStatus::ExportFailed
874                };
875                let _ = reply.try_send(status);
876                break;
877            }
878            Err(mpsc::RecvTimeoutError::Timeout) => export_batch(backend, &mut batch, failures),
879            Err(mpsc::RecvTimeoutError::Disconnected) => {
880                export_batch(backend, &mut batch, failures);
881                break;
882            }
883        }
884    }
885}
886
887fn export_batch(backend: &Backend, batch: &mut Vec<Snapshot>, failures: &AtomicU64) {
888    if batch.is_empty() {
889        return;
890    }
891    if !backend.export(batch) {
892        failures.fetch_add(1, Ordering::Relaxed);
893    }
894    batch.clear();
895}
896
897struct OtlpExporter {
898    endpoint: Url,
899    headers: BTreeMap<String, String>,
900    agent: ureq::Agent,
901    max_retries: u8,
902    retry_backoff: Duration,
903    lifecycle_timeout: Duration,
904    export_sequence: AtomicU64,
905}
906
907impl OtlpExporter {
908    fn new(config: ValidatedOtlp, policy: &TelemetryConfig) -> Self {
909        let agent: ureq::Agent = ureq::Agent::config_builder()
910            .timeout_global(Some(policy.export_timeout))
911            .timeout_connect(Some(policy.export_timeout))
912            .max_redirects(0)
913            .build()
914            .into();
915        Self {
916            endpoint: config.endpoint,
917            headers: config.headers,
918            agent,
919            max_retries: policy.max_retries,
920            retry_backoff: policy.retry_backoff,
921            lifecycle_timeout: policy.lifecycle_timeout,
922            export_sequence: AtomicU64::new(export_sequence_seed()),
923        }
924    }
925
926    fn export(&self, batch: &[Snapshot]) -> bool {
927        let started = Instant::now();
928        for kind in [SignalKind::Trace, SignalKind::Metric, SignalKind::Event] {
929            let records: Vec<_> = batch
930                .iter()
931                .filter(|record| record.signal.kind == kind)
932                .collect();
933            if records.is_empty() {
934                continue;
935            }
936            let sequence = self.export_sequence.fetch_add(1, Ordering::Relaxed);
937            let body = otlp_json(kind, &records, sequence);
938            let body = serde_json::to_string(&body).expect("OTLP JSON serialization");
939            let mut attempt = 0_u8;
940            loop {
941                let mut request = self
942                    .agent
943                    .post(self.signal_url(kind).as_str())
944                    .header("content-type", "application/json");
945                for (name, value) in &self.headers {
946                    request = request.header(name, value);
947                }
948                if request.send(&body).is_ok() {
949                    break;
950                }
951                if attempt >= self.max_retries || started.elapsed() >= self.lifecycle_timeout {
952                    return false;
953                }
954                let delay = self.retry_backoff.saturating_mul(1_u32 << attempt.min(5));
955                thread::sleep(delay.min(self.lifecycle_timeout.saturating_sub(started.elapsed())));
956                attempt += 1;
957            }
958        }
959        true
960    }
961
962    fn signal_url(&self, kind: SignalKind) -> Url {
963        let suffix = match kind {
964            SignalKind::Trace => "v1/traces",
965            SignalKind::Metric => "v1/metrics",
966            SignalKind::Event => "v1/logs",
967        };
968        self.endpoint.join(suffix).expect("validated base URL")
969    }
970}
971
972fn otlp_json(kind: SignalKind, records: &[&Snapshot], sequence: u64) -> Value {
973    let scope = json!({"name": INSTRUMENTATION_SCOPE, "version": INSTRUMENTATION_SCOPE_VERSION});
974    match kind {
975        SignalKind::Trace => {
976            let exported_at = unix_nanos_value();
977            let spans = records
978                .iter()
979                .enumerate()
980                .flat_map(|(index, record)| trace_spans(record, sequence, index, exported_at))
981                .collect::<Vec<_>>();
982            json!({"resourceSpans":[{"scopeSpans":[{"scope":scope,"spans":spans}]}]})
983        }
984        SignalKind::Metric => {
985            json!({"resourceMetrics":[{"scopeMetrics":[{"scope":scope,"metrics":records.iter().map(|record| json!({"name":record.signal.name,"unit":record.signal.unit,"sum":{"aggregationTemporality":2,"isMonotonic":true,"dataPoints":[{"asInt":"1","timeUnixNano":unix_nanos(),"attributes":otel_attributes(record.attributes)}]}})).collect::<Vec<_>>() }]}]})
986        }
987        SignalKind::Event => {
988            json!({"resourceLogs":[{"scopeLogs":[{"scope":scope,"logRecords":records.iter().map(|record| json!({"timeUnixNano":unix_nanos(),"severityNumber":9,"body":{"stringValue":record.signal.name},"attributes":otel_attributes(record.attributes)})).collect::<Vec<_>>() }]}]})
989        }
990    }
991}
992
993fn trace_spans(record: &Snapshot, sequence: u64, index: usize, exported_at: u128) -> Vec<Value> {
994    let trace_id = format!("{sequence:016x}{:016x}", index as u64 + 1);
995    let root_span_id = span_id(sequence, index as u64 + 1);
996    let Some(job) = &record.job else {
997        return vec![json!({
998            "traceId": trace_id,
999            "spanId": root_span_id,
1000            "name": record.signal.name,
1001            "kind": 1,
1002            "startTimeUnixNano": exported_at.saturating_sub(1).to_string(),
1003            "endTimeUnixNano": exported_at.to_string(),
1004            "attributes": otel_attributes(record.attributes)
1005        })];
1006    };
1007
1008    let total = u128::from(job.total_duration_ns());
1009    let root_start = exported_at.saturating_sub(total);
1010    let active_start = root_start + u128::from(job.queue_delay_ns());
1011    let mut root_attributes = vec![
1012        otel_string("graphforge.job.family", job.family.as_str()),
1013        otel_string("graphforge.outcome", job.outcome.as_str()),
1014        otel_int("graphforge.job.queue_delay_ns", job.queue_delay_ns()),
1015        otel_int(
1016            "graphforge.job.active_duration_ns",
1017            job.active_duration_ns(),
1018        ),
1019        otel_int("graphforge.job.total_duration_ns", job.total_duration_ns()),
1020        otel_int("graphforge.job.stage_count", job.stages.len() as u64),
1021        otel_int("graphforge.job.handoff_count", job.handoffs.len() as u64),
1022    ];
1023    if let Some(failure) = job.failure {
1024        root_attributes.push(otel_string("graphforge.failure", failure.as_str()));
1025    }
1026    root_attributes.extend(otel_attributes(record.attributes));
1027    let events = job
1028        .handoffs
1029        .iter()
1030        .enumerate()
1031        .map(|(handoff_index, handoff)| {
1032            let mut attributes = vec![
1033                otel_int("graphforge.handoff.index", handoff_index as u64),
1034                otel_string("graphforge.handoff.from", handoff.from.as_str()),
1035                otel_string("graphforge.handoff.to", handoff.to.as_str()),
1036                otel_string("graphforge.handoff.kind", handoff.kind.as_str()),
1037                otel_int("graphforge.handoff.duration_ns", handoff.duration_ns),
1038                otel_int("graphforge.handoff.wait_duration_ns", handoff.wait_duration_ns),
1039            ];
1040            push_optional_int(&mut attributes, "graphforge.handoff.bytes", handoff.bytes);
1041            push_optional_int(&mut attributes, "graphforge.handoff.records", handoff.records);
1042            json!({"timeUnixNano":(active_start + u128::from(handoff.start_offset_ns)).to_string(),"name":"graphforge.component.handoff","attributes":attributes})
1043        })
1044        .collect::<Vec<_>>();
1045    let mut spans = vec![json!({
1046        "traceId": trace_id,
1047        "spanId": root_span_id,
1048        "name": record.signal.name,
1049        "kind": 1,
1050        "startTimeUnixNano": root_start.to_string(),
1051        "endTimeUnixNano": exported_at.to_string(),
1052        "attributes": root_attributes,
1053        "events": events
1054    })];
1055    spans.extend(job.stages.iter().enumerate().map(|(stage_index, stage)| {
1056        let start = active_start + u128::from(stage.start_offset_ns);
1057        let end = start + u128::from(stage.duration_ns);
1058        let mut attributes = vec![
1059            otel_string("graphforge.stage", stage.stage.as_str()),
1060            otel_string(
1061                "graphforge.workspace.component.kind",
1062                stage.component.as_str(),
1063            ),
1064            otel_string(
1065                "graphforge.workspace.component.role",
1066                stage.component_role.as_str(),
1067            ),
1068            otel_int("graphforge.stage.duration_ns", stage.duration_ns),
1069            otel_int("graphforge.stage.wait_duration_ns", stage.wait_duration_ns),
1070            otel_int("graphforge.stage.attempt", u64::from(stage.attempt)),
1071            otel_string("graphforge.outcome", stage.outcome.as_str()),
1072        ];
1073        if let Some(reason) = stage.wait_reason {
1074            attributes.push(otel_string("graphforge.stage.wait_reason", reason.as_str()));
1075        }
1076        push_optional_int(&mut attributes, "graphforge.stage.bytes", stage.bytes);
1077        push_optional_int(
1078            &mut attributes,
1079            "graphforge.stage.resumed_bytes",
1080            stage.resumed_bytes,
1081        );
1082        push_optional_int(&mut attributes, "graphforge.stage.records", stage.records);
1083        json!({
1084            "traceId": trace_id,
1085            "spanId": span_id(sequence, stage_index as u64 + 2),
1086            "parentSpanId": root_span_id,
1087            "name": "graphforge.workspace.stage",
1088            "kind": 1,
1089            "startTimeUnixNano": start.to_string(),
1090            "endTimeUnixNano": end.to_string(),
1091            "attributes": attributes
1092        })
1093    }));
1094    spans
1095}
1096
1097fn span_id(sequence: u64, ordinal: u64) -> String {
1098    format!(
1099        "{:016x}",
1100        sequence
1101            .wrapping_mul(0x9e37_79b9_7f4a_7c15)
1102            .wrapping_add(ordinal)
1103    )
1104}
1105
1106fn otel_string(key: &'static str, value: &'static str) -> Value {
1107    json!({"key":key,"value":{"stringValue":value}})
1108}
1109
1110fn otel_int(key: &'static str, value: u64) -> Value {
1111    json!({"key":key,"value":{"intValue":value.to_string()}})
1112}
1113
1114fn push_optional_int(attributes: &mut Vec<Value>, key: &'static str, value: Option<u64>) {
1115    if let Some(value) = value {
1116        attributes.push(otel_int(key, value));
1117    }
1118}
1119
1120fn unix_nanos_value() -> u128 {
1121    SystemTime::now()
1122        .duration_since(UNIX_EPOCH)
1123        .unwrap_or_default()
1124        .as_nanos()
1125}
1126
1127fn export_sequence_seed() -> u64 {
1128    let now = SystemTime::now()
1129        .duration_since(UNIX_EPOCH)
1130        .unwrap_or_default();
1131    (now.as_secs().rotate_left(32) ^ u64::from(now.subsec_nanos())).max(1)
1132}
1133
1134fn unix_nanos() -> String {
1135    unix_nanos_value().to_string()
1136}
1137
1138fn otel_attributes(attributes: Attributes) -> Vec<Value> {
1139    let mut values = Vec::with_capacity(5);
1140    if let Some(value) = attributes.operation {
1141        values.push(json!({"key":"graphforge.operation","value":{"stringValue":value.as_str()}}));
1142    }
1143    if let Some(value) = attributes.stage {
1144        values.push(json!({"key":"graphforge.stage","value":{"stringValue":value.as_str()}}));
1145    }
1146    if let Some(value) = attributes.outcome {
1147        values.push(json!({"key":"graphforge.outcome","value":{"stringValue":value.as_str()}}));
1148    }
1149    if let Some(value) = attributes.failure {
1150        values.push(json!({"key":"graphforge.failure","value":{"stringValue":value.as_str()}}));
1151    }
1152    if let Some(value) = attributes.limit {
1153        values.push(json!({"key":"graphforge.limit","value":{"stringValue":value.as_str()}}));
1154    }
1155    values
1156}
1157
1158#[cfg(test)]
1159mod tests {
1160    use super::*;
1161    use std::io::{Read, Write};
1162    use std::net::TcpListener;
1163
1164    #[test]
1165    fn default_is_zero_state_disabled_and_idempotent() {
1166        let runtime = TelemetryRuntime::default();
1167        assert_eq!(format!("{runtime:?}"), "TelemetryRuntime::Disabled");
1168        assert!(!runtime.is_enabled());
1169        assert_eq!(
1170            runtime.record(Signal::OperationCount, Attributes::default()),
1171            RecordStatus::Disabled
1172        );
1173        assert_eq!(runtime.force_flush(), LifecycleStatus::Disabled);
1174        assert_eq!(runtime.shutdown(), LifecycleStatus::Disabled);
1175        assert_eq!(runtime.stats(), TelemetryStats::default());
1176    }
1177
1178    #[test]
1179    fn memory_snapshots_use_only_the_registry() {
1180        let runtime = TelemetryRuntime::new(TelemetryConfig {
1181            mode: TelemetryMode::InMemory,
1182            ..TelemetryConfig::default()
1183        })
1184        .unwrap();
1185        let attributes = Attributes::for_operation(Operation::Query)
1186            .stage(Stage::Execute)
1187            .outcome(Outcome::Ok);
1188        for signal in [
1189            Signal::OperationTrace,
1190            Signal::OperationCount,
1191            Signal::LifecycleEvent,
1192        ] {
1193            assert_eq!(runtime.record(signal, attributes), RecordStatus::Accepted);
1194        }
1195        assert_eq!(runtime.force_flush(), LifecycleStatus::Complete);
1196        let snapshots = runtime.snapshots();
1197        assert_eq!(snapshots.len(), 3);
1198        assert!(
1199            snapshots
1200                .iter()
1201                .all(|snapshot| snapshot.scope == INSTRUMENTATION_SCOPE
1202                    && snapshot.contract_version == SEMANTIC_CONTRACT_VERSION
1203                    && snapshot.attributes == attributes)
1204        );
1205        assert_eq!(runtime.shutdown(), LifecycleStatus::Complete);
1206        assert_eq!(runtime.shutdown(), LifecycleStatus::AlreadyShutdown);
1207    }
1208
1209    #[test]
1210    fn configuration_errors_and_debug_output_never_expose_secrets() {
1211        let secret = "gf-secret-canary";
1212        let config = TelemetryConfig {
1213            mode: TelemetryMode::OtlpHttpJson,
1214            otlp: Some(OtlpConfig {
1215                endpoint: format!("https://user:{secret}@collector.example/v1?token={secret}"),
1216                headers: BTreeMap::from([("authorization".into(), format!("Bearer {secret}"))]),
1217            }),
1218            ..TelemetryConfig::default()
1219        };
1220        let rendered = format!("{config:?}");
1221        assert!(!rendered.contains(secret));
1222        let error = TelemetryRuntime::new(config).unwrap_err();
1223        assert_eq!(error.code, TelemetryConfigErrorCode::InvalidEndpoint);
1224        assert!(!error.to_string().contains(secret));
1225    }
1226
1227    #[test]
1228    fn bounded_queue_saturation_is_fail_open() {
1229        let runtime = TelemetryRuntime::new(TelemetryConfig {
1230            mode: TelemetryMode::InMemory,
1231            queue_capacity: 1,
1232            batch_size: 1,
1233            ..TelemetryConfig::default()
1234        })
1235        .unwrap();
1236        for _ in 0..10_000 {
1237            let _ = runtime.record(Signal::OperationCount, Attributes::default());
1238        }
1239        assert_eq!(runtime.force_flush(), LifecycleStatus::Complete);
1240        let stats = runtime.stats();
1241        assert_eq!(stats.accepted + stats.dropped, 10_000);
1242    }
1243
1244    #[test]
1245    fn clones_share_one_runtime_and_drop_order_does_not_stop_it() {
1246        let runtime = TelemetryRuntime::new(TelemetryConfig {
1247            mode: TelemetryMode::InMemory,
1248            ..TelemetryConfig::default()
1249        })
1250        .unwrap();
1251        let clone = runtime.clone();
1252        drop(runtime);
1253        assert_eq!(
1254            clone.record(Signal::OperationCount, Attributes::default()),
1255            RecordStatus::Accepted
1256        );
1257        assert_eq!(clone.shutdown(), LifecycleStatus::Complete);
1258        assert_eq!(clone.snapshots().len(), 1);
1259    }
1260
1261    #[test]
1262    fn otlp_http_json_uses_signal_routes_and_allowlisted_payloads() {
1263        let listener = TcpListener::bind("127.0.0.1:0").unwrap();
1264        let address = listener.local_addr().unwrap();
1265        let server = thread::spawn(move || {
1266            let mut paths = Vec::new();
1267            for _ in 0..3 {
1268                let (mut stream, _) = listener.accept().unwrap();
1269                let mut bytes = vec![0_u8; 16_384];
1270                let read = stream.read(&mut bytes).unwrap();
1271                let request = String::from_utf8_lossy(&bytes[..read]).into_owned();
1272                let first = request
1273                    .lines()
1274                    .next()
1275                    .unwrap()
1276                    .split_whitespace()
1277                    .nth(1)
1278                    .unwrap()
1279                    .to_owned();
1280                assert!(request.contains("application/json"));
1281                assert!(
1282                    !request.contains("repository-canary")
1283                        && !request.contains("token-canary")
1284                        && !request.contains("018f0f4e")
1285                );
1286                paths.push(first);
1287                stream
1288                    .write_all(b"HTTP/1.1 200 OK\r\ncontent-length: 2\r\n\r\n{}")
1289                    .unwrap();
1290            }
1291            paths
1292        });
1293        let runtime = TelemetryRuntime::new(TelemetryConfig {
1294            mode: TelemetryMode::OtlpHttpJson,
1295            lifecycle_timeout: Duration::from_secs(2),
1296            export_timeout: Duration::from_secs(1),
1297            max_retries: 0,
1298            otlp: Some(OtlpConfig {
1299                endpoint: format!("http://{address}/"),
1300                headers: BTreeMap::new(),
1301            }),
1302            ..TelemetryConfig::default()
1303        })
1304        .unwrap();
1305        let attributes = Attributes::for_operation(Operation::Clone).outcome(Outcome::Ok);
1306        for signal in [
1307            Signal::OperationTrace,
1308            Signal::OperationCount,
1309            Signal::LifecycleEvent,
1310        ] {
1311            assert_eq!(runtime.record(signal, attributes), RecordStatus::Accepted);
1312        }
1313        assert_eq!(runtime.force_flush(), LifecycleStatus::Complete);
1314        assert_eq!(
1315            server.join().unwrap(),
1316            vec!["/v1/traces", "/v1/metrics", "/v1/logs"]
1317        );
1318        assert_eq!(runtime.shutdown(), LifecycleStatus::Complete);
1319    }
1320
1321    #[test]
1322    fn unavailable_exporter_is_bounded_and_sanitized() {
1323        let runtime = TelemetryRuntime::new(TelemetryConfig {
1324            mode: TelemetryMode::OtlpHttpJson,
1325            lifecycle_timeout: Duration::from_millis(100),
1326            export_timeout: Duration::from_millis(20),
1327            max_retries: 1,
1328            retry_backoff: Duration::from_millis(1),
1329            otlp: Some(OtlpConfig {
1330                endpoint: "http://127.0.0.1:1/".into(),
1331                headers: BTreeMap::new(),
1332            }),
1333            ..TelemetryConfig::default()
1334        })
1335        .unwrap();
1336        assert_eq!(
1337            runtime.record(Signal::LifecycleEvent, Attributes::default()),
1338            RecordStatus::Accepted
1339        );
1340        let started = Instant::now();
1341        assert!(matches!(
1342            runtime.force_flush(),
1343            LifecycleStatus::ExportFailed | LifecycleStatus::TimedOut
1344        ));
1345        assert!(started.elapsed() < Duration::from_secs(1));
1346        assert!(runtime.stats().export_failures <= 1);
1347    }
1348
1349    #[test]
1350    fn stalled_exporter_cannot_outlive_the_lifecycle_bound() {
1351        let listener = TcpListener::bind("127.0.0.1:0").unwrap();
1352        let address = listener.local_addr().unwrap();
1353        let server = thread::spawn(move || {
1354            let (_stream, _) = listener.accept().unwrap();
1355            thread::sleep(Duration::from_millis(250));
1356        });
1357        let runtime = TelemetryRuntime::new(TelemetryConfig {
1358            mode: TelemetryMode::OtlpHttpJson,
1359            lifecycle_timeout: Duration::from_millis(50),
1360            export_timeout: Duration::from_millis(25),
1361            max_retries: 0,
1362            otlp: Some(OtlpConfig {
1363                endpoint: format!("http://{address}/"),
1364                headers: BTreeMap::new(),
1365            }),
1366            ..TelemetryConfig::default()
1367        })
1368        .unwrap();
1369        let _ = runtime.record(Signal::LifecycleEvent, Attributes::default());
1370        let started = Instant::now();
1371        assert!(matches!(
1372            runtime.force_flush(),
1373            LifecycleStatus::ExportFailed | LifecycleStatus::TimedOut
1374        ));
1375        assert!(started.elapsed() < Duration::from_millis(100));
1376        server.join().unwrap();
1377    }
1378
1379    #[test]
1380    fn golden_privacy_canaries_are_not_representable() {
1381        for forbidden_key in [
1382            "repository_id",
1383            "project_id",
1384            "graph_id",
1385            "uuid",
1386            "path",
1387            "query_text",
1388            "query_parameters",
1389            "property_data",
1390            "credential",
1391            "manifest",
1392            "result_content",
1393            "token",
1394        ] {
1395            assert!(
1396                !otel_attributes(Attributes::default())
1397                    .iter()
1398                    .any(|value| value.to_string().contains(forbidden_key))
1399            );
1400        }
1401    }
1402
1403    #[test]
1404    fn deterministic_job_fixture_accounts_for_queue_and_active_time() {
1405        let job = JobSnapshot {
1406            family: JobFamily::Clone,
1407            enqueued_ns: 100,
1408            started_ns: 125,
1409            finished_ns: 225,
1410            outcome: Outcome::Ok,
1411            failure: None,
1412            stages: vec![
1413                JobStage {
1414                    stage: Stage::Transfer,
1415                    component: ComponentKind::NetworkTransport,
1416                    component_role: ComponentRole::Transfer,
1417                    start_offset_ns: 0,
1418                    duration_ns: 70,
1419                    wait_duration_ns: 20,
1420                    wait_reason: Some(WaitReason::Network),
1421                    attempt: 2,
1422                    bytes: Some(4096),
1423                    resumed_bytes: Some(1024),
1424                    records: None,
1425                    outcome: Outcome::Ok,
1426                },
1427                JobStage {
1428                    stage: Stage::Commit,
1429                    component: ComponentKind::Storage,
1430                    component_role: ComponentRole::Persistence,
1431                    start_offset_ns: 70,
1432                    duration_ns: 30,
1433                    wait_duration_ns: 0,
1434                    wait_reason: None,
1435                    attempt: 1,
1436                    bytes: Some(4096),
1437                    resumed_bytes: None,
1438                    records: Some(8),
1439                    outcome: Outcome::Ok,
1440                },
1441            ],
1442            handoffs: vec![ComponentHandoff {
1443                start_offset_ns: 70,
1444                from: ComponentKind::NetworkTransport,
1445                to: ComponentKind::Storage,
1446                kind: HandoffKind::Write,
1447                duration_ns: 5,
1448                wait_duration_ns: 0,
1449                bytes: Some(4096),
1450                records: Some(8),
1451            }],
1452        };
1453        job.validate().unwrap();
1454        let mut reversed = job.clone();
1455        let mut earlier = reversed.handoffs[0];
1456        earlier.start_offset_ns = 60;
1457        reversed.handoffs.push(earlier);
1458        assert_eq!(reversed.validate(), Err(InvalidJobSnapshot));
1459        assert_eq!(
1460            job.queue_delay_ns() + job.active_duration_ns(),
1461            job.total_duration_ns()
1462        );
1463
1464        let runtime = TelemetryRuntime::new(TelemetryConfig {
1465            mode: TelemetryMode::InMemory,
1466            ..TelemetryConfig::default()
1467        })
1468        .unwrap();
1469        assert_eq!(
1470            runtime.record_job(job.clone()).unwrap(),
1471            RecordStatus::Accepted
1472        );
1473        assert_eq!(runtime.force_flush(), LifecycleStatus::Complete);
1474        assert_eq!(runtime.snapshots()[0].job.as_ref(), Some(&job));
1475    }
1476
1477    #[test]
1478    fn job_paths_report_only_used_components_and_attribute_injected_slowdown() {
1479        let stage = |component, role, duration_ns| JobStage {
1480            stage: Stage::Execute,
1481            component,
1482            component_role: role,
1483            start_offset_ns: 0,
1484            duration_ns,
1485            wait_duration_ns: 0,
1486            wait_reason: None,
1487            attempt: 1,
1488            bytes: None,
1489            resumed_bytes: None,
1490            records: None,
1491            outcome: Outcome::Ok,
1492        };
1493        let query = JobSnapshot {
1494            family: JobFamily::Query,
1495            enqueued_ns: 0,
1496            started_ns: 10,
1497            finished_ns: 50,
1498            outcome: Outcome::Ok,
1499            failure: None,
1500            stages: vec![stage(ComponentKind::Executor, ComponentRole::Compute, 40)],
1501            handoffs: vec![],
1502        };
1503        let publication = JobSnapshot {
1504            family: JobFamily::Publication,
1505            enqueued_ns: 0,
1506            started_ns: 10,
1507            finished_ns: 100,
1508            outcome: Outcome::Ok,
1509            failure: None,
1510            stages: vec![stage(
1511                ComponentKind::Publication,
1512                ComponentRole::Persistence,
1513                90,
1514            )],
1515            handoffs: vec![],
1516        };
1517        query.validate().unwrap();
1518        publication.validate().unwrap();
1519        assert_eq!(query.stages[0].component, ComponentKind::Executor);
1520        assert_eq!(publication.stages[0].component, ComponentKind::Publication);
1521        assert_eq!(
1522            publication.active_duration_ns() - query.active_duration_ns(),
1523            50
1524        );
1525    }
1526
1527    #[test]
1528    fn overlapping_or_incomplete_job_attribution_is_rejected() {
1529        let invalid = JobSnapshot {
1530            family: JobFamily::Query,
1531            enqueued_ns: 0,
1532            started_ns: 0,
1533            finished_ns: 10,
1534            outcome: Outcome::Ok,
1535            failure: None,
1536            stages: vec![JobStage {
1537                stage: Stage::Execute,
1538                component: ComponentKind::Executor,
1539                component_role: ComponentRole::Compute,
1540                start_offset_ns: 1,
1541                duration_ns: 10,
1542                wait_duration_ns: 0,
1543                wait_reason: None,
1544                attempt: 1,
1545                bytes: None,
1546                resumed_bytes: None,
1547                records: None,
1548                outcome: Outcome::Ok,
1549            }],
1550            handoffs: vec![],
1551        };
1552        assert_eq!(invalid.validate(), Err(InvalidJobSnapshot));
1553    }
1554
1555    #[test]
1556    fn otlp_job_trace_exports_coherent_timing_and_unique_batch_ids() {
1557        let snapshot = Snapshot {
1558            contract_version: SEMANTIC_CONTRACT_VERSION,
1559            scope: INSTRUMENTATION_SCOPE,
1560            scope_version: INSTRUMENTATION_SCOPE_VERSION,
1561            signal: Signal::LocalJobTrace.descriptor(),
1562            attributes: Attributes::default(),
1563            job: Some(JobSnapshot {
1564                family: JobFamily::Clone,
1565                enqueued_ns: 10,
1566                started_ns: 20,
1567                finished_ns: 70,
1568                outcome: Outcome::Ok,
1569                failure: None,
1570                stages: vec![JobStage {
1571                    stage: Stage::Transfer,
1572                    component: ComponentKind::NetworkTransport,
1573                    component_role: ComponentRole::Transfer,
1574                    start_offset_ns: 0,
1575                    duration_ns: 50,
1576                    wait_duration_ns: 5,
1577                    wait_reason: Some(WaitReason::Network),
1578                    attempt: 1,
1579                    bytes: Some(128),
1580                    resumed_bytes: Some(64),
1581                    records: Some(2),
1582                    outcome: Outcome::Ok,
1583                }],
1584                handoffs: vec![ComponentHandoff {
1585                    start_offset_ns: 50,
1586                    from: ComponentKind::NetworkTransport,
1587                    to: ComponentKind::Storage,
1588                    kind: HandoffKind::Write,
1589                    duration_ns: 3,
1590                    wait_duration_ns: 1,
1591                    bytes: Some(128),
1592                    records: Some(2),
1593                }],
1594            }),
1595        };
1596        let first = otlp_json(SignalKind::Trace, &[&snapshot], 41);
1597        let second = otlp_json(SignalKind::Trace, &[&snapshot], 42);
1598        let spans = first["resourceSpans"][0]["scopeSpans"][0]["spans"]
1599            .as_array()
1600            .unwrap();
1601        assert_eq!(spans.len(), 2);
1602        let stage_attributes = spans[1]["attributes"].as_array().unwrap();
1603        assert!(stage_attributes.iter().any(|attribute| {
1604            attribute["key"] == "graphforge.stage.resumed_bytes"
1605                && attribute["value"]["intValue"] == "64"
1606        }));
1607        assert_eq!(spans[0]["traceId"].as_str().unwrap().len(), 32);
1608        assert_eq!(spans[0]["spanId"].as_str().unwrap().len(), 16);
1609        assert_eq!(spans[1]["spanId"].as_str().unwrap().len(), 16);
1610        assert_ne!(
1611            spans[0]["traceId"],
1612            second["resourceSpans"][0]["scopeSpans"][0]["spans"][0]["traceId"]
1613        );
1614        let root_start = spans[0]["startTimeUnixNano"]
1615            .as_str()
1616            .unwrap()
1617            .parse::<u128>()
1618            .unwrap();
1619        let root_end = spans[0]["endTimeUnixNano"]
1620            .as_str()
1621            .unwrap()
1622            .parse::<u128>()
1623            .unwrap();
1624        let stage_start = spans[1]["startTimeUnixNano"]
1625            .as_str()
1626            .unwrap()
1627            .parse::<u128>()
1628            .unwrap();
1629        let stage_end = spans[1]["endTimeUnixNano"]
1630            .as_str()
1631            .unwrap()
1632            .parse::<u128>()
1633            .unwrap();
1634        assert_eq!(root_end - root_start, 60);
1635        assert_eq!(stage_start - root_start, 10);
1636        assert_eq!(stage_end - stage_start, 50);
1637        let rendered = first.to_string();
1638        for expected in [
1639            "graphforge.job.queue_delay_ns",
1640            "graphforge.workspace.component.kind",
1641            "graphforge.component.handoff",
1642            "network_transport",
1643        ] {
1644            assert!(rendered.contains(expected));
1645        }
1646        for forbidden in ["repository", "project", "path", "query", "token", "digest"] {
1647            assert!(!rendered.contains(forbidden));
1648        }
1649    }
1650}