Skip to content

Commit db71e72

Browse files
committed
feat: make PXE boot outcomes visible behind their HTTP 200s
The PXE boot server answers most failures with an HTTP 200 error template, so its `http_*` metrics read healthy while machines fail to boot -- and four of its failure paths do not even log. This makes every boot-path outcome countable. - `carbide_pxe_boot_outcomes_total{endpoint, reason}` -- one outcome per request across `whoami`, `boot`, and the cloud-init routes: `ok`, `interface_not_found`, `instructions_empty`, `metadata_not_found`, `upstream_api_error`, and `architecture_not_found` (counted at the extractor that actually rejects a bad `buildarch`, since such a request never reaches a handler). - The `/metrics` endpoint now serves two registries in one scrape: the existing `http_*` family renders byte-identical first, then the framework's metrics follow -- the forked axum metric layer stays untouched, with its OpenTelemetry migration left to the governance work. Notable details: - The events are metric-only (`log = off`): the binary has no tracing subscriber, and every existing stderr line stays as it was. - Reasons are per-site truthful: the cloud-init generic-error funnel takes its reason from the caller, so the same generic template counts as `interface_not_found` or `metadata_not_found` depending on what was actually missing. - Requests rejected before a handler runs return real 4xx codes the `http_*` metrics already count; only the bad-architecture rejection is additionally counted here, as a boot outcome operators watch for. Tests added! This supports #3177 Signed-off-by: Chet Nichols III <chetn@nvidia.com>
1 parent eb522ae commit db71e72

9 files changed

Lines changed: 529 additions & 66 deletions

File tree

Cargo.lock

Lines changed: 3 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

crates/pxe/Cargo.toml

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,11 +29,13 @@ path = "src/main.rs"
2929
[dependencies]
3030
# [begin-local-dependencies]
3131
carbide-host-support = { path = "../host-support", default-features = false }
32+
carbide-instrument = { path = "../instrument" }
3233
carbide-tls = { path = "../tls" }
3334
carbide-utils = { path = "../utils", default-features = false }
3435
carbide-version = { path = "../version" }
3536
carbide-rpc = { path = "../rpc" }
3637
carbide-uuid = { path = "../uuid" }
38+
metrics-endpoint = { path = "../metrics-endpoint" }
3739
# [end-local-dependencies]
3840

3941
axum = { workspace = true }
@@ -47,6 +49,7 @@ metrics = { workspace = true }
4749
metrics-exporter-prometheus = { workspace = true }
4850
mime = { workspace = true }
4951
pin-project-lite = { workspace = true }
52+
prometheus = { workspace = true }
5053
serde = { features = ["derive"], workspace = true }
5154
tera = { workspace = true }
5255
tokio = { workspace = true }
@@ -62,6 +65,7 @@ uuid = { features = ["v4", "serde"], workspace = true }
6265
carbide-version = { path = "../version" }
6366

6467
[dev-dependencies]
68+
carbide-instrument = { path = "../instrument", features = ["test-support"] }
6569
carbide-test-support = { path = "../test-support" }
6670
tempfile = { workspace = true }
6771

crates/pxe/src/common.rs

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -50,4 +50,33 @@ pub(crate) struct AppState {
5050
// pub request_metrics: RequestMetrics,
5151
pub runtime_config: RuntimeConfig,
5252
pub prometheus_handle: PrometheusHandle,
53+
/// The registry behind the global OTel meter, where the instrumentation
54+
/// framework's events record; `/metrics` renders it alongside the
55+
/// `metrics-exporter-prometheus` recorder above.
56+
pub otel_registry: prometheus::Registry,
57+
}
58+
59+
/// An [`AppState`] for handler tests: an empty template engine, a local
60+
/// (uninstalled) recorder, and a fresh OTel registry.
61+
#[cfg(test)]
62+
pub(crate) fn test_app_state() -> AppState {
63+
use metrics_exporter_prometheus::PrometheusBuilder;
64+
65+
AppState {
66+
engine: Engine::from(Tera::default()),
67+
runtime_config: RuntimeConfig {
68+
internal_api_url: "https://carbide-api.forge-system.svc.cluster.local:1079".to_string(),
69+
client_facing_api_url: "https://carbide-api.forge".to_string(),
70+
pxe_url: "http://carbide-pxe.forge".to_string(),
71+
static_pxe_url: "http://carbide-pxe.forge".to_string(),
72+
forge_root_ca_path: String::new(),
73+
server_cert_path: String::new(),
74+
server_key_path: String::new(),
75+
bind_address: "0.0.0.0".parse().unwrap(),
76+
bind_port: 8080,
77+
template_directory: String::new(),
78+
},
79+
prometheus_handle: PrometheusBuilder::new().build_recorder().handle(),
80+
otel_registry: prometheus::Registry::new(),
81+
}
5382
}

crates/pxe/src/extractors/machine_interface.rs

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -46,15 +46,28 @@ where
4646
type Rejection = PxeRequestError;
4747

