Skip to content

Wire tracing diagnostics through libazureinit-kvp - #319

Open
Peyton Robertson (peytonr18) wants to merge 6 commits into
Azure:mainfrom
peytonr18:probertson-wire-tracing
Open

Peyton Robertson (peytonr18) wants to merge 6 commits into
Azure:mainfrom
peytonr18:probertson-wire-tracing

Conversation

@peytonr18

Copy link
Copy Markdown
Contributor

Tracing, Diagnostics, and KVP Integration

Azure Init now uses libazureinit-kvp for diagnostics and provisioning reports instead of maintaining its own KVP encoder and writer. The reusable, synchronous DiagnosticsKvp bridge handles tracing; the application owns logging policy and report orchestration.

Architecture

Component Responsibility
libazureinit-kvp Pool I/O and locking, DIAG encoding/chunking, the report model and writer, CLI, and optional tracing bridge
libazureinit Provisioning operations, Azure error-to-report mapping, and the HTTP-only wireserver module
azure-init Subscriber setup, configuration and filters, pool cleanup, and final report delivery

Two KVP paths share the same store: diagnostics append operation history; the provisioning report replaces the final status. HTTP reporting is a separate transport.

flowchart TD
    APP["Azure Init"] --> TRACE["Tracing spans and events"]
    TRACE --> LAYER["DiagnosticsKvp Layer<br/>capture and emit"]
    LAYER --> WRITER["DiagnosticWriter<br/>validate, encode, chunk"]
    WRITER -->|append| STORE["KvpPoolStore<br/>flock + OFD fcntl"]
    APP --> RESULT["Provisioning result<br/>one typed report"]
    RESULT --> PUBLISH["publish_provisioning_report<br/>tokio::join!"]
    PUBLISH --> WRITE["write_report<br/>spawn_blocking"]
    PUBLISH --> HTTP["wireserver<br/>report_ready<br/>report_failure"]
    WRITE -->|upsert| STORE
    STORE --> POOL["Guest pool 1<br/>.kvp_pool_1"]
    POOL -. host reads later .-> HOST["hv_kvp_daemon<br/>kernel and Hyper-V host"]
    HTTP --> WS["Azure wireserver"]
Loading

Runtime Behavior

Diagnostics: on_new_span and on_close produce start/finish records with a shared operation UUID; on_record retains field updates. Events receive independent UUIDs, including events outside spans. Payloads contain the tracing target, level, and recorded fields as JSON text.

Span outcomes use an explicit diagnostic.result, otherwise infer failure from observed ERROR events and success when none were observed. Detected unwinding reports failure. This is tracing-based inference: filtering and missing instrumentation can hide errors.

Synchronous delivery: callbacks write through DiagnosticsKvp::emit() and the existing DiagnosticWriter validation and framing. No runtime, worker, queue, or drain lifecycle is required by the bridge. Callback failures go to stderr to avoid recursive tracing. Lock contention can delay callers; successful local writes do not guarantee durability or host receipt.

Final status: the binary constructs one ProvisioningReport from the actual provisioning result and attempts KVP and HTTP delivery with tokio::join!. write_report(&store, &report) runs through spawn_blocking and upserts PROVISIONING_REPORT. Failure HTTP requests reuse the report's encoding and timestamp; successful HTTP requests remain state-only.

Final reports bypass tracing filters and remain available if diagnostic-writer initialization fails after pool preparation. Both delivery results are observed without changing the provisioning exit code. Existing report-size and store-capacity limits remain in effect.

Startup and Configuration

  1. main uses a scoped stderr-only bootstrap subscriber while obtaining the VM ID and loading configuration.
  2. setup_layers runs through spawn_blocking, prepares the log file, and performs explicit stale-pool cleanup when KVP is enabled.
  3. main installs the configured subscriber globally. Defaults are stderr at ERROR, the private 0600 log file at DEBUG, and KVP at INFO (INFO/WARN/ERROR).

AZURE_INIT_LOG controls file/console verbosity independently. KVP filter precedence is AZURE_INIT_KVP_FILTER, then telemetry.kvp_filter, then info; empty or invalid values fall through to the next source.

telemetry.kvp_diagnostics = false disables KVP setup, cleanup, diagnostics, and final-report writes without disabling local logs or wireserver HTTP. The reusable bridge itself installs no subscriber, chooses no filter, and performs no implicit cleanup; it is available behind the crate's tracing feature.

