observabilitytesting

19 — Every Signal, One Map: Telemetry and Test Tooling in Tables

Share X in

19 of 19 · the atlas

This stack generates a lot of artifacts — 36 metric families on the proxy alone, another 40-odd on four exporters, spans, log streams, feedback scores, and alerts. And it is tested with four different rigs. This post is the reference map: what is generated, where each thing goes, and how the load and unit tests run. Each section is a tab.

Metrics

1Metrics: five Prometheus exporters, scraped not pushed

Every mlx_* metric is served as a Prometheus text endpoint and scraped by VictoriaMetrics every 10 s — nothing is pushed over OTLP. That split was an explicit audit decision (post 4): the OTLP metrics path was removed from the proxy so each series exists exactly once.

ExporterPortWhat it reports (families)Scraped at
mlx_metrics_proxy.py (the inference front door):808036 mlx_* families — volume (mlx_requests_total, tokens), latency (mlx_ttft_seconds, mlx_generation_seconds, mlx_token_rate_* histograms), tool calls, finish reasons, temperature, hallucination heuristics (mlx_hallucination_risk, mlx_heuristic_*), context/KV gauges, model info (mlx_model_info{attr}, mlx_model_arch), and the confidence set (mlx_output_perplexity, mlx_token_entropy_nats, mlx_token_confidence_*, mlx_token_margin_mean, mlx_low_confidence_*, mlx_confidence_scored_total{source}); volume/errors/tokens/calls/finish/temperature/risk carry a kind label (reasoning when enable_thinking is set, else chat)192.0.2.64:8080
mlx_hw_telemetry.py (both nodes):910224 gauges — CPU/mem/disk/temp, GPU util/freq/power/mem, ANE & package power, thermal, per-worker CPU/RSS192.0.2.64:9102 (rank0), 203.0.113.2:9102 (rank1, over Ethernet)
mlx_kv_cache_agent.py:9104mlx_kv_cache_sequences/bytes{type}, mlx_kv_cache_utilization, mlx_context_*, mlx_kv_bytes_per_token, mlx_kv_est_bytes_max_context192.0.2.64:9104
mlx_server_supervisor.py:9105Serving truth — mlx_server_state/up/ready, mlx_model_loaded, mlx_server_restarts_total, mlx_server_crashes_total{reason}, mlx_server_uptime_seconds, mlx_server_health_checks_total{result}192.0.2.64:9105
mlx_server_log_tailer.py:9106Log-pipeline health — mlx_log_tailer_up, lines/shipped/dropped/errors, file_bytes, queue_depth192.0.2.64:9106

ⓘVM runs on this Mac (victoria-metrics:8428 in compose). The rank1 scrape target was repointed to 203.0.113.2:9102 so node B is reachable over the direct Ethernet link even when the Wi-Fi segments are split.

The proxy family detail

The full 36-family set on :8080/metrics (labels in parentheses):

MetricTypeMeaning
mlx_requests_total model, streaming, kindCounterCompletion requests (kind = reasoning when the caller sets enable_thinking, else chat)
mlx_tokens_prompt_total / mlx_tokens_completion_total kindCounterToken volume by call kind
mlx_ttft_seconds, mlx_generation_seconds, mlx_token_rate_tokens_per_secondHistogramLatency + throughput
mlx_tool_calls_total kind, tool, type, mlx_finish_reason_total{reason, kind}CounterAgentic behaviour by call kind; tool is the function name, type the OpenAI call type (function / code_interpreter / …)
mlx_temperature kind, mlx_hallucination_risk kind, mlx_hallucination_risk_histogram, mlx_hallucination_flag_totalGauge/Hist/CounterSampling + text heuristics
mlx_heuristic_repetition/_refusal/_hedging/_ungroundedGaugeIndividual heuristic scores
mlx_up, mlx_in_flight, mlx_error_total{status, kind}Gauge/CounterLiveness + errors by status class and call kind
mlx_context_length_max_tokens, mlx_context_used_tokens, mlx_context_utilization, mlx_kv_bytes_per_tokenGaugeContext window + KV cache footprint
mlx_model_info{attr}, mlx_model_arch{arch, dtype}GaugeStatic architecture facts from config.json: layers, KV heads, head dim, hidden size, attention heads, vocab, FFN intermediate, quant bits/group size (feeds the MLX Model dashboard)
mlx_output_perplexity +histogram, mlx_token_entropy_nats +histogramGaugeSampler-flatness signals
mlx_token_confidence_mean/_min/_std, mlx_token_margin_mean, mlx_low_confidence_token_ratioGaugeToken confidence (post 18)
mlx_confidence_scored_total{source}, mlx_low_confidence_response_totalCounterScoring coverage + low-confidence responses

