mlxobservabilitymonitoringllm

MLX-OC — How LLM Monitoring Works

Share X in

MLX

Component by component: what is measured, which macOS tool or Python library produces it, how it travels, and where it lands.

1Data Path

The One-Sentence Version

Two independent paths carry everything. Pull: five Python agents expose /metrics and VictoriaMetrics scrapes them every 10 seconds. Push: the proxy and the log tailer ship OpenTelemetry over HTTP to a collector, which writes metrics into the same store. Nothing in cluster/ ever pushes a hardware metric — the hardware exporter is pull-only by design, so those series exist exactly once.

Data Path Overview — Pull & Push Lanes

Lane A — Pull · Prometheus Scrape · Every 10 s mlx_metrics_proxy :8080 RED + Quality mlx_hw_telemetry :9102 · Rank 0 Power · Thermal mlx_hw_telemetry :9102 · Rank 1 Over Thunderbolt mlx_kv_cache_agent :9104 Cache · Context mlx_server_supervisor :9105 Restart · Crash /metrics VictoriaMetrics · :8428 Single-Node TSDB · 30-Day Retention · Prometheus API vm-scrape.yml — 6 Jobs · 8 Targets Grafana · :3000 12 Dashboards vmalert · :8880 46 Rules → Alertmanager Lane B — Push · OTLP/HTTP mlx_metrics_proxy Trace Per Request Batch Span Processor mlx_server_log_tailer :9106 · Tails server.log Shipped / Dropped Counters otel-collector :4317 gRPC · :4318 HTTP One Processor: batch(128, 5 s) Runs As UID 0 For File Sink prometheus_remote_write → /api/v1/write · Metrics Pipeline File Exporter mlx-server-logs.jsonl 50 MB × 5 Rotations Second Span Processor — Bypasses The Collector Opik · :32173 Per-Request Traces Plus SDK Trace Path The Traces Pipeline Exits To debug Only — Nothing Persists Them Here. Opik Is The System Of Record For Traces.

The Eight Components

Two Mac Minis (rank 0 and rank 1, joined by a Thunderbolt ring) plus one management node. Every row below is a real process with a real port.

ComponentPortRuns OnWhat It OwnsTransport
mlx_metrics_proxy.py8080Rank 0The OpenAI-compatible front door. Every LLM RED, quality and confidence metric is born here.Pull and Push
mlx_lm.server8081Rank 0 (+ ring)The actual inference engine. Exposes no stats endpoint — which is why a log-scraping agent exists.None
mlx_hw_telemetry.py9102Both nodesGPU/ANE/CPU power, thermals, memory, processes, Thunderbolt counters. Pull-only, deliberately.Pull
mlx_kv_cache_agent.py9104Rank 0KV cache occupancy and context budget — parsed out of server.log text.Pull
mlx_server_supervisor.py9105Rank 0Process truth: alive, ready, model loaded, restarts, crash reason, backoff.Pull
mlx_server_log_tailer.py9106Rank 0Tails server.log, ships lines as OTLP logs, counts shipped and dropped.Push
victoria-metrics8428Rank 0Scrapes everything every 10 s and receives remote_write. Single TSDB, 30-day retention.—
vmalert + alertmanager8880 9093Rank 046 alert rules evaluated against VM; routed, grouped and inhibited, then POSTed to two webhooks.—

Why Two Paths At All

The split is a deliberate design decision, and the proxy’s own docstring states it.

Hardware Is Pull-Only

mlx_hw_* series exist exactly once in VictoriaMetrics because the hardware exporter has no OTLP exporter at all — mlx_hw_telemetry.py:17-19 says so directly. If it also pushed, every gauge would be double-written.

LLM Metrics Are Local

The proxy serves its own /metrics on the same port that serves inference (mlx_metrics_proxy.py:915-921). One listener, two purposes — scraping costs nothing and keeps the counters exactly where the requests already are.

Opik Bypasses The Collector

Opik receives traces directly from the proxy via a second BatchSpanProcessor, not through otel-collector. There is no opik exporter anywhere in observability/.

Push Toward Nodes Only

Nothing runs on the Macs to be scraped for push purposes. VictoriaMetrics scrapes outward, Opik is called outward, and rank 0 is the only host that needs to reach anything.

