future(telemetry-plugin): add opt-in survey OTLP metrics exporter

This commit is contained in:
Ivan Golovach 2026-05-06 18:26:52 -07:00 committed by GitHub
parent a12c34ad6a
commit e2a0b5770a
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
20 changed files with 2334 additions and 33 deletions

View 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
View file

@ -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"

View file

@ -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`.

View file

@ -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"

View file

@ -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

View file

@ -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!(

View file

@ -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,

View file

@ -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,

View file

@ -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>,

View file

@ -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(

View file

@ -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());
}

View file

@ -1,3 +1,4 @@
pub mod blackboard;
pub mod blobstore;
pub mod openai_endpoint;
pub mod telemetry;

View 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
}

View file

@ -769,6 +769,7 @@ pub(crate) fn proto_config_to_mesh(
assignment,
parallel: None,
},
telemetry: Default::default(),
models,
plugins,
}

View file

@ -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()),

View file

@ -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();

View file

@ -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),

File diff suppressed because it is too large Load diff

View file

@ -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
View 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.