Skip to content

Commit 822880d

Browse files
bfjeldsCopilot
andcommitted
telemetry: enrich platform and startup metrics
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: d0e7cadc-8f44-443f-affe-6ce8e856a218
1 parent 1092d40 commit 822880d

6 files changed

Lines changed: 229 additions & 36 deletions

File tree

‎Cargo.toml‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -61,6 +61,7 @@ nix = { version = "0.30.1", features = [
6161
"user",
6262
"socket",
6363
"signal",
64+
"time",
6465
], default-features = false }
6566
oci-client = "0.15.0"
6667
once_cell = "1.19"

‎crates/osutils/src/virt.rs‎

Lines changed: 69 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,24 @@
1-
//! This module contains helper functions for working with virtualized environments.
1+
//! Detects whether Trident is running inside a virtual machine, and if so,
2+
//! which hypervisor, by reading DMI/SMBIOS sysfs files. Shared by
3+
//! `trident::diagnostics` (the support-bundle `HostDescription::virt_type`,
4+
//! the detected hypervisor name or `None`) and `trident::logging::tracestream`
5+
//! (the `platform_info.vm` telemetry field, a plain boolean), so the two
6+
//! never diverge on what counts as "virtual". Also home to the unrelated
7+
//! `virtdeploy` best-effort detection used by boot-order workarounds.
8+
//!
9+
//! `detect_hypervisor` only recognizes hypervisors that override the DMI
10+
//! vendor/product strings to a known value (QEMU/KVM, Hyper-V). It does not
11+
//! use the CPUID hypervisor-present bit, since a hardened or nested
12+
//! hypervisor can mask both signals; this is a best-effort heuristic, not a
13+
//! guarantee.
14+
15+
use anyhow::Context;
16+
17+
/// Path to DMI file with vendor information.
18+
const DMI_SYS_VENDOR_FILE: &str = "/sys/class/dmi/id/sys_vendor";
19+
20+
/// Path to DMI file with product name.
21+
const DMI_PRODUCT_NAME_FILE: &str = "/sys/class/dmi/id/product_name";
222