2Metric Collection

The Proxy Is The Whole LLM Observability Story

Forty metric families live in one 1732-line file. This is the instrumentation lifecycle of a single chat completion, and which metric families each phase feeds.

Inside The Proxy — One Request, Six Instrumentation Phases

Request Lifecycle Metric Families Emitted 1 · Request Arrives POST /v1/chat/completions · In-Flight++ 2 · Guardrails & Rejects Model Match · 12k Prompt Cap · Rate Limit 3 · Logprobs Acquisition Client · Injected · 5% Destreamed 4 · Forward To Upstream :8081 · Queue-Wait Clock Starts 5 · Parse The Response Usage · Finish Reason · Tool Calls 6 · Score & Emit Heuristics · Confidence · OTLP Span Admission & Traffic — Counters, Histograms, Gauges mlx_in_flight — Queue-Depth Gauge, Incremented On Arrival mlx_error_total{model,status,kind} — 400 Mismatch, 413 Body, 429 Rate Limit mlx_queue_wait_seconds — Histogram, Arrival To Connection Send mlx_requests_total{model,streaming,kind} — Chat vs Reasoning mlx_up — 15 s Background Thread Polls /v1/models /v1/models Polls Excluded — They Would Poison Latency Generation — The RED Signal mlx_ttft_seconds — Time To First Token · 11 Buckets 10 ms→60 s mlx_generation_seconds — Total Wall Time Per Request mlx_token_rate_tokens_per_second — Completion Tokens ÷ Seconds mlx_tokens_prompt_total / mlx_tokens_completion_total mlx_prompt_cached_tokens_total + mlx_prompt_cache_ratio Usage Absent? Falls Back To A Word-Count Estimate. TTFT Skipped, Never Faked. Output Quality — The Differentiator mlx_hallucination_risk — 0.5·repetition + 0.25·refusal + 0.4·hedging + 0.7·ungrounded mlx_heuristic_repetition / _refusal / _hedging / _ungrounded mlx_output_perplexity · mlx_token_entropy_nats · mlx_token_margin_mean mlx_token_confidence_{mean,min,std} · mlx_low_confidence_token_ratio mlx_finish_reason_total{model,reason,kind} — Truncation Signal Confidence Stays Separate From Composite Risk — Mixing Makes One Gauge Mean Two Things.

How Logprobs Are Obtained — Three Sources

The confidence metrics only exist if the proxy can get logprobs out of the inference engine. mlx_lm.server returns logprobs only for non-streaming responses, so the proxy manufactures a scored path three different ways and records which one was used in mlx_confidence_scored_total{source}.

Client

The caller asked for logprobs itself. Cheapest path — the proxy just reads what came back and passes it through.

Injected

A non-streaming request that did not ask. The proxy adds logprobs: 3 (MLX_LOGPROBS), scores it, then strips it back out so the client never sees it.

Destreamed

A streaming request, re-run non-streamed so it can be scored. Costs one extra generation and the client gets its whole answer at once. Sampled at MLX_LOGPROBS_STREAM_SAMPLE (default 0.05).

Cost you should know about: destreaming doubles the work for the sampled fraction of streaming traffic. MLXConfidenceScoringStopped exists specifically to tell you when this pipeline silently dies — and its own description notes that “the confidence alerts above are blind until this clears.”

Hardware Collection — Ten Commands, Two Cadences

The hardware exporter is a plain prometheus_client registry behind a ThreadingHTTPServer. Two sampling threads run at different speeds because powermetrics is a ~1 second blocking call. Ten distinct commands are shelled out, across nine boxes below — ifconfig also runs, to auto-detect which interface carries the ring.

mlx_hw_telemetry.py — Command → Cadence → Exported Series