Logs

2Logs: two producers, one collector, a durable JSONL tail

ProducerOriginHow it leaves the hostDestination
mlx_server_log_tailer.py (:9106)cluster/logs/server.log — the live mlx_lm.server log (ring, GPU, rank, generation lines; HTTP access lines excluded by default)OTLP HTTP → 192.0.2.64:4318/v1/logs, classified with attrs mlx.log.severity/category/source/project, header projectName: mlx, rate-limited 50 lines/sotel-collector logs pipeline → durable JSONL at /var/log/otelcol/mlx-server-logs.jsonl (50 MB × 5 backups) + debug stdout
proxy mlx.proxy.http loggerOne access-log line per requestOTLP HTTP → same collector /v1/logs (only when --otlp-endpoint is set)same JSONL file

Local, unbuffered logs for operators (rotated by the stack, not shipped):

FileWrites
cluster/logs/server.logmlx_lm.server / ring output (tailer also watches this)
cluster/logs/proxy.log, supervisor.log, hw0.log/hw1.log, kvagent.log, logtailer.log, bootstrap.logEach component, plus the start script’s own action log

⚠The collector’s traces pipeline only prints to debug. Durable trace storage lives in Opik (next tab); the JSONL log file is the only long-lived artifact the collector itself keeps.

Traces & Opik

3Traces: one span, three sinks — and Opik’s feedback loop

The proxy creates one span mlx.chat.completions (tracer mlx.proxy) per request and stamps it as it passes through:

PhaseAttributes
Request starthttp.request.method, url.path, mlx.model, mlx.streaming, mlx.temperature, mlx.tools_provided
Completionmlx.ttft_seconds, mlx.gen_seconds, mlx.tokens_prompt/completion, mlx.tool_calls, mlx.hallucination_risk, mlx.hallucination_flagged, http.response.status_code
Confidence (only when scored)mlx.logprobs_source, mlx.perplexity, mlx.token_entropy, mlx.confidence_mean, mlx.confidence_min, mlx.low_confidence_token_ratio
OpenInference (only with --opik-otlp-endpoint)openinference.span.kind=LLM, input.value, llm.model_name, llm.token_count.*, metadata JSON (incl. _conf_metadata: scored_tokens, confidence_std, token_margin_mean, …)

Where the span goes

SinkAddressContents
otel-collector traces192.0.2.64:4318/v1/traces (gRPC :4317 also open)debug/stdout only
Opik OTLP ingestion192.0.2.10:32173/api/v1/private/otel/v1/traces, header projectName: mlxthe same span with OpenInference attrs — this is what populates the mlx project
Opik SDK (when OPIK_ENDPOINT set)192.0.2.10:32173/apitrace + LLM span, plus in-band feedback scores

Feedback scores (written by the proxy, in-band)

ScoreValueCategory
hallucination_quality1.0 - riskquality
hallucination_flagged0.0 if flagged else 1.0safety
token_confidenceconfidence_mean (only when scored)quality

The judge loop (post 6) — cluster/opik_evaluator.py