Cleanup and CI

  • Remove the legacy libazureinit KVP, health, and logging modules and their unused OpenTelemetry, fs2, and sysinfo dependencies. The binary retains sysinfo for OS/kernel information.
  • Update instrumentation and callers for the new diagnostics and wireserver APIs. CLI cleanup tests disable KVP to avoid touching the host pool.
  • Run KVP CLI conformance checks in a separate testinit CI step using scratch pools, without launching extra provisioning runs.
  • Use a private container tmpfs for the agent pool and systemd results for service completion. Collect parsed telemetry through the shipped CLI after the run, replacing the mock wireserver's POST-time snapshot. Container checks cover local pool behavior, not host retrieval.

@codecov-commenter

Codecov Comments Bot (codecov-commenter) commented Sep 24, 2026 •

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 97.87956% with 25 lines in your changes missing coverage. Please review.
✅ Project coverage is 99.34%. Comparing base (c6d1301) to head (2757b2a).

Files with missing lines Patch % Lines
src/main.rs 88.78% 25 Missing ⚠️
Additional details and impacted files
@@            Coverage Diff             @@
##             main     #319      +/-   ##
==========================================
+ Coverage   96.66%   99.34%   +2.68%     
==========================================
  Files          29       29              
  Lines        9960     9673     -287     
==========================================
- Hits         9628     9610      -18     
+ Misses        332       63     -269     

☔ View full report in Codecov by Harness.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.

@peytonr18

Peyton Robertson (peytonr18) commented Sep 24, 2026 •

Copy link
Copy Markdown
Contributor Author

Codecov Report

❌ Patch coverage is 95.50459% with 49 lines in your changes missing coverage. Please review. ✅ Project coverage is 98.96%. Comparing base (c6d1301) to head (47ed958).

Files with missing lines Patch % Lines
src/main.rs 83.83% 27 Missing ⚠️
src/logging.rs 86.50% 22 Missing ⚠️
Additional details and impacted files

@@            Coverage Diff             @@
##             main     #319      +/-   ##
==========================================
+ Coverage   96.66%   98.96%   +2.30%     
==========================================
  Files          29       29              
  Lines        9960     9596     -364     
==========================================
- Hits         9628     9497     -131     
+ Misses        332       99     -233     

☔ View full report in Codecov by Harness. 📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:

I can circle back to this and give it another shot, but there are pieces of main.rs specifically that I don't think we can "cover" without writing strange production code. Most of the functionality in this file is covered by integration tests, but those aren't counted towards "line coverage".

I'll give updating coverage in these two files another shot, but I don't want to sacrifice better and clearer production code for the sake of hitting 100% coverage. I don't think there's value in that, though I'm open to suggestions if others disagree.

In the meantime, I'll try my best to get us closer to 100% here.

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot review overview

🟡 Changes recommended

Final KVP reports can be lost, HTTP status handling does not follow the intended policy, and the new proxy-based test cannot reach its listener.

Review effort: Balanced
Findings: 6 Medium severity

Open (6)
What changed in this PR

This PR moves Azure Init’s KVP diagnostics and final provisioning reports to libazureinit-kvp, while keeping HTTP health reporting in libazureinit and subscriber setup in the binary.

Changes:

  • Add a synchronous tracing-to-KVP bridge and typed final reports.
  • Move logging setup and report delivery into azure-init; separate wireserver HTTP reporting.
  • Update container checks, CLI verification, CI artifacts, and documentation.
