From 624aea38215b098ad060dbb0deb7431543752d77 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pawe=C5=82=20Chojnacki?= Date: Thu, 14 May 2026 15:30:37 +0200 Subject: [PATCH 1/2] feat(libdd-healthplatform): add no_std crate for healthplatform.proto Vendors `agent-payload/proto/healthplatform/healthplatform.proto` and ships prost-generated Rust types as `no_std + alloc`. Encoding is always on; the `decode` feature gates a public decode helper module. A feature-gated example (`evp_proxy_send`) demonstrates POSTing a JSON `HealthReport` through the trace-agent's evp_proxy to `agenthealth-intake./api/v2/agenthealth`, mirroring the wire format used by `comp/healthplatform/impl/forwarder.go`. --- Cargo.lock | 129 ++++++- Cargo.toml | 1 + libdd-healthplatform/Cargo.toml | 49 +++ libdd-healthplatform/README.md | 25 ++ libdd-healthplatform/build.rs | 73 ++++ .../examples/evp_proxy_send.rs | 353 ++++++++++++++++++ libdd-healthplatform/src/healthplatform.rs | 223 +++++++++++ libdd-healthplatform/src/lib.rs | 54 +++ .../src/pb/healthplatform.proto | 155 ++++++++ libdd-healthplatform/tests/roundtrip.rs | 135 +++++++ 10 files changed, 1181 insertions(+), 16 deletions(-) create mode 100644 libdd-healthplatform/Cargo.toml create mode 100644 libdd-healthplatform/README.md create mode 100644 libdd-healthplatform/build.rs create mode 100644 libdd-healthplatform/examples/evp_proxy_send.rs create mode 100644 libdd-healthplatform/src/healthplatform.rs create mode 100644 libdd-healthplatform/src/lib.rs create mode 100644 libdd-healthplatform/src/pb/healthplatform.proto create mode 100644 libdd-healthplatform/tests/roundtrip.rs diff --git a/Cargo.lock b/Cargo.lock index 4839ed758a..f34a93b52e 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2065,9 +2065,11 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "73fea8450eea4bac3940448fb7ae50d91f034f941199fcd9d909a5a07aa455f0" dependencies = [ "cfg-if", + "js-sys", "libc", "r-efi", "wasi 0.14.2+wasi-0.2.4", + "wasm-bindgen", ] [[package]] @@ -3048,6 +3050,20 @@ dependencies = [ "tracing", ] +[[package]] +name = "libdd-healthplatform" +version = "0.1.0" +dependencies = [ + "anyhow", + "prost", + "prost-build", + "prost-types", + "protoc-bin-vendored", + "reqwest", + "serde_json", + "tokio", +] + [[package]] name = "libdd-http-client" version = "33.0.0" @@ -3536,6 +3552,12 @@ dependencies = [ "hashbrown 0.16.1", ] +[[package]] +name = "lru-slab" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "112b39cec0b298b6c1999fee3e31427f74f676e4cb9879ed1a121b43661a4154" + [[package]] name = "manual_future" version = "0.1.1" @@ -4384,12 +4406,13 @@ dependencies = [ [[package]] name = "protoc-bin-vendored" -version = "3.1.0" +version = "3.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dd89a830d0eab2502c81a9b8226d446a52998bb78e5e33cb2637c0cdd6068d99" +checksum = "d1c381df33c98266b5f08186583660090a4ffa0889e76c7e9a5e175f645a67fa" dependencies = [ "protoc-bin-vendored-linux-aarch_64", "protoc-bin-vendored-linux-ppcle_64", + "protoc-bin-vendored-linux-s390_64", "protoc-bin-vendored-linux-x86_32", "protoc-bin-vendored-linux-x86_64", "protoc-bin-vendored-macos-aarch_64", @@ -4399,45 +4422,51 @@ dependencies = [ [[package]] name = "protoc-bin-vendored-linux-aarch_64" -version = "3.1.0" +version = "3.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f563627339f1653ea1453dfbcb4398a7369b768925eb14499457aeaa45afe22c" +checksum = "c350df4d49b5b9e3ca79f7e646fde2377b199e13cfa87320308397e1f37e1a4c" [[package]] name = "protoc-bin-vendored-linux-ppcle_64" -version = "3.1.0" +version = "3.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a55a63e6c7244f19b5c6393f025017eb5d793fd5467823a099740a7a4222440c" + +[[package]] +name = "protoc-bin-vendored-linux-s390_64" +version = "3.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5025c949a02cd3b60c02501dd0f348c16e8fff464f2a7f27db8a9732c608b746" +checksum = "1dba5565db4288e935d5330a07c264a4ee8e4a5b4a4e6f4e83fad824cc32f3b0" [[package]] name = "protoc-bin-vendored-linux-x86_32" -version = "3.1.0" +version = "3.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9c9500ce67d132c2f3b572504088712db715755eb9adf69d55641caa2cb68a07" +checksum = "8854774b24ee28b7868cd71dccaae8e02a2365e67a4a87a6cd11ee6cdbdf9cf5" [[package]] name = "protoc-bin-vendored-linux-x86_64" -version = "3.1.0" +version = "3.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5462592380cefdc9f1f14635bcce70ba9c91c1c2464c7feb2ce564726614cc41" +checksum = "b38b07546580df720fa464ce124c4b03630a6fb83e05c336fea2a241df7e5d78" [[package]] name = "protoc-bin-vendored-macos-aarch_64" -version = "3.1.0" +version = "3.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c637745681b68b4435484543667a37606c95ddacf15e917710801a0877506030" +checksum = "89278a9926ce312e51f1d999fee8825d324d603213344a9a706daa009f1d8092" [[package]] name = "protoc-bin-vendored-macos-x86_64" -version = "3.1.0" +version = "3.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "38943f3c90319d522f94a6dfd4a134ba5e36148b9506d2d9723a82ebc57c8b55" +checksum = "81745feda7ccfb9471d7a4de888f0652e806d5795b61480605d4943176299756" [[package]] name = "protoc-bin-vendored-win32" -version = "3.1.0" +version = "3.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7dc55d7dec32ecaf61e0bd90b3d2392d721a28b95cfd23c3e176eccefbeab2f2" +checksum = "95067976aca6421a523e491fce939a3e65249bac4b977adee0ee9771568e8aa3" [[package]] name = "pyo3" @@ -4512,6 +4541,62 @@ dependencies = [ "memchr", ] +[[package]] +name = "quinn" +version = "0.11.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9e20a958963c291dc322d98411f541009df2ced7b5a4f2bd52337638cfccf20" +dependencies = [ + "bytes", + "cfg_aliases", + "pin-project-lite", + "quinn-proto", + "quinn-udp", + "rustc-hash", + "rustls", + "socket2 0.6.1", + "thiserror 2.0.17", + "tokio", + "tracing", + "web-time", +] + +[[package]] +name = "quinn-proto" +version = "0.11.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "434b42fec591c96ef50e21e886936e66d3cc3f737104fdb9b737c40ffb94c098" +dependencies = [ + "aws-lc-rs", + "bytes", + "getrandom 0.3.2", + "lru-slab", + "rand 0.9.0", + "ring", + "rustc-hash", + "rustls", + "rustls-pki-types", + "slab", + "thiserror 2.0.17", + "tinyvec", + "tracing", + "web-time", +] + +[[package]] +name = "quinn-udp" +version = "0.5.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "addec6a0dcad8a8d96a771f815f0eaf55f9d1805756410b39f5fa81332574cbd" +dependencies = [ + "cfg_aliases", + "libc", + "once_cell", + "socket2 0.6.1", + "tracing", + "windows-sys 0.60.2", +] + [[package]] name = "quote" version = "1.0.37" @@ -4712,6 +4797,7 @@ dependencies = [ "once_cell", "percent-encoding", "pin-project-lite", + "quinn", "rustls", "rustls-pki-types", "rustls-platform-verifier", @@ -4868,6 +4954,7 @@ version = "1.13.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "21e6f2ab2928ca4291b86736a8bd920a277a399bba1589409d72154ff87c1282" dependencies = [ + "web-time", "zeroize", ] @@ -6433,6 +6520,16 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "web-time" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a6580f308b1fad9207618087a65c04e7a10bc77e02c8e84e9b00dd4b12fa0bb" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + [[package]] name = "webpki-root-certs" version = "1.0.5" diff --git a/Cargo.toml b/Cargo.toml index 327fa9fa5d..efe8b0f5f3 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -51,6 +51,7 @@ members = [ "libdd-ddsketch-ffi", "libdd-tinybytes", "libdd-dogstatsd-client", + "libdd-healthplatform", "libdd-http-client", "libdd-log", "libdd-log-ffi", diff --git a/libdd-healthplatform/Cargo.toml b/libdd-healthplatform/Cargo.toml new file mode 100644 index 0000000000..e1b40f7ff3 --- /dev/null +++ b/libdd-healthplatform/Cargo.toml @@ -0,0 +1,49 @@ +# Copyright 2026-Present Datadog, Inc. https://www.datadoghq.com/ +# SPDX-License-Identifier: Apache-2.0 + +[package] +name = "libdd-healthplatform" +version = "0.1.0" +description = "no_std-first Rust types for Datadog's agent-payload healthplatform.proto" +homepage = "https://github.com/DataDog/libdatadog/tree/main/libdd-healthplatform" +repository = "https://github.com/DataDog/libdatadog/tree/main/libdd-healthplatform" +rust-version.workspace = true +edition.workspace = true +license.workspace = true +authors.workspace = true + +[lib] +bench = false + +[features] +default = [] +# Regenerate src/healthplatform.rs from src/pb/*.proto. Off by default; CI builds use checked-in output. +generate-protobuf = ["dep:prost-build", "dep:protoc-bin-vendored"] +# Expose the decoding helper module (prost::Message::decode wrappers). +decode = [] +# Build the evp_proxy example. Pulls in std + HTTP stack. +example-client = ["dep:reqwest", "dep:tokio", "dep:anyhow", "dep:serde_json"] + +[dependencies] +# default-features = false drops the std feature; prost-generated code only needs alloc + derive. +prost = { version = "0.14.3", default-features = false, features = ["derive"] } +prost-types = { version = "0.14.3", default-features = false } + +# Example-only deps -- gated. +reqwest = { version = "0.13.2", default-features = false, features = ["rustls"], optional = true } +tokio = { version = "1.43", features = ["macros", "rt-multi-thread"], optional = true } +anyhow = { version = "1.0", optional = true } +serde_json = { version = "1.0", optional = true } + +[build-dependencies] +prost-build = { version = "0.14.3", optional = true } +protoc-bin-vendored = { version = "3.2", optional = true } + +[dev-dependencies] +# std + prost-with-std for round-trip tests. +prost = "0.14.3" +prost-types = "0.14.3" + +[[example]] +name = "evp_proxy_send" +required-features = ["example-client", "decode"] diff --git a/libdd-healthplatform/README.md b/libdd-healthplatform/README.md new file mode 100644 index 0000000000..c158f812fe --- /dev/null +++ b/libdd-healthplatform/README.md @@ -0,0 +1,25 @@ +# libdd-healthplatform + +`no_std + alloc` Rust types for Datadog's [`agent-payload` healthplatform protobuf schema][upstream-proto]. +Encoding is always available; decoding helpers are gated behind the `decode` cargo feature. + +## Regenerating bindings + +`src/healthplatform.rs` is checked in. Re-generate it whenever `src/pb/healthplatform.proto` changes: + +```sh +cargo build -p libdd-healthplatform --features generate-protobuf +``` + +The vendored `.proto` records the upstream commit SHA in its header — bump it when re-vendoring. + +## Example client + +A minimal end-to-end demo that POSTs a `HealthReport` through the trace-agent's `evp_proxy` endpoint +lives at `examples/evp_proxy_send.rs`. Build it with: + +```sh +cargo build -p libdd-healthplatform --example evp_proxy_send --features example-client,decode +``` + +[upstream-proto]: https://github.com/DataDog/agent-payload/blob/master/proto/healthplatform/healthplatform.proto diff --git a/libdd-healthplatform/build.rs b/libdd-healthplatform/build.rs new file mode 100644 index 0000000000..c7a51fb5d8 --- /dev/null +++ b/libdd-healthplatform/build.rs @@ -0,0 +1,73 @@ +// Copyright 2026-Present Datadog, Inc. https://www.datadoghq.com/ +// SPDX-License-Identifier: Apache-2.0 + +use std::io::Result; + +#[cfg(feature = "generate-protobuf")] +use { + std::env, + std::fs::File, + std::io::{Read, Write}, + std::path::Path, +}; + +// to re-generate the protobuf structs, run: +// cargo build -p libdd-healthplatform --features generate-protobuf +fn main() -> Result<()> { + #[cfg(feature = "generate-protobuf")] + { + std::env::set_var("PROTOC", protoc_bin_vendored::protoc_bin_path().unwrap()); + generate_protobuf(); + } + #[cfg(not(feature = "generate-protobuf"))] + { + println!("cargo:rerun-if-changed=build.rs"); + } + + Ok(()) +} + +#[cfg(feature = "generate-protobuf")] +fn generate_protobuf() { + let cur_working_dir = env::var("CARGO_MANIFEST_DIR").unwrap(); + let current_working_dir_path = Path::new(&cur_working_dir); + let output_path = current_working_dir_path.join("src"); + + let mut config = prost_build::Config::new(); + config.out_dir(output_path.clone()); + // Use BTreeMap for all map fields so the generated code stays no_std-clean. + // (HashMap requires the std feature on prost's encoding side.) + config.btree_map(["."]); + + // prost-build maps `.google.protobuf` types to `::prost_types` automatically; we + // rely on prost-types' bundled descriptor for google/protobuf/struct.proto so we + // don't have to vendor it ourselves. + config + .compile_protos(&["src/pb/healthplatform.proto"], &["src/pb"]) + .unwrap(); + + // prost-build emits a file named after the proto package: datadog.healthplatform.rs. + // Rename to healthplatform.rs so the include! path in lib.rs stays short. + let generated = output_path.join("datadog.healthplatform.rs"); + let target = output_path.join("healthplatform.rs"); + std::fs::rename(&generated, &target).unwrap(); + + let license = b"// Copyright 2026-Present Datadog, Inc. https://www.datadoghq.com/ +// SPDX-License-Identifier: Apache-2.0 +// +// @generated by prost-build from src/pb/healthplatform.proto. +// Do not edit by hand; run `cargo build -p libdd-healthplatform --features generate-protobuf`. + +"; + prepend_to_file(license, &target); +} + +#[cfg(feature = "generate-protobuf")] +fn prepend_to_file(data: &[u8], file_path: &Path) { + let mut f = File::open(file_path).unwrap(); + let mut content = data.to_owned(); + f.read_to_end(&mut content).unwrap(); + + let mut f = File::create(file_path).unwrap(); + f.write_all(content.as_slice()).unwrap(); +} diff --git a/libdd-healthplatform/examples/evp_proxy_send.rs b/libdd-healthplatform/examples/evp_proxy_send.rs new file mode 100644 index 0000000000..1b754f9a22 --- /dev/null +++ b/libdd-healthplatform/examples/evp_proxy_send.rs @@ -0,0 +1,353 @@ +// Copyright 2026-Present Datadog, Inc. https://www.datadoghq.com/ +// SPDX-License-Identifier: Apache-2.0 +// +// Minimal demo of how a `HealthReport` is shipped to Datadog via the local +// trace-agent's evp_proxy. Build with: +// cargo build -p libdd-healthplatform --example evp_proxy_send \ +// --features example-client,decode +// +// Wire format +// ----------- +// The datadog-agent's healthplatform forwarder (comp/healthplatform/impl/forwarder.go) +// posts the report as JSON with Content-Type: application/json — NOT protobuf. +// The agthealth-worker dispatches on `event.textFormat`: only `INTERNAL_INTAKE_REQUEST` +// (a batched protobuf format) or JSON-text are accepted; anything else increments +// `dd.evp_worker.agthealth.events.dropped_invalid_text_format` and the event is +// dropped silently (the EVP intake still returns 202). +// +// This example therefore serialises the in-memory `HealthReport` to JSON with +// snake_case keys matching the protoc-gen-go JSON tags used by the agent. + +use anyhow::{anyhow, Result}; +use libdd_healthplatform::{ + HealthReport, HostInfo, Issue, IssueState, PersistedIssue, Remediation, RemediationStep, +}; +use serde_json::{json, Map, Value}; +use std::collections::BTreeMap; +use std::process::ExitCode; +use std::time::{SystemTime, UNIX_EPOCH}; + +// Defaults target the per-track agenthealth intake: +// https://agenthealth-intake./api/v2/agenthealth +const DEFAULT_TRACE_AGENT_URL: &str = "http://localhost:8136"; +const DEFAULT_EVP_PROXY_PATH: &str = "/evp_proxy/v4/api/v2/agenthealth"; +const DEFAULT_EVP_PROXY_SUBDOMAIN: &str = "agenthealth-intake"; + +fn now_rfc3339() -> String { + let secs = SystemTime::now() + .duration_since(UNIX_EPOCH) + .map(|d| d.as_secs()) + .unwrap_or(0); + let mut days = (secs / 86_400) as i64; + let mut sod = secs % 86_400; + let hh = sod / 3600; + sod %= 3600; + let mm = sod / 60; + let ss = sod % 60; + let mut year = 1970i64; + loop { + let leap = (year % 4 == 0 && year % 100 != 0) || year % 400 == 0; + let yd = if leap { 366 } else { 365 }; + if days < yd { + break; + } + days -= yd; + year += 1; + } + let leap = (year % 4 == 0 && year % 100 != 0) || year % 400 == 0; + let mdays = [ + 31, + if leap { 29 } else { 28 }, + 31, + 30, + 31, + 30, + 31, + 31, + 30, + 31, + 30, + 31, + ]; + let mut month = 0usize; + while month < 12 && days >= mdays[month] { + days -= mdays[month]; + month += 1; + } + let day = days + 1; + format!( + "{:04}-{:02}-{:02}T{:02}:{:02}:{:02}Z", + year, + month + 1, + day, + hh, + mm, + ss + ) +} + +fn sample_report() -> HealthReport { + let now = now_rfc3339(); + + let issue = Issue { + id: "postgres_connectivity:instance-1".into(), + issue_name: "postgres_connectivity".into(), + title: "Postgres check cannot reach instance-1".into(), + description: "The postgres integration failed to connect to host db-1:5432 — \ + connection refused. The check has been failing for 3 consecutive runs." + .into(), + category: "connectivity".into(), + location: "core-agent".into(), + severity: "high".into(), + detected_at: now.clone(), + source: "datadog-agent".into(), + extra: None, + remediation: Some(Remediation { + summary: "Confirm the postgres instance is reachable and credentials are valid.".into(), + steps: vec![ + RemediationStep { + order: 1, + text: "Verify the postgres process is running and listening on the configured port.".into(), + }, + RemediationStep { + order: 2, + text: "From the agent host run `psql -h db-1 -p 5432 -U datadog` and check it succeeds.".into(), + }, + RemediationStep { + order: 3, + text: "If the connection still fails, review firewall / security group rules between the agent host and the database.".into(), + }, + ], + script: None, + }), + tags: vec![ + "env:staging".into(), + "team:libdatadog".into(), + "integration:postgres".into(), + "source:libdd-healthplatform-demo".into(), + ], + persisted_issue: Some(PersistedIssue { + state: IssueState::New as i32, + first_seen: now.clone(), + last_seen: now.clone(), + resolved_at: None, + }), + }; + + let mut issues = BTreeMap::new(); + issues.insert("postgres_connectivity:instance-1".into(), issue); + + HealthReport { + schema_version: "1".into(), + event_type: "agent_health".into(), + emitted_at: now, + host: Some(HostInfo { + hostname: "libdd-healthplatform-demo".into(), + agent_version: Some("7.77.3".into()), + par_ids: vec![], + }), + issues, + service: "datadog-agent".into(), + } +} + +/// Serialise a `HealthReport` to JSON with snake_case keys, matching the agent +/// forwarder (`encoding/json` on protoc-gen-go structs). Empty/None fields are +/// omitted to mirror Go's `omitempty` behaviour. +fn health_report_to_json(report: &HealthReport) -> Value { + let mut obj = Map::new(); + if !report.schema_version.is_empty() { + obj.insert( + "schema_version".into(), + Value::String(report.schema_version.clone()), + ); + } + if !report.event_type.is_empty() { + obj.insert( + "event_type".into(), + Value::String(report.event_type.clone()), + ); + } + if !report.emitted_at.is_empty() { + obj.insert( + "emitted_at".into(), + Value::String(report.emitted_at.clone()), + ); + } + if let Some(host) = &report.host { + let mut h = Map::new(); + if !host.hostname.is_empty() { + h.insert("hostname".into(), Value::String(host.hostname.clone())); + } + if let Some(v) = &host.agent_version { + h.insert("agent_version".into(), Value::String(v.clone())); + } + if !host.par_ids.is_empty() { + h.insert( + "par_ids".into(), + Value::Array(host.par_ids.iter().cloned().map(Value::String).collect()), + ); + } + obj.insert("host".into(), Value::Object(h)); + } + if !report.issues.is_empty() { + let mut m = Map::new(); + for (k, issue) in &report.issues { + m.insert(k.clone(), issue_to_json(issue)); + } + obj.insert("issues".into(), Value::Object(m)); + } + if !report.service.is_empty() { + obj.insert("service".into(), Value::String(report.service.clone())); + } + Value::Object(obj) +} + +fn issue_to_json(issue: &Issue) -> Value { + let mut o = Map::new(); + if !issue.id.is_empty() { + o.insert("id".into(), Value::String(issue.id.clone())); + } + if !issue.issue_name.is_empty() { + o.insert("issue_name".into(), Value::String(issue.issue_name.clone())); + } + if !issue.title.is_empty() { + o.insert("title".into(), Value::String(issue.title.clone())); + } + if !issue.description.is_empty() { + o.insert( + "description".into(), + Value::String(issue.description.clone()), + ); + } + if !issue.category.is_empty() { + o.insert("category".into(), Value::String(issue.category.clone())); + } + if !issue.location.is_empty() { + o.insert("location".into(), Value::String(issue.location.clone())); + } + if !issue.severity.is_empty() { + o.insert("severity".into(), Value::String(issue.severity.clone())); + } + if !issue.detected_at.is_empty() { + o.insert( + "detected_at".into(), + Value::String(issue.detected_at.clone()), + ); + } + if !issue.source.is_empty() { + o.insert("source".into(), Value::String(issue.source.clone())); + } + if let Some(rem) = &issue.remediation { + let mut r = Map::new(); + if !rem.summary.is_empty() { + r.insert("summary".into(), Value::String(rem.summary.clone())); + } + if !rem.steps.is_empty() { + let steps: Vec = rem + .steps + .iter() + .map(|s| json!({ "order": s.order, "text": s.text })) + .collect(); + r.insert("steps".into(), Value::Array(steps)); + } + if let Some(script) = &rem.script { + let mut s = Map::new(); + if !script.language.is_empty() { + s.insert("language".into(), Value::String(script.language.clone())); + } + if !script.language_version.is_empty() { + s.insert( + "language_version".into(), + Value::String(script.language_version.clone()), + ); + } + if !script.filename.is_empty() { + s.insert("filename".into(), Value::String(script.filename.clone())); + } + if script.requires_root { + s.insert("requires_root".into(), Value::Bool(true)); + } + if !script.content.is_empty() { + s.insert("content".into(), Value::String(script.content.clone())); + } + r.insert("script".into(), Value::Object(s)); + } + o.insert("remediation".into(), Value::Object(r)); + } + if !issue.tags.is_empty() { + o.insert( + "tags".into(), + Value::Array(issue.tags.iter().cloned().map(Value::String).collect()), + ); + } + if let Some(pi) = &issue.persisted_issue { + let mut p = Map::new(); + // protoc-gen-go serialises proto enums as their integer value by default. + p.insert("state".into(), Value::Number(pi.state.into())); + if !pi.first_seen.is_empty() { + p.insert("first_seen".into(), Value::String(pi.first_seen.clone())); + } + if !pi.last_seen.is_empty() { + p.insert("last_seen".into(), Value::String(pi.last_seen.clone())); + } + if let Some(v) = &pi.resolved_at { + p.insert("resolved_at".into(), Value::String(v.clone())); + } + o.insert("persisted_issue".into(), Value::Object(p)); + } + Value::Object(o) +} + +fn env_or(key: &str, default: &str) -> String { + std::env::var(key).unwrap_or_else(|_| default.to_string()) +} + +#[tokio::main] +async fn main() -> ExitCode { + match run().await { + Ok(()) => ExitCode::SUCCESS, + Err(err) => { + eprintln!("evp_proxy_send: {err:?}"); + ExitCode::FAILURE + } + } +} + +async fn run() -> Result<()> { + let trace_agent_url = env_or("DD_TRACE_AGENT_URL", DEFAULT_TRACE_AGENT_URL); + let evp_path = env_or("DD_EVP_PROXY_PATH", DEFAULT_EVP_PROXY_PATH); + let subdomain = env_or("DD_EVP_PROXY_SUBDOMAIN", DEFAULT_EVP_PROXY_SUBDOMAIN); + + let report = sample_report(); + let body = serde_json::to_vec(&health_report_to_json(&report))?; + + let url = format!("{trace_agent_url}{evp_path}"); + println!( + "evp_proxy_send: POST {url} (subdomain={subdomain}, {} bytes JSON)", + body.len() + ); + let response = reqwest::Client::new() + .post(&url) + .header("Content-Type", "application/json") + .header("X-Datadog-EVP-Subdomain", &subdomain) + // DD-API-KEY is required by evp_proxy validation; the trace-agent + // overwrites it with its configured key before forwarding upstream. + .header("DD-API-KEY", "dummy") + .body(body) + .send() + .await?; + + let status = response.status(); + let bytes = response.bytes().await?; + + if !status.is_success() { + let preview = String::from_utf8_lossy(&bytes); + return Err(anyhow!( + "non-2xx response from {url}: {status} body: {preview}" + )); + } + + println!("evp_proxy_send: {status} ({} bytes received)", bytes.len()); + Ok(()) +} diff --git a/libdd-healthplatform/src/healthplatform.rs b/libdd-healthplatform/src/healthplatform.rs new file mode 100644 index 0000000000..2348c05e0b --- /dev/null +++ b/libdd-healthplatform/src/healthplatform.rs @@ -0,0 +1,223 @@ +// Copyright 2026-Present Datadog, Inc. https://www.datadoghq.com/ +// SPDX-License-Identifier: Apache-2.0 +// +// @generated by prost-build from src/pb/healthplatform.proto. +// Do not edit by hand; run `cargo build -p libdd-healthplatform --features generate-protobuf`. + +// This file is @generated by prost-build. +/// Issue represents an individual issue to be reported +#[derive(Clone, PartialEq, ::prost::Message)] +pub struct Issue { + /// ID is the unique identifier for the issue + #[prost(string, tag = "1")] + pub id: ::prost::alloc::string::String, + /// IssueName is the human-readable name for the issue + #[prost(string, tag = "2")] + pub issue_name: ::prost::alloc::string::String, + /// Title is the short title/headline of the issue + #[prost(string, tag = "3")] + pub title: ::prost::alloc::string::String, + /// Description is the detailed description of the issue + #[prost(string, tag = "4")] + pub description: ::prost::alloc::string::String, + /// Category indicates the type/category of the issue (e.g., permissions, connectivity, etc.) + #[prost(string, tag = "5")] + pub category: ::prost::alloc::string::String, + /// Location indicates where the issue occurred (e.g., core agent, log agent, etc.) + #[prost(string, tag = "6")] + pub location: ::prost::alloc::string::String, + /// Severity indicates the impact level of the issue + #[prost(string, tag = "7")] + pub severity: ::prost::alloc::string::String, + /// DetectedAt is the timestamp when the issue was detected + #[prost(string, tag = "8")] + pub detected_at: ::prost::alloc::string::String, + /// Source is the sub-agent or product that reported the issue + /// (e.g., "logs", "apm", "error-tracking", "network-monitoring") + #[prost(string, tag = "9")] + pub source: ::prost::alloc::string::String, + /// Extra is optional complementary structured information + #[prost(message, optional, tag = "10")] + pub extra: ::core::option::Option<::prost_types::Struct>, + /// Remediation provides steps to fix the issue + #[prost(message, optional, tag = "11")] + pub remediation: ::core::option::Option, + /// Tags are additional labels for the issue + #[prost(string, repeated, tag = "12")] + pub tags: ::prost::alloc::vec::Vec<::prost::alloc::string::String>, + /// PersistedIssue tracks the lifecycle state of the issue + #[prost(message, optional, tag = "13")] + pub persisted_issue: ::core::option::Option, +} +/// Remediation represents remediation steps for an issue +#[derive(Clone, PartialEq, ::prost::Message)] +pub struct Remediation { + /// Summary is a brief description of the remediation + #[prost(string, tag = "1")] + pub summary: ::prost::alloc::string::String, + /// Steps are the ordered steps to fix the issue + #[prost(message, repeated, tag = "2")] + pub steps: ::prost::alloc::vec::Vec, + /// Script is an automated script to fix the issue + #[prost(message, optional, tag = "3")] + pub script: ::core::option::Option