mirror of
https://github.com/Mesh-LLM/mesh-llm.git
synced 2026-08-08 22:23:19 -04:00
future(telemetry-plugin): add opt-in survey OTLP metrics exporter
This commit is contained in:
parent
a12c34ad6a
commit
e2a0b5770a
20 changed files with 2334 additions and 33 deletions
50
.agents/skills/telemetry-privacy-review/SKILL.md
Normal file
50
.agents/skills/telemetry-privacy-review/SKILL.md
Normal file
|
|
@ -0,0 +1,50 @@
|
|||
---
|
||||
name: telemetry-privacy-review
|
||||
description: Use this skill when adding, renaming, removing, or reviewing mesh-llm OTLP metrics, telemetry attributes, metrics exporter settings, or telemetry documentation.
|
||||
metadata:
|
||||
short-description: Review mesh-llm telemetry privacy
|
||||
---
|
||||
|
||||
# telemetry-privacy-review
|
||||
|
||||
Use this skill before changing mesh-llm OTLP metrics, exporter activation, or
|
||||
telemetry attribute names.
|
||||
|
||||
## Review Contract
|
||||
|
||||
- Keep telemetry metrics-only. Do not export prompts, completions, logs, traces,
|
||||
hostnames, mesh gossip, relay messages, raw node IDs, raw GPU stable IDs,
|
||||
endpoint URLs, local absolute paths, or prompt hashes.
|
||||
- Keep egress explicit. There must be no hard-coded collector. Generic OTel env
|
||||
endpoints may only be consumed after `telemetry.enabled = true`; mesh config
|
||||
endpoints are explicit operator configuration.
|
||||
- Treat hashed IDs as stable pseudonymous identifiers, not anonymous data.
|
||||
- Keep request-path telemetry non-blocking and bounded.
|
||||
- Keep model labels sanitized with the runtime telemetry model-label helper.
|
||||
- Prefer bounded enums, buckets, counts, and hashes over high-cardinality raw
|
||||
values.
|
||||
|
||||
## Required Updates
|
||||
|
||||
- Update `TELEMETRY_ATTRIBUTE_ALLOWLIST` in
|
||||
`crates/mesh-llm/src/runtime/survey.rs` for every new exported attribute.
|
||||
- Update `docs/plugins/telemetry.md` with the metric or attribute inventory and
|
||||
privacy handling.
|
||||
- Add focused tests for private-path, raw-ID, endpoint-URL, prompt, and
|
||||
completion exclusion when the change touches those surfaces.
|
||||
|
||||
## Validation
|
||||
|
||||
Run the narrowest relevant checks for the touched area. For telemetry runtime
|
||||
changes, start with:
|
||||
|
||||
```bash
|
||||
cargo test -p mesh-llm runtime::survey::tests --lib
|
||||
cargo test -p mesh-llm telemetry_config --lib
|
||||
```
|
||||
|
||||
If routing telemetry changed, also run the focused mesh routing telemetry test:
|
||||
|
||||
```bash
|
||||
cargo test -p mesh-llm routing_telemetry_sink_receives_request_pressure_and_attempt_events --lib
|
||||
```
|
||||
32
Cargo.lock
generated
32
Cargo.lock
generated
|
|
@ -4032,6 +4032,9 @@ dependencies = [
|
|||
"model-ref",
|
||||
"nostr-sdk",
|
||||
"openai-frontend",
|
||||
"opentelemetry",
|
||||
"opentelemetry-otlp",
|
||||
"opentelemetry_sdk",
|
||||
"prost",
|
||||
"prost-build",
|
||||
"protoc-bin-vendored",
|
||||
|
|
@ -5002,6 +5005,35 @@ dependencies = [
|
|||
"tracing",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "opentelemetry-http"
|
||||
version = "0.31.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d7a6d09a73194e6b66df7c8f1b680f156d916a1a942abf2de06823dd02b7855d"
|
||||
dependencies = [
|
||||
"async-trait",
|
||||
"bytes",
|
||||
"http",
|
||||
"opentelemetry",
|
||||
"reqwest 0.12.28",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "opentelemetry-otlp"
|
||||
version = "0.31.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1f69cd6acbb9af919df949cd1ec9e5e7fdc2ef15d234b6b795aaa525cc02f71f"
|
||||
dependencies = [
|
||||
"http",
|
||||
"opentelemetry",
|
||||
"opentelemetry-http",
|
||||
"opentelemetry-proto",
|
||||
"opentelemetry_sdk",
|
||||
"prost",
|
||||
"reqwest 0.12.28",
|
||||
"thiserror 2.0.18",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "opentelemetry-proto"
|
||||
version = "0.31.0"
|
||||
|
|
|
|||
|
|
@ -277,6 +277,8 @@ Precedence rules:
|
|||
- Explicit `--ctx-size` overrides configured `ctx_size` for the selected startup models.
|
||||
- Plugin entries still live in the same file.
|
||||
|
||||
Telemetry metrics export is available through the built-in `telemetry` plugin. Configure a `[telemetry]` endpoint to export metrics; no collector is hard-coded, and the plugin can be opted out with `[[plugin]] name = "telemetry" enabled = false`. See [docs/plugins/telemetry.md](docs/plugins/telemetry.md).
|
||||
|
||||
Pinned startup notes:
|
||||
|
||||
- `assignment = "pinned"` requires every configured `[[models]]` entry to include a `gpu_id`.
|
||||
|
|
|
|||
|
|
@ -31,6 +31,9 @@ dirs = "6.0.0"
|
|||
hex = "0.4.3"
|
||||
include_dir = "0.7"
|
||||
nostr-sdk = { version = "0.44.1", default-features = false }
|
||||
opentelemetry = { version = "0.31.0", default-features = false, features = ["metrics"] }
|
||||
opentelemetry_sdk = { version = "0.31.0", default-features = false, features = ["metrics"] }
|
||||
opentelemetry-otlp = { version = "0.31.0", default-features = false, features = ["metrics", "http-proto", "reqwest-blocking-client"] }
|
||||
rustls = "0.23.36"
|
||||
reqwest = { version = "0.12", features = ["stream", "json"] }
|
||||
semver = "1"
|
||||
|
|
|
|||
|
|
@ -32,7 +32,9 @@ Notable built-ins under `src/plugins/` today:
|
|||
```text
|
||||
plugins/
|
||||
├── blackboard/ shared mesh message feed + MCP surface
|
||||
└── lemonade/ external OpenAI-compatible inference endpoint bridge
|
||||
├── blobstore/ request-scoped media object storage for multimodal
|
||||
├── openai_endpoint/ external OpenAI-compatible inference endpoint bridge
|
||||
└── telemetry/ opt-in OTLP metrics-only local runtime telemetry
|
||||
```
|
||||
|
||||
## Runtime model
|
||||
|
|
|
|||
|
|
@ -14,6 +14,12 @@ pub(crate) async fn run_plugin_command(command: &PluginCommand, cli: &Cli) -> Re
|
|||
eprintln!("Blobstore is auto-registered by mesh-llm. Nothing to install.");
|
||||
eprintln!("Disable it with [[plugin]] name = \"blobstore\" enabled = false in the config if needed.");
|
||||
}
|
||||
PluginCommand::Install { name } if name == plugin::TELEMETRY_PLUGIN_ID => {
|
||||
eprintln!("Telemetry is built into mesh-llm. Nothing to install.");
|
||||
eprintln!(
|
||||
"Configure [telemetry] to export metrics, or disable it with [[plugin]] name = \"telemetry\" enabled = false."
|
||||
);
|
||||
}
|
||||
PluginCommand::Install { name } => {
|
||||
let config = plugin::config_path(cli.config.as_deref())?;
|
||||
anyhow::bail!(
|
||||
|
|
|
|||
|
|
@ -976,6 +976,8 @@ pub struct Node {
|
|||
inflight_requests: Arc<std::sync::atomic::AtomicUsize>,
|
||||
inflight_change_tx: watch::Sender<u64>,
|
||||
routing_metrics: crate::network::metrics::RoutingMetrics,
|
||||
routing_telemetry:
|
||||
Arc<std::sync::Mutex<Option<Arc<dyn crate::network::metrics::RoutingTelemetrySink>>>>,
|
||||
local_request_metrics: Arc<LocalRequestMetricsSampler>,
|
||||
runtime_data_producer: crate::runtime_data::RuntimeDataProducer,
|
||||
tunnel_tx: tokio::sync::mpsc::Sender<(iroh::endpoint::SendStream, iroh::endpoint::RecvStream)>,
|
||||
|
|
@ -1395,6 +1397,7 @@ pub struct InflightRequestGuard {
|
|||
local_request_metrics: Arc<LocalRequestMetricsSampler>,
|
||||
started_at: std::time::Instant,
|
||||
routing_metrics: crate::network::metrics::RoutingMetrics,
|
||||
routing_telemetry: Option<Arc<dyn crate::network::metrics::RoutingTelemetrySink>>,
|
||||
runtime_data_producer: crate::runtime_data::RuntimeDataProducer,
|
||||
}
|
||||
|
||||
|
|
@ -1414,6 +1417,9 @@ impl Drop for InflightRequestGuard {
|
|||
let current_inflight_requests =
|
||||
self.inflight_requests
|
||||
.load(std::sync::atomic::Ordering::Relaxed) as u64;
|
||||
if let Some(routing_telemetry) = &self.routing_telemetry {
|
||||
routing_telemetry.observe_inflight_requests(current_inflight_requests);
|
||||
}
|
||||
self.runtime_data_producer.publish_routing_snapshot(
|
||||
self.routing_metrics
|
||||
.collector_snapshot(current_inflight_requests),
|
||||
|
|
@ -1422,6 +1428,25 @@ impl Drop for InflightRequestGuard {
|
|||
}
|
||||
|
||||
impl Node {
|
||||
pub(crate) fn set_routing_telemetry_sink(
|
||||
&self,
|
||||
sink: Option<Arc<dyn crate::network::metrics::RoutingTelemetrySink>>,
|
||||
) {
|
||||
*self
|
||||
.routing_telemetry
|
||||
.lock()
|
||||
.expect("routing telemetry sink lock poisoned") = sink;
|
||||
}
|
||||
|
||||
fn routing_telemetry_sink(
|
||||
&self,
|
||||
) -> Option<Arc<dyn crate::network::metrics::RoutingTelemetrySink>> {
|
||||
self.routing_telemetry
|
||||
.lock()
|
||||
.expect("routing telemetry sink lock poisoned")
|
||||
.clone()
|
||||
}
|
||||
|
||||
fn publish_routing_runtime_snapshot(&self) {
|
||||
self.runtime_data_producer.publish_routing_snapshot(
|
||||
self.routing_metrics
|
||||
|
|
@ -1438,6 +1463,10 @@ impl Node {
|
|||
.load(std::sync::atomic::Ordering::Relaxed) as u64;
|
||||
let _ = self.inflight_change_tx.send(current);
|
||||
self.routing_metrics.observe_inflight(current);
|
||||
let routing_telemetry = self.routing_telemetry_sink();
|
||||
if let Some(sink) = &routing_telemetry {
|
||||
sink.observe_inflight_requests(current);
|
||||
}
|
||||
self.publish_routing_runtime_snapshot();
|
||||
InflightRequestGuard {
|
||||
inflight_requests: self.inflight_requests.clone(),
|
||||
|
|
@ -1445,6 +1474,7 @@ impl Node {
|
|||
local_request_metrics: self.local_request_metrics.clone(),
|
||||
started_at: std::time::Instant::now(),
|
||||
routing_metrics: self.routing_metrics.clone(),
|
||||
routing_telemetry,
|
||||
runtime_data_producer: self.runtime_data_producer.clone(),
|
||||
}
|
||||
}
|
||||
|
|
@ -1819,12 +1849,15 @@ impl Node {
|
|||
};
|
||||
self.routing_metrics.record_attempt(
|
||||
model,
|
||||
attempt_target,
|
||||
attempt_target.clone(),
|
||||
queue_wait,
|
||||
attempt_time,
|
||||
outcome,
|
||||
completion_tokens,
|
||||
);
|
||||
if let Some(sink) = self.routing_telemetry_sink() {
|
||||
sink.record_route_attempt(model, &attempt_target, outcome);
|
||||
}
|
||||
self.publish_routing_runtime_snapshot();
|
||||
}
|
||||
|
||||
|
|
@ -1838,14 +1871,18 @@ impl Node {
|
|||
completion_tokens: Option<u64>,
|
||||
) {
|
||||
let model_ref = model.map(canonical_demand_model_ref);
|
||||
let attempt_target = crate::network::metrics::AttemptTarget::Endpoint(endpoint.to_string());
|
||||
self.routing_metrics.record_attempt(
|
||||
model_ref.as_deref(),
|
||||
crate::network::metrics::AttemptTarget::Endpoint(endpoint.to_string()),
|
||||
attempt_target.clone(),
|
||||
queue_wait,
|
||||
attempt_time,
|
||||
outcome,
|
||||
completion_tokens,
|
||||
);
|
||||
if let Some(sink) = self.routing_telemetry_sink() {
|
||||
sink.record_route_attempt(model_ref.as_deref(), &attempt_target, outcome);
|
||||
}
|
||||
self.publish_routing_runtime_snapshot();
|
||||
}
|
||||
|
||||
|
|
@ -1858,6 +1895,9 @@ impl Node {
|
|||
let model_ref = model.map(canonical_demand_model_ref);
|
||||
self.routing_metrics
|
||||
.record_request(model_ref.as_deref(), attempts, outcome);
|
||||
if let Some(sink) = self.routing_telemetry_sink() {
|
||||
sink.record_model_request(model_ref.as_deref(), attempts, outcome);
|
||||
}
|
||||
self.publish_routing_runtime_snapshot();
|
||||
}
|
||||
|
||||
|
|
@ -2106,6 +2146,7 @@ impl Node {
|
|||
inflight_requests: Arc::new(std::sync::atomic::AtomicUsize::new(0)),
|
||||
inflight_change_tx,
|
||||
routing_metrics: crate::network::metrics::RoutingMetrics::default(),
|
||||
routing_telemetry: Arc::new(std::sync::Mutex::new(None)),
|
||||
local_request_metrics: Arc::new(LocalRequestMetricsSampler::default()),
|
||||
runtime_data_producer,
|
||||
tunnel_tx,
|
||||
|
|
@ -2225,6 +2266,7 @@ impl Node {
|
|||
inflight_requests: Arc::new(std::sync::atomic::AtomicUsize::new(0)),
|
||||
inflight_change_tx,
|
||||
routing_metrics: crate::network::metrics::RoutingMetrics::default(),
|
||||
routing_telemetry: Arc::new(std::sync::Mutex::new(None)),
|
||||
local_request_metrics: Arc::new(LocalRequestMetricsSampler::default()),
|
||||
runtime_data_producer,
|
||||
tunnel_tx,
|
||||
|
|
|
|||
|
|
@ -95,6 +95,7 @@ async fn make_test_node(role: super::NodeRole) -> Result<Node> {
|
|||
inflight_requests: Arc::new(std::sync::atomic::AtomicUsize::new(0)),
|
||||
inflight_change_tx,
|
||||
routing_metrics: crate::network::metrics::RoutingMetrics::default(),
|
||||
routing_telemetry: Arc::new(std::sync::Mutex::new(None)),
|
||||
local_request_metrics: Arc::new(LocalRequestMetricsSampler::default()),
|
||||
runtime_data_producer,
|
||||
tunnel_tx,
|
||||
|
|
@ -152,6 +153,118 @@ async fn local_request_metrics_snapshot_tracks_accepted_and_completed_requests()
|
|||
assert_eq!(snapshot.latency_samples_ms.len(), 1);
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
struct TestRoutingTelemetrySink {
|
||||
inflight: std::sync::Mutex<Vec<u64>>,
|
||||
requests: std::sync::Mutex<
|
||||
Vec<(
|
||||
Option<String>,
|
||||
usize,
|
||||
crate::network::metrics::RequestOutcome,
|
||||
)>,
|
||||
>,
|
||||
attempts: std::sync::Mutex<
|
||||
Vec<(
|
||||
Option<String>,
|
||||
String,
|
||||
crate::network::metrics::AttemptOutcome,
|
||||
)>,
|
||||
>,
|
||||
}
|
||||
|
||||
impl crate::network::metrics::RoutingTelemetrySink for TestRoutingTelemetrySink {
|
||||
fn observe_inflight_requests(&self, current: u64) {
|
||||
self.inflight.lock().unwrap().push(current);
|
||||
}
|
||||
|
||||
fn record_model_request(
|
||||
&self,
|
||||
model: Option<&str>,
|
||||
attempts: usize,
|
||||
outcome: crate::network::metrics::RequestOutcome,
|
||||
) {
|
||||
self.requests
|
||||
.lock()
|
||||
.unwrap()
|
||||
.push((model.map(str::to_string), attempts, outcome));
|
||||
}
|
||||
|
||||
fn record_route_attempt(
|
||||
&self,
|
||||
model: Option<&str>,
|
||||
target: &crate::network::metrics::AttemptTarget,
|
||||
outcome: crate::network::metrics::AttemptOutcome,
|
||||
) {
|
||||
let target_kind = match target {
|
||||
crate::network::metrics::AttemptTarget::Local(_) => "local",
|
||||
crate::network::metrics::AttemptTarget::Remote(_) => "remote",
|
||||
crate::network::metrics::AttemptTarget::Endpoint(_) => "endpoint",
|
||||
};
|
||||
self.attempts.lock().unwrap().push((
|
||||
model.map(str::to_string),
|
||||
target_kind.into(),
|
||||
outcome,
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn routing_telemetry_sink_receives_request_pressure_and_attempt_events() {
|
||||
let node = make_test_node(super::NodeRole::Client)
|
||||
.await
|
||||
.expect("test node should initialize");
|
||||
let sink = Arc::new(TestRoutingTelemetrySink::default());
|
||||
node.set_routing_telemetry_sink(Some(sink.clone()));
|
||||
|
||||
{
|
||||
let _request = node.begin_inflight_request();
|
||||
assert_eq!(sink.inflight.lock().unwrap().as_slice(), &[1]);
|
||||
}
|
||||
assert_eq!(sink.inflight.lock().unwrap().as_slice(), &[1, 0]);
|
||||
|
||||
node.record_routed_request(
|
||||
Some("Qwen/Qwen3-8B-GGUF:Q4_K_M"),
|
||||
2,
|
||||
crate::network::metrics::RequestOutcome::Success(
|
||||
crate::network::metrics::RequestService::Remote,
|
||||
),
|
||||
);
|
||||
node.record_inference_attempt(
|
||||
Some("Qwen/Qwen3-8B-GGUF:Q4_K_M"),
|
||||
&crate::inference::election::InferenceTarget::Remote(iroh::EndpointId::from(
|
||||
SecretKey::from_bytes(&[0x45; 32]).public(),
|
||||
)),
|
||||
std::time::Duration::from_millis(3),
|
||||
std::time::Duration::from_millis(5),
|
||||
crate::network::metrics::AttemptOutcome::Success,
|
||||
Some(16),
|
||||
);
|
||||
|
||||
let requests = sink.requests.lock().unwrap();
|
||||
assert_eq!(requests.len(), 1);
|
||||
assert_eq!(
|
||||
requests[0],
|
||||
(
|
||||
Some("Qwen/Qwen3-8B-GGUF:Q4_K_M".into()),
|
||||
2,
|
||||
crate::network::metrics::RequestOutcome::Success(
|
||||
crate::network::metrics::RequestService::Remote
|
||||
)
|
||||
)
|
||||
);
|
||||
drop(requests);
|
||||
|
||||
let attempts = sink.attempts.lock().unwrap();
|
||||
assert_eq!(
|
||||
attempts.as_slice(),
|
||||
&[(
|
||||
Some("Qwen/Qwen3-8B-GGUF:Q4_K_M".into()),
|
||||
"remote".into(),
|
||||
crate::network::metrics::AttemptOutcome::Success,
|
||||
)]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_merge_demand_takes_max() {
|
||||
let mut ours = HashMap::new();
|
||||
|
|
@ -3248,6 +3361,7 @@ async fn make_test_node_with_owner(
|
|||
inflight_requests: Arc::new(std::sync::atomic::AtomicUsize::new(0)),
|
||||
inflight_change_tx,
|
||||
routing_metrics: crate::network::metrics::RoutingMetrics::default(),
|
||||
routing_telemetry: Arc::new(std::sync::Mutex::new(None)),
|
||||
local_request_metrics: Arc::new(LocalRequestMetricsSampler::default()),
|
||||
runtime_data_producer,
|
||||
tunnel_tx,
|
||||
|
|
@ -3570,6 +3684,7 @@ async fn config_subscribe_rejects_pinned_snapshot_for_older_peer() -> Result<()>
|
|||
assignment: crate::plugin::GpuAssignment::Pinned,
|
||||
..Default::default()
|
||||
},
|
||||
telemetry: Default::default(),
|
||||
models: vec![crate::plugin::ModelConfigEntry {
|
||||
model: "Qwen3-8B-Q4_K_M".into(),
|
||||
mmproj: None,
|
||||
|
|
@ -3673,6 +3788,7 @@ async fn config_subscribe_rejects_pinned_snapshot_for_malformed_peer_version() -
|
|||
assignment: crate::plugin::GpuAssignment::Pinned,
|
||||
..Default::default()
|
||||
},
|
||||
telemetry: Default::default(),
|
||||
models: vec![crate::plugin::ModelConfigEntry {
|
||||
model: "Qwen3-8B-Q4_K_M".into(),
|
||||
mmproj: None,
|
||||
|
|
@ -3774,6 +3890,7 @@ async fn config_subscribe_allows_pinned_snapshot_for_same_release_prerelease_pee
|
|||
assignment: crate::plugin::GpuAssignment::Pinned,
|
||||
..Default::default()
|
||||
},
|
||||
telemetry: Default::default(),
|
||||
models: vec![crate::plugin::ModelConfigEntry {
|
||||
model: "Qwen3-8B-Q4_K_M".into(),
|
||||
mmproj: None,
|
||||
|
|
@ -3954,6 +4071,7 @@ async fn config_subscribe_closes_when_revision_becomes_pinned_for_malformed_peer
|
|||
assignment: crate::plugin::GpuAssignment::Pinned,
|
||||
..Default::default()
|
||||
},
|
||||
telemetry: Default::default(),
|
||||
models: vec![crate::plugin::ModelConfigEntry {
|
||||
model: "Qwen3-8B-Q4_K_M".into(),
|
||||
mmproj: None,
|
||||
|
|
@ -4059,6 +4177,7 @@ async fn config_subscribe_closes_when_revision_becomes_pinned_for_older_peer() -
|
|||
assignment: crate::plugin::GpuAssignment::Pinned,
|
||||
..Default::default()
|
||||
},
|
||||
telemetry: Default::default(),
|
||||
models: vec![crate::plugin::ModelConfigEntry {
|
||||
model: "Qwen3-8B-Q4_K_M".into(),
|
||||
mmproj: None,
|
||||
|
|
@ -4165,6 +4284,7 @@ async fn config_subscribe_keeps_stream_open_when_revision_becomes_pinned_for_sam
|
|||
assignment: crate::plugin::GpuAssignment::Pinned,
|
||||
..Default::default()
|
||||
},
|
||||
telemetry: Default::default(),
|
||||
models: vec![crate::plugin::ModelConfigEntry {
|
||||
model: "Qwen3-8B-Q4_K_M".into(),
|
||||
mmproj: None,
|
||||
|
|
|
|||
|
|
@ -282,6 +282,19 @@ pub(crate) enum RequestOutcome {
|
|||
Unavailable,
|
||||
}
|
||||
|
||||
pub(crate) trait RoutingTelemetrySink: Send + Sync {
|
||||
fn observe_inflight_requests(&self, current: u64);
|
||||
|
||||
fn record_model_request(&self, model: Option<&str>, attempts: usize, outcome: RequestOutcome);
|
||||
|
||||
fn record_route_attempt(
|
||||
&self,
|
||||
model: Option<&str>,
|
||||
target: &AttemptTarget,
|
||||
outcome: AttemptOutcome,
|
||||
);
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct RoutingMetrics {
|
||||
globals: Arc<GlobalMetrics>,
|
||||
|
|
|
|||
|
|
@ -1,4 +1,7 @@
|
|||
use super::{PluginSummary, BLACKBOARD_PLUGIN_ID, BLOBSTORE_PLUGIN_ID, OPENAI_ENDPOINT_PLUGIN_ID};
|
||||
use super::{
|
||||
PluginSummary, BLACKBOARD_PLUGIN_ID, BLOBSTORE_PLUGIN_ID, OPENAI_ENDPOINT_PLUGIN_ID,
|
||||
TELEMETRY_PLUGIN_ID,
|
||||
};
|
||||
use anyhow::{bail, Context, Result};
|
||||
use mesh_llm_plugin::MeshVisibility;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
|
@ -13,6 +16,8 @@ pub struct MeshConfig {
|
|||
#[serde(default)]
|
||||
pub gpu: GpuConfig,
|
||||
#[serde(default)]
|
||||
pub telemetry: TelemetryConfig,
|
||||
#[serde(default)]
|
||||
pub models: Vec<ModelConfigEntry>,
|
||||
#[serde(rename = "plugin", default)]
|
||||
pub plugins: Vec<PluginConfigEntry>,
|
||||
|
|
@ -57,6 +62,32 @@ pub struct ModelConfigEntry {
|
|||
pub flash_attention: Option<FlashAttentionType>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Default, Deserialize, Serialize, PartialEq, Eq)]
|
||||
pub struct TelemetryConfig {
|
||||
#[serde(default)]
|
||||
pub enabled: Option<bool>,
|
||||
#[serde(default)]
|
||||
pub service_name: Option<String>,
|
||||
#[serde(default)]
|
||||
pub endpoint: Option<String>,
|
||||
#[serde(default)]
|
||||
pub headers: BTreeMap<String, String>,
|
||||
#[serde(default)]
|
||||
pub export_interval_secs: Option<u64>,
|
||||
#[serde(default)]
|
||||
pub queue_size: Option<usize>,
|
||||
#[serde(default)]
|
||||
pub prompt_shape_metrics: bool,
|
||||
#[serde(default)]
|
||||
pub metrics: TelemetryMetricsConfig,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Default, Deserialize, Serialize, PartialEq, Eq)]
|
||||
pub struct TelemetryMetricsConfig {
|
||||
#[serde(default)]
|
||||
pub endpoint: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Deserialize, Serialize)]
|
||||
pub struct PluginConfigEntry {
|
||||
pub name: String,
|
||||
|
|
@ -126,6 +157,7 @@ pub(crate) fn validate_config(config: &MeshConfig) -> Result<()> {
|
|||
bail!("gpu.parallel must be at least 1, got {parallel}");
|
||||
}
|
||||
}
|
||||
validate_telemetry_config(&config.telemetry)?;
|
||||
for (index, model) in config.models.iter().enumerate() {
|
||||
if model.model.trim().is_empty() {
|
||||
bail!("models[{index}].model must not be empty");
|
||||
|
|
@ -175,6 +207,52 @@ pub(crate) fn validate_config(config: &MeshConfig) -> Result<()> {
|
|||
Ok(())
|
||||
}
|
||||
|
||||
fn validate_telemetry_config(config: &TelemetryConfig) -> Result<()> {
|
||||
if let Some(service_name) = &config.service_name {
|
||||
if service_name.trim().is_empty() {
|
||||
bail!("telemetry.service_name must not be empty when set");
|
||||
}
|
||||
}
|
||||
if let Some(endpoint) = &config.endpoint {
|
||||
if endpoint.trim().is_empty() {
|
||||
bail!("telemetry.endpoint must not be empty when set");
|
||||
}
|
||||
}
|
||||
if let Some(endpoint) = &config.metrics.endpoint {
|
||||
if endpoint.trim().is_empty() {
|
||||
bail!("telemetry.metrics.endpoint must not be empty when set");
|
||||
}
|
||||
}
|
||||
for key in config.headers.keys() {
|
||||
if key.trim().is_empty() {
|
||||
bail!("telemetry.headers keys must not be empty");
|
||||
}
|
||||
}
|
||||
if let Some(export_interval_secs) = config.export_interval_secs {
|
||||
if export_interval_secs < 1 {
|
||||
bail!("telemetry.export_interval_secs must be at least 1");
|
||||
}
|
||||
}
|
||||
if let Some(queue_size) = config.queue_size {
|
||||
if queue_size < 1 {
|
||||
bail!("telemetry.queue_size must be at least 1");
|
||||
}
|
||||
}
|
||||
if config.prompt_shape_metrics {
|
||||
bail!("telemetry.prompt_shape_metrics is not supported yet and must remain false");
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub(crate) fn telemetry_plugin_enabled(config: &MeshConfig) -> bool {
|
||||
config
|
||||
.plugins
|
||||
.iter()
|
||||
.find(|entry| entry.name == TELEMETRY_PLUGIN_ID)
|
||||
.map(|entry| entry.enabled.unwrap_or(true))
|
||||
.unwrap_or(true)
|
||||
}
|
||||
|
||||
pub fn resolve_plugins(config: &MeshConfig, _host_mode: PluginHostMode) -> Result<ResolvedPlugins> {
|
||||
let mut externals = Vec::new();
|
||||
let inactive = Vec::new();
|
||||
|
|
@ -183,6 +261,7 @@ pub fn resolve_plugins(config: &MeshConfig, _host_mode: PluginHostMode) -> Resul
|
|||
let mut blobstore_enabled = true;
|
||||
let mut openai_endpoint_enabled = false;
|
||||
let mut openai_endpoint_url: Option<String> = None;
|
||||
let mut telemetry_enabled = true;
|
||||
for entry in &config.plugins {
|
||||
if names.insert(entry.name.clone(), ()).is_some() {
|
||||
bail!("Duplicate plugin entry '{}'", entry.name);
|
||||
|
|
@ -221,6 +300,16 @@ pub fn resolve_plugins(config: &MeshConfig, _host_mode: PluginHostMode) -> Resul
|
|||
}
|
||||
continue;
|
||||
}
|
||||
if entry.name == TELEMETRY_PLUGIN_ID {
|
||||
if entry.command.is_some() || !entry.args.is_empty() || entry.url.is_some() {
|
||||
bail!(
|
||||
"Plugin '{}' is served by mesh-llm itself; only `enabled` may be set",
|
||||
TELEMETRY_PLUGIN_ID
|
||||
);
|
||||
}
|
||||
telemetry_enabled = enabled;
|
||||
continue;
|
||||
}
|
||||
if !enabled {
|
||||
continue;
|
||||
}
|
||||
|
|
@ -239,6 +328,10 @@ pub fn resolve_plugins(config: &MeshConfig, _host_mode: PluginHostMode) -> Resul
|
|||
if blackboard_enabled {
|
||||
externals.insert(0, blackboard_plugin_spec()?);
|
||||
}
|
||||
if telemetry_enabled {
|
||||
let insert_at = usize::from(blackboard_enabled).min(externals.len());
|
||||
externals.insert(insert_at, telemetry_plugin_spec()?);
|
||||
}
|
||||
if openai_endpoint_enabled {
|
||||
let mut spec = openai_endpoint_plugin_spec()?;
|
||||
spec.url = openai_endpoint_url;
|
||||
|
|
@ -308,6 +401,24 @@ pub fn openai_endpoint_plugin_spec() -> Result<ExternalPluginSpec> {
|
|||
})
|
||||
}
|
||||
|
||||
pub fn telemetry_plugin_spec() -> Result<ExternalPluginSpec> {
|
||||
let command = std::env::current_exe()
|
||||
.context("Cannot determine mesh-llm executable path")?
|
||||
.display()
|
||||
.to_string();
|
||||
Ok(ExternalPluginSpec {
|
||||
name: TELEMETRY_PLUGIN_ID.to_string(),
|
||||
command,
|
||||
args: vec![
|
||||
"--log-format".into(),
|
||||
"json".into(),
|
||||
"--plugin".into(),
|
||||
TELEMETRY_PLUGIN_ID.into(),
|
||||
],
|
||||
url: None,
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
|
@ -356,6 +467,90 @@ command = "/tmp/demo"
|
|||
assert_eq!(config.plugins[0].name, "demo");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn telemetry_config_deserializes_standard_metrics_settings() {
|
||||
let config: MeshConfig = toml::from_str(
|
||||
r#"
|
||||
version = 1
|
||||
|
||||
[telemetry]
|
||||
enabled = true
|
||||
service_name = "mesh-llm"
|
||||
endpoint = "https://otel.example.com"
|
||||
headers = { "authorization" = "Bearer TOKEN" }
|
||||
export_interval_secs = 15
|
||||
queue_size = 2048
|
||||
prompt_shape_metrics = false
|
||||
|
||||
[telemetry.metrics]
|
||||
endpoint = "https://otel.example.com/v1/metrics"
|
||||
|
||||
[[plugin]]
|
||||
name = "telemetry"
|
||||
enabled = true
|
||||
"#,
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(config.telemetry.enabled, Some(true));
|
||||
assert_eq!(config.telemetry.service_name.as_deref(), Some("mesh-llm"));
|
||||
assert_eq!(
|
||||
config.telemetry.endpoint.as_deref(),
|
||||
Some("https://otel.example.com")
|
||||
);
|
||||
assert_eq!(
|
||||
config.telemetry.metrics.endpoint.as_deref(),
|
||||
Some("https://otel.example.com/v1/metrics")
|
||||
);
|
||||
assert_eq!(
|
||||
config
|
||||
.telemetry
|
||||
.headers
|
||||
.get("authorization")
|
||||
.map(String::as_str),
|
||||
Some("Bearer TOKEN")
|
||||
);
|
||||
assert_eq!(config.telemetry.export_interval_secs, Some(15));
|
||||
assert_eq!(config.telemetry.queue_size, Some(2048));
|
||||
assert!(!config.telemetry.prompt_shape_metrics);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn telemetry_config_rejects_zero_queue_size() {
|
||||
let config: MeshConfig = toml::from_str(
|
||||
r#"
|
||||
[telemetry]
|
||||
queue_size = 0
|
||||
"#,
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let err = validate_config(&config).unwrap_err();
|
||||
assert!(
|
||||
err.to_string()
|
||||
.contains("telemetry.queue_size must be at least 1"),
|
||||
"unexpected error: {err}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn telemetry_config_rejects_prompt_shape_metrics_until_reviewed() {
|
||||
let config: MeshConfig = toml::from_str(
|
||||
r#"
|
||||
[telemetry]
|
||||
prompt_shape_metrics = true
|
||||
"#,
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let err = validate_config(&config).unwrap_err();
|
||||
assert!(
|
||||
err.to_string()
|
||||
.contains("telemetry.prompt_shape_metrics is not supported yet"),
|
||||
"unexpected error: {err}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pinned_gpu_config_accepted_pinned_config() {
|
||||
let config: MeshConfig = toml::from_str(
|
||||
|
|
|
|||
|
|
@ -28,14 +28,15 @@ use std::time::{Duration, Instant};
|
|||
use tokio::sync::{mpsc, Mutex};
|
||||
use url::Url;
|
||||
|
||||
pub(crate) use self::config::validate_config;
|
||||
#[allow(unused_imports)]
|
||||
pub use self::config::ExternalPluginSpec;
|
||||
#[allow(unused_imports)]
|
||||
pub use self::config::{
|
||||
config_path, load_config, resolve_plugins, GpuAssignment, GpuConfig, MeshConfig,
|
||||
ModelConfigEntry, PluginConfigEntry, PluginHostMode, ResolvedPlugins,
|
||||
ModelConfigEntry, PluginConfigEntry, PluginHostMode, ResolvedPlugins, TelemetryConfig,
|
||||
TelemetryMetricsConfig,
|
||||
};
|
||||
pub(crate) use self::config::{telemetry_plugin_enabled, validate_config};
|
||||
use self::runtime::ExternalPlugin;
|
||||
pub(crate) use self::support::parse_optional_json;
|
||||
use self::support::{format_args_for_log, format_slice_for_log, format_tool_names_for_log};
|
||||
|
|
@ -50,6 +51,8 @@ use mesh_llm_plugin::MeshVisibility;
|
|||
pub const BLACKBOARD_PLUGIN_ID: &str = "blackboard";
|
||||
pub const BLOBSTORE_PLUGIN_ID: &str = "blobstore";
|
||||
pub const OPENAI_ENDPOINT_PLUGIN_ID: &str = "openai-endpoint";
|
||||
pub const TELEMETRY_PLUGIN_ID: &str = "telemetry";
|
||||
pub const TELEMETRY_CAPABILITY: &str = "telemetry.metrics.v1";
|
||||
#[allow(dead_code)]
|
||||
pub const BLACKBOARD_CAPABILITY: &str = "blackboard.v1";
|
||||
pub(crate) const PROTOCOL_VERSION: u32 = mesh_llm_plugin::PROTOCOL_VERSION;
|
||||
|
|
@ -1716,6 +1719,7 @@ pub async fn run_plugin_process(name: String) -> Result<()> {
|
|||
BLACKBOARD_PLUGIN_ID => crate::plugins::blackboard::run_plugin(name).await,
|
||||
BLOBSTORE_PLUGIN_ID => crate::plugins::blobstore::run_plugin(name).await,
|
||||
OPENAI_ENDPOINT_PLUGIN_ID => crate::plugins::openai_endpoint::run_plugin(name).await,
|
||||
TELEMETRY_PLUGIN_ID => crate::plugins::telemetry::run_plugin(name).await,
|
||||
_ => bail!("Unknown built-in plugin '{}'", name),
|
||||
}
|
||||
}
|
||||
|
|
@ -1764,9 +1768,10 @@ mod tests {
|
|||
#[test]
|
||||
fn resolves_default_blackboard_plugin() {
|
||||
let resolved = resolve_plugins(&MeshConfig::default(), private_host_mode()).unwrap();
|
||||
assert_eq!(resolved.externals.len(), 2);
|
||||
assert_eq!(resolved.externals.len(), 3);
|
||||
assert_eq!(resolved.externals[0].name, BLACKBOARD_PLUGIN_ID);
|
||||
assert_eq!(resolved.externals[1].name, BLOBSTORE_PLUGIN_ID);
|
||||
assert_eq!(resolved.externals[1].name, TELEMETRY_PLUGIN_ID);
|
||||
assert_eq!(resolved.externals[2].name, BLOBSTORE_PLUGIN_ID);
|
||||
assert!(resolved.inactive.is_empty());
|
||||
}
|
||||
|
||||
|
|
@ -1783,8 +1788,9 @@ mod tests {
|
|||
..MeshConfig::default()
|
||||
};
|
||||
let resolved = resolve_plugins(&config, private_host_mode()).unwrap();
|
||||
assert_eq!(resolved.externals.len(), 1);
|
||||
assert_eq!(resolved.externals[0].name, BLOBSTORE_PLUGIN_ID);
|
||||
assert_eq!(resolved.externals.len(), 2);
|
||||
assert_eq!(resolved.externals[0].name, TELEMETRY_PLUGIN_ID);
|
||||
assert_eq!(resolved.externals[1].name, BLOBSTORE_PLUGIN_ID);
|
||||
assert!(resolved.inactive.is_empty());
|
||||
}
|
||||
|
||||
|
|
@ -1801,11 +1807,58 @@ mod tests {
|
|||
..MeshConfig::default()
|
||||
};
|
||||
let resolved = resolve_plugins(&config, private_host_mode()).unwrap();
|
||||
assert_eq!(resolved.externals.len(), 1);
|
||||
assert_eq!(resolved.externals.len(), 2);
|
||||
assert_eq!(resolved.externals[0].name, BLACKBOARD_PLUGIN_ID);
|
||||
assert_eq!(resolved.externals[1].name, TELEMETRY_PLUGIN_ID);
|
||||
assert!(resolved.inactive.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn telemetry_plugin_is_opt_out_builtin() {
|
||||
let resolved = resolve_plugins(&MeshConfig::default(), private_host_mode()).unwrap();
|
||||
assert_eq!(resolved.externals.len(), 3);
|
||||
assert_eq!(resolved.externals[0].name, BLACKBOARD_PLUGIN_ID);
|
||||
assert_eq!(resolved.externals[1].name, TELEMETRY_PLUGIN_ID);
|
||||
assert_eq!(resolved.externals[2].name, BLOBSTORE_PLUGIN_ID);
|
||||
assert!(resolved.externals[1].args.contains(&"--plugin".to_string()));
|
||||
assert!(resolved.externals[1]
|
||||
.args
|
||||
.contains(&TELEMETRY_PLUGIN_ID.to_string()));
|
||||
|
||||
let config = MeshConfig {
|
||||
plugins: vec![PluginConfigEntry {
|
||||
name: TELEMETRY_PLUGIN_ID.into(),
|
||||
enabled: Some(false),
|
||||
command: None,
|
||||
args: Vec::new(),
|
||||
url: None,
|
||||
}],
|
||||
..MeshConfig::default()
|
||||
};
|
||||
|
||||
let resolved = resolve_plugins(&config, private_host_mode()).unwrap();
|
||||
assert_eq!(resolved.externals.len(), 2);
|
||||
assert_eq!(resolved.externals[0].name, BLACKBOARD_PLUGIN_ID);
|
||||
assert_eq!(resolved.externals[1].name, BLOBSTORE_PLUGIN_ID);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn telemetry_plugin_rejects_custom_runtime_fields() {
|
||||
let config = MeshConfig {
|
||||
plugins: vec![PluginConfigEntry {
|
||||
name: TELEMETRY_PLUGIN_ID.into(),
|
||||
enabled: Some(true),
|
||||
command: Some("/tmp/telemetry".into()),
|
||||
args: Vec::new(),
|
||||
url: None,
|
||||
}],
|
||||
..MeshConfig::default()
|
||||
};
|
||||
|
||||
let result = resolve_plugins(&config, private_host_mode());
|
||||
assert!(result.is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn openai_endpoint_can_be_enabled_with_url() {
|
||||
let config = MeshConfig {
|
||||
|
|
@ -1819,11 +1872,12 @@ mod tests {
|
|||
..MeshConfig::default()
|
||||
};
|
||||
let resolved = resolve_plugins(&config, private_host_mode()).unwrap();
|
||||
assert_eq!(resolved.externals.len(), 3);
|
||||
assert_eq!(resolved.externals.len(), 4);
|
||||
assert_eq!(resolved.externals[0].name, BLACKBOARD_PLUGIN_ID);
|
||||
assert_eq!(resolved.externals[1].name, OPENAI_ENDPOINT_PLUGIN_ID);
|
||||
assert_eq!(resolved.externals[2].name, BLOBSTORE_PLUGIN_ID);
|
||||
let spec = &resolved.externals[1];
|
||||
assert_eq!(resolved.externals[1].name, TELEMETRY_PLUGIN_ID);
|
||||
assert_eq!(resolved.externals[2].name, OPENAI_ENDPOINT_PLUGIN_ID);
|
||||
assert_eq!(resolved.externals[3].name, BLOBSTORE_PLUGIN_ID);
|
||||
let spec = &resolved.externals[2];
|
||||
assert!(spec.args.contains(&"openai-endpoint".to_string()));
|
||||
assert_eq!(spec.url.as_deref(), Some("http://gpu-box:8000/v1"));
|
||||
}
|
||||
|
|
@ -1841,12 +1895,13 @@ mod tests {
|
|||
..MeshConfig::default()
|
||||
};
|
||||
let resolved = resolve_plugins(&config, private_host_mode()).unwrap();
|
||||
assert_eq!(resolved.externals.len(), 3);
|
||||
assert_eq!(resolved.externals.len(), 4);
|
||||
assert_eq!(resolved.externals[0].name, BLACKBOARD_PLUGIN_ID);
|
||||
assert_eq!(resolved.externals[1].name, OPENAI_ENDPOINT_PLUGIN_ID);
|
||||
assert_eq!(resolved.externals[2].name, BLOBSTORE_PLUGIN_ID);
|
||||
assert_eq!(resolved.externals[1].name, TELEMETRY_PLUGIN_ID);
|
||||
assert_eq!(resolved.externals[2].name, OPENAI_ENDPOINT_PLUGIN_ID);
|
||||
assert_eq!(resolved.externals[3].name, BLOBSTORE_PLUGIN_ID);
|
||||
// Verify the spec args dispatch to the right plugin binary
|
||||
let spec = &resolved.externals[1];
|
||||
let spec = &resolved.externals[2];
|
||||
assert!(spec.args.contains(&"--plugin".to_string()));
|
||||
assert!(spec.args.contains(&"openai-endpoint".to_string()));
|
||||
}
|
||||
|
|
@ -1876,9 +1931,10 @@ mod tests {
|
|||
},
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(resolved.externals.len(), 2);
|
||||
assert_eq!(resolved.externals.len(), 3);
|
||||
assert_eq!(resolved.externals[0].name, BLACKBOARD_PLUGIN_ID);
|
||||
assert_eq!(resolved.externals[1].name, BLOBSTORE_PLUGIN_ID);
|
||||
assert_eq!(resolved.externals[1].name, TELEMETRY_PLUGIN_ID);
|
||||
assert_eq!(resolved.externals[2].name, BLOBSTORE_PLUGIN_ID);
|
||||
assert!(resolved.inactive.is_empty());
|
||||
}
|
||||
|
||||
|
|
@ -1895,10 +1951,11 @@ mod tests {
|
|||
..MeshConfig::default()
|
||||
};
|
||||
let resolved = resolve_plugins(&config, private_host_mode()).unwrap();
|
||||
assert_eq!(resolved.externals.len(), 3);
|
||||
assert_eq!(resolved.externals.len(), 4);
|
||||
assert_eq!(resolved.externals[0].name, BLACKBOARD_PLUGIN_ID);
|
||||
assert_eq!(resolved.externals[1].name, "demo");
|
||||
assert_eq!(resolved.externals[2].name, BLOBSTORE_PLUGIN_ID);
|
||||
assert_eq!(resolved.externals[1].name, TELEMETRY_PLUGIN_ID);
|
||||
assert_eq!(resolved.externals[2].name, "demo");
|
||||
assert_eq!(resolved.externals[3].name, BLOBSTORE_PLUGIN_ID);
|
||||
assert!(resolved.inactive.is_empty());
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -1,3 +1,4 @@
|
|||
pub mod blackboard;
|
||||
pub mod blobstore;
|
||||
pub mod openai_endpoint;
|
||||
pub mod telemetry;
|
||||
|
|
|
|||
34
crates/mesh-llm/src/plugins/telemetry/mod.rs
Normal file
34
crates/mesh-llm/src/plugins/telemetry/mod.rs
Normal file
|
|
@ -0,0 +1,34 @@
|
|||
use anyhow::Result;
|
||||
use mesh_llm_plugin::{
|
||||
capability, plugin_server_info, PluginMetadata, PluginRuntime, PluginStartupPolicy,
|
||||
};
|
||||
|
||||
fn build_plugin(name: String) -> mesh_llm_plugin::SimplePlugin {
|
||||
mesh_llm_plugin::plugin! {
|
||||
metadata: PluginMetadata::new(
|
||||
name,
|
||||
crate::VERSION,
|
||||
plugin_server_info(
|
||||
"mesh-telemetry",
|
||||
crate::VERSION,
|
||||
"Telemetry Metrics Plugin",
|
||||
"Enables host-owned OTLP metrics export for model lifecycle and routing telemetry.",
|
||||
Some(
|
||||
"Configure [telemetry] to export metrics-only OTLP telemetry, \
|
||||
or set [[plugin]] name = \"telemetry\" enabled = false to opt out.",
|
||||
),
|
||||
),
|
||||
),
|
||||
startup_policy: PluginStartupPolicy::Any,
|
||||
provides: [
|
||||
capability(crate::plugin::TELEMETRY_CAPABILITY),
|
||||
],
|
||||
health: |_context| {
|
||||
Box::pin(async move { Ok("metrics=host-owned".to_string()) })
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) async fn run_plugin(name: String) -> Result<()> {
|
||||
PluginRuntime::run(build_plugin(name)).await
|
||||
}
|
||||
|
|
@ -769,6 +769,7 @@ pub(crate) fn proto_config_to_mesh(
|
|||
assignment,
|
||||
parallel: None,
|
||||
},
|
||||
telemetry: Default::default(),
|
||||
models,
|
||||
plugins,
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1575,6 +1575,7 @@ mod tests {
|
|||
assignment: GpuAssignment::Auto,
|
||||
parallel: None,
|
||||
},
|
||||
telemetry: Default::default(),
|
||||
models: vec![ModelConfigEntry {
|
||||
model: "Qwen3-8B.gguf".to_string(),
|
||||
mmproj: Some("mm.gguf".to_string()),
|
||||
|
|
@ -1631,6 +1632,7 @@ mod tests {
|
|||
assignment: GpuAssignment::Auto,
|
||||
parallel: None,
|
||||
},
|
||||
telemetry: Default::default(),
|
||||
models: vec![ModelConfigEntry {
|
||||
model: "test.gguf".to_string(),
|
||||
mmproj: None,
|
||||
|
|
@ -1657,6 +1659,7 @@ mod tests {
|
|||
assignment: GpuAssignment::Auto,
|
||||
parallel: None,
|
||||
},
|
||||
telemetry: Default::default(),
|
||||
models: vec![ModelConfigEntry {
|
||||
model: "other.gguf".to_string(),
|
||||
mmproj: None,
|
||||
|
|
@ -1686,6 +1689,7 @@ mod tests {
|
|||
assignment: GpuAssignment::Pinned,
|
||||
parallel: None,
|
||||
},
|
||||
telemetry: Default::default(),
|
||||
models: vec![ModelConfigEntry {
|
||||
model: "Qwen3-8B-Q4_K_M".to_string(),
|
||||
mmproj: Some("mmproj-f16.gguf".to_string()),
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
use anyhow::Result;
|
||||
use sha2::{Digest, Sha256};
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
use crate::plugin::{load_config, validate_config, MeshConfig};
|
||||
|
|
@ -38,7 +39,7 @@ pub(crate) struct ConfigState {
|
|||
config_hash: [u8; 32],
|
||||
config: MeshConfig,
|
||||
config_path: PathBuf,
|
||||
last_write_hash: [u8; 32],
|
||||
last_write_config_hash: [u8; 32],
|
||||
}
|
||||
|
||||
fn revision_sidecar_path(config_path: &Path) -> PathBuf {
|
||||
|
|
@ -106,6 +107,16 @@ fn atomic_write(target: &Path, contents: &[u8]) -> std::io::Result<()> {
|
|||
Ok(())
|
||||
}
|
||||
|
||||
fn local_config_write_hash(config: &MeshConfig) -> [u8; 32] {
|
||||
let bytes = serde_json::to_vec(config)
|
||||
.or_else(|_| toml::to_string(config).map(String::into_bytes))
|
||||
.unwrap_or_default();
|
||||
let digest = Sha256::digest(bytes);
|
||||
let mut out = [0u8; 32];
|
||||
out.copy_from_slice(&digest);
|
||||
out
|
||||
}
|
||||
|
||||
impl Default for ConfigState {
|
||||
fn default() -> Self {
|
||||
let config = crate::plugin::MeshConfig::default();
|
||||
|
|
@ -116,7 +127,7 @@ impl Default for ConfigState {
|
|||
config_hash,
|
||||
config,
|
||||
config_path: std::path::PathBuf::from("config.toml"),
|
||||
last_write_hash: [0xFF; 32],
|
||||
last_write_config_hash: [0xFF; 32],
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -127,8 +138,8 @@ impl ConfigState {
|
|||
let revision = read_revision(&revision_sidecar_path(path));
|
||||
let proto = mesh_config_to_proto(&config);
|
||||
let config_hash = canonical_config_hash(&proto);
|
||||
let last_write_hash = if path.exists() {
|
||||
config_hash
|
||||
let last_write_config_hash = if path.exists() {
|
||||
local_config_write_hash(&config)
|
||||
} else {
|
||||
[0xFF; 32]
|
||||
};
|
||||
|
|
@ -137,7 +148,7 @@ impl ConfigState {
|
|||
config_hash,
|
||||
config,
|
||||
config_path: path.to_path_buf(),
|
||||
last_write_hash,
|
||||
last_write_config_hash,
|
||||
})
|
||||
}
|
||||
|
||||
|
|
@ -166,8 +177,9 @@ impl ConfigState {
|
|||
|
||||
let proto = mesh_config_to_proto(&new_config);
|
||||
let new_hash = canonical_config_hash(&proto);
|
||||
let new_write_hash = local_config_write_hash(&new_config);
|
||||
|
||||
if new_hash == self.last_write_hash {
|
||||
if new_write_hash == self.last_write_config_hash {
|
||||
return ApplyResult::Applied {
|
||||
revision: self.revision,
|
||||
hash: self.config_hash,
|
||||
|
|
@ -189,6 +201,7 @@ impl ConfigState {
|
|||
if let Err(e) = atomic_write(&sidecar, new_revision.to_string().as_bytes()) {
|
||||
self.config = new_config;
|
||||
self.config_hash = new_hash;
|
||||
self.last_write_config_hash = new_write_hash;
|
||||
self.revision = new_revision;
|
||||
return ApplyResult::PersistedWithRevisionTrackingError {
|
||||
revision: self.revision,
|
||||
|
|
@ -201,7 +214,7 @@ impl ConfigState {
|
|||
|
||||
self.config = new_config;
|
||||
self.config_hash = new_hash;
|
||||
self.last_write_hash = new_hash;
|
||||
self.last_write_config_hash = new_write_hash;
|
||||
self.revision = new_revision;
|
||||
|
||||
ApplyResult::Applied {
|
||||
|
|
@ -231,6 +244,7 @@ mod tests {
|
|||
assignment: GpuAssignment::Auto,
|
||||
parallel: None,
|
||||
},
|
||||
telemetry: Default::default(),
|
||||
models: vec![],
|
||||
plugins: vec![],
|
||||
}
|
||||
|
|
@ -349,6 +363,7 @@ mod tests {
|
|||
assignment: GpuAssignment::Auto,
|
||||
parallel: None,
|
||||
},
|
||||
telemetry: Default::default(),
|
||||
models: vec![crate::plugin::ModelConfigEntry {
|
||||
model: model.to_string(),
|
||||
mmproj: None,
|
||||
|
|
@ -388,6 +403,7 @@ mod tests {
|
|||
assignment: GpuAssignment::Auto,
|
||||
parallel: None,
|
||||
},
|
||||
telemetry: Default::default(),
|
||||
models: vec![crate::plugin::ModelConfigEntry {
|
||||
model: "test.gguf".to_string(),
|
||||
mmproj: None,
|
||||
|
|
@ -434,6 +450,7 @@ mod tests {
|
|||
assignment: GpuAssignment::Auto,
|
||||
parallel: None,
|
||||
},
|
||||
telemetry: Default::default(),
|
||||
models: vec![crate::plugin::ModelConfigEntry {
|
||||
model: "noop-test.gguf".to_string(),
|
||||
mmproj: None,
|
||||
|
|
@ -489,6 +506,53 @@ mod tests {
|
|||
std::fs::remove_dir_all(&dir).ok();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn config_sync_telemetry_only_change_is_persisted_locally() {
|
||||
let dir = test_dir();
|
||||
let config_path = dir.join("config.toml");
|
||||
let mut state = ConfigState::load(&config_path).expect("load");
|
||||
|
||||
let base = minimal_valid_config();
|
||||
let r1 = state.apply(base.clone(), 0);
|
||||
let rev_after_first = match r1 {
|
||||
ApplyResult::Applied {
|
||||
revision,
|
||||
apply_mode,
|
||||
..
|
||||
} => {
|
||||
assert_eq!(apply_mode, ConfigApplyMode::Staged);
|
||||
revision
|
||||
}
|
||||
other => panic!("expected Applied, got {other:?}"),
|
||||
};
|
||||
|
||||
let mut telemetry_only = base;
|
||||
telemetry_only.telemetry.enabled = Some(true);
|
||||
telemetry_only.telemetry.endpoint = Some("https://otel.example.com".to_string());
|
||||
|
||||
let r2 = state.apply(telemetry_only, rev_after_first);
|
||||
match r2 {
|
||||
ApplyResult::Applied {
|
||||
revision,
|
||||
apply_mode,
|
||||
..
|
||||
} => {
|
||||
assert_eq!(
|
||||
apply_mode,
|
||||
ConfigApplyMode::Staged,
|
||||
"local-only telemetry changes must still be written to config.toml"
|
||||
);
|
||||
assert_eq!(revision, rev_after_first + 1);
|
||||
}
|
||||
other => panic!("expected Applied with Staged apply_mode, got {other:?}"),
|
||||
}
|
||||
|
||||
let persisted = std::fs::read_to_string(&config_path).expect("persisted config");
|
||||
assert!(persisted.contains("https://otel.example.com"));
|
||||
|
||||
std::fs::remove_dir_all(&dir).ok();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn config_sync_sidecar_path_derived_from_filename() {
|
||||
let dir = test_dir();
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@ pub mod instance;
|
|||
mod interactive;
|
||||
mod local;
|
||||
mod proxy;
|
||||
mod survey;
|
||||
pub(crate) mod wakeable;
|
||||
|
||||
use self::discovery::{nostr_rediscovery, start_new_mesh};
|
||||
|
|
@ -1030,6 +1031,8 @@ struct StartupLocalModelTask {
|
|||
slots: usize,
|
||||
parallel_override: Option<usize>,
|
||||
split: bool,
|
||||
survey_telemetry: survey::SurveyTelemetry,
|
||||
survey_launch_kind: survey::SurveyLaunchKind,
|
||||
stop_rx: tokio::sync::watch::Receiver<bool>,
|
||||
dashboard_processes: Arc<tokio::sync::Mutex<Vec<api::RuntimeProcessPayload>>>,
|
||||
dashboard_context_usage: DashboardContextUsage,
|
||||
|
|
@ -1065,6 +1068,8 @@ async fn startup_local_model_loop(params: StartupLocalModelTask) {
|
|||
slots,
|
||||
parallel_override,
|
||||
split,
|
||||
survey_telemetry,
|
||||
survey_launch_kind,
|
||||
mut stop_rx,
|
||||
dashboard_processes,
|
||||
dashboard_context_usage,
|
||||
|
|
@ -1106,6 +1111,16 @@ async fn startup_local_model_loop(params: StartupLocalModelTask) {
|
|||
.unwrap_or_else(|| node.vram_bytes());
|
||||
let model_bytes = election::total_model_bytes(&model_path);
|
||||
let runtime_plan = startup_runtime_plan(split, local_capacity, model_bytes);
|
||||
let launch_kind = match runtime_plan {
|
||||
StartupRuntimePlan::Local => survey_launch_kind,
|
||||
StartupRuntimePlan::Split {
|
||||
reason: SplitRuntimeReason::Forced,
|
||||
} => survey::SurveyLaunchKind::MoeShard,
|
||||
StartupRuntimePlan::Split {
|
||||
reason: SplitRuntimeReason::LocalCapacity,
|
||||
} => survey::SurveyLaunchKind::MoeFallback,
|
||||
};
|
||||
let launch_started = Instant::now();
|
||||
let (
|
||||
mut loaded_name,
|
||||
mut handle,
|
||||
|
|
@ -1154,6 +1169,18 @@ async fn startup_local_model_loop(params: StartupLocalModelTask) {
|
|||
return;
|
||||
}
|
||||
Err(err) => {
|
||||
survey_telemetry.record_launch_failure(
|
||||
survey::SurveyModelSpec {
|
||||
model: &model_name,
|
||||
model_path: Some(&model_path),
|
||||
launch_kind,
|
||||
pinned_gpu: pinned_gpu.as_ref(),
|
||||
backend: None,
|
||||
context_length: ctx_size.map(u64::from),
|
||||
},
|
||||
launch_started.elapsed(),
|
||||
survey::classify_launch_failure(&err),
|
||||
);
|
||||
let _ = emit_event(OutputEvent::Error {
|
||||
message: format!("Failed to start model {model_name}: {err:#}"),
|
||||
context: Some(format!("model={model_name}")),
|
||||
|
|
@ -1171,6 +1198,18 @@ async fn startup_local_model_loop(params: StartupLocalModelTask) {
|
|||
(loaded_name, handle, death_rx, None, None, None)
|
||||
}
|
||||
Err(err) => {
|
||||
survey_telemetry.record_launch_failure(
|
||||
survey::SurveyModelSpec {
|
||||
model: &model_name,
|
||||
model_path: Some(&model_path),
|
||||
launch_kind,
|
||||
pinned_gpu: pinned_gpu.as_ref(),
|
||||
backend: None,
|
||||
context_length: ctx_size.map(u64::from),
|
||||
},
|
||||
launch_started.elapsed(),
|
||||
survey::classify_launch_failure(&err),
|
||||
);
|
||||
let _ = emit_event(OutputEvent::Error {
|
||||
message: format!("Failed to start model {model_name}: {err:#}"),
|
||||
context: Some(format!("model={model_name}")),
|
||||
|
|
@ -1185,6 +1224,16 @@ async fn startup_local_model_loop(params: StartupLocalModelTask) {
|
|||
};
|
||||
drop(startup_load_guard);
|
||||
|
||||
let mut survey_loaded_model = survey_telemetry.model(survey::SurveyModelSpec {
|
||||
model: &loaded_name,
|
||||
model_path: Some(&model_path),
|
||||
launch_kind,
|
||||
pinned_gpu: pinned_gpu.as_ref(),
|
||||
backend: Some(&handle.backend),
|
||||
context_length: Some(u64::from(handle.context_length)),
|
||||
});
|
||||
survey_telemetry.record_launch_success(&survey_loaded_model, launch_started.elapsed());
|
||||
|
||||
add_runtime_local_target(&target_tx, &loaded_name, handle.port);
|
||||
tunnel_mgr.set_http_port(api_port);
|
||||
node.set_role(NodeRole::Host {
|
||||
|
|
@ -1250,6 +1299,7 @@ async fn startup_local_model_loop(params: StartupLocalModelTask) {
|
|||
|
||||
let mut context_usage_tick = tokio::time::interval(DASHBOARD_CONTEXT_USAGE_REFRESH_INTERVAL);
|
||||
context_usage_tick.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Skip);
|
||||
let mut survey_exited_unexpectedly = false;
|
||||
|
||||
loop {
|
||||
tokio::select! {
|
||||
|
|
@ -1263,6 +1313,8 @@ async fn startup_local_model_loop(params: StartupLocalModelTask) {
|
|||
);
|
||||
}
|
||||
_ = &mut death_rx => {
|
||||
survey_exited_unexpectedly = true;
|
||||
survey_telemetry.record_unexpected_exit(&survey_loaded_model);
|
||||
let _ = emit_event(OutputEvent::Warning {
|
||||
message: format!("Startup model '{loaded_name}' exited unexpectedly"),
|
||||
context: Some(format!("model={loaded_name} port={}", handle.port)),
|
||||
|
|
@ -1332,7 +1384,20 @@ async fn startup_local_model_loop(params: StartupLocalModelTask) {
|
|||
&old_handle,
|
||||
)
|
||||
.await;
|
||||
survey_telemetry.record_unload(&survey_loaded_model);
|
||||
loaded_name = next.loaded_name;
|
||||
survey_loaded_model = survey_telemetry.model(survey::SurveyModelSpec {
|
||||
model: &loaded_name,
|
||||
model_path: Some(&model_path),
|
||||
launch_kind,
|
||||
pinned_gpu: pinned_gpu.as_ref(),
|
||||
backend: Some(&handle.backend),
|
||||
context_length: Some(u64::from(handle.context_length)),
|
||||
});
|
||||
survey_telemetry.record_launch_success(
|
||||
&survey_loaded_model,
|
||||
Duration::from_secs(0),
|
||||
);
|
||||
refresh_dashboard_context_usage(&dashboard_context_usage, &loaded_name, &handle)
|
||||
.await;
|
||||
publish_runtime_llama_slots(
|
||||
|
|
@ -1367,6 +1432,9 @@ async fn startup_local_model_loop(params: StartupLocalModelTask) {
|
|||
task.abort();
|
||||
let _ = task.await;
|
||||
}
|
||||
if !survey_exited_unexpectedly {
|
||||
survey_telemetry.record_unload(&survey_loaded_model);
|
||||
}
|
||||
let port = handle.port;
|
||||
remove_runtime_local_target(&target_tx, &loaded_name, port);
|
||||
tunnel_mgr.set_http_port(api_port);
|
||||
|
|
@ -3681,6 +3749,25 @@ async fn run_auto(
|
|||
.await?;
|
||||
node.set_plugin_manager(plugin_manager.clone()).await;
|
||||
node.start_plugin_channel_forwarder(plugin_mesh_rx);
|
||||
let survey_hardware = if is_client {
|
||||
hardware::HardwareSurvey::default()
|
||||
} else {
|
||||
hardware::query(&[
|
||||
hardware::Metric::GpuName,
|
||||
hardware::Metric::GpuCount,
|
||||
hardware::Metric::IsSoc,
|
||||
hardware::Metric::GpuFacts,
|
||||
])
|
||||
};
|
||||
let survey_telemetry = survey::SurveyTelemetry::start(
|
||||
&config,
|
||||
survey_hardware,
|
||||
survey::SurveyTelemetrySource {
|
||||
node_id: node.id().fmt_short().to_string(),
|
||||
node_role: if is_client { "client" } else { "worker" }.into(),
|
||||
},
|
||||
);
|
||||
node.set_routing_telemetry_sink(survey_telemetry.routing_sink());
|
||||
|
||||
// Advertise what we have on disk and what we want the mesh to serve
|
||||
node.set_available_models(local_models.clone()).await;
|
||||
|
|
@ -4049,6 +4136,7 @@ async fn run_auto(
|
|||
let (runtime_event_tx, mut runtime_event_rx) =
|
||||
tokio::sync::mpsc::unbounded_channel::<RuntimeEvent>();
|
||||
let mut runtime_models: HashMap<String, RuntimeModelHandleEntry> = HashMap::new();
|
||||
let mut runtime_survey_models: HashMap<String, survey::SurveyLoadedModel> = HashMap::new();
|
||||
let mut managed_models: HashMap<String, ManagedModelController> = HashMap::new();
|
||||
let runtime_instance_registry: RuntimeInstanceRegistry =
|
||||
Arc::new(tokio::sync::Mutex::new(HashMap::new()));
|
||||
|
|
@ -4273,6 +4361,7 @@ async fn run_auto(
|
|||
let console_state_for_election = console_state.clone();
|
||||
let interactive_console_state = console_state.clone();
|
||||
let interactive_control_tx = control_tx.clone();
|
||||
let survey_telemetry_for_primary = survey_telemetry.clone();
|
||||
|
||||
let primary_model_name_for_advertise = model_name.clone();
|
||||
let startup_model_names: Vec<String> = startup_models
|
||||
|
|
@ -4347,6 +4436,8 @@ async fn run_auto(
|
|||
slots: primary_slots,
|
||||
parallel_override: primary_parallel_override,
|
||||
split: startup_split,
|
||||
survey_telemetry: survey_telemetry_for_primary,
|
||||
survey_launch_kind: survey::SurveyLaunchKind::Startup,
|
||||
stop_rx: primary_stop_rx,
|
||||
dashboard_processes: dashboard_processes_for_primary_task,
|
||||
dashboard_context_usage: dashboard_context_usage_for_primary_task,
|
||||
|
|
@ -4418,6 +4509,7 @@ async fn run_auto(
|
|||
let dashboard_context_usage_for_extra_task = dashboard_context_usage.clone();
|
||||
let runtime_instance_registry_for_extra_task = runtime_instance_registry.clone();
|
||||
let extra_control_tx = control_tx.clone();
|
||||
let extra_survey_telemetry = survey_telemetry.clone();
|
||||
let extra_task = tokio::spawn(async move {
|
||||
startup_local_model_loop(StartupLocalModelTask {
|
||||
node: extra_node,
|
||||
|
|
@ -4439,6 +4531,8 @@ async fn run_auto(
|
|||
slots: extra_slots,
|
||||
parallel_override: extra_parallel_override,
|
||||
split: startup_split,
|
||||
survey_telemetry: extra_survey_telemetry,
|
||||
survey_launch_kind: survey::SurveyLaunchKind::MultiModel,
|
||||
stop_rx: extra_stop_rx,
|
||||
dashboard_processes: dashboard_processes_for_extra_task,
|
||||
dashboard_context_usage: dashboard_context_usage_for_extra_task,
|
||||
|
|
@ -4583,7 +4677,11 @@ async fn run_auto(
|
|||
|
||||
let instance_id =
|
||||
next_runtime_instance_id(&mut next_runtime_instance_sequence);
|
||||
let (loaded_name, handle, death_rx) = start_runtime_local_model(
|
||||
let requested_model = spec.clone();
|
||||
add_serving_assignment(&node, &primary_model_name, &requested_model)
|
||||
.await;
|
||||
let launch_started = Instant::now();
|
||||
let (loaded_name, handle, death_rx) = match start_runtime_local_model(
|
||||
LocalRuntimeModelStartSpec {
|
||||
node: &node,
|
||||
model_path: &model_path,
|
||||
|
|
@ -4603,7 +4701,37 @@ async fn run_auto(
|
|||
parallel_override,
|
||||
},
|
||||
)
|
||||
.await?;
|
||||
.await
|
||||
{
|
||||
Ok(result) => result,
|
||||
Err(err) => {
|
||||
remove_serving_assignment(&node, &requested_model).await;
|
||||
survey_telemetry.record_launch_failure(
|
||||
survey::SurveyModelSpec {
|
||||
model: &requested_model,
|
||||
model_path: Some(&model_path),
|
||||
launch_kind: survey::SurveyLaunchKind::RuntimeLoad,
|
||||
pinned_gpu: None,
|
||||
backend: None,
|
||||
context_length: cli.ctx_size.map(u64::from),
|
||||
},
|
||||
launch_started.elapsed(),
|
||||
survey::classify_launch_failure(&err),
|
||||
);
|
||||
return Err(err);
|
||||
}
|
||||
};
|
||||
let survey_loaded_model =
|
||||
survey_telemetry.model(survey::SurveyModelSpec {
|
||||
model: &loaded_name,
|
||||
model_path: Some(&model_path),
|
||||
launch_kind: survey::SurveyLaunchKind::RuntimeLoad,
|
||||
pinned_gpu: None,
|
||||
backend: Some(&handle.backend),
|
||||
context_length: Some(u64::from(handle.context_length)),
|
||||
});
|
||||
survey_telemetry
|
||||
.record_launch_success(&survey_loaded_model, launch_started.elapsed());
|
||||
|
||||
add_runtime_local_target(&target_tx, &loaded_name, handle.port);
|
||||
register_runtime_instance(
|
||||
|
|
@ -4665,6 +4793,8 @@ async fn run_auto(
|
|||
Some(&instance_id),
|
||||
&handle,
|
||||
);
|
||||
runtime_survey_models
|
||||
.insert(instance_id.clone(), survey_loaded_model);
|
||||
runtime_models.insert(
|
||||
instance_id.clone(),
|
||||
RuntimeModelHandleEntry {
|
||||
|
|
@ -4698,6 +4828,11 @@ async fn run_auto(
|
|||
let model = entry.model_name;
|
||||
let handle = entry.handle;
|
||||
let port = handle.port;
|
||||
if let Some(survey_model) =
|
||||
runtime_survey_models.remove(&unload.instance_id)
|
||||
{
|
||||
survey_telemetry.record_unload(&survey_model);
|
||||
}
|
||||
remove_runtime_local_target(&target_tx, &model, port);
|
||||
if unregister_runtime_instance(
|
||||
&runtime_instance_registry,
|
||||
|
|
@ -4816,6 +4951,11 @@ async fn run_auto(
|
|||
if matches {
|
||||
if let Some(entry) = runtime_models.remove(&instance_id) {
|
||||
let handle = entry.handle;
|
||||
if let Some(survey_model) =
|
||||
runtime_survey_models.remove(&instance_id)
|
||||
{
|
||||
survey_telemetry.record_unexpected_exit(&survey_model);
|
||||
}
|
||||
if unregister_runtime_instance(
|
||||
&runtime_instance_registry,
|
||||
&node,
|
||||
|
|
@ -4900,6 +5040,9 @@ async fn run_auto(
|
|||
for (instance_id, entry) in runtime_models.drain() {
|
||||
let name = entry.model_name;
|
||||
let handle = entry.handle;
|
||||
if let Some(survey_model) = runtime_survey_models.remove(&instance_id) {
|
||||
survey_telemetry.record_unload(&survey_model);
|
||||
}
|
||||
let shutting_down_payload = runtime_process_payload_with_status(
|
||||
&name,
|
||||
Some(&instance_id),
|
||||
|
|
|
|||
1391
crates/mesh-llm/src/runtime/survey.rs
Normal file
1391
crates/mesh-llm/src/runtime/survey.rs
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -6,6 +6,10 @@ It describes the target architecture, not just the code as it exists today.
|
|||
|
||||
As implementation lands, this document should be updated to match the intended end state and the concrete protocol and runtime decisions that have been made.
|
||||
|
||||
Plugin-specific documentation:
|
||||
|
||||
- [Telemetry](telemetry.md) - built-in OTLP metrics-only runtime and routing telemetry
|
||||
|
||||
The main goals are:
|
||||
|
||||
- keep `mesh-llm` decoupled from specific plugins
|
||||
|
|
|
|||
137
docs/plugins/telemetry.md
Normal file
137
docs/plugins/telemetry.md
Normal file
|
|
@ -0,0 +1,137 @@
|
|||
# Telemetry Plugin
|
||||
|
||||
The built-in `telemetry` plugin enables metrics-only OTLP/HTTP export for local
|
||||
model lifecycle and routing telemetry. The plugin is enabled by default, while
|
||||
export still requires a configured OTLP metrics endpoint. No collector or
|
||||
project-owned destination is hard-coded.
|
||||
|
||||
## Configuration
|
||||
|
||||
Configure an OTLP metrics endpoint:
|
||||
|
||||
```toml
|
||||
[telemetry]
|
||||
enabled = true
|
||||
service_name = "mesh-llm"
|
||||
endpoint = "https://otel.example.com"
|
||||
headers = { "authorization" = "Bearer TOKEN" }
|
||||
export_interval_secs = 15
|
||||
queue_size = 2048
|
||||
|
||||
[telemetry.metrics]
|
||||
endpoint = "https://otel.example.com/v1/metrics"
|
||||
|
||||
[[plugin]]
|
||||
name = "telemetry"
|
||||
enabled = true
|
||||
```
|
||||
|
||||
The `[[plugin]]` entry is optional when telemetry should stay enabled. To opt
|
||||
out of the built-in plugin entirely, set:
|
||||
|
||||
```toml
|
||||
[[plugin]]
|
||||
name = "telemetry"
|
||||
enabled = false
|
||||
```
|
||||
|
||||
Endpoint precedence is:
|
||||
|
||||
1. `telemetry.metrics.endpoint`
|
||||
2. `telemetry.endpoint` normalized to `/v1/metrics`
|
||||
3. `OTEL_EXPORTER_OTLP_METRICS_ENDPOINT`, only when `telemetry.enabled = true`
|
||||
4. `OTEL_EXPORTER_OTLP_ENDPOINT` normalized to `/v1/metrics`, only when
|
||||
`telemetry.enabled = true`
|
||||
|
||||
If no endpoint is configured, telemetry export stays disabled. Ambient OTel
|
||||
environment variables are not consumed unless telemetry is explicitly enabled in
|
||||
mesh-llm config.
|
||||
|
||||
## Exported Metrics
|
||||
|
||||
Request and route metrics are emitted per fronting node. A collector or
|
||||
dashboard can aggregate `mesh_llm_requests_inflight` across nodes for a
|
||||
mesh-wide in-flight request view.
|
||||
|
||||
Counters:
|
||||
|
||||
- `mesh_llm_model_launch_total`
|
||||
- `mesh_llm_model_launch_success_total`
|
||||
- `mesh_llm_model_launch_failure_total`
|
||||
- `mesh_llm_model_unload_total`
|
||||
- `mesh_llm_model_exit_unexpected_total`
|
||||
- `mesh_llm_model_request_total`
|
||||
- `mesh_llm_route_attempt_total`
|
||||
|
||||
Gauges:
|
||||
|
||||
- `mesh_llm_loaded_models`
|
||||
- `mesh_llm_model_loaded`
|
||||
- `mesh_llm_model_context_length`
|
||||
- `mesh_llm_requests_inflight`
|
||||
|
||||
Histograms:
|
||||
|
||||
- `mesh_llm_model_launch_duration_ms`
|
||||
- `mesh_llm_model_uptime_s`
|
||||
|
||||
## Privacy Boundary
|
||||
|
||||
The telemetry plugin exports metrics only. It does not export prompts,
|
||||
completions, logs, traces, hostnames, mesh gossip, relay messages, raw node IDs,
|
||||
raw GPU stable IDs, endpoint URLs, or prompt hashes.
|
||||
|
||||
Local absolute and path-like model labels are reduced to filenames before export.
|
||||
Hugging Face refs are preserved. GPU stable IDs and node IDs are exported as
|
||||
stable pseudonymous hashes, not raw identifiers. Route-attempt metrics label
|
||||
local, remote, and endpoint target kinds; remote target IDs are exported only as
|
||||
stable hashes so collectors can aggregate node-to-node traffic without exposing
|
||||
raw peer IDs.
|
||||
|
||||
Telemetry attributes are intentionally allowlisted in code. Any new exported
|
||||
attribute must update the allowlist, tests, and this document before it is added
|
||||
to an OTLP record.
|
||||
|
||||
| Attribute | Used by | Privacy handling |
|
||||
|---|---|---|
|
||||
| `mesh_llm.model` | lifecycle, request, route | Local/path-like labels are reduced to filenames; Hugging Face refs are preserved. |
|
||||
| `mesh_llm.launch_kind` | lifecycle | Bounded enum. |
|
||||
| `mesh_llm.gpu_count` | lifecycle | Count only. |
|
||||
| `mesh_llm.is_soc` | lifecycle | Boolean only. |
|
||||
| `mesh_llm.service_version` | lifecycle, request, route, in-flight | Build version only. |
|
||||
| `mesh_llm.architecture` | lifecycle | GGUF architecture string when available. |
|
||||
| `mesh_llm.quantization` | lifecycle | Derived quantization label. |
|
||||
| `mesh_llm.gpu_name` | lifecycle | Hardware product label; no hostname or stable device ID. |
|
||||
| `mesh_llm.gpu_stable_id` | lifecycle | Stable pseudonymous hash of the GPU ID. |
|
||||
| `mesh_llm.backend_device` | lifecycle | Backend-local slot label such as `CUDA0`, `ROCm0`, `Vulkan0`, or `MTL0`. |
|
||||
| `mesh_llm.backend` | lifecycle | Runtime/backend label. |
|
||||
| `mesh_llm.context_bucket` | lifecycle | Bucketed context length, not the exact configured value. |
|
||||
| `mesh_llm.failure_reason` | lifecycle | Bounded enum. |
|
||||
| `mesh_llm.source_node_role` | request, route, in-flight | Bounded node role label such as `client` or `worker`. |
|
||||
| `mesh_llm.source_node_id` | request, route, in-flight | Stable pseudonymous hash of the source node ID. |
|
||||
| `mesh_llm.route_service` | request | Bounded service label: `local`, `remote`, `endpoint`, or `unavailable`. |
|
||||
| `mesh_llm.request_outcome` | request | Bounded enum. |
|
||||
| `mesh_llm.route_attempt_bucket` | request | Bounded retry bucket: `1`, `2`, `3_4`, or `5_plus`. |
|
||||
| `mesh_llm.target_kind` | route | Bounded target kind: `local`, `remote`, or `endpoint`. |
|
||||
| `mesh_llm.target_node_id` | route | Stable pseudonymous hash for local/remote node targets; omitted for endpoint targets. |
|
||||
| `mesh_llm.attempt_outcome` | route | Bounded enum. |
|
||||
|
||||
## Review Checklist
|
||||
|
||||
Before adding, renaming, or removing OTLP metrics or attributes:
|
||||
|
||||
1. Run the repo-local telemetry privacy review skill:
|
||||
`.agents/skills/telemetry-privacy-review/SKILL.md`.
|
||||
2. Keep export destination behavior explicit: no default collector and no ambient
|
||||
OTel env export unless `telemetry.enabled = true`.
|
||||
3. Update `TELEMETRY_ATTRIBUTE_ALLOWLIST` in
|
||||
`crates/mesh-llm/src/runtime/survey.rs`.
|
||||
4. Update the attribute inventory above.
|
||||
5. Add or update focused tests proving private paths, raw node IDs, raw GPU
|
||||
stable IDs, endpoint URLs, prompts, and completions are not exported.
|
||||
|
||||
## Runtime Safety
|
||||
|
||||
Telemetry exporter setup failures disable telemetry without failing inference
|
||||
startup. Runtime events are buffered through a bounded queue; when the queue is
|
||||
full, the oldest event is dropped instead of blocking inference.
|
||||
Loading…
Add table
Add a link
Reference in a new issue