File Description
tests/​functional_tests.rs Uses the wireserver reporting API.
tests/​cli.rs Adds failure-report tests and disables KVP for cleanup tests.
testinit/​verify_kvp.py Adds scratch-pool CLI checks.
testinit/​testing-server/​wireserver_handler.py Removes POST-time pool snapshots.
testinit/​testing-server/​test_healthcheck.py Tests mock-server readiness probes.
testinit/​testing-server/​healthcheck.py Probes both mock listeners without HTTP requests.
testinit/​testing-server/​docker-compose.yml Uses the new readiness probe.
testinit/​start-all.sh Waits for server readiness and the agent’s systemd result.
testinit/​README.md Documents the updated container checks.
testinit/​Dockerfile Builds and installs the agent and KVP CLI.
testinit/​docker-compose.yml Gives the agent a private KVP tmpfs.
src/​main.rs Orchestrates logging, provisioning, and final reports.
src/​logging.rs Configures stderr, file, and KVP tracing layers.
libazureinit/​src/​wireserver.rs Provides HTTP-only health reporting.
libazureinit/​src/​status.rs Updates VM-ID and status tracing.
libazureinit/​src/​provision/​user.rs Updates user-provisioning tracing.
libazureinit/​src/​provision/​ssh.rs Updates SSH-provisioning tracing.
libazureinit/​src/​provision/​password.rs Updates password-provisioning tracing.
libazureinit/​src/​provision/​mod.rs Updates provisioning span instrumentation.
libazureinit/​src/​provision/​hostname.rs Records hostname errors in tracing.
libazureinit/​src/​media.rs Updates media-operation tracing.
libazureinit/​src/​logging.rs Removes legacy library logging.
libazureinit/​src/​lib.rs Exports wireserver in place of legacy modules.
libazureinit/​src/​kvp.rs Removes the legacy KVP writer.
libazureinit/​src/​http.rs Updates HTTP request tracing.
libazureinit/​src/​health.rs Removes combined HTTP and KVP reporting.
libazureinit/​src/​error.rs Maps errors to typed provisioning reports.
libazureinit/​src/​config.rs Clarifies telemetry settings and tracing.
libazureinit/​Cargo.toml Adds the KVP library and removes unused dependencies.
libazureinit-kvp/​tests/​diagnostics.rs Tests tracing records through the public API.
libazureinit-kvp/​src/​vm_id.rs Raises empty VM-ID logging to a warning.
libazureinit-kvp/​src/​report.rs Exposes report encoding.
libazureinit-kvp/​src/​lib.rs Exports the optional tracing bridge.
libazureinit-kvp/​src/​diagnostics/​writer.rs Supports captured diagnostic timestamps.
libazureinit-kvp/​src/​diagnostics/​tracing_layer.rs Implements synchronous tracing delivery.
libazureinit-kvp/​src/​diagnostics/​mod.rs Gates and exports the tracing layer.
libazureinit-kvp/​Cargo.toml Adds the optional tracing feature.
doc/​kvp.md Updates pool-write documentation.
doc/​diagnostics.md Documents tracing-produced diagnostics.
doc/​configuration.md Documents KVP filters and logging defaults.
Cargo.toml Enables KVP tracing for the binary.
.github/​workflows/​e2e-testing.yml Adds CLI verification and telemetry artifacts.

💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.

Comment on lines +199 to +215
let explicit = match state.fields.outcome() {
Ok(result) => result,
Err(error) => {
eprintln!("Dropped KVP diagnostic finish: {error}");
return;
}
};
// A span closing during a panic reports failure even without an ERROR.
let result = if std::thread::panicking() {
Outcome::Failure
} else {
explicit.unwrap_or(if state.saw_error {
Outcome::Failure
} else {
Outcome::Success
})
};

