Skip to content

feat(kvp): add diagnostics layer over KvpPoolStore - #313

Open
Peyton Robertson (peytonr18) wants to merge 33 commits into
Azure:mainfrom
peytonr18:probertson-diganostics-kvp
Open

Peyton Robertson (peytonr18) wants to merge 33 commits into
Azure:mainfrom
peytonr18:probertson-diganostics-kvp

Conversation

@peytonr18

@peytonr18 Peyton Robertson (peytonr18) commented Jul 9, 2026

Copy link
Copy Markdown
Contributor

Summary

This PR adds a DiagnosticsKvp layer that centralizes azure-init's KVP telemetry behavior, including event key formatting, message chunking, and message reassembly. This provides a single tested implementation that can be reused by the follow-up wiring PR and simplifies the existing logic in kvp.rs and logging.rs.

It also extends the existing dump command with a --parse-diagnostics mode for decoding and viewing KVP telemetry as readable events, making provisioning diagnostics easier to inspect and troubleshoot.

Specifically, this PR adds:

  • DiagnosticsKvp, a typed view over KvpPoolStore that implements telemetry-specific behavior while keeping the underlying store format-agnostic:

    • Event keys formatted and parsed as <prefix>|<vm_id>|<level>|<name>|<event_id>.
    • Message chunking at UTF-8 boundaries for values larger than a single KVP record, written via append_multiple under a single lock. Each chunk gets a unique |<subevent_index>-suffixed key so the Hyper-V host (which keeps one record per key) retains every fragment, and the chunks are reassembled when read.
    • Classification of records as diagnostic events, raw records such as PROVISIONING_REPORT, or malformed event keys.
  • Diagnostics folded into the existing raw KVP commands via flags rather than a separate command tree:

    • dump --parse-diagnostics reassembles chunked events and decodes each record. Raw output remains the default when the flag is absent.
    • --include-raw also prints raw (non-event) records; --level, --name, and -n/--tail narrow the output to a filtered, events-only view.
    • The filter flags (--level, --name, -n/--tail) require --parse-diagnostics, and --include-raw is mutually exclusive with them — it applies only to the unfiltered view, where raw records can appear.
    • clear --diagnostics removes every diagnostic key (events and malformed event keys) while leaving raw records such as PROVISIONING_REPORT intact.

Example

A long event is stored as multiple records, each under a unique |<subevent_index> key. The raw view shows the fragments; --parse-diagnostics decodes and reassembles them into a single event:

$ libazureinit-kvp dump
azure-init-1.0.0|3f25…|INFO|user:create_user|8f3e…|0=Time: … | Event: creating
azure-init-1.0.0|3f25…|INFO|user:create_user|8f3e…|1= azureuser (uid 1000) …
PROVISIONING_REPORT=result=success|…

$ libazureinit-kvp dump --parse-diagnostics --name user:create_user
event level=INFO name=user:create_user event_id=8f3e… message=Time: … | Event: creating azureuser (uid 1000) …

$ libazureinit-kvp dump --parse-diagnostics --include-raw
event level=INFO name=user:create_user event_id=8f3e… chunks=2 message=Time: … | Event: creating azureuser (uid 1000) …
raw key=PROVISIONING_REPORT value=result=success|…

$ libazureinit-kvp dump --parse-diagnostics --include-raw --json
[{"chunks":2,"event_id":"8f3e…","kind":"event","level":"INFO","message":"…","name":"user:create_user"},{"key":"PROVISIONING_REPORT","kind":"raw","value":"result=success|…"}]

@codecov-commenter

Codecov Comments Bot (codecov-commenter) commented Jul 9, 2026

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.
✅ Project coverage is 96.67%. Comparing base (23645ec) to head (5ee6eeb).

Additional details and impacted files
@@            Coverage Diff             @@
##             main     #313      +/-   ##
==========================================
+ Coverage   95.70%   96.67%   +0.97%     
==========================================
  Files          23       29       +6     
  Lines        7705     9962    +2257     
==========================================
+ Hits         7374     9631    +2257     
  Misses        331      331              

☔ 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.

Copilot AI review requested due to automatic review settings July 21, 2026 16:39

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.

Pull request overview

This PR introduces a DiagnosticsKvp layer in libazureinit-kvp to provide a typed, reusable view over KvpPoolStore for diagnostic telemetry (event key formatting/parsing, UTF-8-safe chunking, and chunk reassembly), and adds new diag CLI subcommands to inspect this decoded event view.