4848
async fn from_request_parts(parts: &mut Parts, state: &S) -> Result<Self, Self::Rejection> {
49+
// A missing or malformed build architecture is a boot outcome
50+
// operators watch for, so both rejection shapes count as one --
51+
// even though the client gets a real 4xx rather than a template.
52+
// This extractor serves only the boot route.
53+
let count_bad_architecture = || {
54+
carbide_instrument::emit(crate::metrics::PxeBootOutcome {
55+
endpoint: crate::metrics::BootEndpoint::Boot,
56+
reason: crate::metrics::OutcomeReason::ArchitectureNotFound,
57+
});
58+
};
59+
4960
let Ok(maybe) = Query::<MaybeMachineInterface>::from_request_parts(parts, state).await
5061
else {
5162
// Query parsing only fails on the required build_architecture
5263
// field; everything else is optional.
64+
count_bad_architecture();
5365
return Err(PxeRequestError::InvalidBuildArch);
5466
};
5567
let maybe = maybe.0;
5668

57-
let build_architecture = MachineArchitecture::try_from(maybe.build_architecture.as_str())?;
69+
let build_architecture = MachineArchitecture::try_from(maybe.build_architecture.as_str())
70+
.inspect_err(|_| count_bad_architecture())?;
5871

5972
// Note: This does *NOT* look at X-Forwarded-For, due to security issues with the header. We
6073
// don't currently have use cases for a proxy in front of carbide-pxe... if that changes

crates/pxe/src/main.rs

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -69,6 +69,13 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
6969
println!("Start carbide-pxe version {}", carbide_version::version!());
7070
let prometheus_handle = metrics::setup_prometheus();
7171

72+
// The instrumentation framework's events resolve their instruments from
73+
// the global OTel meter, so it installs before the router (and any first
74+
// emit) exists. The returned setup owns the meter provider and must stay
75+
// alive for the process lifetime -- dropping it stops collection.
76+
let otel_metrics = metrics_endpoint::new_metrics_setup("carbide-pxe", "carbide", true)
77+
.expect("unable to install the OTel meter provider?");
78+
7279
let runtime_config =
7380
config::RuntimeConfig::from_env().expect("unable to build runtime config?");
7481

@@ -80,6 +87,7 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
8087
engine: Engine::from(tera),
8188
runtime_config,
8289
prometheus_handle,
90+
otel_registry: otel_metrics.registry.clone(),
8391
};
8492

8593
let app = Router::new()

crates/pxe/src/metrics.rs

Lines changed: 176 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@
1616
*/
1717
use std::time::Duration;
1818

19+
use carbide_instrument::{Event, LabelValue};
1920
use metrics_exporter_prometheus::{Matcher, PrometheusBuilder, PrometheusHandle};
2021
use tokio::time::sleep;
2122

