1use crate::{requests::EngineTokenTimingEvidence, resource_trace::ResourceTraceEvent};
4use chrono::{DateTime, Utc};
5use serde::{Deserialize, Serialize};
6use serde_json::Value;
7use std::collections::BTreeMap;
8use std::path::PathBuf;
9
10pub const OBSERVABILITY_PROFILE_SCHEMA_VERSION: u32 = 1;
11pub const DEFAULT_OBSERVABILITY_PROFILE_SAMPLE_RATE: f64 = 0.01;
12pub const SYNTHETIC_RUNTIME_PRESET_HASH: &str =
13 "sha256:6c3b8d2c431c47cf612289b02a8c631c894f34f532508fc58841e572aedaa7bc";
14pub const ENGINE_RUNTIME_TRACE_PRESET_HASH: &str =
15 "sha256:30c1be62aa61858deca261ebcbfb4115918c1d6d0466f7ad5ffd7bc8d901e782";
16
17pub fn engine_token_timing_profile_attributes(
18 timing: &EngineTokenTimingEvidence,
19) -> BTreeMap<String, Value> {
20 let intervals = timing.inter_token_nanos();
21 let total_itl_nanos = intervals
22 .iter()
23 .fold(0_u128, |total, interval| total + u128::from(*interval));
24 let average_itl_nanos = if intervals.is_empty() {
25 0
26 } else {
27 u64::try_from(total_itl_nanos / intervals.len() as u128).unwrap_or(u64::MAX)
28 };
29 let mut attributes = BTreeMap::from([
30 (
31 "engine_token_clock_source".to_string(),
32 serde_json::json!(timing.clock_source),
33 ),
34 (
35 "engine_token_wall_anchor_unix_nanos".to_string(),
36 serde_json::json!(timing.wall_anchor_unix_nanos),
37 ),
38 (
39 "clock_conversion_max_error_nanos".to_string(),
40 serde_json::json!(timing.wall_anchor_max_error_nanos),
41 ),
42 (
43 "engine_token_commit_nanos_since_request_start".to_string(),
44 serde_json::json!(timing.token_commit_nanos_since_request_start),
45 ),
46 (
47 "engine_token_commit_count".to_string(),
48 serde_json::json!(timing.token_commit_nanos_since_request_start.len()),
49 ),
50 (
51 "engine_decode_stage_intervals".to_string(),
52 serde_json::json!(timing.decode_stage_intervals),
53 ),
54 (
55 "engine_decode_stage_interval_count".to_string(),
56 serde_json::json!(timing.decode_stage_intervals.len()),
57 ),
58 (
59 "itl_source".to_string(),
60 serde_json::json!("engine_token_commit"),
61 ),
62 (
63 "itl_interval_count".to_string(),
64 serde_json::json!(intervals.len()),
65 ),
66 ("itl_nanos".to_string(), serde_json::json!(intervals)),
67 (
68 "itl_us_avg".to_string(),
69 serde_json::json!(average_itl_nanos / 1_000),
70 ),
71 ]);
72 if let Some(ttft_nanos) = timing.ttft_nanos() {
73 attributes.insert("ttft_us".to_string(), serde_json::json!(ttft_nanos / 1_000));
74 }
75 if let Some(decode_ready_nanos) = timing.decode_ready_nanos_since_request_start {
76 attributes.insert(
77 "engine_decode_ready_nanos_since_request_start".to_string(),
78 serde_json::json!(decode_ready_nanos),
79 );
80 }
81 if let Some(decode_wall_nanos) = timing.decode_wall_nanos().filter(|value| *value > 0) {
82 let conversion_error_ppm = u64::try_from(
83 u128::from(timing.wall_anchor_max_error_nanos).saturating_mul(1_000_000)
84 / u128::from(decode_wall_nanos),
85 )
86 .unwrap_or(u64::MAX);
87 attributes.insert(
88 "engine_decode_wall_nanos".to_string(),
89 serde_json::json!(decode_wall_nanos),
90 );
91 attributes.insert(
92 "clock_conversion_error_ppm".to_string(),
93 serde_json::json!(conversion_error_ppm),
94 );
95 attributes.insert(
96 "decode_wall_timing_eligible".to_string(),
97 serde_json::json!(true),
98 );
99 } else {
100 let reason = if timing.token_commit_nanos_since_request_start.is_empty() {
101 "no_token_commits"
102 } else if timing.decode_ready_nanos_since_request_start.is_none() {
103 "decode_not_entered"
104 } else {
105 "no_positive_decode_commit_interval"
106 };
107 attributes.insert(
108 "decode_wall_timing_eligible".to_string(),
109 serde_json::json!(false),
110 );
111 attributes.insert(
112 "decode_wall_timing_unavailable_reason".to_string(),
113 serde_json::json!(reason),
114 );
115 }
116 attributes
117}
118
119#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
120#[serde(rename_all = "snake_case")]
121pub enum ProfileEntrypoint {
122 Run,
123 Serve,
124 BenchServe,
125 Synthetic,
126}
127
128impl ProfileEntrypoint {
129 pub fn parse(value: &str) -> Option<Self> {
130 match value.trim().to_ascii_lowercase().as_str() {
131 "run" => Some(Self::Run),
132 "serve" => Some(Self::Serve),
133 "bench_serve" | "bench-serve" | "benchserve" => Some(Self::BenchServe),
134 "synthetic" => Some(Self::Synthetic),
135 _ => None,
136 }
137 }
138
139 pub fn as_str(self) -> &'static str {
140 match self {
141 Self::Run => "run",
142 Self::Serve => "serve",
143 Self::BenchServe => "bench_serve",
144 Self::Synthetic => "synthetic",
145 }
146 }
147}
148
149#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
150#[serde(rename_all = "snake_case")]
151pub enum ProfileEventKind {
152 Instant,
153 TimedSpan,
154 Resource,
155 Memory,
156 Error,
157}
158
159#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
160#[serde(rename_all = "snake_case")]
161pub enum ProfileStatus {
162 Ok,
163 Failure,
164 DiagnosticOnly,
165}
166
167#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
168#[serde(rename_all = "snake_case")]
169pub enum ObservabilityProfileDetail {
170 #[default]
171 Off,
172 Basic,
173 Resource,
174 Latency,
175 Kernel,
176 Debug,
177 Replay,
178 Verify,
179 Full,
180}
181
182impl ObservabilityProfileDetail {
183 pub fn parse(value: &str) -> Option<Self> {
184 match value.trim().to_ascii_lowercase().as_str() {
185 "off" => Some(Self::Off),
186 "basic" => Some(Self::Basic),
187 "resource" => Some(Self::Resource),
188 "latency" => Some(Self::Latency),
189 "kernel" => Some(Self::Kernel),
190 "debug" => Some(Self::Debug),
191 "replay" => Some(Self::Replay),
192 "verify" => Some(Self::Verify),
193 "full" => Some(Self::Full),
194 _ => None,
195 }
196 }
197
198 pub fn as_str(self) -> &'static str {
199 match self {
200 Self::Off => "off",
201 Self::Basic => "basic",
202 Self::Resource => "resource",
203 Self::Latency => "latency",
204 Self::Kernel => "kernel",
205 Self::Debug => "debug",
206 Self::Replay => "replay",
207 Self::Verify => "verify",
208 Self::Full => "full",
209 }
210 }
211
212 pub fn diagnostic_only(self) -> bool {
213 matches!(
214 self,
215 Self::Kernel | Self::Debug | Self::Replay | Self::Verify | Self::Full
216 )
217 }
218
219 pub fn captures_engine_token_timing(self) -> bool {
220 matches!(
221 self,
222 Self::Latency | Self::Kernel | Self::Replay | Self::Verify | Self::Full
223 )
224 }
225}
226
227#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
228pub struct FerrumObservabilityConfig {
229 pub entrypoint: ProfileEntrypoint,
230 pub model: String,
231 #[serde(default, skip_serializing_if = "Option::is_none")]
232 pub profile_jsonl: Option<PathBuf>,
233 pub profile_detail: ObservabilityProfileDetail,
234 #[serde(default, skip_serializing_if = "Option::is_none")]
235 pub memory_profile_jsonl: Option<PathBuf>,
236 #[serde(default, skip_serializing_if = "Option::is_none")]
237 pub scheduler_trace_jsonl: Option<PathBuf>,
238 #[serde(default, skip_serializing_if = "Option::is_none")]
239 pub request_dump_dir: Option<PathBuf>,
240 pub profile_sample_rate: f64,
241}
242
243impl FerrumObservabilityConfig {
244 #[allow(clippy::too_many_arguments)]
245 pub fn new(
246 entrypoint: ProfileEntrypoint,
247 model: impl Into<String>,
248 profile_jsonl: Option<PathBuf>,
249 profile_detail: ObservabilityProfileDetail,
250 memory_profile_jsonl: Option<PathBuf>,
251 scheduler_trace_jsonl: Option<PathBuf>,
252 request_dump_dir: Option<PathBuf>,
253 profile_sample_rate: f64,
254 ) -> Self {
255 Self {
256 entrypoint,
257 model: model.into(),
258 profile_jsonl,
259 profile_detail,
260 memory_profile_jsonl,
261 scheduler_trace_jsonl,
262 request_dump_dir,
263 profile_sample_rate,
264 }
265 }
266
267 pub fn enabled(&self) -> bool {
268 self.profile_detail != ObservabilityProfileDetail::Off
269 || self.profile_jsonl.is_some()
270 || self.memory_profile_jsonl.is_some()
271 || self.scheduler_trace_jsonl.is_some()
272 || self.request_dump_dir.is_some()
273 }
274
275 pub fn synthetic_no_weight_enabled(&self) -> bool {
276 self.enabled() && self.model == "synthetic/no-weight"
277 }
278
279 pub fn unified_product_profile_enabled(&self) -> bool {
280 self.enabled()
281 && (self.profile_detail != ObservabilityProfileDetail::Off
282 || self.memory_profile_jsonl.is_some()
283 || self.scheduler_trace_jsonl.is_some()
284 || self.request_dump_dir.is_some())
285 }
286
287 pub fn validate(&self) -> std::result::Result<(), String> {
288 if !self.profile_sample_rate.is_finite()
289 || self.profile_sample_rate < 0.0
290 || self.profile_sample_rate > 1.0
291 {
292 return Err("profile_sample_rate must be between 0.0 and 1.0".to_string());
293 }
294 if self.enabled()
295 && self.profile_jsonl.is_none()
296 && self.memory_profile_jsonl.is_none()
297 && self.scheduler_trace_jsonl.is_none()
298 && self.request_dump_dir.is_none()
299 {
300 return Err(
301 "observability profile detail requires at least one artifact path".to_string(),
302 );
303 }
304 Ok(())
305 }
306}
307
308#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
309pub struct MemorySnapshot {
310 pub scope: String,
311 #[serde(default, skip_serializing_if = "Option::is_none")]
312 pub backend: Option<String>,
313 pub before_bytes: Option<u64>,
314 pub after_bytes: Option<u64>,
315 #[serde(default, skip_serializing_if = "Option::is_none")]
316 pub current_bytes: Option<u64>,
317 #[serde(default, skip_serializing_if = "Option::is_none")]
318 pub high_water_bytes: Option<u64>,
319 #[serde(default, skip_serializing_if = "Option::is_none")]
320 pub available_bytes: Option<i64>,
321}
322
323impl MemorySnapshot {
324 pub fn validate(&self) -> std::result::Result<(), String> {
325 if self.scope.trim().is_empty() {
326 return Err("memory scope must be non-empty".to_string());
327 }
328 if self.before_bytes.is_none() || self.after_bytes.is_none() {
329 return Err("memory before_bytes and after_bytes are required".to_string());
330 }
331 if self.high_water_bytes.is_none() {
332 return Err("memory high_water_bytes is required".to_string());
333 }
334 if self.current_bytes.is_none() {
335 return Err("memory current_bytes is required".to_string());
336 }
337 if self.available_bytes.is_some_and(|bytes| bytes < 0) {
338 return Err("memory available_bytes must be non-negative".to_string());
339 }
340 Ok(())
341 }
342}
343
344#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
345pub struct ProfileError {
346 pub kind: String,
347 pub message: String,
348 #[serde(default)]
349 pub blocking: bool,
350}
351
352impl ProfileError {
353 fn validate(&self) -> std::result::Result<(), String> {
354 if self.kind.trim().is_empty() {
355 return Err("error kind must be non-empty".to_string());
356 }
357 if self.message.trim().is_empty() {
358 return Err("error message must be non-empty".to_string());
359 }
360 Ok(())
361 }
362}
363
364#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
365pub struct ReplayReference {
366 pub command: String,
367 #[serde(default, skip_serializing_if = "Option::is_none")]
368 pub bundle_dir: Option<String>,
369}
370
371impl ReplayReference {
372 fn validate(&self) -> std::result::Result<(), String> {
373 if self.command.trim().is_empty() {
374 return Err("replay command must be non-empty".to_string());
375 }
376 Ok(())
377 }
378}
379
380#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
381pub struct FerrumProfileEvent {
382 pub schema_version: u32,
383 pub ts_unix_nanos: i64,
384 pub event_id: String,
385 pub request_id: String,
386 #[serde(default, skip_serializing_if = "Option::is_none")]
387 pub correlation_id: Option<String>,
388 pub entrypoint: ProfileEntrypoint,
389 pub backend: String,
390 pub runtime_preset_hash: String,
391 pub phase: String,
392 pub event_kind: ProfileEventKind,
393 pub timestamp: DateTime<Utc>,
394 pub status: ProfileStatus,
395 #[serde(default, skip_serializing_if = "Option::is_none")]
396 pub model: Option<String>,
397 #[serde(default, skip_serializing_if = "Option::is_none")]
398 pub duration_us: Option<u64>,
399 #[serde(default, skip_serializing_if = "Option::is_none")]
400 pub memory: Option<MemorySnapshot>,
401 #[serde(default, skip_serializing_if = "Option::is_none")]
402 pub resource: Option<ResourceTraceEvent>,
403 #[serde(default, skip_serializing_if = "Option::is_none")]
404 pub error: Option<ProfileError>,
405 #[serde(default, skip_serializing_if = "Option::is_none")]
406 pub replay: Option<ReplayReference>,
407 #[serde(default)]
408 pub shape: BTreeMap<String, Value>,
409 #[serde(default, skip_serializing_if = "Option::is_none")]
410 pub backend_detail: Option<BTreeMap<String, Value>>,
411 #[serde(default)]
412 pub attributes: BTreeMap<String, Value>,
413}
414
415impl FerrumProfileEvent {
416 pub fn validate(&self) -> std::result::Result<(), String> {
417 if self.schema_version != OBSERVABILITY_PROFILE_SCHEMA_VERSION {
418 return Err(format!(
419 "schema_version must be {OBSERVABILITY_PROFILE_SCHEMA_VERSION}"
420 ));
421 }
422 if self.ts_unix_nanos <= 0 {
423 return Err("ts_unix_nanos must be positive".to_string());
424 }
425 if self.event_id.trim().is_empty() {
426 return Err("event_id must be non-empty".to_string());
427 }
428 if self.request_id.trim().is_empty() {
429 return Err("request_id must be non-empty".to_string());
430 }
431 if self
432 .correlation_id
433 .as_ref()
434 .is_none_or(|value| value.trim().is_empty())
435 {
436 return Err("correlation_id must be non-empty".to_string());
437 }
438 if self.backend.trim().is_empty() {
439 return Err("backend must be non-empty".to_string());
440 }
441 if self.runtime_preset_hash.trim().is_empty() {
442 return Err("runtime_preset_hash must be non-empty".to_string());
443 }
444 if self.phase.trim().is_empty() {
445 return Err("phase must be non-empty".to_string());
446 }
447 if self.shape.is_empty() {
448 return Err("shape must contain at least one field".to_string());
449 }
450 if self.event_kind == ProfileEventKind::TimedSpan && self.duration_us.is_none() {
451 return Err("duration_us is required for timed_span events".to_string());
452 }
453 if let Some(memory) = &self.memory {
454 memory.validate()?;
455 }
456 if let Some(resource) = &self.resource {
457 resource.validate()?;
458 }
459 if let Some(error) = &self.error {
460 error.validate()?;
461 }
462 if self.status == ProfileStatus::Failure && self.error.is_none() {
463 return Err("failure events must include error detail".to_string());
464 }
465 if let Some(replay) = &self.replay {
466 replay.validate()?;
467 }
468 Ok(())
469 }
470}
471
472#[cfg(test)]
473mod tests {
474 use super::*;
475 use crate::requests::{EngineDecodeStage, EngineDecodeStageInterval};
476
477 fn base_event() -> FerrumProfileEvent {
478 FerrumProfileEvent {
479 schema_version: OBSERVABILITY_PROFILE_SCHEMA_VERSION,
480 ts_unix_nanos: Utc::now()
481 .timestamp_nanos_opt()
482 .expect("test timestamp should fit i64 nanos"),
483 event_id: "evt-1".to_string(),
484 request_id: "req-1".to_string(),
485 correlation_id: Some("corr-1".to_string()),
486 entrypoint: ProfileEntrypoint::Synthetic,
487 backend: "synthetic".to_string(),
488 runtime_preset_hash: SYNTHETIC_RUNTIME_PRESET_HASH.to_string(),
489 phase: "request".to_string(),
490 event_kind: ProfileEventKind::TimedSpan,
491 timestamp: Utc::now(),
492 status: ProfileStatus::Ok,
493 model: Some("synthetic/no-weight".to_string()),
494 duration_us: Some(100),
495 memory: None,
496 resource: None,
497 error: None,
498 replay: None,
499 shape: BTreeMap::from([("batch_size".to_string(), Value::from(1))]),
500 backend_detail: None,
501 attributes: BTreeMap::new(),
502 }
503 }
504
505 #[test]
506 fn timed_span_requires_duration_and_request_id() {
507 base_event().validate().unwrap();
508
509 let mut missing_duration = base_event();
510 missing_duration.duration_us = None;
511 assert!(missing_duration.validate().is_err());
512
513 let mut missing_request = base_event();
514 missing_request.request_id.clear();
515 assert!(missing_request.validate().is_err());
516
517 let mut missing_correlation = base_event();
518 missing_correlation.correlation_id = None;
519 assert!(missing_correlation.validate().is_err());
520
521 let mut missing_runtime_preset = base_event();
522 missing_runtime_preset.runtime_preset_hash.clear();
523 assert!(missing_runtime_preset.validate().is_err());
524
525 let mut missing_shape = base_event();
526 missing_shape.shape.clear();
527 assert!(missing_shape.validate().is_err());
528 }
529
530 #[test]
531 fn replay_profile_detail_is_typed_and_diagnostic_only() {
532 assert_eq!(
533 ObservabilityProfileDetail::parse(" replay "),
534 Some(ObservabilityProfileDetail::Replay)
535 );
536 assert_eq!(ObservabilityProfileDetail::Replay.as_str(), "replay");
537 assert!(ObservabilityProfileDetail::Replay.diagnostic_only());
538 assert!(!ObservabilityProfileDetail::Basic.diagnostic_only());
539 }
540
541 #[test]
542 fn staged_product_profile_details_are_typed_with_distinct_claim_status() {
543 for (name, expected) in [
544 ("resource", ObservabilityProfileDetail::Resource),
545 ("latency", ObservabilityProfileDetail::Latency),
546 ("kernel", ObservabilityProfileDetail::Kernel),
547 ] {
548 assert_eq!(ObservabilityProfileDetail::parse(name), Some(expected));
549 assert_eq!(expected.as_str(), name);
550 }
551 assert!(!ObservabilityProfileDetail::Resource.diagnostic_only());
552 assert!(!ObservabilityProfileDetail::Latency.diagnostic_only());
553 assert!(ObservabilityProfileDetail::Kernel.diagnostic_only());
554 assert!(!ObservabilityProfileDetail::Resource.captures_engine_token_timing());
555 assert!(ObservabilityProfileDetail::Latency.captures_engine_token_timing());
556 assert!(ObservabilityProfileDetail::Kernel.captures_engine_token_timing());
557 }
558
559 #[test]
560 fn engine_token_timing_preserves_exact_commit_intervals() {
561 let timing = EngineTokenTimingEvidence {
562 clock_source: "rust_std_instant".to_string(),
563 wall_anchor_unix_nanos: 1_700_000_000_000_000_000,
564 wall_anchor_max_error_nanos: 400,
565 decode_ready_nanos_since_request_start: Some(2_000_000),
566 token_commit_nanos_since_request_start: vec![1_000_000, 2_500_000, 5_000_000],
567 decode_stage_intervals: vec![
568 EngineDecodeStageInterval {
569 stage: EngineDecodeStage::DecodeScheduling,
570 start_nanos_since_request_start: 2_000_000,
571 end_nanos_since_request_start: 2_100_000,
572 },
573 EngineDecodeStageInterval {
574 stage: EngineDecodeStage::DecodeExecution,
575 start_nanos_since_request_start: 2_100_000,
576 end_nanos_since_request_start: 4_900_000,
577 },
578 EngineDecodeStageInterval {
579 stage: EngineDecodeStage::DecodePostprocess,
580 start_nanos_since_request_start: 4_900_000,
581 end_nanos_since_request_start: 5_000_000,
582 },
583 ],
584 };
585 timing.validate(3).unwrap();
586 assert!(timing.validate(2).is_err());
587
588 let attributes = engine_token_timing_profile_attributes(&timing);
589 assert_eq!(
590 attributes["engine_token_commit_count"],
591 serde_json::json!(3)
592 );
593 assert_eq!(
594 attributes["engine_decode_stage_intervals"],
595 serde_json::json!([
596 {
597 "stage": "decode_scheduling",
598 "start_nanos_since_request_start": 2_000_000,
599 "end_nanos_since_request_start": 2_100_000,
600 },
601 {
602 "stage": "decode_execution",
603 "start_nanos_since_request_start": 2_100_000,
604 "end_nanos_since_request_start": 4_900_000,
605 },
606 {
607 "stage": "decode_postprocess",
608 "start_nanos_since_request_start": 4_900_000,
609 "end_nanos_since_request_start": 5_000_000,
610 },
611 ])
612 );
613 assert_eq!(
614 attributes["engine_decode_stage_interval_count"],
615 serde_json::json!(3)
616 );
617 assert_eq!(attributes["ttft_us"], serde_json::json!(1_000));
618 assert_eq!(attributes["itl_interval_count"], serde_json::json!(2));
619 assert_eq!(
620 attributes["itl_nanos"],
621 serde_json::json!([1_500_000, 2_500_000])
622 );
623 assert_eq!(attributes["itl_us_avg"], serde_json::json!(2_000));
624 assert_eq!(
625 attributes["engine_decode_wall_nanos"],
626 serde_json::json!(3_000_000)
627 );
628 assert_eq!(
629 attributes["clock_conversion_error_ppm"],
630 serde_json::json!(133)
631 );
632 assert_eq!(
633 attributes["decode_wall_timing_eligible"],
634 serde_json::json!(true)
635 );
636 assert_eq!(
637 attributes["itl_source"],
638 serde_json::json!("engine_token_commit")
639 );
640 }
641
642 #[test]
643 fn verification_profile_detail_is_typed_and_diagnostic_only() {
644 assert_eq!(
645 ObservabilityProfileDetail::parse(" verify "),
646 Some(ObservabilityProfileDetail::Verify)
647 );
648 assert_eq!(ObservabilityProfileDetail::Verify.as_str(), "verify");
649 assert!(ObservabilityProfileDetail::Verify.diagnostic_only());
650 }
651
652 #[test]
653 fn observability_config_requires_artifact_path_when_detail_enabled() {
654 let disabled = FerrumObservabilityConfig::new(
655 ProfileEntrypoint::Serve,
656 "model",
657 None,
658 ObservabilityProfileDetail::Off,
659 None,
660 None,
661 None,
662 DEFAULT_OBSERVABILITY_PROFILE_SAMPLE_RATE,
663 );
664 assert!(!disabled.enabled());
665 assert!(!disabled.unified_product_profile_enabled());
666 assert!(disabled.validate().is_ok());
667
668 let config = FerrumObservabilityConfig::new(
669 ProfileEntrypoint::Run,
670 "synthetic/no-weight",
671 None,
672 ObservabilityProfileDetail::Basic,
673 None,
674 None,
675 None,
676 DEFAULT_OBSERVABILITY_PROFILE_SAMPLE_RATE,
677 );
678 assert!(config.enabled());
679 assert!(config.synthetic_no_weight_enabled());
680 assert!(config.validate().is_err());
681
682 let with_artifact = FerrumObservabilityConfig::new(
683 ProfileEntrypoint::Serve,
684 "synthetic/no-weight",
685 Some(PathBuf::from("profile.jsonl")),
686 ObservabilityProfileDetail::Basic,
687 None,
688 None,
689 None,
690 DEFAULT_OBSERVABILITY_PROFILE_SAMPLE_RATE,
691 );
692 assert!(with_artifact.validate().is_ok());
693 assert!(with_artifact.unified_product_profile_enabled());
694
695 let invalid_sample_rate = FerrumObservabilityConfig::new(
696 ProfileEntrypoint::Run,
697 "model",
698 Some(PathBuf::from("profile.jsonl")),
699 ObservabilityProfileDetail::Off,
700 None,
701 None,
702 None,
703 1.1,
704 );
705 assert!(invalid_sample_rate.validate().is_err());
706 }
707}