StepHow
ReadGET 192.0.2.10:32173/api/v1/private/traces?…
JudgeLLM-as-judge — the judge model is the cluster proxy itself at 127.0.0.1:8080/v1
WritePUT 192.0.2.10:32173/api/v1/private/traces/feedback-scores with correctness, helpfulness, hallucination_free

Opik dashboards (tools/opik_dashboards.py) turn the traces + judge scores into a project-scoped “mlx: serving health & quality” view at the same base URL (post 17).

Alerts & Dashboards

4Alerts & Dashboards: 46 rules to a webhook, ten dashboards to Grafana

StageComponentHow
Rulesvmalert (:8880)46 rules in 6 groups — mlx-hardware (15), mlx-serving (8), mlx-inference (7), mlx-quality (6), mlx-confidence (5, post 18), mlx-slo (5: burn-rate + budget alerts for the SLOs on the SRE dashboard)
Evaluationvmalert → VMqueries victoria-metrics:8428, writes ALERTS series back via remote-write
RoutingAlertmanager (:9093)critical → host.containers.internal:9000/hooks/oncall; everything else → .../hooks/alert; inhibit rules (e.g. ProxyDown silences UpstreamDown)
DashboardsGrafana (:3000)10 provisioned: MLX Cluster (50 panels), MLX Performance (29), MLX GPU & Power (17), MLX Node (33), MLX Alerts & Serving Health (28), + MLX SRE (SLI/SLA/SLO/error budget), MLX HTTP Requests (20), MLX Call Types (21, reasoning vs chat incl. tool breakdown), MLX Model (25, architecture dims), MLX Tool Calls (14, dynamic tool usage); datasource victoria-metrics:8428

✓Every confidence alert is gated on mlx_confidence_scored_total > 0, and MLXConfidenceScoringStopped fires when traffic flows but nothing is scored — so “blind” and “healthy” never look identical (post 18).

Load tests

5Load tests: four rigs, all against the proxy

Every bench tool targets the proxy (127.0.0.1:8080/v1/chat/completions), streams by default, uses temperature: 0.0, and sends X-Mlx-Trace: 1 so every request lands in Opik. Concurrency is thread-based (stdlib urllib + thread pools), never async.

RigMeasuresHow it runsOutput
tools/bench.pyStreaming TTFT, generation time, tok/s per requestPrompt matrix (short/medium/long) or one fixed-concurrency burst; --iters, --concurrencytable + optional --json
tools/bench_load.py main rigTTFT/latency/tps p50-95-99, throughput tok/s, error rate--sweep ramps concurrency 1→2→4→8→16; --sustain N soaks while snapshotting :9102/:9104/:9105 every interval; --profile stream on/off × prompt size. --duration/--settle control each levelJSON report → tools/bench-reports/{label}-{ts}.json
tools/bench_agentic.pyTurns, per-turn TTFT, wall time, tool-call validity, completion-in-budgetScenarios calculator/tri_tool/planner against a deterministic stub tool registry; streaming chat with tools; full transcript re-sent each turn; --runs × --agents concurrent loopsJSON report → tools/bench-reports/
tools/smoke.pyRing init + all_sum correctness across ranksRuns mlx.core on the cluster nodes directly (not HTTP); --backend ringconsole

How a load run actually works

  • Each concurrency level spawns N worker threads that loop run_one() until a stop event fires after --duration seconds.
  • Each run_one returns per-request ttft, total, gen_time, comp_tokens, tps, ok/error — aggregated into percentiles and error rate.
  • Results are correlated against VM-scraped cluster metrics (GPU mem, KV util, supervisor state) snapshotted during --sustain.
  • No RPS controller — workers fire as fast as they can; there is no rate limiter or think time (documented gap, ghz/k6/locust are drop-in options).

Measured results (from the blogs)