Changes:

  • Added DiagnosticsKvp, DiagnosticEvent, and DiagnosticRecord with chunking/reassembly and record classification logic.
  • Added diag CLI subcommands (dump, events, tail, clear) for decoding and filtering diagnostic events (including JSON output support).
  • Added integration and CLI tests covering round-trips, chunking, classification, and clear scoping; enabled uuid v4 generation via crate features.

Reviewed changes

Copilot reviewed 7 out of 7 changed files in this pull request and generated 3 comments.

Show a summary per file
File Description
libazureinit-kvp/tests/diagnostics.rs New integration tests for diagnostics emit/read, chunking, classification, scoped clear, and concurrency behavior.
libazureinit-kvp/tests/cli.rs New CLI tests validating diag command behaviors for text/JSON output, filtering, tailing, and clear confirmation.
libazureinit-kvp/src/lib.rs Exposes the new diagnostics module and re-exports its public types/constants.
libazureinit-kvp/src/error.rs Adds a dedicated error for rejecting `
libazureinit-kvp/src/diagnostics.rs Implements the diagnostics layer: event key format/parse, UTF-8 chunking, reassembly, classification, and scoped clearing.
libazureinit-kvp/src/cli.rs Adds diag subcommands and renders diagnostics records/events in text and JSON.
libazureinit-kvp/Cargo.toml Enables uuid v4 feature required for Uuid::new_v4().

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment thread libazureinit-kvp/src/cli.rs Outdated
Comment thread libazureinit-kvp/src/diagnostics.rs Outdated
Comment thread libazureinit-kvp/src/cli.rs
Comment thread libazureinit-kvp/src/cli.rs Outdated
event_prefix: String,
/// Confirm the removal (required).
#[arg(long)]
yes: bool,

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

yes is overkill i think. It's volatile data and likely past the window of collection if someone is using it. Would suggest removing it.

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.

Removed this

Comment thread libazureinit-kvp/src/cli.rs Outdated
Clear {
/// VM identifier whose events to remove.
#[arg(long)]
vm_id: String,

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

this seems more like "delete" than "clear" to me.

I would expect clear to nuke all diagnostics-based keys.

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.

I've adjusted this so clear gets rid of all diagnostic keys!

Append a chunk index to diagnostic event keys so the Hyper-V host retains all chunks. Reassembly and cleanup now operate on the base event key to correctly reconstruct and delete multi-record events.
Copilot AI review requested due to automatic review settings July 23, 2026 18:01

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.

Pull request overview

Copilot reviewed 7 out of 7 changed files in this pull request and generated 1 comment.

Comments suppressed due to low confidence (1)

libazureinit-kvp/src/diagnostics.rs:455

  • reassemble() currently concatenates any consecutive records whose base_event_key() matches, which also merges adjacent raw records that happen to share the same key (e.g., two consecutive PROVISIONING_REPORT records appended via KvpPoolStore::append). That loses record boundaries and contradicts the doc comment that grouping is for event-chunk reassembly.
        while dumped
            .peek()
            .is_some_and(|(next, _)| base_event_key(next) == base)
        {

Comment thread libazureinit-kvp/src/cli.rs Outdated
Copilot AI review requested due to automatic review settings July 27, 2026 22:55

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.

Pull request overview

Copilot reviewed 7 out of 7 changed files in this pull request and generated 1 comment.

Comment thread libazureinit-kvp/src/diagnostics.rs Outdated
Copilot AI review requested due to automatic review settings July 27, 2026 23:20

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.

Pull request overview

Copilot reviewed 7 out of 7 changed files in this pull request and generated 2 comments.

Comment thread libazureinit-kvp/src/diagnostics.rs Outdated
Comment thread libazureinit-kvp/src/diagnostics.rs Outdated
Copilot AI review requested due to automatic review settings July 27, 2026 23:33

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.

Pull request overview

Copilot reviewed 7 out of 7 changed files in this pull request and generated 1 comment.

Comments suppressed due to low confidence (1)

libazureinit-kvp/src/diagnostics.rs:490

  • records()/reassemble() assumes that all chunks for a given event remain consecutive in on-disk order. While emit() writes chunks contiguously under one lock, other store operations (notably KvpPoolStore::delete_multiple, which swaps deletions with the tail and does not preserve order) can break that contiguity and lead to incorrect reassembly for surviving multi-chunk events. Consider either documenting this invariant explicitly or making reassembly robust to record reordering.
    /// Records are returned in on-disk order. Consecutive records that
    /// share an event key — ignoring the `|<subevent_index>` suffix — are
    /// one event; because [`emit`](Self::emit) writes an event's chunks
    /// contiguously under a single lock, reassembly is correct even under
    /// concurrent writers.

Comment thread libazureinit-kvp/src/cli.rs Outdated
Copilot AI review requested due to automatic review settings July 28, 2026 00:11
Comment thread doc/kvp.md Outdated
| Command | Output |
|---------|--------|
| `libazureinit-kvp dump` | JSON array of physical key/value records in pool order, including duplicates |
| `libazureinit-kvp dump --parse` | JSON array of diagnostics and reports in timestamp order, then raw entries |

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

i think pool ordering should continue to be maintained 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.

Added this in latest commit, reverted the timeline sorting and fixed all the tests and such accordingly.

Comment thread doc/kvp.md Outdated
| `libazureinit-kvp dump` | JSON array of physical key/value records in pool order, including duplicates |
| `libazureinit-kvp dump --parse` | JSON array of diagnostics and reports in timestamp order, then raw entries |
| `libazureinit-kvp dump --text` | Physical records as `KEY=VALUE` lines |
| `libazureinit-kvp dump --parse --text` | The same timestamp ordering, with binary payloads under `payload_b64` |

@cjp256 Chris Patterson (cjp256) Sep 14, 2026

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

i'm not sure what this means? binary payloads will have payload_b64=<base64 content>?

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! Sorry, that may not have been obvious enough with that wording, which I've since adjusted a little.

That line is intended to clarify how binary payloads are displayed in --text output.

Internally, decoded diagnostics can contain either text (Text) or raw bytes (Bytes). When the payload is raw bytes, we can't safely print it directly, so the CLI base64-encodes it for display and renders it as payload_b64=. Text payloads continue to be rendered as payload=.

The important detail is that the different key name (payload_b64) signals that the displayed value is a base64 representation of the payload, not the payload itself. This is only a presentation detail for --text output; JSON output preserves the payload structure separately.

Comment thread doc/kvp.md Outdated
@@ -0,0 +1,512 @@
# KVP Diagnostics Specification

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

delete before merge? anything of value here that can be moved out? into /doc or docstrings?

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.

I meant to ask this in our chat -- I could trim this down and put some of the info in kvp.md, but I was also thinking if we choose to ship libazureinit-kvp independently, it might be good to have a /docs section in there as well?

Open to whatever approach but my initial reaction would be to trim it down, put any kvp specific things into kvp.md, and then create a /docs/diagnostics.md or something similar (and possibly move kvp.md into that folder too)

Comment thread libazureinit-kvp/Cargo.toml Outdated
Comment thread doc/kvp.md Outdated
accepts any of those canonical precisions.
Payloads are plain UTF-8 text or gzip plus base64 (`Encoding::GzB64`).

Each reader call takes one fresh snapshot and returns `Entry::Diagnostic`,

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

I get why it is referred to as a snapshot, but it's awkward framing. I would suggest scrubbing it and simplifying it some.

.decode(compact)
.map_err(|_| DecodeError::Undecodable)?;
let mut bytes = Vec::new();
// cloud-init labels zlib streams and line-wrapped base64 as `gz+b64`.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

isn't gz+b64 universal here? does zlibdecoder path ever get used?

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!! The zlibdecoder path does get used -- the reason for this is that the gz+b64 label is shared, but the compressed format isn’t always gzip. Cloud-init’s Azure compressed-event producer calls base64.encodebytes(zlib.compress(event_content)) and labels the result gz+b64. That produces a zlib stream, not a gzip stream, despite the label. Base64 turns the compressed bytes into text suitable for KVP storage, but it doesn’t make those compression formats interchangeable.

So, we have to handle zlib too because we read cloud-init records. When cloud-init stores a compressed log, we need the correct decompressor to recover its contents because a gzip-only decoder would fail, leaving those records as Raw with an Undecodable error instead of returning the decoded payload.

Self { store }
}

/// Invalid pool UTF-8 fails the snapshot; entries keep first-seen order.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

what could cause invalid pool utf-8? is this any underlying pool error?

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.

Invalid UTF-8 is one specific cause of a pool-read failure, not a catch-all for any underlying pool error.
It could happen if another process writes binary data where the pool expects text, truncates a key or value in the middle of a UTF-8 character, or if the pool contents become corrupted. Those are just plausible causes, not issues we've observed in production. Also, we only validate the actual key/value contents up to the terminating NUL; any padding after that is ignored.

When this happens, our storage API cannot represent the key or value as a Rust string, so the read fails with KvpError::Io(InvalidData). Other underlying pool errors, such as permission failures or file-read errors, are surfaced separately and are not treated as UTF-8 errors.

I mostly introduced that as a safeguard but if you think we don't need to account for any of those scenarios, I can easily remove the invalid poot utf-8 error!

…cumentation. Adopt DIAG and name/VERSION identifiers, add configurable decimal-second durations and zlib+b64 encoding, and update tests while preserving gzip and cloud-init compatibility.
Comment thread doc/diagnostics.md Outdated

| Condition | Error token |
|---|---|
| Unsupported `DIAG_V*` schema | `unsupported_version` |

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

s/DIAG_V/DIAG

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.

Done in latest!

/// When this diagnostic was emitted, in UTC.
#[serde(serialize_with = "serialize_timestamp")]
pub timestamp: DateTime<Utc>,
/// Stored payload encoding; `None` means plain text.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

plaintext -> utf-8 encoded plain-text

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.

Done in latest!

pub key: String,
/// Original value, without payload decoding.
pub value: String,
/// Why a recognized record could not be decoded; `None` for unrelated keys.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

s/unrelated/unknown, or non-diagnmostic, key-pairs

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.

Done in latest!

use serde_json::{json, Value};

const AGENT: &str = "azure-init-0.1.1";
const AGENT: &str = "azure-init/0.1.1";

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

:D

Comment thread doc/diagnostics.md
Comment on lines +34 to +36
cannot contain `|` or NUL; there is no key-field escaping. Agent strings remain
opaque to readers, including unversioned names and older naming conventions.
UUID spellings are preserved rather than rewritten during reading.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Suggested change
cannot contain `|` or NUL; there is no key-field escaping. Agent strings remain
opaque to readers, including unversioned names and older naming conventions.
UUID spellings are preserved rather than rewritten during reading.
cannot contain `|` or NUL; there is no key-field escaping.

Comment thread doc/diagnostics.md
Comment on lines +47 to +48
carries its own elapsed duration; do not derive it by subtracting wall-clock
timestamps. Either endpoint remains valid if its counterpart is missing.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Suggested change
carries its own elapsed duration; do not derive it by subtracting wall-clock
timestamps. Either endpoint remains valid if its counterpart is missing.
carries its own elapsed duration to allow caller to accurately measure
the operation of interest without relying on the timestamps of the emitted
diagnostics.

Comment thread doc/diagnostics.md

Timestamps use RFC 3339 UTC `Z` form, with zero, three, six or nine fractional
digits. The default is milliseconds, for example `2026-08-31T12:34:56.789Z`.
Numeric offsets and other fractional widths are invalid in native records.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Do we care so long as it conforms to RFC 3339? In this case I would say it MUST conform to RFC 3339, this implementation has options for three forms....

Just a thought. It's fine to keep it constrained.

Comment thread doc/diagnostics.md
Producers can select zero, three, six or nine digits; lower digits are discarded.

Accept decimal digits with an optional decimal point and one to nine fractional
digits. The whole-seconds component is at most `18446744073709551615`. Signs,

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

That's a lot of seconds :D

It might be cleaner to state that it MUST conform to IEEE 754 double and >= 0.

Comment thread doc/diagnostics.md

### Kusto Consumption

Use `zlib+b64` for new compressed telemetry targeting Kusto's

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

drop this.

Comment thread doc/diagnostics.md
| Agent / name | 32 / 48 bytes |
| Each UUID | 36 bytes |

These budgets do not impose equivalent read limits on other producers' records.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

There isn't really a full accounting here of potential max widths vs. expected/typical widths and how they fit in the available space.

Comment thread doc/diagnostics.md
These budgets do not impose equivalent read limits on other producers' records.
Encoded size also does not bound decompressed size.

Invalid metadata, unsupported encodings, duplicate or missing indices, invalid

@cjp256 Chris Patterson (cjp256) Sep 18, 2026

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

I don't know that this paragraph says much. It's fair to assume that missing/corrupted data will induce errors. Focus on the ones that are worth calling out like the next paragraph (missing trailing chunks) because it was a design consideration.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

and maybe split Limits section from Error Handling or something like that.

Comment thread doc/diagnostics.md
split each decoded field on its first `=`. Field order is not significant for
reading.

Required fields are `result`, `agent`, `vm_id`, `pps_type` and an RFC 3339

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

maybe turn this into a table for easy reading

Comment thread doc/diagnostics.md
uses zlib compression and line-wrapped base64 while labeling the envelope
`gz+b64`. Remove ASCII base64 whitespace and accept zlib or gzip under that
source label, using the actual stream format to select the decompressor.
This exception does not apply to native `gz+b64` records. Other envelope

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

it's implied in here, but maybe call out the reason (cloud-init says gz+b64 but it actually is zlib+b64).

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.

5 participants