@@ -62,3 +63,178 @@ pub(crate) fn setup_prometheus() -> PrometheusHandle {
6263

6364
prometheus_handle
6465
}
66+
67+
/// The boot-path endpoint an outcome describes, as a bounded metric label:
68+
/// the two iPXE script routes plus the cloud-init route family
69+
/// (user-data, meta-data, vendor-data).
70+
#[derive(Debug, Clone, Copy, PartialEq, Eq, LabelValue)]
71+
pub(crate) enum BootEndpoint {
72+
Whoami,
73+
Boot,
74+
CloudInit,
75+
}
76+
77+
/// How a boot-path request resolved, as a bounded metric label. Every
78+
/// non-`Ok` variant is a response the machine receives as an error script
79+
/// or generic error template over HTTP 200 -- this label is what makes
80+
/// those outcomes visible, since the status-code metrics cannot see them.
81+
/// Requests rejected before a handler runs (a malformed `buildarch`, an
82+
/// upstream failure inside the `Machine` extractor) return real 4xx codes
83+
/// the `http_*` metrics already count; only `architecture_not_found` is
84+
/// also emitted from its extractor, because a bad architecture is a boot
85+
/// outcome operators watch for. `upstream_api_error` is therefore
86+
/// structurally boot-only. `ok` means the request resolved to a servable
87+
/// response; a template that later fails to render returns a real 5xx the
88+
/// `http_*` metrics count, which is outside this metric's HTTP-200 scope.
89+
#[derive(Debug, Clone, Copy, PartialEq, Eq, LabelValue)]
90+
pub(crate) enum OutcomeReason {
91+
Ok,
92+
ArchitectureNotFound,
93+
InterfaceNotFound,
94+
InstructionsEmpty,
95+
MetadataNotFound,
96+
UpstreamApiError,
97+
}
98+
99+
/// A boot-path request resolved to a servable response -- the real script
100+
/// or an error template. Metric-only: the existing stderr lines on the
101+
/// failure paths stay as they are, and the rate is the signal.
102+
#[derive(Event)]
103+
#[event(
104+
name = "carbide_pxe_boot_outcomes_total",
105+
component = "carbide-pxe",
106+
log = off,
107+
metric = counter,
108+
describe = "Number of PXE boot-path outcomes served, by endpoint and reason."
109+
)]
110+
pub(crate) struct PxeBootOutcome {
111+
#[label]
112+
pub endpoint: BootEndpoint,
113+
#[label]
114+
pub reason: OutcomeReason,
115+
}
116+
117+
#[cfg(test)]
118+
mod tests {
119+
use carbide_instrument::emit;
120+
use carbide_instrument::testing::{MetricsCapture, capture_logs};
121+
use carbide_test_support::{Check, check_values};
122+
123+
use super::*;
124+
125+
/// The label vocabulary is the dashboard contract: each variant renders
126+
/// as its snake_case name, byte for byte.
127+
#[test]
128+
fn label_values_render_as_snake_case() {
129+
check_values(
130+
[
131+
Check {
132+
scenario: "whoami endpoint",
133+
input: BootEndpoint::Whoami.label_value(),
134+
expect: "whoami".to_string(),
135+
},
136+
Check {
137+
scenario: "boot endpoint",
138+
input: BootEndpoint::Boot.label_value(),
139+
expect: "boot".to_string(),
140+
},
141+
Check {
142+
scenario: "cloud-init endpoint",
143+
input: BootEndpoint::CloudInit.label_value(),
144+
expect: "cloud_init".to_string(),
145+
},
146+
Check {
147+
scenario: "ok",
148+
input: OutcomeReason::Ok.label_value(),
149+
expect: "ok".to_string(),
150+
},
151+
Check {
152+
scenario: "architecture not found",
153+
input: OutcomeReason::ArchitectureNotFound.label_value(),
154+
expect: "architecture_not_found".to_string(),
155+
},
156+
Check {
157+
scenario: "interface not found",
158+
input: OutcomeReason::InterfaceNotFound.label_value(),
159+
expect: "interface_not_found".to_string(),
160+
},
161+
Check {
162+
scenario: "instructions empty",
163+
input: OutcomeReason::InstructionsEmpty.label_value(),
164+
expect: "instructions_empty".to_string(),
165+
},
166+
Check {
167+
scenario: "upstream API error",
168+
input: OutcomeReason::UpstreamApiError.label_value(),
169+
expect: "upstream_api_error".to_string(),
170+
},
171+
Check {
172+
scenario: "render failure",
173+
input: OutcomeReason::MetadataNotFound.label_value(),
174+
expect: "metadata_not_found".to_string(),
175+
},
176+
],
177+
|value| value.to_string(),
178+
);
179+
}
180+
181+
/// Each emit moves exactly its label pair's series, and none of them
182+
/// builds a log line -- the event is declared `log = off` because this
183+
/// binary has no tracing subscriber and its stderr lines stay untouched.
184+
#[test]
185+
fn boot_outcomes_count_per_label_without_logging() {
186+
let metrics = MetricsCapture::start();
187+
let logs = capture_logs(|| {
188+
emit(PxeBootOutcome {
189+
endpoint: BootEndpoint::Whoami,
190+
reason: OutcomeReason::Ok,
191+
});
192+
emit(PxeBootOutcome {
193+
endpoint: BootEndpoint::Boot,
194+
reason: OutcomeReason::UpstreamApiError,
195+
});
196+
emit(PxeBootOutcome {
197+
endpoint: BootEndpoint::Boot,
198+
reason: OutcomeReason::UpstreamApiError,
199+
});
200+
emit(PxeBootOutcome {
201+
endpoint: BootEndpoint::CloudInit,
202+
reason: OutcomeReason::MetadataNotFound,
203+
});
204+
});
205+
206+
assert!(
207+
logs.is_empty(),
208+
"log = off must not construct any log line, got {logs:?}"
209+
);
210+
assert_eq!(
211+
metrics.counter_delta(
212+
"carbide_pxe_boot_outcomes_total",
213+
&[("endpoint", "whoami"), ("reason", "ok")],
214+
),
215+
1.0,
216+
);
217+
assert_eq!(
218+
metrics.counter_delta(
219+
"carbide_pxe_boot_outcomes_total",
220+
&[("endpoint", "boot"), ("reason", "upstream_api_error")],
221+
),
222+
2.0,
223+
);
224+
assert_eq!(
225+
metrics.counter_delta(
226+
"carbide_pxe_boot_outcomes_total",
227+
&[("endpoint", "cloud_init"), ("reason", "metadata_not_found")],
228+
),
229+
1.0,
230+
);
231+
assert_eq!(
232+
metrics.counter_delta(
233+
"carbide_pxe_boot_outcomes_total",
234+
&[("endpoint", "boot"), ("reason", "ok")],
235+
),
236+
0.0,
237+
"an untouched label pair must not move",
238+
);
239+
}
240+
}

0 commit comments

Comments
 (0)