Comprehensive guide to the ai-resume distributed tracing, metrics, and logging infrastructure. Written for operators and developers with zero assumed prior knowledge of observability tooling.
- Observability Concepts
- Architecture
- Instrumentation Guide
- Dashboard Reference
- Runbooks
- Quick Start
- Configuration Reference
- Glossary
ai-resume is a polyglot microservices system: a TypeScript/React frontend, a Python/FastAPI backend, and a Rust gRPC service. When a user sends a chat message, the request crosses three language runtimes, two network protocols (HTTP and gRPC), and an external LLM API. Without observability, a slow response could originate anywhere in that chain and you would have no way to narrow it down.
Observability is the ability to understand the internal state of a system by examining the signals it produces. Those signals fall into three categories, called the three pillars.
A trace is a record of a single request as it moves through multiple
services. It is identified by a trace ID, a 32-hex-character string
(example: 4bf92f3577b34da6a3ce929d0e0e4736) that stays the same across every
service boundary.
A trace is composed of spans. A span is a single timed operation, such as "search the memvid index" or "call the OpenRouter LLM." Each span records:
- Its name (e.g.,
memvid.search) - Start and end timestamps (duration)
- Key-value attributes (e.g.,
search.chunks_retrieved=5,llm.tokens_used=342) - A span ID (16 hex chars) uniquely identifying this span within the trace
- A parent span ID linking it to the span that initiated it
Spans form a tree. In ai-resume, a typical chat request produces this hierarchy:
browser: chat.send_message (frontend)
POST /api/v1/chat (auto-instrumented by FastAPIInstrumentor)
guardrail.check_input (Python manual span)
memvid.search (Python manual span)
search (Rust #[instrument] span, child via traceparent propagation)
llm.openrouter_call (Python manual span)
guardrail.check_output (Python manual span)
session.store (Python manual span)
The waterfall view in Grafana displays this tree visually with bars whose width represents duration. It immediately shows which span consumed the most time.
A metric is a numeric measurement aggregated over time. Metrics are cheap to store and query, making them ideal for dashboards and alerting. Three metric types appear in this project:
- Counter: A value that only goes up. Example:
llm_requests_totalcounts every LLM call. You apply arate()function to see requests per second. - Histogram: Records the distribution of values in configurable buckets.
Example:
llm_latency_secondsrecords how long each LLM call takes, with buckets at 0.5s, 1s, 2s, 5s, 10s, 30s, 60s, 120s. From a histogram you can compute percentiles (p50, p95, p99). - Gauge: A value that goes up and down. Example:
llm_active_requeststracks how many LLM calls are in-flight right now.
This project uses RED metrics (Rate, Errors, Duration) as the primary health indicators:
- Rate: How many requests per second each endpoint handles.
- Errors: What fraction of those requests fail (4xx or 5xx).
- Duration: How long requests take (measured at p50, p95, p99).
Structured logs are JSON-formatted log entries emitted by each service. The Python API service uses structlog to produce log lines that include machine-readable fields:
{
"event": "llm_response",
"trace_id": "4bf92f3577b34da6a3ce929d0e0e4736",
"span_id": "00f067aa0ba902b7",
"model": "nvidia/nemotron-nano-9b-v2:free",
"tokens_total": 342,
"latency_ms": 2150,
"level": "info",
"timestamp": "2026-03-01T14:23:01.456Z"
}Container logs from all services are captured by Fluent Bit (a lightweight log shipper) and forwarded to Loki (a log aggregation backend).
The three signals are linked by the trace_id field:
- A trace in Tempo has a trace ID.
- Structured logs in Loki include the same trace ID.
- Tempo's metrics generator derives RED metrics from traces and writes them
to Prometheus as
traces_spanmetrics_*metrics. - Grafana cross-links all three: click a trace to jump to its logs, view metrics derived from the same spans.
This means you can start from any pillar and pivot to the others. See a latency spike in metrics, find the slow traces in Tempo, then read the detailed logs in Loki for the exact error message.
In production, the application services and the observability stack run on separate hosts to isolate monitoring overhead from request-serving resources.
[OpenWRT Production Host] [LAN Observer Host]
ai-resume-frontend (8080) otel-collector (4317/4318)
ai-resume-api (3000) tempo (3200)
ai-resume-memvid (50051, 9090) prometheus (9090)
fluent-bit (sidecar) loki (3100)
grafana (3000)
--- OTLP gRPC (4317) ---------->
--- HTTP logs (3100) ---------->
<-- Prometheus scrape (3000, 9090) --
Network: Application services connect to a pre-created yellow-net bridge
network with static IPs (192.168.100.10-12). Fluent Bit also joins
yellow-net to reach Loki on the observer host. Prometheus on the observer
host scrapes the application host via host.containers.internal (or the LAN
IP).
Data flows:
- Traces: Application services send OTLP gRPC to the collector on the observer host. The collector forwards traces to Tempo.
- Logs (OTLP): Application services can also send logs via OTLP to the collector, which exports to Loki.
- Logs (container): Fluent Bit tails container log files from the podman log directory and pushes them to Loki via HTTP.
- Metrics: Prometheus scrapes
/metricsendpoints on the API service (port 3000) and memvid service (port 9090) every 15 seconds. It also scrapes Tempo (port 3200) for internally generated span metrics.
graph LR
subgraph ProductionHost["Production Host (yellow-net)"]
FE["frontend :8080"]
API["api-service :3000"]
MV["memvid-service :50051/:9090"]
FB["fluent-bit"]
end
subgraph ObserverHost["Observer Host (observability network)"]
OC["otel-collector :4317/:4318"]
T["tempo :3200"]
P["prometheus :9090"]
L["loki :3100"]
G["grafana :3000"]
end
FE -->|"HTTP"| API
API -->|"gRPC"| MV
API -->|"OTLP gRPC"| OC
MV -->|"OTLP gRPC"| OC
FB -->|"HTTP push"| L
OC -->|"OTLP gRPC"| T
OC -->|"HTTP push"| L
T -->|"remote write"| P
P -->|"scrape :3000"| API
P -->|"scrape :9090"| MV
P -->|"scrape :3200"| T
G -->|"query"| T
G -->|"query"| P
G -->|"query"| L
In development, all 9 services share a single dev-net bridge network using
container DNS (no static IPs). Grafana is remapped to port 3001 to avoid
conflict with the API service on port 3000.
[Development Host - dev-net]
ai-resume-frontend (8080)
ai-resume-api (3000)
ai-resume-memvid (50051)
fluent-bit (sidecar)
otel-collector (4317, 4318)
grafana (3001 -> internal 3000)
tempo (3200)
prometheus (9090)
loki (3100)
graph LR
subgraph DevNet["dev-net (single bridge)"]
FE["frontend :8080"]
API["api-service :3000"]
MV["memvid-service :50051"]
FB["fluent-bit"]
OC["otel-collector :4317/:4318"]
T["tempo :3200"]
P["prometheus :9090"]
L["loki :3100"]
G["grafana :3001"]
end
FE -->|"HTTP"| API
API -->|"gRPC"| MV
API -->|"OTLP gRPC"| OC
MV -->|"OTLP gRPC"| OC
FB -->|"HTTP push"| L
OC -->|"OTLP gRPC"| T
OC -->|"HTTP push"| L
T -->|"remote write"| P
P -->|"scrape"| API
P -->|"scrape"| MV
P -->|"scrape"| T
G -->|"query"| T
G -->|"query"| P
G -->|"query"| L
| Component | Image | Exposed Ports | Config File | Role |
|---|---|---|---|---|
| ai-resume-frontend | ai-resume-frontend |
8080 | frontend/nginx-default.conf |
OpenResty SPA server, proxies API, injects OTel endpoint |
| ai-resume-api | ai-resume-api |
3000 | api-service/ai_resume_api/main.py |
FastAPI backend, LLM orchestration, Prometheus /metrics |
| ai-resume-memvid | ai-resume-memvid |
50051 (gRPC), 9090 (metrics) | memvid-service/src/main.rs |
Rust semantic search over .mv2 files |
| fluent-bit | cr.fluentbit.io/fluent/fluent-bit |
(none) | deployment/observability/fluent-bit.conf |
Tails container logs, ships to Loki |
| otel-collector | otel/opentelemetry-collector-contrib |
4317 (gRPC), 4318 (HTTP) | deployment/observability/otel-collector.yaml |
Receives OTLP, exports to Tempo and Loki |
| tempo | grafana/tempo |
3200, 4317 (internal) | deployment/observability/tempo.yaml |
Trace storage, TraceQL queries, metrics generation |
| prometheus | prom/prometheus |
9090 | deployment/observability/prometheus.yml |
Scrapes metrics, stores time series |
| loki | grafana/loki |
3100 | deployment/observability/loki.yaml |
Log aggregation, LogQL queries |
| grafana | grafana/grafana |
3000 (prod) / 3001 (dev) | deployment/observability/provisioning/ |
Dashboards, data source exploration |
| Aspect | Production | Development |
|---|---|---|
| Network | yellow-net (static IPs) + podman bridge |
dev-net (container DNS) |
| Grafana port | 3000 | 3001 (remapped) |
| Prometheus config | deployment/observability/prometheus.yml (uses host.containers.internal) |
deployment/prometheus.dev.yml (uses container names) |
| Service discovery | Static IP targets | DNS-based container names |
| OTEL_EXPORTER_OTLP_ENDPOINT | Points to observer host | http://otel-collector:4317 |
| LOKI_HOST (fluent-bit) | observer (observer hostname) |
loki (container name) |
On macOS, podman runs containers inside a Linux VM. The PODMAN_LOG_ROOT
environment variable must point to the container log directory inside the VM.
Discover it with:
podman info --format '{{.Store.GraphRoot}}' | sed 's|/libpod/.*||'On Linux hosts, the default /var/log/containers usually works. Override it
via the PODMAN_LOG_ROOT variable in your .env file.
FastAPIInstrumentor from the opentelemetry-instrumentation-fastapi package
automatically creates a span for every HTTP request. These spans record the
HTTP method, route, status code, and duration. The instrumentation is
initialized in api-service/ai_resume_api/otel.py:
from opentelemetry.instrumentation.fastapi import FastAPIInstrumentor
FastAPIInstrumentor.instrument_app(app)This produces spans with names like POST /api/v1/chat and
GET /api/v1/profile.
The following spans are created manually in the request handlers to provide fine-grained timing for each processing stage:
| Span Name | Location | Attributes |
|---|---|---|
guardrail.check_input |
main.py (chat, assess-fit) |
message.length, guardrail.passed |
memvid.search |
main.py (chat, assess-fit) |
search.query_length, search.top_k, search.mode, search.chunks_retrieved, search.retrieval_ms, search.reranking_ms |
llm.openrouter_call |
main.py (chat, assess-fit) |
llm.model, llm.stream, llm.context_chunks, llm.tokens_used, llm.finish_reason, llm.response_length, time_to_first_token_ms, total_streaming_duration_ms |
guardrail.check_output |
main.py (chat) |
(timing only) |
session.store |
main.py (chat) |
(timing only) |
response.parse |
main.py (assess-fit) |
response.content_length |
The API service uses structlog configured with JSON rendering. On every
request, a middleware injects the trace ID into the structlog context:
structlog.contextvars.bind_contextvars(trace_id=trace_id)All subsequent log calls within that request automatically include trace_id.
When OTel is active, the span_id is also included in LLM-related logs. This
enables jumping from a Loki log entry directly to the corresponding trace in
Tempo.
The API supports two trace ID sources:
- OpenTelemetry trace ID (preferred): When an OTel span is active, the trace ID is extracted from the current span context. This is the standard W3C-format 32-hex-char ID.
- Custom X-Trace-ID (fallback): When OTel is disabled (no
OTEL_EXPORTER_OTLP_ENDPOINT), the middleware generates a random 32-hex trace ID and propagates it via theX-Trace-IDheader. This ensures request correlation is always available, even without the tracing backend.
The get_trace_id() function in observability.py automatically selects the
correct source.
The API exposes these application-level metrics on its /metrics endpoint:
| Metric | Type | Labels | Description |
|---|---|---|---|
llm_requests_total |
Counter | model, status, stream |
Total LLM API requests |
llm_tokens_total |
Counter | model, type (prompt/completion/total) |
Token usage for cost tracking |
llm_latency_seconds |
Histogram | model, stream |
LLM response latency (buckets: 0.5s-120s) |
memvid_retrieval_chunks |
Histogram | (none) | Chunks retrieved per request (buckets: 0-20) |
memvid_context_chars |
Histogram | (none) | Total characters in retrieved context |
llm_active_requests |
Gauge | model |
Currently in-flight LLM requests |
chat_feedback_total |
Counter | rating (up, down) |
Total chat feedback submissions. Idempotent: only increments once per (session_id, message_id) pair. |
The POST /api/v1/chat/{session_id}/feedback endpoint accepts a
FeedbackRequest body (message_id, rating: "up"|"down",
comment: str|None max 500). Each submission emits a structured log with
session_id, message_id, rating, and comment fields for Loki queries.
Additionally, FastAPIInstrumentor auto-generates
http_server_request_duration_seconds histograms with labels for handler,
method, and status_code.
The OTel collector receives logs via its OTLP receiver and exports them to Loki. This supplements the Fluent Bit container log pipeline with application-emitted structured logs that include trace context.
The Rust service uses the tracing crate for instrumentation and bridges it
to OpenTelemetry via tracing-opentelemetry. When OTEL_EXPORTER_OTLP_ENDPOINT
is set, an OpenTelemetryLayer is added to the tracing subscriber stack. When
unset, only console JSON logging is active (zero overhead).
Initialization is in memvid-service/src/otel.rs. The OTLP exporter uses
gRPC/tonic with connect-lazy semantics, meaning the service starts immediately
even if the collector is unreachable.
Every gRPC method is annotated with #[instrument], which automatically
creates an OTel span for each call. The span fields are declared at the
annotation and recorded during execution:
| Method | Span Fields |
|---|---|
search |
query, trace_id, session_id, client_ip, traceparent, chunks_retrieved, retrieval_ms, search.max_relevance, search.min_relevance, search.avg_relevance, search.chunks_returned |
ask |
question, trace_id, session_id, client_ip, traceparent, chunks_retrieved, retrieval_ms, reranking_ms, search.max_relevance, search.min_relevance, search.avg_relevance, search.chunks_returned |
get_state |
entity, trace_id, session_id, client_ip, traceparent |
The Rust service extracts the W3C traceparent header from gRPC metadata,
establishing parent-child span relationships with the calling Python service.
This is handled by the extract_correlation() function in
memvid-service/src/grpc/service.rs, which reads both traceparent and
legacy x-trace-id headers from request metadata.
The memvid service exposes these application-level metrics on its /metrics
endpoint (port 9090):
| Metric | Type | Labels | Description |
|---|---|---|---|
memvid_search_latency_ms |
Histogram | method (search, ask, get_state, unknown) |
Time taken for memvid operations in milliseconds, labeled by gRPC method |
memvid_search_total |
Counter | (none) | Total number of search requests processed |
memvid_search_errors_total |
Counter | (none) | Total number of search errors |
memvid_search_relevance_score |
Histogram | (none) | Cosine similarity scores for search results (buckets: 0.0 to 1.0 in 0.1 increments) |
memvid_search_chunks_returned |
Histogram | (none) | Number of chunks returned per search operation (buckets: 0, 1, 2, 3, 4, 5, 10, 15, 20) |
Browser tracing is controlled by the window.__OTEL_ENDPOINT__ global,
injected by nginx/lua at serve time via sub_filter:
set_by_lua_block $otel_endpoint {
return os.getenv("OTEL_COLLECTOR_HTTP") or ""
}
sub_filter '</head>' '<script>window.__OTEL_ENDPOINT__="$otel_endpoint"</script></head>';When the variable is empty (default), all tracing calls are no-ops. The OTel SDK is always bundled but never activates without an endpoint.
Initialization in frontend/src/lib/otel.ts creates a WebTracerProvider
with an OTLPTraceExporter (HTTP) pointed at the collector's /v1/traces
endpoint. A ZoneContextManager ensures span context propagates across
async operations.
| Span Name | Component | Trigger | Attributes |
|---|---|---|---|
chat.send_message |
useStreamingChat.ts |
User sends a chat message | chat.time_to_first_token_ms (time from fetch resolve to first SSE data event), chat.streaming_duration_ms (first token to stream end), chat.user_cancelled (boolean, true if cancelled mid-stream), chat.total_tokens (total token count) |
fit.assess |
FitAssessment.tsx |
User submits a job description for assessment | |
profile.fetch |
useProfile.ts |
Profile data loaded on mount | |
suggested_questions.fetch |
AIChat.tsx |
Suggested questions loaded on mount |
Cancel handling: When the user cancels a streaming response mid-stream, the
span status is set to UNSET (not ERROR). If the first token was already received,
chat.time_to_first_token_ms is preserved. The chat.streaming_duration_ms
records the time from first token to the point of cancellation.
Instrumentation guard: The isOtelInitialized() function in otel.ts
returns whether the OTel SDK was successfully initialized. The streaming chat
hook checks this before creating spans, ensuring zero overhead when OTel is
disabled.
The getTraceparent() function in otel.ts builds a W3C traceparent header
value from the currently active span. The traceHeaders() helper in
api-client.ts attaches this header to every fetch() call:
function traceHeaders(): Record<string, string> {
const headers: Record<string, string> = {};
const tp = getTraceparent();
if (tp) {
headers['traceparent'] = tp;
}
return headers;
}The full propagation path from browser to Rust:
Browser (OTel Web SDK)
| traceparent header on fetch()
v
Nginx (proxy_set_header traceparent $http_traceparent)
| passes header through unchanged
v
Python FastAPI (FastAPIInstrumentor auto-extracts traceparent)
| creates child span under browser's trace context
| opentelemetry.propagate.inject(carrier) on gRPC metadata
v
gRPC metadata (traceparent key)
|
v
Rust memvid-service (extract_correlation reads "traceparent" from metadata)
| records traceparent on #[instrument] span
v
OTel Collector receives spans from all three services with shared trace_id
When OTel is disabled on any service, the propagation gracefully degrades.
The X-Trace-ID custom header provides a fallback correlation path that does
not require OTel infrastructure.
Six pre-provisioned dashboards are included in
deployment/observability/dashboards/. They are auto-loaded by Grafana via
the file-based dashboard provider.
UID: request-waterfall
Purpose: Explore distributed traces across all ai-resume services. Use this
dashboard to find specific traces by time range, service name, or trace ID,
and to view the waterfall visualization of span timing.
When to use: Investigating a specific slow or failed request. Start here when a user reports a problem and you have a time window or trace ID.
Variables:
| Variable | Type | Description |
|---|---|---|
$service |
Query (multi-select) | Filter by service name. Values from traces_spanmetrics_calls_total. |
$traceId |
Textbox | Paste a trace ID to look up a specific trace. |
Panels:
| Panel | Type | Data Source | Query/Metric | What It Shows |
|---|---|---|---|---|
| Trace Search | Traces | Tempo | TraceQL search with filters: service.name, name, duration, status |
Interactive trace search with configurable filters for service name, span name, minimum duration, and status. Results appear as a list of matching traces with their durations. Click any trace to expand its waterfall view. |
| Trace by ID | Traces | Tempo | ${traceId} (TraceQL by ID) |
Displays the full waterfall for a single trace when you paste its ID into the traceId variable. Shows every span in the trace as a horizontal bar with parent-child nesting. |
| Service Graph - Request Rate | Time series | Prometheus | sum(rate(traces_spanmetrics_calls_total{service_name=~"$service"}[$__rate_interval])) by (service_name, span_name) |
Request rate (ops/sec) per service and span name derived from Tempo's span metrics. Shows how traffic distributes across operations over time. Legend table includes mean and max. |
| Service Graph - Error Rate | Time series | Prometheus | sum(rate(traces_spanmetrics_calls_total{service_name=~"$service", status_code="STATUS_CODE_ERROR"}[$__rate_interval])) by (service_name, span_name) |
Error rate (ops/sec) per service and span name. Only counts spans with error status. Spikes here indicate failing operations. Legend table includes mean and max. |
Default time range: Last 1 hour, auto-refresh every 30 seconds.
UID: latency-breakdown
Purpose: Analyze latency per processing stage -- guardrail checks, memvid
semantic search, and LLM inference. Use this dashboard to identify which stage
of request processing is the bottleneck.
When to use: When overall latency is high and you need to determine whether the problem is in input validation, retrieval, or LLM inference.
Variables:
| Variable | Type | Description |
|---|---|---|
$service |
Query (multi-select) | Filter by service name. Values from traces_spanmetrics_calls_total. |
$operation |
Query (multi-select) | Filter by span name within the selected service. Values from traces_spanmetrics_latency_bucket. |
$endpoint |
Query (multi-select) | Filter HTTP endpoint for the duration distribution panel. Values from http_server_request_duration_seconds_bucket. |
$method |
Query (multi-select) | Filter by gRPC method label on memvid_search_latency_ms_bucket. Values: search, ask, get_state, unknown. |
Panels:
| Panel | Type | Data Source | Query/Metric | What It Shows |
|---|---|---|---|---|
| p50 / p95 / p99 Latency by Operation | Bar chart | Prometheus | histogram_quantile(0.50/0.95/0.99, sum(rate(traces_spanmetrics_latency_bucket{service_name=~"$service", span_name=~"$operation"}[$__rate_interval])) by (le, span_name)) |
Side-by-side comparison of latency percentiles for each operation (span name). Horizontal bars grouped by operation, with p50/p95/p99 series. Unit: milliseconds. Legend table with mean and max. |
| Guardrail Latency Trend | Time series | Prometheus | histogram_quantile(0.50/0.95/0.99, sum(rate(traces_spanmetrics_latency_bucket{span_name=~".*guardrail.*"}[$__rate_interval])) by (le)) |
p50/p95/p99 latency trend for guardrail spans over time. Thresholds: green (<100ms), yellow (100-500ms), red (>500ms). Dashed threshold lines on chart. |
| Memvid Search Latency Trend | Time series | Prometheus | histogram_quantile(0.50/0.95/0.99, sum(rate(traces_spanmetrics_latency_bucket{span_name=~".*memvid.*|.*search.*|.*semantic.*"}[$__rate_interval])) by (le)) |
p50/p95/p99 latency trend for memvid search spans. Filters by method=~"$method" when the $method variable is set. Thresholds: green (<5ms), yellow (5-20ms), red (>20ms). Memvid searches are expected to be sub-10ms. |
| Memvid gRPC Method Latency (p50/p95/p99) | Time series | Prometheus | histogram_quantile(0.50/0.95/0.99, sum(rate(memvid_search_latency_ms_bucket{method=~"$method"}[$__rate_interval])) by (le, method)) |
Per-method latency breakdown for the memvid service. Shows p50/p95/p99 for each gRPC method (search, ask, get_state) independently. Unit: milliseconds. |
| LLM Inference Latency Trend | Time series | Prometheus | histogram_quantile(0.50/0.95/0.99, sum(rate(traces_spanmetrics_latency_bucket{span_name=~".*llm.*|.*openrouter.*|.*chat_completion.*"}[$__rate_interval])) by (le)) |
p50/p95/p99 latency trend for LLM inference spans. Thresholds: green (<1000ms), yellow (1-5s), red (>5s). LLM calls are typically the dominant latency contributor. |
| Overall Request Duration Distribution | Histogram | Prometheus | sum(increase(http_server_request_duration_seconds_bucket{handler=~"$endpoint"}[$__rate_interval])) by (le) |
Heatmap/histogram of overall HTTP request durations across endpoints. Shows the distribution shape -- whether most requests are fast with a long tail, or uniformly slow. Unit: seconds. |
| SSE Streaming (row) | ||||
| TTFT Histogram | Histogram | Tempo | TraceQL: {span.chat.time_to_first_token_ms > 0} |
Distribution of time-to-first-token values for SSE streaming chat responses. Shows how long users wait before seeing the first token. Sourced from the chat.time_to_first_token_ms span attribute on chat.send_message spans. |
| Streaming Duration Histogram | Histogram | Tempo | TraceQL: {span.chat.streaming_duration_ms > 0} |
Distribution of total streaming duration from first token to stream end. Shows how long the full SSE stream takes to complete. Sourced from the chat.streaming_duration_ms span attribute on chat.send_message spans. |
Default time range: Last 1 hour, auto-refresh every 30 seconds.
UID: endpoint-overview
Purpose: Monitor request rate, error rate, and latency percentiles per HTTP
endpoint. This is the primary operational dashboard for the API service.
When to use: First dashboard to check during any incident. Provides an at-a-glance view of API health using RED metrics.
Variables:
| Variable | Type | Description |
|---|---|---|
$endpoint |
Query (multi-select) | Filter by HTTP handler (route). Values from http_server_request_duration_seconds_count. |
Panels:
| Panel | Type | Data Source | Query/Metric | What It Shows |
|---|---|---|---|---|
| Request Rate per Endpoint | Time series | Prometheus | sum(rate(http_server_request_duration_seconds_count{handler=~"$endpoint"}[$__rate_interval])) by (handler, method) |
Requests per second for each endpoint, split by HTTP method. Shows traffic distribution and patterns. Unit: req/s. Legend table with mean, max, and last value. |
| Error Rate per Endpoint | Time series | Prometheus | 5xx: sum(rate(http_server_request_duration_seconds_count{handler=~"$endpoint", status_code=~"5.."}[$__rate_interval])) by (handler, method) / 4xx: same with status_code=~"4.." |
Stacked area chart of error rates. 5xx errors in red, 4xx errors in orange. Stacked to show cumulative error volume. Unit: req/s. |
| Error Rate % | Stat | Prometheus | 100 * sum(rate(http_server_request_duration_seconds_count{status_code=~"5.."}[$__rate_interval])) / sum(rate(http_server_request_duration_seconds_count[$__rate_interval])) |
Single-value display of the current 5xx error percentage across all endpoints. Background color: green (<1%), yellow (1-5%), red (>5%). |
| p50 Latency per Endpoint | Time series | Prometheus | histogram_quantile(0.50, sum(rate(http_server_request_duration_seconds_bucket{handler=~"$endpoint"}[$__rate_interval])) by (le, handler)) |
Median (p50) response time per endpoint over time. Shows what the typical user experience is. Unit: seconds. |
| p95 Latency per Endpoint | Time series | Prometheus | histogram_quantile(0.95, sum(rate(http_server_request_duration_seconds_bucket{handler=~"$endpoint"}[$__rate_interval])) by (le, handler)) |
95th percentile response time per endpoint. Represents the experience of the slowest 5% of requests. Unit: seconds. |
| p99 Latency per Endpoint | Time series | Prometheus | histogram_quantile(0.99, sum(rate(http_server_request_duration_seconds_bucket{handler=~"$endpoint"}[$__rate_interval])) by (le, handler)) |
99th percentile response time per endpoint. Tail latency indicator. Thresholds: green (<1s), yellow (1-5s), red (>5s). Unit: seconds. |
| Total Requests (24h) | Stat | Prometheus | sum(increase(http_server_request_duration_seconds_count[24h])) |
Total number of requests across all endpoints in the last 24 hours. Colored blue. |
| Avg Response Time | Stat | Prometheus | sum(rate(http_server_request_duration_seconds_sum[$__rate_interval])) / sum(rate(http_server_request_duration_seconds_count[$__rate_interval])) |
Average response time across all endpoints. Background color: green (<0.5s), yellow (0.5-2s), red (>2s). Unit: seconds. |
| Active Endpoints | Stat | Prometheus | count(count by (handler) (http_server_request_duration_seconds_count{handler=~"$endpoint"})) |
Count of distinct endpoint handlers that have received traffic. Colored purple. |
| Success Rate | Stat | Prometheus | sum(rate(http_requests_total{status=~"2.."}[5m])) / sum(rate(http_requests_total{path!~"/health|/api/v1/health"}[5m])) * 100 |
Percentage of successful (2xx) requests, excluding health checks. Thresholds: green (>=99%), yellow (>=95%), red (<95%). |
| Total Errors | Stat | Prometheus | sum(increase(http_requests_total{status=~"[45].."}[1h])) |
Total 4xx and 5xx errors in the last hour. Thresholds: green (0), yellow (>=1), red (>=10). |
Default time range: Last 1 hour, auto-refresh every 30 seconds.
UID: llm-cost
Purpose: Track token usage, estimated cost, and request volume per LLM
model. Essential for monitoring spend on the OpenRouter API.
When to use: Daily cost review, investigating unexpected billing increases, comparing model efficiency.
Variables:
| Variable | Type | Description |
|---|---|---|
$model |
Query (multi-select) | Filter by LLM model name. Values from llm_tokens_total. |
$cost_per_input_token |
Textbox | Cost per 1K input tokens in USD. Default: 0.003. |
$cost_per_output_token |
Textbox | Cost per 1K output tokens in USD. Default: 0.015. |
Panels:
| Panel | Type | Data Source | Query/Metric | What It Shows |
|---|---|---|---|---|
| Token Usage Over Time | Time series | Prometheus | Input: sum(rate(llm_tokens_total{type="input", model=~"$model"}[$__rate_interval])) by (model) / Output: same with type="output" |
Stacked area chart of token consumption rate (tokens/sec) split by input and output for each model. Shows usage patterns and spikes over time. Legend table with mean, max, and sum. |
| Total Tokens (24h) | Stat | Prometheus | sum(increase(llm_tokens_total[24h])) |
Total token consumption in the last 24 hours. Background color: green (<100K), yellow (100K-1M), red (>1M). |
| LLM Requests (24h) | Stat | Prometheus | sum(increase(llm_requests_total[24h])) |
Total LLM request count in the last 24 hours. Colored blue. |
| Token Usage per Model | Table | Prometheus | Input: sum(increase(llm_tokens_total{type="input"}[$__range])) by (model) / Output: same with type="output" / Requests: sum(increase(llm_requests_total[$__range])) by (model) |
Table with one row per model showing input tokens, output tokens, and request count for the selected time range. Sorted by input tokens descending. Footer row shows totals. |
| Estimated Cost per Model | Table | Prometheus | Input cost: sum(increase(llm_tokens_total{type="input"}[$__range])) by (model) * $cost_per_input_token / 1000 / Output cost: same with type="output" and $cost_per_output_token |
Table with estimated USD cost per model, split into input and output cost. Uses the configurable cost-per-token variables. Footer shows total cost. Thresholds: green (<$1), yellow ($1-$10), red (>$10). |
| Request Count per Model | Time series (bars) | Prometheus | sum(rate(llm_requests_total{model=~"$model"}[$__rate_interval])) by (model) |
Stacked bar chart of request rate per model over time. Shows which models handle the most traffic. Unit: req/s. |
| Cumulative Cost Over Time | Time series | Prometheus | (sum(increase(llm_tokens_total{type="input"}[$__rate_interval])) * $cost_per_input_token / 1000) + (sum(increase(llm_tokens_total{type="output"}[$__rate_interval])) * $cost_per_output_token / 1000) |
Running estimated cost over the selected time window. Dark green line with area fill. Unit: USD. Legend shows cumulative sum. |
| Input vs Output Token Ratio | Pie chart (donut) | Prometheus | Input: sum(increase(llm_tokens_total{type="input"}[$__range])) / Output: sum(increase(llm_tokens_total{type="output"}[$__range])) |
Donut chart showing the proportion of input vs output tokens. Legend table with percentage and absolute values. A high output ratio may indicate verbose responses. |
| Avg Tokens per Request | Stat | Prometheus | sum(increase(llm_tokens_total[$__range])) / sum(increase(llm_requests_total[$__range])) |
Average total tokens consumed per LLM request. Background color: green (<2000), yellow (2000-4000), red (>4000). |
| Avg Cost per Request | Stat | Prometheus | ((sum(increase(llm_tokens_total{type="input"}[$__range])) * $cost_per_input_token / 1000) + (sum(increase(llm_tokens_total{type="output"}[$__range])) * $cost_per_output_token / 1000)) / sum(increase(llm_requests_total[$__range])) |
Average estimated cost per LLM request in USD. Background color: green (<$0.01), yellow ($0.01-$0.10), red (>$0.10). |
Default time range: Last 24 hours, auto-refresh every 1 minute.
UID: retrieval-quality
Purpose: Monitor the quality and volume of memvid semantic search results.
Tracks relevance scores, chunk counts, and low-relevance query rates to detect
retrieval degradation before it affects chat quality.
When to use: When chat answers seem irrelevant or shallow, or after updating
the .mv2 file (re-ingestion). Also useful for tuning the top_k parameter or
evaluating resume content coverage.
Variables:
| Variable | Type | Description |
|---|---|---|
$threshold |
Custom (multi-value) | Low Relevance Threshold. Values: 0.3, 0.4, 0.5, 0.6, 0.7, 0.8. Default: 0.5. Queries with max relevance below this threshold are counted as "low relevance." |
Panels:
| Panel | Type | Data Source | Query/Metric | What It Shows |
|---|---|---|---|---|
| Search Volume | Time series | Prometheus | sum(rate(memvid_search_total[$__rate_interval])) |
Search request rate over time. Shows traffic patterns to the memvid service. |
| Low Relevance Rate | Stat | Prometheus | Derived from memvid_search_relevance_score histogram buckets below $threshold |
Percentage of search queries whose max relevance score falls below the configured threshold. High values indicate poor retrieval quality. |
| Total Searches 24h | Stat | Prometheus | sum(increase(memvid_search_total[24h])) |
Total search operations in the last 24 hours. |
| Relevance Score Percentiles | Time series | Prometheus | histogram_quantile(0.50/0.95/0.99, sum(rate(memvid_search_relevance_score_bucket[$__rate_interval])) by (le)) |
p50/p95/p99 relevance scores over time. Dropping percentiles indicate degrading retrieval quality. |
| Relevance Score Distribution | Heatmap | Prometheus | sum(increase(memvid_search_relevance_score_bucket[$__rate_interval])) by (le) |
Heatmap of relevance score distribution across buckets (0.0-1.0). Shows whether scores cluster near 1.0 (good) or spread across lower values. |
| Chunks per Query Percentiles | Time series | Prometheus | histogram_quantile(0.50/0.95/0.99, sum(rate(memvid_search_chunks_returned_bucket[$__rate_interval])) by (le)) |
p50/p95/p99 chunk counts returned per query over time. |
| Chunks per Query Distribution | Histogram | Prometheus | sum(increase(memvid_search_chunks_returned_bucket[$__rate_interval])) by (le) |
Distribution of chunk counts per query. Shows whether most queries return a healthy number of chunks. |
| Low Relevance Rate Over Time | Time series | Prometheus | Derived from memvid_search_relevance_score histogram buckets below $threshold over $__rate_interval |
Trend of low-relevance query rate. Useful for spotting gradual degradation. |
| Avg Relevance Score | Stat | Prometheus | sum(rate(memvid_search_relevance_score_sum[$__rate_interval])) / sum(rate(memvid_search_relevance_score_count[$__rate_interval])) |
Average relevance score across all search queries. |
| Avg Chunks per Query | Stat | Prometheus | sum(rate(memvid_search_chunks_returned_sum[$__rate_interval])) / sum(rate(memvid_search_chunks_returned_count[$__rate_interval])) |
Average number of chunks returned per query. |
| p99 Relevance | Stat | Prometheus | histogram_quantile(0.99, sum(rate(memvid_search_relevance_score_bucket[$__rate_interval])) by (le)) |
99th percentile relevance score. |
| p50 Chunks | Stat | Prometheus | histogram_quantile(0.50, sum(rate(memvid_search_chunks_returned_bucket[$__rate_interval])) by (le)) |
Median chunks returned per query. |
Default time range: Last 1 hour, auto-refresh every 30 seconds.
UID: quality-evals
Purpose: Track user feedback (thumbs up/down) on chat responses. Provides
visibility into perceived answer quality and links feedback events to traces
for root-cause analysis.
When to use: Monitoring user satisfaction trends, investigating clusters of negative feedback, and correlating poor ratings with retrieval quality or LLM behavior.
Panels:
| Panel | Type | Data Source | Query/Metric | What It Shows |
|---|---|---|---|---|
| Feedback Rate | Time series | Prometheus | sum(rate(chat_feedback_total[$__rate_interval])) by (rating) |
Feedback submission rate over time, split by rating (up / down). Shows engagement and satisfaction trends. |
| Positive/Negative Ratio | Stat or Pie | Prometheus | sum(increase(chat_feedback_total{rating="up"}[$__range])) vs sum(increase(chat_feedback_total{rating="down"}[$__range])) |
Ratio of positive to negative feedback over the selected time range. A declining ratio warrants investigation. |
| Feedback Log | Log panel | Loki | {service="ai-resume"} | json | event="Chat feedback received" |
Structured log entries for each feedback submission showing session_id, message_id, rating, and comment. Data Links to Tempo allow clicking a log entry to open the corresponding chat trace for full request context. |
Default time range: Last 24 hours, auto-refresh every 1 minute.
Scenario: A user reports slow chat responses, or the Endpoint Overview
dashboard shows p95 latency exceeding 5 seconds on POST /api/v1/chat.
Severity: Medium -- user experience degradation.
Dashboards: Request Waterfall, Latency Breakdown.
Steps:
-
Find the trace. Open the Request Waterfall dashboard. If you have a trace ID (e.g., from SSE stats sent to the browser), paste it into the
traceIdvariable. Otherwise, use the Trace Search panel with these filters:service.name=ai-resume-apiname=POST /api/v1/chatduration>5s
TraceQL query for the Tempo Explore view:
{resource.service.name="ai-resume-api" && name="POST /api/v1/chat" && duration > 5s} -
Open the waterfall. Click on a slow trace. The waterfall view shows every span as a horizontal bar. Identify which span consumes the most time.
-
Identify the bottleneck and resolve per stage:
-
guardrail.check_inputis slow (>100ms): Check regex complexity inapi-service/ai_resume_api/guardrails.py. Guardrail checks should complete in under 10ms. If regex matching is catastrophically backtracking, simplify the patterns. -
memvid.searchis slow (>20ms): Check the span attributessearch.chunks_retrievedandsearch.retrieval_ms. Normal retrieval is under 10ms. If slow, check memvid service health, memory usage, and whether the .mv2 file is loaded. Look for the corresponding Rust span in the waterfall for more detail. -
llm.openrouter_callis slow (>3s): This is the most common bottleneck. Check attributes:llm.model,llm.tokens_used,time_to_first_token_ms. High token counts increase latency. Also check OpenRouter status for provider outages. -
guardrail.check_outputorsession.storeis slow: These should be negligible. If not, investigate memory pressure on the API container (200MB limit).
-
-
Check the Latency Breakdown dashboard to see if the bottleneck is systemic (affecting all requests) or isolated to specific traces.
Self-escalation: If llm.openrouter_call consistently exceeds 3 seconds at
p95, consider switching to a faster model via the LLM_MODEL environment
variable, reducing max_tokens, or optimizing the system prompt length.
If you see no data: Verify OTel is configured (check
OTEL_EXPORTER_OTLP_ENDPOINT is set in the API container). Check that the
otel-collector and Tempo are running. Send a test chat request and wait 10
seconds for the trace to appear.
Scenario: Token usage jumps unexpectedly on the LLM Cost Tracker dashboard.
Severity: Low-Medium -- financial impact.
Dashboard: LLM Cost Tracker.
Steps:
-
Check the Total Tokens (24h) stat panel. Compare with the previous day's value by expanding the time range to 48h.
-
Break down by model. Use the Token Usage per Model table to see which model is consuming the most tokens. If a model switch occurred, the new model may have different token economics.
-
Check request count vs tokens per request. Determine whether the spike is from more requests or more tokens per request:
# Token rate per hour rate(llm_tokens_total[1h]) # Tokens consumed in last 24h by type increase(llm_tokens_total{type="input"}[24h]) increase(llm_tokens_total{type="output"}[24h]) # Average tokens per request sum(increase(llm_tokens_total[24h])) / sum(increase(llm_requests_total[24h])) -
Check the Input vs Output Token Ratio panel. A shift toward more output tokens may indicate verbose LLM responses, possibly from prompt injection attempts that trigger long outputs.
-
Correlate with logs. In Loki, search for high-token responses:
{service="ai-resume"} | json | tokens_total > 1000
Resolution:
- Adjust
max_tokensin the LLM call to cap response length. - Switch to a cheaper model (e.g., a free tier model on OpenRouter).
- Check for prompt injection: review
guardrail.check_inputspans for blocked messages that might be getting through a different path.
Self-escalation: If daily cost exceeds your budget threshold, switch to a
cheaper model immediately by updating the LLM_MODEL environment variable and
restarting the API service.
If you see no data: Verify that llm_tokens_total and llm_requests_total
metrics appear on the Prometheus targets page (http://prometheus:9090/targets).
These metrics are emitted by the Python API service on its /metrics endpoint.
Scenario: Error rate spike on the Endpoint Overview dashboard (Error Rate % panel turns yellow or red).
Severity: High -- service disruption.
Dashboards: Endpoint Overview, then Request Waterfall and Loki.
Steps:
-
Identify which endpoints are failing. On the Endpoint Overview, the Error Rate per Endpoint panel shows 5xx (red) and 4xx (orange) rates per route.
-
Find error traces in Tempo. Open the Request Waterfall or Tempo Explore:
{resource.service.name="ai-resume-api" && status=error} -
Correlate with logs. Once you have a trace ID from step 2, search Loki:
{service="ai-resume"} |= "error" | json | trace_id="<paste-trace-id-here>"For broader error log search:
{service="ai-resume"} | json | level="error" -
Identify root cause from the trace waterfall and logs:
- memvid service down: The
memvid.searchspan will show an error. Check memvid container health:podman exec ai-resume-memvid /usr/local/bin/memvid-service --health. - OpenRouter 429 (rate limited): The
llm.openrouter_callspan will fail. The API returns 503. Check OpenRouter rate limits and consider reducing request rate. - OpenRouter 5xx: Provider outage. Wait or switch model.
- Bad input (422): Client sending malformed requests. Check the 4xx series. These are typically not service issues.
- memvid service down: The
Resolution depends on root cause:
- memvid down: Restart with
podman restart ai-resume-memvid. - API key expired: Update
OPENROUTER_API_KEYin.envand restart. - Rate limiting: Reduce the
RATE_LIMIT_PER_MINUTEthreshold or add request queuing.
Self-escalation: If the 5xx error rate exceeds 5% for more than 5 minutes, check all container health endpoints and restart any unhealthy services.
If you see no data: Verify Prometheus is scraping the API service. Check
http://prometheus:9090/targets for the api-service job status.
Scenario: After a deployment or incident recovery, verify all systems are operational.
Severity: N/A (proactive check).
Checklist:
-
Check application health endpoints:
# API service curl -s http://localhost:3000/api/v1/health | jq . # Frontend curl -s http://localhost:8080/health # Grafana (dev) curl -s http://localhost:3001/api/health | jq .
Expected: API health returns
{"status": "ok", "memvid_connected": true}. -
Send a test chat request:
curl -s -X POST http://localhost:3000/api/v1/chat \ -H "Content-Type: application/json" \ -d '{"message": "What is your experience?", "stream": false}' | jq .
Expected: JSON response with a non-empty
messagefield. -
Verify the trace appeared in Grafana. Open Grafana Tempo Explore (http://localhost:3001/explore) and search for traces from the last 5 minutes. The test request trace should appear within 10 seconds.
-
Verify all 6 dashboards show data:
- Endpoint Overview: Request Rate panel should show recent traffic.
- Latency Breakdown: At least one operation should have data.
- Request Waterfall: Trace Search should return results.
- LLM Cost Tracker: Token Usage should show recent activity.
- Retrieval Quality: Search Volume and Relevance Score panels should have data.
- Quality Evals: Feedback Rate panel will show data once users submit feedback.
-
Check Loki for recent logs:
{service="ai-resume"} | jsonExpected: Recent log entries from the API service.
Scenario: Planning infrastructure changes or evaluating whether the current setup can handle projected traffic growth.
Dashboard: Endpoint Overview.
Steps:
-
Check request rate trends. Set the time range to 7 days. Look at the Request Rate per Endpoint panel for traffic patterns:
# Request rate over 1h windows rate(http_server_request_duration_seconds_count[1h]) # Peak hourly rate over last 7 days max_over_time(sum(rate(http_server_request_duration_seconds_count[5m]))[7d:1h]) -
Check p99 latency trends over the same period:
histogram_quantile(0.99, sum(rate(http_server_request_duration_seconds_bucket[1h])) by (le)) -
Check error rate trends:
100 * sum(rate(http_server_request_duration_seconds_count{status_code=~"5.."}[1h])) / sum(rate(http_server_request_duration_seconds_count[1h])) -
Identify peak usage times from the Request Rate panel. Note the time of day and day of week with highest traffic.
-
Check LLM cost projections on the LLM Cost Tracker. Extrapolate current daily cost to monthly.
Scaling triggers (consider infrastructure changes when):
- p99 latency consistently exceeds 5 seconds
- 5xx error rate exceeds 1% sustained
- Request rate exceeds 60 requests per minute
- Daily LLM cost exceeds budget threshold
Scenario: After deploying a new version of any service.
Dashboards: Endpoint Overview, Latency Breakdown.
Steps:
-
Note baseline metrics before deployment:
# Current p50/p95/p99 histogram_quantile(0.50, sum(rate(http_server_request_duration_seconds_bucket[5m])) by (le)) histogram_quantile(0.95, sum(rate(http_server_request_duration_seconds_bucket[5m])) by (le)) histogram_quantile(0.99, sum(rate(http_server_request_duration_seconds_bucket[5m])) by (le)) # Current error rate 100 * sum(rate(http_server_request_duration_seconds_count{status_code=~"5.."}[5m])) / sum(rate(http_server_request_duration_seconds_count[5m])) -
Deploy the new version (e.g.,
podman compose up -d). -
Wait 5 minutes for the new version to handle enough requests for meaningful metrics.
-
Compare post-deploy metrics with the baselines from step 1. Use the same PromQL queries.
-
Verify traces still flowing. Open the Request Waterfall dashboard and confirm recent traces appear with the expected span hierarchy.
-
Check the Latency Breakdown dashboard for any new latency regressions in specific stages (e.g., a code change that made guardrail checks slower).
Rollback criteria (roll back if any of these are true):
- p95 latency increased by more than 50% compared to baseline
- 5xx error rate increased by more than 0.5 percentage points
- Traces are missing or incomplete (broken instrumentation)
- Any health endpoint returns unhealthy
Scenario: Chat answers seem irrelevant, shallow, or the system returns "I couldn't find relevant information" too frequently.
Dashboards: Retrieval Quality, Latency Breakdown (memvid panels), Request Waterfall.
Steps:
-
Check the Retrieval Quality dashboard first. Open the Retrieval Quality dashboard (
retrieval-quality) and review:- Low Relevance Rate: If this stat is high (e.g., >20%), many queries
are returning poor results. Adjust the
$thresholdvariable to tune the sensitivity. - Relevance Score Percentiles: If p50 is below 0.5, most queries are getting mediocre results. If p95 is also low, the index may be degraded.
- Avg Chunks per Query: If this is consistently 0-1, the index may be corrupt or the resume content is too sparse for the query types.
- Low Relevance Rate Over Time: Check for a sudden drop correlated with a deployment or re-ingestion.
- Low Relevance Rate: If this stat is high (e.g., >20%), many queries
are returning poor results. Adjust the
-
Find chat traces. Open the Request Waterfall and search for recent chat traces:
{resource.service.name="ai-resume-api" && name="POST /api/v1/chat"} -
Inspect the
memvid.searchspan attributes:search.chunks_retrieved: Expected 3-10 for a typical query. If 0, the index may be corrupt or the query did not match any content.search.retrieval_ms: Expected under 10ms. If slow, the memvid service may be under memory pressure.search.reranking_ms: Time spent re-ranking results. Should be minimal.search.max_relevance: Highest cosine similarity among returned chunks. Values below 0.5 indicate weak semantic matches.search.avg_relevance: Average cosine similarity. Consistently low values suggest the query topic is not well-covered in the resume.search.chunks_returned: Number of chunks returned by the Rust service.
-
Check for zero-chunk responses. If
chunks_retrieved=0appears frequently, look for patterns:{resource.service.name="ai-resume-api" && name="memvid.search" && span.search.chunks_retrieved=0} -
Check memvid service logs in Loki for errors:
{container_name="ai-resume-memvid"} |= "error" -
Inspect the Rust-side span in the waterfall. The
searchoraskspan fromai-resume-memvidwill showchunks_retrieved,retrieval_ms,search.max_relevance,search.min_relevance, andsearch.avg_relevancefrom the Rust perspective.
Resolution:
- Index corrupt or missing: Re-ingest the .mv2 file with
python ingest.pyand restart the memvid service. - Low chunk counts: The resume content may not cover the topic. Check the source markdown file.
- Low relevance scores: The resume content may need more detail on the topics users are querying. Review the Relevance Score Distribution heatmap to identify score clustering patterns.
- Slow retrieval: Check memvid container memory usage (
podman stats). The 200MB memory limit may be insufficient for large .mv2 files.
Self-escalation: If chunk retrieval consistently returns 0 for queries that should match, re-run the ingestion pipeline and verify the .mv2 file contains the expected content. If the Low Relevance Rate exceeds 30% sustained, review the resume markdown for content gaps.
If you see no data: Verify the memvid service has
OTEL_EXPORTER_OTLP_ENDPOINT set and the collector is reachable. In dev, this
should be http://otel-collector:4317. For Prometheus metrics
(memvid_search_relevance_score, memvid_search_chunks_returned), verify
Prometheus is scraping the memvid service on port 9090.
Scenario: You want to track user satisfaction with chat responses, investigate clusters of negative feedback, or correlate poor ratings with specific retrieval or LLM issues.
Dashboard: Quality Evals.
Steps:
-
Check the Feedback Rate panel. Open the Quality Evals dashboard (
quality-evals). The Feedback Rate time series shows thumbs-up and thumbs-down submissions over time. A spike in negative feedback warrants investigation. -
Review the Positive/Negative Ratio. If the ratio is declining over a recent time window, something changed in answer quality. Correlate the timing with deployments, model changes, or re-ingestion events.
-
Inspect the Feedback Log panel. Each log entry shows the
session_id,message_id,rating, and optionalcomment. Use the Data Links to click through to the corresponding trace in Tempo. This reveals the full request context: what the user asked, which chunks were retrieved, and how the LLM responded. -
Correlate with retrieval quality. If negative feedback correlates with low relevance scores, check the Retrieval Quality dashboard (Runbook 7). Poor chunk retrieval is the most common cause of bad answers.
-
Correlate with LLM behavior. If retrieval looks fine but answers are poor, check the
llm.openrouter_callspan in the trace for:llm.model: Was a different model used?llm.tokens_used: Was the response truncated due to token limits?llm.finish_reason: Did the model stop normally or hit a limit?
-
Search for specific feedback in Loki:
{service="ai-resume"} | json | event="Chat feedback received" | rating="down"Add
| comment != ""to filter for entries with user comments.
Resolution:
- Retrieval-related: Improve resume content coverage or re-ingest with better chunking. See Runbook 7.
- LLM-related: Adjust the system prompt, switch to a better model, or
increase
max_tokensif responses are being truncated. - Systematic negative feedback: Review the feedback comments for patterns. Users may be asking about topics not covered in the resume.
If you see no data: Verify the chat_feedback_total metric appears on the
Prometheus targets page. The metric is emitted by the Python API service. Also
verify Loki is receiving logs from the API service (check the Feedback Log panel
for recent entries).
cd deployment
podman compose -f compose.dev.yaml up -dThis starts all 9 services: 4 application + 5 observability.
# API service (waits for memvid to be healthy first)
curl --retry 10 --retry-delay 3 --retry-connrefused \
-s http://localhost:3000/api/v1/health | jq .
# Grafana
curl --retry 10 --retry-delay 3 --retry-connrefused \
-s http://localhost:3001/api/health | jq .curl -s -X POST http://localhost:3000/api/v1/chat \
-H "Content-Type: application/json" \
-d '{"message": "What programming languages do you know?", "stream": false}' \
| jq .- Open Grafana at http://localhost:3001.
- Navigate to Explore (compass icon in the left sidebar).
- Select the Tempo data source.
- Choose "Search" query type and search for traces from the last 5 minutes.
- Click on a trace to view the waterfall.
Expected result: Within 10 seconds, a trace appears showing the full span
hierarchy: POST /api/v1/chat as the root span, with child spans for
guardrail.check_input, memvid.search, llm.openrouter_call,
guardrail.check_output, and session.store.
Navigate to Dashboards in the Grafana sidebar. Six dashboards are pre-provisioned:
- Endpoint Overview -- start here for overall API health
- Latency Breakdown -- drill into per-stage timing
- Request Waterfall -- trace search and waterfall views
- LLM Cost Tracker -- token usage and estimated cost
- Retrieval Quality -- memvid search relevance and chunk metrics
- Quality Evals -- user feedback (thumbs up/down) tracking
All observability features are gated on environment variables. When a variable is unset, the corresponding feature is disabled with zero overhead.
| Variable | Service | Default | Description |
|---|---|---|---|
OTEL_EXPORTER_OTLP_ENDPOINT |
api-service, memvid-service | (unset = OTel disabled) | OTLP collector gRPC endpoint. Set to http://otel-collector:4317 in dev. When unset, all tracing calls are no-ops. |
OTEL_SERVICE_NAME |
api-service | ai-resume-api |
Service name that appears in traces and span metrics. |
OTEL_SERVICE_NAME |
memvid-service | ai-resume-memvid |
Service name for the Rust service in traces. |
OTEL_COLLECTOR_HTTP |
frontend (nginx) | (unset = browser OTel disabled) | OTLP collector HTTP endpoint for the browser SDK. Injected into window.__OTEL_ENDPOINT__ by nginx/lua. Set to http://<collector-host>:4318 to enable. |
LOKI_HOST |
fluent-bit | observer (prod) / loki (dev) |
Hostname of the Loki instance for log shipping. |
LOKI_PORT |
fluent-bit | 3100 |
Loki HTTP port. |
PODMAN_LOG_ROOT |
fluent-bit | /var/log/containers |
Container log directory on the host. Must be discovered via podman info on macOS. |
RUST_LOG |
memvid-service | info |
Rust log level filter. Set to debug for verbose span logging. |
LOG_LEVEL |
api-service | INFO |
Python log level. Set to DEBUG for verbose logging. |
| Component | Retention | Config Location |
|---|---|---|
| Tempo (traces) | 7 days | deployment/observability/tempo.yaml (block_retention: 168h) |
| Loki (logs) | 7 days | deployment/observability/loki.yaml (retention_period: 168h) |
| Prometheus (metrics) | Default (15 days) | deployment/observability/prometheus.yml |
Prometheus scrapes three targets every 15 seconds:
| Job | Target (dev) | Target (prod) | Metrics Endpoint |
|---|---|---|---|
api-service |
ai-resume-api:3000 |
host.containers.internal:3000 |
/metrics |
memvid-service |
ai-resume-memvid:9090 |
host.containers.internal:9090 |
/metrics |
tempo-metrics |
tempo:3200 |
tempo:3200 |
/metrics |
Pre-provisioned in deployment/observability/provisioning/datasources.yaml:
| Name | Type | UID | URL | Default |
|---|---|---|---|---|
| Tempo | tempo |
tempo |
http://tempo:3200 |
Yes |
| Prometheus | prometheus |
prometheus |
http://prometheus:9090 |
No |
| Loki | loki |
loki |
http://loki:3100 |
No |
Configured in deployment/observability/otel-collector.yaml:
| Pipeline | Receiver | Exporter | Description |
|---|---|---|---|
traces |
OTLP (gRPC :4317, HTTP :4318) | otlp/tempo (gRPC to tempo:4317) |
Forwards traces from application services to Tempo |
logs |
OTLP (gRPC :4317, HTTP :4318) | loki (HTTP push to loki:3100) |
Forwards OTLP logs to Loki |
The HTTP receiver has CORS enabled (allowed_origins: ["*"]) to support
browser-based OTLP export.
Tempo generates RED metrics from ingested traces and remote-writes them to
Prometheus. Configured in deployment/observability/tempo.yaml:
- Processors:
service-graphs,span-metrics - Remote write target:
http://prometheus:9090/api/v1/write - Resulting metrics:
traces_spanmetrics_calls_total,traces_spanmetrics_latency_bucket(used by the Latency Breakdown and Request Waterfall dashboards)
Counter
A metric type whose value only increases. Used for counting events like total
requests or total tokens consumed. Apply rate() to compute per-second
values.
Gauge A metric type whose value can go up or down. Used for current-state measurements like the number of in-flight requests.
Histogram
A metric type that records the distribution of values in pre-defined buckets.
Used for latency and size measurements. Supports computing percentiles via
histogram_quantile().
Log A timestamped, structured record of an event within a service. In this project, logs are JSON objects emitted by structlog (Python) or tracing (Rust).
LogQL
The query language used by Loki. Combines label matchers
({service="ai-resume"}) with pipeline stages (| json | level="error").
Loki A log aggregation system by Grafana Labs. Stores and indexes log streams by labels. Queried via LogQL.
Metric A numeric measurement aggregated over time. See Counter, Gauge, Histogram.
OTLP OpenTelemetry Protocol. The standard wire format for transmitting traces, logs, and metrics between services and the collector. Supports both gRPC (port 4317) and HTTP (port 4318) transports.
PromQL
The query language used by Prometheus. Supports functions like rate(),
histogram_quantile(), sum(), increase().
Prometheus A time-series database and monitoring system. Collects metrics by scraping HTTP endpoints at regular intervals.
RED Metrics Rate, Errors, Duration. A methodology for monitoring request-driven services. Rate is requests per second, Errors is the fraction of failed requests, Duration is response time percentiles.
Span A single timed operation within a trace. Has a name, start/end time, parent reference, and key-value attributes.
Span ID
A 16-hex-character identifier uniquely identifying a span within a trace.
Example: 00f067aa0ba902b7.
Tempo A distributed tracing backend by Grafana Labs. Stores traces and supports TraceQL queries.
Trace A collection of spans representing the lifecycle of a single request across one or more services. Identified by a trace ID.
Trace ID
A 32-hex-character identifier shared by all spans belonging to the same trace.
Example: 4bf92f3577b34da6a3ce929d0e0e4736.
Traceparent
A W3C Trace Context HTTP header that carries the trace ID, parent span ID, and
trace flags across service boundaries. Format:
00-<trace-id>-<span-id>-<flags>.
TraceQL
The query language used by Tempo. Selects traces based on resource and span
attributes. Example:
{resource.service.name="ai-resume-api" && duration > 5s}.
W3C Trace Context
A W3C standard defining how trace
context is propagated across service boundaries via HTTP headers (traceparent
and tracestate).
Waterfall View A visualization in Grafana that displays all spans in a trace as nested horizontal bars. The width of each bar represents the span's duration. Parent spans contain their children, making it easy to see where time is spent.