FindingSource
2-node ring is ~3× slower than single node at 1.7B scale (ring collectives dominate): short prompt 0.8 s/65 tok/s single vs 2.4 s/~18 tok/s ringREADME Benchmarks
Peak ring throughput ~28 tok/s at concurrency 4; TTFT degrades linearly as it saturatespost 9
18-request burst: TTFT p50 0.38 s / p95 0.91 s, ~8.8 decode tok/spost 7
Agentic: calculator 5 turns/4 calls 100% valid; tri_tool 2 turns/3 calls; planner 0 tool calls; 2 concurrent agents crashed the serverpost 9
Confidence layer: “what is 2+2” ppl 1.00 / conf_min 1.00 vs obscure factual ppl 1.41 / conf_min 0.119post 18

Unit tests

6Unit & integration tests: 40, in the repo venv

Run with .venv/bin/python -m pytest tests/ -q (Python 3.14 venv at repo root; both modules import mlx_metrics_proxy directly, no network needed for the confidence tests).

FileTestsWhat it proves
tests/test_confidence.py30 pure unitperplexity, renormalised top-k entropy (lower-bound), top-2 margin, confidence_stats, score_confidence incl. malformed-input tolerance, strip_logprobs/choice_logprobs, SSE-frame synthesis round-trips (content, tool calls, identity fields), _conf_metadata
tests/test_proxy_rewrite.py10 e2eagainst a StubUpstream that mirrors mlx_lm.server (logprobs on non-streaming only, SSE + [DONE] + connection-close framing, optional tool calls, configurable error status). The proxy runs in-process on an ephemeral port; real HTTP drives it. Verifies: logprobs injected upstream but stripped for the client with matching Content-Length, client-requested logprobs pass through, injection disable, tool calls survive the strip, upstream 503 passthrough, streaming untouched when sampling off, de-streamed requests still parse as SSE, de-streamed tool calls reach the client, and de-streamed requests record no TTFT. Metrics are read back from the in-process proxy’s /metrics.

ⓘThe bench tools themselves are not unit-tested — only the proxy’s confidence/rewrite layer is. The stub upstream is the trick that makes the whole injection/de-streaming path testable without a GPU.

Flow & ports

7Flow & ports: how it all moves

The four data paths in pictures. Moving dots are live traffic; dashed lines are idle by design.

Metrics — five exporters scraped by VictoriaMetrics every 10 s

mlx_metrics_proxy 36 mlx_* families · hallucination + confidence 8080 mlx_hw_telemetry GPU / CPU / mem / power — rank0 + rank1 9102 mlx_kv_cache_agent KV cache + context-window gauges 9104 mlx_server_supervisor serving state / restarts / crash reasons 9105 mlx_server_log_tailer log-pipeline health counters 9106 VictoriaMetrics scrape every 10 s → TSDB (30 d) 8428

Logs — OTLP HTTP push to the collector, then a durable JSONL tail

mlx_server_log_tailer server.log → classified lines, 50/s 9106 mlx_metrics_proxy one access-log line per request 8080 otel-collector OTLP HTTP :4318 / gRPC :4317 4318 durable JSONL /var/log/otelcol/mlx-server-logs.jsonl 50 MB × 5 rotations VictoriaMetrics metrics remote-write (idle) 8428 logs pipeline metrics pipeline

Traces & Opik — one span, three sinks, and the judge loop

mlx_metrics_proxy span mlx.chat.completions + 3 in-band feedback scores 8080 otel-collector traces → debug stdout 4318 Opik · OTLP ingestion /api/v1/private/otel/v1/traces project “mlx” (OpenInference attrs) 32173 Opik · SDK /api — trace + LLM span feedback scores flush here 32173 cluster/opik_evaluator.py judge = the proxy itself spans read traces / write correctness · helpfulness · hallucination_free judge calls

Alerts & dashboards — 46 rules to a webhook, ten dashboards to Grafana

Grafana 10 dashboards · 255 panels 3000 VictoriaMetrics query + ALERTS store 8428 vmalert 46 rules · 6 groups 8880 Alertmanager inhibit rules · routing 9093 webhook target host.containers.internal:9000 /hooks/oncall (critical) · /hooks/alert 9000 queries ALERTS fired alerts

