1#![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
19pub const SEMANTIC_CONTRACT_VERSION: u16 = 1;
21pub const INSTRUMENTATION_SCOPE: &str = "io.graphforge.engine";
23pub const INSTRUMENTATION_SCOPE_VERSION: &str = "1";
25
26#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize)]
28#[serde(rename_all = "snake_case")]
29pub enum TelemetryConfigErrorCode {
30 InvalidBound,
32 BatchExceedsQueue,
34 InvalidEndpoint,
36 InvalidHeader,
38 InvalidMode,
40 ExporterUnavailable,
42}
43
44impl TelemetryConfigErrorCode {
45 #[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#[derive(Clone, Debug, Eq, PartialEq, thiserror::Error)]
61#[error("GraphForge telemetry configuration rejected ({code:?})")]
62pub struct TelemetryConfigError {
63 pub code: TelemetryConfigErrorCode,
65}
66
67#[derive(Clone, Copy, Debug, Default, Eq, PartialEq, Serialize)]
69#[serde(rename_all = "snake_case")]
70pub enum TelemetryMode {
71 #[default]
73 Disabled,
74 InMemory,
76 OtlpHttpJson,
78}
79
80#[derive(Clone, Eq, PartialEq)]
82pub struct OtlpConfig {
83 pub endpoint: String,
85 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#[derive(Clone, Debug, Eq, PartialEq)]
104pub struct TelemetryConfig {
105 pub mode: TelemetryMode,
107 pub queue_capacity: usize,
109 pub batch_size: usize,
111 pub scheduled_delay: Duration,
113 pub export_timeout: Duration,
115 pub lifecycle_timeout: Duration,
117 pub max_retries: u8,
119 pub retry_backoff: Duration,
121 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#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize)]
278pub struct JobStage {
279 pub stage: Stage,
281 pub component: ComponentKind,
283 pub component_role: ComponentRole,
285 pub start_offset_ns: u64,
287 pub duration_ns: u64,
289 pub wait_duration_ns: u64,
291 pub wait_reason: Option<WaitReason>,
293 pub attempt: u32,
295 pub bytes: Option<u64>,
297 pub resumed_bytes: Option<u64>,
299 pub records: Option<u64>,
301 pub outcome: Outcome,
303}
304
305#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize)]
307pub struct ComponentHandoff {
308 pub start_offset_ns: u64,
310 pub from: ComponentKind,
312 pub to: ComponentKind,
314 pub kind: HandoffKind,
316 pub duration_ns: u64,
318 pub wait_duration_ns: u64,
320 pub bytes: Option<u64>,
322 pub records: Option<u64>,
324}
325
326#[derive(Clone, Debug, Eq, PartialEq, Serialize)]
328pub struct JobSnapshot {
329 pub family: JobFamily,
331 pub enqueued_ns: u64,
333 pub started_ns: u64,
335 pub finished_ns: u64,
337 pub outcome: Outcome,
339 pub failure: Option<Failure>,
341 pub stages: Vec<JobStage>,
343 pub handoffs: Vec<ComponentHandoff>,
345}
346
347#[derive(Clone, Copy, Debug, Eq, PartialEq, thiserror::Error)]
349#[error("invalid GraphForge local-job telemetry")]
350pub struct InvalidJobSnapshot;
351
352impl JobSnapshot {
353 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 #[must_use]
392 pub const fn queue_delay_ns(&self) -> u64 {
393 self.started_ns - self.enqueued_ns
394 }
395
396 #[must_use]
398 pub const fn active_duration_ns(&self) -> u64 {
399 self.finished_ns - self.started_ns
400 }
401
402 #[must_use]
404 pub const fn total_duration_ns(&self) -> u64 {
405 self.finished_ns - self.enqueued_ns
406 }
407}
408
409#[derive(Clone, Copy, Debug, Default, Eq, PartialEq, Serialize)]
411pub struct Attributes {
412 #[serde(skip_serializing_if = "Option::is_none")]
414 pub operation: Option<Operation>,
415 #[serde(skip_serializing_if = "Option::is_none")]
417 pub stage: Option<Stage>,
418 #[serde(skip_serializing_if = "Option::is_none")]
420 pub outcome: Option<Outcome>,
421 #[serde(skip_serializing_if = "Option::is_none")]
423 pub failure: Option<Failure>,
424 #[serde(skip_serializing_if = "Option::is_none")]
426 pub limit: Option<Limit>,
427}
428
429impl Attributes {
430 #[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 #[must_use]
443 pub const fn stage(mut self, stage: Stage) -> Self {
444 self.stage = Some(stage);
445 self
446 }
447 #[must_use]
449 pub const fn outcome(mut self, outcome: Outcome) -> Self {
450 self.outcome = Some(outcome);
451 self
452 }
453 #[must_use]
455 pub const fn failure(mut self, failure: Failure) -> Self {
456 self.failure = Some(failure);
457 self
458 }
459 #[must_use]
461 pub const fn limit(mut self, limit: Limit) -> Self {
462 self.limit = Some(limit);
463 self
464 }
465}
466
467#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize)]
469#[serde(rename_all = "snake_case")]
470pub enum SignalKind {
471 Trace,
473 Metric,
475 Event,
477}
478
479#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize)]
481pub enum Signal {
482 LocalJobTrace,
484 OperationTrace,
486 OperationCount,
488 LifecycleEvent,
490}
491
492#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize)]
494pub struct SignalDescriptor {
495 pub name: &'static str,
497 pub kind: SignalKind,
499 pub unit: &'static str,
501}
502
503impl Signal {
504 #[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#[derive(Clone, Debug, Eq, PartialEq, Serialize)]
534pub struct Snapshot {
535 pub contract_version: u16,
537 pub scope: &'static str,
539 pub scope_version: &'static str,
541 pub signal: SignalDescriptor,
543 pub attributes: Attributes,
545 #[serde(skip_serializing_if = "Option::is_none")]
547 pub job: Option<JobSnapshot>,
548}
549
550#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize)]
552#[serde(rename_all = "snake_case")]
553pub enum RecordStatus {
554 Disabled,
556 Accepted,
558 DroppedQueueFull,
560 Shutdown,
562}
563
564#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize)]
566#[serde(rename_all = "snake_case")]
567pub enum LifecycleStatus {
568 Disabled,
570 Complete,
572 TimedOut,
574 ExportFailed,
576 AlreadyShutdown,
578}
579
580impl LifecycleStatus {
581 #[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#[derive(Clone, Copy, Debug, Default, Eq, PartialEq, Serialize)]
596pub struct TelemetryStats {
597 pub accepted: u64,
599 pub dropped: u64,
601 pub export_failures: u64,
603}
604
605#[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
621pub 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 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 #[must_use]
681 pub const fn is_enabled(&self) -> bool {
682 self.active.is_some()
683 }
684
685 #[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 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 #[must_use]
741 pub fn force_flush(&self) -> LifecycleStatus {
742 self.lifecycle(false)
743 }
744
745 #[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 #[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 #[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}