Observability (OpenTelemetry)¶
Particles can emit traces, metrics, and trace-correlated logs via OpenTelemetry. It is off by default and is an opt-in optional dependency — a stock install ships only the no-op OTel API and pays nothing.
The motivating case: on the client–server topology (the
remote engine) a request runs in a different
process from the CLI that issued it. When a client deposit hangs while the
engine is busy, the engine logs nothing until the request finishes — so a hung
request is invisible. With observability on, the request is a traceparent-
propagated span tree that shows exactly where its time went — URL fetch vs
write-lock wait vs blob write vs LLM call — across the process boundary.
Turn it on¶
- Install the extra (the base install does not include the OTel SDK):
- Enable it in
config.yaml:
observability:
enabled: true
exporter: console # print spans/metrics to the log (zero infra)
# exporter: otlp # or ship to a collector / SaaS backend
# endpoint: "http://localhost:4318"
service_name: "particles"
traces: true
metrics: true
logs: true
sample_ratio: 1.0 # always-on (correct for single-operator volume)
Or per-process, without editing the file:
The bootstrap runs at every process entry point — the CLI, the MCP server, and
the FastAPI engine (particles engine serve wires the FastAPI server span). With
enabled: false or the otel extra absent, every span/metric call is a
cheap no-op.
Exporters¶
exporter selects where signals go — one OTLP code path, three targets:
exporter |
Where it goes | Use |
|---|---|---|
console |
printed to the log | zero-infra, diagnose-now (resolves the hung-request case) |
otlp |
endpoint (OTLP/HTTP) |
a local collector (http://localhost:4318) or a SaaS backend |
none |
nowhere (provider only) | tests / pure no-op |
The collector and SaaS cases are the same otlp setting pointed at a
different endpoint.
The exporter credential is a secret¶
endpoint (a URL) is non-secret and lives in config.yaml. The credential an
authenticated collector or SaaS backend requires is a secret and is read
from the environment — never put it in config.yaml:
What you get¶
- Traces — auto-instrumented FastAPI (server spans), httpx (client spans +
traceparentpropagation), and SQLAlchemy/aiosqlite (DB spans, including the write-lock wait), plus hand-rolledextract.snapshot→embed.batch/llm.completespans on the extraction path. - Metrics — LLM- and embed-call duration histograms, the
particles.extractedthroughput counter, and theparticles.sqlite.busycounter — incremented on everydatabase is lockedat the DB boundary, so it measures cross-process write-lock contention (a direct-I/O CLI verb on the engine host vs. the always-on engine) regardless of which writer lost the lock — plus the engine's per-requesthttp.serverduration from the FastAPI instrumentation. - Logs — the existing stdlib logs, with the active trace/span ID injected so a log line ties back to its span.
Grafana dashboard¶
A ready-made dashboard ships alongside this page:
particles-dashboard.json. It covers HTTP RED
(request rate, errors, and latency by route), the Particles-internal metrics
(extraction throughput, SQLite write-lock contention, LLM + embedding call
latency), outbound httpx calls, a recent-traces table, log volume + a live log
panel, and host CPU / memory / load / network / disk.
Import it: Grafana → Dashboards → New → Import → upload the JSON, then pick your Prometheus, Tempo, and Loki data sources when prompted — the dashboard uses data source variables, so it adapts to your setup rather than hard-coding UIDs.
Assumptions. The metric panels expect OTLP metrics to reach Prometheus via
a collector (Grafana Alloy or the OTel Collector) that renders them in the
Prometheus idiom — http_server_duration_milliseconds_*,
particles_sqlite_busy_total, particles_llm_duration_seconds_*, … — and maps
service.name onto the job label (so job="particles", the default
service_name). A pipeline that keeps dotted OTLP names, or exports straight to
a non-Prometheus backend, will name things differently and the queries won't
match. Host panels read node_exporter (node_*) metrics.
What lights up when. The HTTP-server and Particles-internal metrics come from
the engine (particles engine serve) and its extraction / LLM paths; the CLI
and MCP server emit traces and logs but not those metrics. OTLP metrics export
every 60 s by default — set OTEL_METRIC_EXPORT_INTERVAL (milliseconds) lower if
you want the rate() panels to fill in sooner. The live-logs panel needs a logs
pipeline into Loki; since OTLP log export is deferred (above), that means
tailing the engine's log file into Loki today.
For network exposure of the engine itself, see Remote engine; observability rides whatever channel (Tailscale / SSH tunnel) that uses. If the engine runs as a container, the OTel environment goes in the same place as the rest of its configuration — see Running in a container. The longest-running spans you will see belong to the nightly passes; what they are is Scheduled consolidation.