ⓘPink dots are forward traffic (scrape, push, fire); teal dots are loopback/return (judge calls, ALERTS remote-write). Dashed gray = deliberately idle: the collector’s metrics remote-write is unused because the proxy exports no OTLP metrics (post 4 audit).

Files & configs

7Files & configurations: where everything lives

FileRoleKey configuration
cluster/cluster.envSingle source of truth for the model + knobsMLX_MODEL=mlx-community/Qwen3.5-4B-MLX-8bit, MLX_DEFAULT_TEMP=0.0, MLX_LOGPROBS=3, MLX_LOGPROBS_STREAM_SAMPLE=0.05, MLX_LOW_CONFIDENCE=0.5
cluster/start_server.shStart/stop/status; wires flags + endpointsPorts :8081 server, :8080 proxy, :9102/9104/9105/9106 agents; OTLP=192.0.2.64:4318, OPIK_OTLP=192.0.2.10:32173/api/v1/private/otel, RANK1=203.0.113.2 (Ethernet)
cluster/hosts.jsonRing hostfilerank0 ssh 127.0.0.1 / ips 203.0.113.1; rank1 ssh 203.0.113.2 / ips 203.0.113.2
cluster/mlx_metrics_proxy.pyPublic OpenAI API + metrics + spans + scores36 metric families; kind label (reasoning vs chat); span attrs; 3 in-band feedback scores; flags --otlp-endpoint/--opik-otlp-endpoint/--opik-endpoint/--logprobs*
cluster/mlx_hw_telemetry.py · mlx_kv_cache_agent.py · mlx_server_supervisor.py · mlx_server_log_tailer.pyHardware / KV / serving / log exportersPorts :9102 / :9104 / :9105 / :9106; log tailer rate-limit 50/s, JSONL attrs
cluster/opik_evaluator.pyLLM-as-judge feedback loopjudge = proxy 127.0.0.1:8080/v1; reads/writes 192.0.2.10:32173/api
observability/compose.yamlThe observability stack (podman)victoria-metrics :8428, grafana :3000, otelcol :4317/:4318, vmalert :8880, alertmanager :9093
observability/vm-scrape.ymlScrape targets (generated from vm-scrape.tmpl.yml by setup.sh)interval 10s; proxy/hw/kv/supervisor/logtailer targets; rank1 hw at 203.0.113.2:9102
observability/otelcol-config.yamlCollector pipelinesreceivers gRPC :4317/HTTP :4318; logs → JSONL /var/log/otelcol/mlx-server-logs.jsonl (50 MB ×5); traces → debug; metrics → VM remote-write (idle)
observability/vmalert/rules.yml46 alert rules / 6 groupshardware 15, serving 8, inference 7, quality 6, confidence 5, slo 5
observability/alertmanager/alertmanager.ymlNotification routing + inhibitcritical → .../hooks/oncall, rest → .../hooks/alert at host.containers.internal:9000
observability/grafana/dashboards/*.json9 provisioned dashboardsdatasource victoria-metrics:8428; + SRE / HTTP / Call Types / Model (this post)
tools/bench.py · tools/bench_load.py · tools/bench_agentic.py · tools/smoke.pyTest rigsall against proxy :8080/v1; reports → tools/bench-reports/ (git-ignored)
tests/test_confidence.py · tests/test_proxy_rewrite.pyUnit/integration testsrun: .venv/bin/python -m pytest tests/ -q (40 passing)
tools/opik_dashboards.pyOpik dashboard provisioningbase 192.0.2.10:32173/api (needs the /api suffix)

⚠Addresses in this table are the current operational values (rank1 repointed to the Ethernet link after the Wi-Fi split, post 19’s fieldwork). If the LAN changes, observability/setup.sh regenerates vm-scrape.yml; the proxy endpoint flags live in start_server.sh.

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.