323
/// Does a best-effort check to determine whether we are running in a virtdeploy VM.
424
pub fn is_virtdeploy() -> bool {
@@ -14,3 +34,51 @@ pub fn is_virtdeploy() -> bool {
1434

1535
false
1636
}
37+
38+
/// Returns the detected hypervisor name (e.g. `"qemu"`, `"hyperv"`), or
39+
/// `None` if the DMI vendor/product strings were read successfully but did
40+
/// not match a known hypervisor (including physical hardware).
41+
///
42+
/// Returns `Err` whenever a required DMI file could not be read, so callers
43+
/// that want to report collection failures (see
44+
/// `trident::diagnostics::get_virtualization_info`) can distinguish
45+
/// "unreadable" from "read fine, not virtualized". The error carries the
46+
/// failing file's path via context, so it remains actionable after being
47+
/// formatted/logged.
48+
pub fn detect_hypervisor() -> Result<Option<String>, anyhow::Error> {
49+
let vendor = std::fs::read_to_string(DMI_SYS_VENDOR_FILE)
50+
.with_context(|| format!("Failed to read '{DMI_SYS_VENDOR_FILE}'"))?
51+
.trim()
52+
.to_lowercase();
53+
if vendor.contains("qemu") {
54+
return Ok(Some("qemu".to_string()));
55+
}
56+
57+
let product = std::fs::read_to_string(DMI_PRODUCT_NAME_FILE)
58+
.with_context(|| format!("Failed to read '{DMI_PRODUCT_NAME_FILE}'"))?
59+
.trim()
60+
.to_lowercase();
61+
if vendor.contains("microsoft corporation") && product.contains("virtual machine") {
62+
return Ok(Some("hyperv".to_string()));
63+
}
64+
65+
Ok(None)
66+
}
67+
68+
/// Convenience wrapper over [`detect_hypervisor`] for callers (e.g.
69+
/// telemetry) that only care whether this is a VM, not which hypervisor,
70+
/// and don't need to distinguish "unreadable DMI files" from "not a VM" --
71+
/// both cases are reported as `false`.
72+
pub fn is_virtual() -> bool {
73+
detect_hypervisor().unwrap_or(None).is_some()
74+
}
75+
76+
#[cfg(test)]
77+
mod tests {
78+
use super::*;
79+
80+
#[test]
81+
fn test_is_virtual_matches_detect_hypervisor() {
82+
assert_eq!(is_virtual(), detect_hypervisor().unwrap_or(None).is_some());
83+
}
84+
}

‎crates/trident/src/diagnostics.rs‎

Lines changed: 5 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ use osutils::{
1818
findmnt::FindMnt,
1919
lsblk,
2020
pcrlock::{self, LogOutput},
21+
virt,
2122
};
2223
use trident_api::{
2324
config::{Check, Health},
@@ -35,12 +36,6 @@ use crate::{
3536
/// Name of the top-level directory in the diagnostics tarball
3637
const DIAGNOSTICS_BUNDLE_PREFIX: &str = "trident-diagnostics";
3738

38-
/// Path to DMI file with vendor information
39-
const DMI_SYS_VENDOR_FILE: &str = "/sys/class/dmi/id/sys_vendor";
40-
41-
/// Path to DMI file with product name
42-
const DMI_PRODUCT_NAME_FILE: &str = "/sys/class/dmi/id/product_name";
43-
4439
/// Name of the trident systemd service
4540
const TRIDENT_SERVICE_NAME: &str = "trident.service";
4641

@@ -330,33 +325,13 @@ fn collect_service_status(
330325

331326
fn get_virtualization_info(failures: &mut Vec<CollectionFailure>) -> Option<String> {
332327
debug!("Collecting virtualization info");
333-
let content = match fs::read_to_string(DMI_SYS_VENDOR_FILE) {
334-
Ok(c) => c,
328+
match virt::detect_hypervisor() {
329+
Ok(virt_type) => virt_type,
335330
Err(e) => {
336-
record_failure(failures, "virtualization info (dmi_sys vendor)", &e);
337-
return None;
338-
}
339-
};
340-
341-
let vendor = content.trim().to_lowercase();
342-
if vendor.contains("qemu") {
343-
return Some("qemu".to_string());
344-
}
345-
346-
let product = match fs::read_to_string(DMI_PRODUCT_NAME_FILE) {
347-
Ok(p) => p,
348-
Err(e) => {
349-
record_failure(failures, "virtualization info (dmi_sys product)", &e);
350-
return None;
331+
record_failure(failures, "virtualization info", &e);
332+
None
351333
}
352-
};
353-
354-
let product = product.trim().to_lowercase();
355-
if vendor.contains("microsoft corporation") && product.contains("virtual machine") {
356-
return Some("hyperv".to_string());
357334
}
358-
359-
None
360335
}
361336

362337
struct FileToCollect {

‎crates/trident/src/lib.rs‎

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -320,7 +320,17 @@ impl Trident {
320320
));
321321
}
322322

323-
tracing::info!(metric_name = "trident_start");
323+
// See `logging::tracestream::clock_boottime_uptime_secs` for the
324+
// NaN-fallback rationale and its regression test.
325+
let uptime_secs = logging::tracestream::clock_boottime_uptime_secs(
326+
nix::time::clock_gettime(nix::time::ClockId::CLOCK_BOOTTIME),
327+
);
328+
// `acl` and `arch` are process-lifetime, host-level facts (like
329+
// `vm`), so they're stamped onto every telemetry event via
330+
// `PLATFORM_INFO` (see
331+
// `logging::tracestream::populate_platform_info`) instead of
332+
// being scoped to just this one metric.
333+
tracing::info!(metric_name = "trident_start", value = uptime_secs,);
324334

325335
Ok(Self {
326336
host_config,

‎crates/trident/src/logging/tracestream.rs‎

Lines changed: 130 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -23,14 +23,41 @@ use tracing_subscriber::{layer::Layer, registry::LookupSpan};
2323
use crate::command_kind::CommandKind;
2424
use trident_api::error::TridentError;
2525

26+
/// Reads `CLOCK_BOOTTIME` (nanosecond-resolution time since boot,
27+
/// including any suspended time -- unlike `sysinfo::System::uptime()` or a
28+
/// naive `/proc/uptime` parse, which only expose whole-second resolution)
29+
/// and converts it into the `trident_start` uptime metric value. Used by
30+
/// `Trident::new`'s startup telemetry (see the `trident_start` call site
31+
/// in `lib.rs`), which passes it the live `nix::time::clock_gettime`
32+
/// result.
33+
///
34+
/// Best-effort: `clock_gettime` with a valid clock ID essentially never
35+
/// fails on Linux, but falls back to `f64::NAN` -- which `json!()`
36+
/// serializes as JSON `null`, a genuine "not available" rather than a
37+
/// misleading literal zero (see
38+
/// `test_clock_boottime_uptime_secs_nan_fallback_serializes_as_json_null`
39+
/// below) -- rather than failing startup if it somehow does.
40+
pub(crate) fn clock_boottime_uptime_secs(
41+
clock_read: Result<nix::sys::time::TimeSpec, nix::errno::Errno>,
42+
) -> f64 {
43+
clock_read
44+
.map(|ts| std::time::Duration::from(ts).as_secs_f64())
45+
.unwrap_or_else(|e| {
46+
warn!("Failed to read CLOCK_BOOTTIME: {e}");
47+
f64::NAN
48+
})
49+
}
50+
2651
use osutils::{
2752
dmi, files,
2853
osrelease::{OsRelease, OS_RELEASE_PATH},
29-
uname,
54+
uname, virt,
3055
};
56+
use sysdefs::arch::SystemArchitecture;
3157

3258
use crate::{
33-
datastore::DataStore, logging::operation_context, TRIDENT_METRICS_FILE_PATH, TRIDENT_VERSION,
59+
datastore::DataStore, init::acl, logging::operation_context, TRIDENT_METRICS_FILE_PATH,
60+
TRIDENT_VERSION,
3461
};
3562

3663
lazy_static::lazy_static! {
@@ -802,6 +829,30 @@ fn populate_platform_info() -> BTreeMap<String, Value> {
802829
"unknown".to_string()
803830
});
804831
platform_info.insert("kernel_version".to_string(), json!(kernel_release.trim()));
832+
833+
// Whether this host is virtualized (see `osutils::virt` for the
834+
// detection heuristic and its caveats).
835+
platform_info.insert("vm".to_string(), json!(virt::is_virtual()));
836+
837+
let arch: &'static str = SystemArchitecture::current().into();
838+
platform_info.insert("arch".to_string(), json!(arch));
839+
// Best-effort: a failure to determine whether this is an ACL (Azure
840+
// Container Linux) host must never fail startup, it only means this
841+
// one field is missing from every telemetry event for this
842+
// invocation. A detection failure is reported as "unknown", not
843+
// "false" -- conflating "known non-ACL" with "couldn't tell" would
844+
// misclassify a host whose ACL check simply failed to run as
845+
// definitively non-ACL.
846+
let acl = match acl::is_acl() {
847+
Ok(true) => "true",
848+
Ok(false) => "false",
849+
Err(e) => {
850+
warn!("Failed to determine if host is running ACL: {e:?}");
851+
"unknown"
852+
}
853+
};
854+
platform_info.insert("acl".to_string(), json!(acl));
855+
805856
platform_info
806857
}
807858

@@ -937,6 +988,63 @@ mod tests {
937988
);
938989
}
939990

991+
#[test]
992+
/// Regression test for a Copilot review finding on PR #778: claimed
993+
/// that `clock_boottime_uptime_secs`'s `f64::NAN` fallback, passed
994+
/// through `tracing::info!(value = ...)` to
995+
/// `TraceEntryVisitor::record_f64`'s `json!(value)`, would make
996+
/// `serde_json` unable to serialize the entry, panicking instead of
997+
/// staying best-effort. Calls the exact function `lib.rs`'s
998+
/// `trident_start` metric uses (see its call site), forcing the
999+
/// failure branch with a synthetic `Errno`, then sends the resulting
1000+
/// value through the real tracing -> visitor -> file-write pipeline
1001+
/// (not just a standalone `serde_json` snippet): `json!(f64)` goes
1002+
/// through `Value::from(f64)`, which maps non-finite floats to
1003+
/// `Value::Null` rather than erroring, so the written line contains a
1004+
/// valid JSON `"value":null` and the file write completes normally.
1005+
fn test_clock_boottime_uptime_secs_nan_fallback_serializes_as_json_null() {
1006+
let uptime_secs = clock_boottime_uptime_secs(Err(nix::errno::Errno::EINVAL));
1007+
assert!(
1008+
uptime_secs.is_nan(),
1009+
"a failed clock read must fall back to NaN, not a misleading literal value"
1010+
);
1011+
1012+
let temp_dir = tempfile::tempdir().unwrap();
1013+
let metrics_path = temp_dir.path().join("metrics.jsonl");
1014+
let tracestream = TraceStream::default();
1015+
let trace_sender = tracestream
1016+
.make_trace_sender_with_metrics_path(metrics_path.to_str().unwrap())
1017+
.with_filter(filter::LevelFilter::INFO);
1018+
1019+
let _guard = tracing::subscriber::set_default(
1020+
tracing_subscriber::Registry::default().with(trace_sender),
1021+
);
1022+
1023+
tracing::info!(metric_name = "trident_start", value = uptime_secs);
1024+
1025+
std::thread::sleep(std::time::Duration::from_millis(100));
1026+
1027+
let file = File::open(&metrics_path).unwrap();
1028+
let reader = BufReader::new(file);
1029+
let lines: Vec<String> = reader.lines().map(|l| l.unwrap()).collect();
1030+
1031+
let entry_line = lines
1032+
.iter()
1033+
.find(|line| line.contains(r#""metric_name":"trident_start""#))
1034+
.expect("expected trident_start metric not found in the local metrics file");
1035+
1036+
// Confirm the line is valid, parseable JSON (i.e. writing a NaN
1037+
// value never broke serialization) and that the value field landed
1038+
// as JSON null rather than a literal 0 or a serialization failure.
1039+
let parsed: serde_json::Value =
1040+
serde_json::from_str(entry_line).expect("trace entry line must be valid JSON");
1041+
assert_eq!(
1042+
parsed.get("value"),
1043+
Some(&Value::Null),
1044+
"NaN metric value should serialize as JSON null, not a literal 0 or an error"
1045+
);
1046+
}
1047+
9401048
#[test]
9411049
/// Regression test: `TraceStream::set_installation_id` must actually
9421050
/// reach the serialized trace entry's `additional_fields.installation_id`
@@ -1309,13 +1417,31 @@ mod functional_test {
13091417
"kernel_version".to_string(),
13101418
json!(uname::kernel_release().unwrap().trim()),
13111419
);
1420+
expected_platform_info.insert("vm".to_string(), json!(virt::is_virtual()));
13121421

13131422
// Call the function to get the actual result.
13141423
let platform_info = populate_platform_info();
13151424

1316-
// Assert that the actual result matches the expected result.
1425+
let expected_arch: &'static str = SystemArchitecture::current().into();
1426+
assert_eq!(platform_info.get("arch").unwrap(), &json!(expected_arch));
1427+
// Host-dependent (like the fields above): just assert the field is
1428+
// present and one of the values `acl::is_acl()` can actually
1429+
// produce, rather than a fixed expectation, since whether the VM
1430+
// running this test is an ACL host isn't controlled by the test.
1431+
let acl = platform_info.get("acl").unwrap().as_str().unwrap();
1432+
assert!(
1433+
["true", "false", "unknown"].contains(&acl),
1434+
"unexpected acl value: {acl}"
1435+
);
1436+
1437+
// Assert that the actual result matches the expected result for
1438+
// every field with a fixed expectation (arch/acl are asserted
1439+
// separately above since they are host-dependent).
1440+
let mut actual_platform_info = platform_info;
1441+
actual_platform_info.remove("arch");
1442+
actual_platform_info.remove("acl");
13171443
assert_eq!(
1318-
platform_info, expected_platform_info,
1444+
actual_platform_info, expected_platform_info,
13191445
"Platform info does not match the expected result"
13201446
);
13211447
}

‎docs/Reference/Telemetry.md‎

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,19 @@ host along with the metrics/spans themselves:
4444
- `kernel_version`: the running kernel release (`uname -r`).
4545
- `total_cpu`: the number of CPUs.
4646
- `total_memory_gib`: total memory, in GiB.
47+
- `vm`: whether the host appears to be a virtual machine, detected from DMI
48+
vendor/product strings (`/sys/class/dmi/id/sys_vendor`,
49+
`/sys/class/dmi/id/product_name`). Best-effort: reports `false` if the
50+
DMI files are unreadable or the hypervisor is not one of the recognized
51+
vendors, not necessarily "definitely bare metal".
52+
- `arch`: the CPU architecture Trident is running on, as reported by
53+
`SystemArchitecture` (e.g. `amd64`, `arm64` -- not the kernel's own
54+
`x86_64`/`aarch64` naming).
55+
- `acl`: whether the host is running Azure Container Linux (ACL), as a
56+
string -- `"true"`, `"false"`, or `"unknown"` if detection itself
57+
failed. Best-effort, like `vm` above: an `"unknown"` detection failure
58+
is kept distinct from a known negative, rather than folded into
59+
`"false"`.
4760
- `trident_version`: the running Trident version.
4861
- `installation_id`: an ID that lets separate events be correlated back to
4962
the same host installation over time.

0 commit comments

Comments
 (0)