Source Commands Sampling Cadence Exported Series sysctl -n kern.boottime No Sudo vm_stat No Sudo pmset -g therm No Sudo ps -axo pid=,pcpu=,rss=,command= No Sudo podman stats --no-stream No Sudo · Rootless netstat -ib -I <iface> No Sudo · Thunderbolt Ring sudo -n powermetrics --samplers smc Requires Passwordless Sudo sudo -n powermetrics cpu_power,gpu_power Requires Passwordless Sudo ioreg -c IOAccelerator -r -d 2 No Sudo 5 s Sample Loop --interval Flag, Floor 1.0 s mlx_hw_up mlx_hw_uptime_seconds mlx_hw_load1 / load5 / load15 mlx_hw_cpu_temp_celsius mlx_hw_mem_pressure mlx_hw_thermal_pressure mlx_proc_cpu_percent · rss_bytes mlx_net_{rx,tx}_*_total Plus Disk, CPU Count, GPU Memory 15 s GPU Sampler Hard-Coded, No Flag mlx_hw_gpu_utilization_percent mlx_hw_gpu_frequency_mhz mlx_hw_{cpu,gpu,ane,package}_power 31 Metric Families All Gauge or Counter All Labelled node No Histograms Here Power & GPU Load cpu · gpu · ane package (sum) gpu_util · IOKit Thermal cpu_temp_celsius thermal_pressure Memory host used / total gpu used / alloc / total Ring Interconnect 4 Counters, iface=auto 192.0.2.220/24 Processes 5 host components 5 podman containers First Sample Stores A Baseline Only — No Fake Jump :9102 /metrics ThreadingHTTPServer generate_latest() Binds 0.0.0.0 Rank 0: Local Rank 1: Over SSH Scraped Every 10 s By VictoriaMetrics No OTLP Exporter — Pull-Only By Design Py3.14 venv On Rank 0 Py3.12 venv On Rank 1 For TCC Local-Network

Passwordless sudo is not optional here. Two powermetrics invocations drive 7 of the 31 hardware gauges — CPU temperature (--samplers smc) plus GPU utilization, GPU frequency, and CPU/GPU/ANE/package power (--samplers cpu_power,gpu_power). Without sudo -n working they all degrade to NaN, and every power and throttling alert goes blind. The -n flag makes them fail fast rather than hang.

The Odd One Out — KV Cache By Log Parsing

mlx_lm.server has no stats endpoint, so mlx_kv_cache_agent.py reads the server’s own log file. It seeks to the last 1,000,000 bytes, discards the partial first line, and regex-matches the cache banner.

KV

Prompt Cache: 4 sequences, 0.03 GB

Two regexes recover cache state — one for the total, one for the per-role breakdown (assistant / user / system). GB is multiplied by 1e9 to get bytes. Only the fixed four role gauges are published; anything else is zeroed.

cluster/mlx_kv_cache_agent.py:46-47, 73-96

Utilization Is Derived, Not Reported

mlx_kv_cache_utilization is computed as total_bytes / (10 × kv_bytes_for_max_context). The 10 is a hard-coded literal matching the server’s --prompt-cache-size 4 and LRUPromptCache default — it is not read from config.

A Metric Name Lives In Two Places

mlx_context_length_max_tokens and mlx_kv_bytes_per_token are exported by both the proxy (labelled model) and the KV agent (unlabelled). Two series sets, one name. Grafana papers over it with max(...).

3Transport & Storage

The Collector — One Receiver, One Processor, Three Pipelines

otel-collector-config.yaml is only 49 lines. Everything flows through a single batch processor, then fans out to three exporters depending on signal type.

otelcol-config.yaml — Pipeline Graph

otlp Receiver :4317 gRPC :4318 HTTP The Only Receiver batch size 128 timeout 5 s Only Processor Three Pipelines, Three Destinations metrics Stored traces Discarded logs Written To Disk prometheus_remote_write VictoriaMetrics /api/v1/write debug Exporter stdout Only — Nowhere To Query file Exporter mlx-server-logs.jsonl · 50 MB × 5 No memory_limiter, no k8sattributes, no tailsampling, no self-telemetry block. The JSONL log sink is written but has no reader in this stack — no Loki, no file datasource. All 12 dashboards query VictoriaMetrics only.

Every Scrape Job

Generated by setup.sh from vm-scrape.tmpl.yml via sed — two placeholders, __MLX_IP__ and __RANK1_IP__. The output vm-scrape.yml is gitignored.

JobTargetsIntervalStatic LabelsMeasures
mlx-proxy<MLX_IP>:808010 sservice=mlx-proxy, node=rank0Requests, tokens, TTFT, generation time, token rate, finish reason, tool calls, cache ratio, hallucination risk, confidence
mlx-hw<MLX_IP>:9102 and <RANK1_IP>:910210 sservice=mlx-hw, node=rank0 / rank1Power, thermals, GPU utilization and frequency, memory, processes, Thunderbolt ring counters
mlx-kv<MLX_IP>:910410 sservice=mlx-kv, node=rank0KV cache sequences and bytes by role, cache utilization, context budget
mlx-supervisor<MLX_IP>:910510 sservice=mlx-supervisor, node=rank0Server state, up, ready, model loaded, restarts, crash reason, backoff, health-check outcomes
mlx-logtailer<MLX_IP>:910610 sservice=mlx-logtailer, node=rank0Tailer health, lines read, shipped, dropped by reason, file offset, queue depth
sglang<MLX_IP>:30000, <RANK1_IP>:3000015 sservice=sglang, node=rank0 / rank1, provider=sglangExperimental second inference backend. Marked unverified in the config header — no ring or tensor-parallel backend on Apple Silicon.

Alert Routing

vmalert evaluates 46 rules against VictoriaMetrics and POSTs to Alertmanager. The root route groups by alertname, node, model, waits 30 s, repeats every 4 h, and splits on severity.

Two Receivers, Both Webhooks

critical → oncall receiver. Everything else → default. Both POST to host.containers.internal:9000 with send_resolved: true. No email, no Slack, no PagerDuty.

Two Inhibition Rules

MLXProxyDown suppresses MLXUpstreamDown, MLXThroughputDrop and MLXInferenceStalled. Any hardware-exporter-down silences GPU, thermal and load alerts on the same node. Noise control, by design.

The 12 Dashboards

Provisioned from observability/grafana/dashboards/ into folder MLX Cluster, re-read every 10 seconds, all with a 5-minute auto-refresh. All query VictoriaMetrics — there is no log-content datasource.

DashboardPanelsFocus
mlx-cluster45The overview — nodes online, req/min, tokens/min, avg tok/s, TTFT p95, plus per-node hardware
mlx-performance38KV cache, throughput, hallucination risk, truncation gauge, and the largest token-confidence section
mlx-model-internals43Linear-attention internals, weight footprint, ring interconnect counters, shard counts
mlx-node33Per-Mac hardware gauges + GPU utilization heatmap + that node’s LLM traffic
mlx-http33Request traffic, queue depth, errors, TTFT percentiles, cache hit ratio, entropy and risk heatmaps
mlx-alerts29Firing alerts by severity, flapping, supervisor truth, log-tailer health
mlx-model23Static architecture from config.json — layers, heads, dims, quantization, context sizing
mlx-call-types17Reasoning vs chat, stream vs sync, tool calls and risk broken down by call kind
mlx-gpu-power15GPU utilization heatmap, IOKit util, frequency, memory, CPU/GPU/ANE/package watts, thermal pressure
mlx-tools14Tool-call usage over 30 d / 7 d / 24 h, calls per minute by tool and type
mlx-logs12Metrics about the log pipeline — lines/min, shipped, dropped, queue depth. Not log content.
mlx-sre18The two SLOs as gauges, remaining error budget, burn-rate panels

4Alerts & Gaps

46 Alerts In 6 Groups

Thirteen critical, twenty-four warning, nine info — every rule carries team: ml-platform. The two groups in the middle are what make this LLM monitoring rather than generic infrastructure monitoring.

mlx-hardware  15 Rules · 30 s

MLXGPUHighUtilization · MLXGPUMemoryPressure crit · MLXHighPackagePower · MLXPowerSpike · MLXThermalThrottling · MLXCriticalThermalPressure crit · MLXMemoryPressureHigh · MLXLoadHigh · MLXDiskNearlyFull · MLXHardwareExporterDown crit · MLXProxyDown crit · MLXUpstreamDown crit

mlx-serving  8 Rules · 15 s

MLXServerDown crit · MLXModelNotLoaded · MLXServerCrashLoop crit · MLXServerRepeatedCrashes crit · MLXServerReadyProbeFailing · MLXServerMetalFault crit · MLXLogTailerDown · MLXLogTailerNotSending

mlx-inference  7 Rules · 15 s

MLXHighErrorRate · MLXErrorSpike · MLXHighTTFTP95 (>5 s) · MLXSlowGenerationP95 (>30 s) · MLXInferenceStalled crit · MLXInFlightBacklog (>8) · MLXThroughputDrop

mlx-quality  6 Rules · 30 s

Text heuristics. MLXHallucinationRiskCritical (>0.8) crit · MLXHallucinationRiskElevated (>0.5) · MLXHighRepetition · MLXHighHedging · MLXHighUngrounded · MLXHighRefusalRate. All gated on real traffic.

mlx-confidence  5 Rules · 30 s

Sampler logprobs — leads the text heuristics. MLXConfidenceCollapse (margin <0.15) crit — the rule annotation blames “a corrupt or mis-quantised weight load, not hard prompts.” · MLXHighPerplexity · MLXHighTokenEntropy · MLXLowTokenConfidence · MLXConfidenceScoringStopped

mlx-slo  5 Rules · 30 s

Error-budget burn. Availability 99.5 % / 30 d and latency p95 TTFT ≤ 10 s. Fast burn 14.4× critical, slow burn 3× warning, over 1 h and 6 h windows. Plus MLXErrorBudgetExhausted. All gated on traffic > 0 so an idle cluster never pages.

Verified Gaps

These came out of reading the configs against what the code actually emits. Each is a factual mismatch between documentation, config and implementation.

Three alerts can never fire. MLXVictoriaMetricsDown, MLXVmalertDown and MLXAlertmanagerDown match on up{job="victoria-metrics"|"vmalert"|"alertmanager"} — but no scrape job by those names exists in vm-scrape.tmpl.yml. README.md:208-212 documents a self-scrape the template never implements. Self-monitoring is described but absent.

Truncation has a metric and a dashboard panel, but no alert. mlx_finish_reason_total{reason="length"} is charted on mlx-performance.json as “Truncated completions (5m)”. Zero of the 46 rules reference finish_reason. For an agentic or coding workload — where truncation cuts off a half-written thought — this is the most consequential missing alert in the set.

Traces are not persisted by this stack. The traces pipeline exits to the debug exporter only. Opik, reached directly from the proxy, is the system of record. If you query this stack for a trace you will find nothing.

All five images are :latest. victoria-metrics, opentelemetry-collector-contrib, grafana, vmalert, alertmanager. There are also no memory or CPU limits anywhere in the 93-line compose file — for a TSDB holding 30 days of data.

The Grafana datasource has no explicit uid. The provisioning file declares name: VictoriaMetrics but no uid, while all 12 dashboards reference VictoriaMetrics — and two reference lowercase victoriametrics. If panels ever render empty, this is why. The k8s path sidesteps it: apply-dashboards.sh rewrites every uid to mlx-vm at apply time.

One unit trap worth knowing. mlx_hw_mem_pressure is a raw free page count, not bytes and not a 0–1 ratio — despite its help string. MLXMemoryPressureHigh correctly alerts on it as < 20000 pages. Multiply by 4096 for bytes.

Boot Order

cluster/start_server.sh starts telemetry before confirming the server is ready, so hardware metrics flow while the model is still loading.

  1. Preflight — verifies both venvs, the prometheus_client import, and passwordless SSH to rank 1. Warns (does not fail) if any of 8081, 8080, 9102, 9104, 9105 or 9106 is still bound.
  2. Supervisor → mlx.launch → mlx_lm.server on :8081, ring backend via --hostfile hosts.json, health probe on /v1/models, 5 s interval, exponential backoff 3–30 s.
  3. Proxy on :8080 with OTLP and Opik endpoints wired in from cluster.env.
  4. Hardware exporter on :9102 — locally for rank 0, and over ssh for rank 1, deliberately in the py3.12 venv to dodge macOS Local-Network TCC blocking.
  5. KV agent on :9104, coupled to the server purely by pointing at the same log path the supervisor was told to write.
  6. Log tailer on :9106, then wait for /v1/models (180 s TTL) before declaring ready.
Share this post X in

Enjoyed this post?

Get the next one in your inbox — only when I ship something worth reading.

Newsletter form not configured.

Or follow on Substack for the newsletter.

Comments via GitHub Discussions

Comments not configured. Set GISCUS env vars to enable.