let mut remaining = retry_for;
while !remaining.is_zero() {
let (response, new_remaining) = http::post(
Comment thread src/logging.rs
let kvp_layer = if config.telemetry.kvp_diagnostics {
match KvpPoolStore::new_in(KvpPool::Guest, kvp_dir, PoolMode::Safe)
.and_then(|store| {
store.clear_if_stale()?;
Comment thread src/main.rs
if let Some(store) = store {
let store = store.clone();
let report = report.clone();
tokio::task::spawn_blocking(move || write_report(&store, &report))
Comment thread src/main.rs
Comment on lines 324 to 326
let err = LibError::LoadSshdConfig {
details: format!("{error:?}"),
};
Comment thread tests/cli.rs
Comment on lines +78 to +81
.env("HTTP_PROXY", &proxy)
.env("http_proxy", &proxy)
.env("NO_PROXY", "")
.env("no_proxy", "")

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Adding an initial review to provide feedback, but this is looking awesome so far! Testinit is showing very solid output!

Comment thread libazureinit/src/provision/mod.rs Outdated
self.disable_password_authentication,
) {
tracing::error!(
tracing::debug!(

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Since this section is only reached in the event that ssh_config_update_required is true, this tracing message showing a failure in expected configuration is likely relevant to a user when looking at the KVP file. Especially since the default filter level is Info which debug falls below, this failure may not be collected in that case. I feel the original error level may be best here, but my interpretation could be incorrect here!

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Agreed, addressed in latest!

Comment thread libazureinit/src/provision/ssh.rs Outdated
"No authorizedkeysfile setting found in sshd configuration"
);
} else {
tracing::Span::current().record("diagnostic.result", "success");

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Could we use Outcome::Success or Outcome::Success.to_str() (whatever helps fit the Rust compile checker) here so that we can avoid passing plain strings? It looks like we check if it matches the string in that spot.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Absolutely. I added an as_str() method to the Outcome enum and updated all related callers to use it instead of hard-coded outcome strings.

fn outcome(&self) -> Result<Option<Outcome>, &'static str> {
match self.0.get(OUTCOME_FIELD) {
None => Ok(None),
Some(Value::String(value)) if value == "success" => {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Similar here, would it make sense to avoid the hard coded string since we do already provide the enum?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Agreed that it would make sense -- addressed in latest!

Ok(Some(Outcome::Success))
}
Some(Value::String(value)) if value == "fail" => {
Ok(Some(Outcome::Failure))

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I believe I missed the discussion on the previous PR: what was the reason for the Outcome::Success -> success but Outcome::Failure -> fail instead of failure?

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We can update the in line comment to include a one line justification potentially as well 😁

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good question!!!! I went back and checked this too.

Failure is just the name of our Rust enum variant, while the DIAG format from the earlier PR uses fail. Cloud-init reports SUCCESS/FAIL, which our reader maps to success/fail, so using fail here keeps the parsed outcome consistent with that vocabulary.

There isn't anything in Rust that requires us to use fail instead of failure, I just originally chose it to match the cloud-init contract. For now, I've kept fail and added a short comment next to the enum to make that mapping clearer, but I'm definitely open to changing it if you think failure would be better!

Comment thread libazureinit/src/media.rs Outdated
let mount_result = mount_fn();
if let Err(ref e) = mount_result {
tracing::error!(error = ?e, "Failed to mount media.");
tracing::debug!(error = ?e, "Failed to mount media.");

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This likely falls under the same discussion as the earlier SSHD config error message so this comment may be a duplicate, but this message may also be important enough to link as a warning to avoid filtering from the Info level default.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Agreed, addressed in latest!

Comment thread libazureinit/src/media.rs Outdated
let unmount_result = unmount_fn(mounted);
if let Err(ref e) = unmount_result {
tracing::error!(error = ?e, "Failed to remove media.");
tracing::debug!(error = ?e, "Failed to remove media.");

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

ditto, copying the same here:
This likely falls under the same discussion as the earlier SSHD config error message so this comment may be a duplicate, but this message may also be important enough to link as a warning to avoid filtering from the Info level default.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Agreed, addressed in latest!

Comment thread libazureinit/src/media.rs Outdated
let unmount_result = unmount_fn(mounted);
if let Err(ref e) = unmount_result {
tracing::error!(error = ?e, "Failed to remove media.");
tracing::debug!(error = ?e, "Failed to remove media.");

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Would it make sense to update remove to unmount?

tracing::error!(error = ?e, "Failed to unmount media.");

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes! That got changed along the way, but it's back to unmount!

Comment thread libazureinit/src/status.rs Outdated
"VM ID (Gen1, swapped): {}",
final_id
);
tracing::Span::current().record("diagnostic.result", "success");

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Could we consume the const OUTCOME_FIELD: &str = "diagnostic.result"; from the tracing layer to avoid needing to copy/paste the same diagnostic.result string?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yep, good call! I moved OUTCOME_FIELD out so we can reuse it for these calls instead of hardcoding "diagnostic.result" in multiple places."

I also kept the constant independent of the optional tracing feature, so producers can use it without needing a dependency on the tracing layer.

Comment thread libazureinit/src/lib.rs Outdated
let stderr = String::from_utf8_lossy(&output.stderr);
let stdout = String::from_utf8_lossy(&output.stdout);
tracing::error!(
tracing::debug!(

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

ditto, copying the same here:
This likely falls under the same discussion as the earlier SSHD config error message so this comment may be a duplicate, but this message may also be important enough to link as a warning to avoid filtering from the Info level default.

Feel free to resolve all of these debug/error comments when we come to a conclusion, I just figured I would point to the ones I see.😁

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

These are helpful! I agreed with the findings for all of them, so I restored them to error. It's a good call out!

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants