Skip to content

Telemetry & Observability

Applies tov1.4.0DifficultyIntermediateImpactNo restart

Oculis emits OpenTelemetry traces and Prometheus metrics. It does not store them — point it at the backend you already run.

The value is in what it measures: per-hop timing inside a request (guardrail, retrieval, queue, prefill, decode), and per-request cost attributed to a tenant.

oculis-config.yaml
oculis:
observability:
log_level: info
log_format: json # use `text` only for local development
tracing:
enabled: true
exporter: otlp
endpoint: 'http://otel-collector.observability.svc:4317'
protocol: grpc # grpc | http/protobuf
sample_rate: 0.1 # 10% of requests
# Always trace failures and slow requests regardless of sample rate.
always_sample:
- errors
- slower_than_ms: 5000
resource_attributes:
service.name: oculis-gateway
deployment.environment: production

A single request produces one trace with a predictable shape, which is what makes oculis trace get useful during an incident:

oculis.request 1,847ms
├── oculis.auth 2ms
├── oculis.ratelimit 1ms
├── oculis.guardrail.request policy=pii-strict 38ms
├── oculis.retrieval store=docs 94ms
│ ├── oculis.embed model=text-emb-3 61ms
│ └── oculis.vector.search k=8 11ms
├── oculis.context.assemble tokens=3891 3ms
├── oculis.route strategy=p_w_f 1ms
├── oculis.queue.wait depth=12 412ms ← the real problem
└── oculis.upstream name=vllm-primary 1,290ms
├── oculis.prefill tokens=3891 380ms
└── oculis.decode tokens=512 910ms

When someone reports “the model is slow”, this is how you show them 412 ms of it was queueing and not the model at all.

oculis-config.yaml
oculis:
observability:
metrics:
enabled: true
exporter: prometheus
port: 9090
path: /metrics
# High-cardinality labels are opt-in; `tenant` is usually worth it.
labels:
include: [route, upstream, tenant, status]
exclude: [request_id, api_key_id] # never label by these
Metric Type Why it matters
oculis_requests_total counter Baseline volume, by route, upstream, and status.
oculis_request_duration_seconds histogram End-to-end latency. Alert on p95, not mean.
oculis_ttft_seconds histogram Perceived responsiveness. The leading indicator for users.
oculis_queue_depth gauge Saturation. Rising queue depth precedes every latency incident.
oculis_breaker_state gauge 1 when open. Any open breaker deserves attention.
oculis_overflow_total counter Requests spilled to fallback. Rising means the primary is sick.
oculis_upstream_errors_total counter By upstream and error code.
oculis_tokens_total counter Input and output tokens, by tenant and model.
oculis_cost_usd_total counter Estimated spend, by tenant.
oculis_guardrail_actions_total counter Blocks and redactions, by policy and detector.
oculis_retrieval_fail_open_total counter Ungrounded answers served during a store outage.
oculis_kv_cache_utilization gauge Preemption risk. Sustained >0.9 precedes throughput collapse.
alerts.yaml
groups:
- name: oculis
rules:
- alert: OculisTTFTDegraded
expr: histogram_quantile(0.95, sum(rate(oculis_ttft_seconds_bucket[5m])) by (le, route)) > 1.5
for: 10m
labels: { severity: warning }
annotations:
summary: 'p95 TTFT above 1.5s on route {{ $labels.route }}'
runbook: 'https://docs.selaware.ai/ai/performance-tuning/#reducing-time-to-first-token'
- alert: OculisBreakerOpen
expr: max(oculis_breaker_state{state="open"}) by (upstream) == 1
for: 2m
labels: { severity: critical }
annotations:
summary: 'Circuit breaker open for {{ $labels.upstream }}'
runbook: 'https://docs.selaware.ai/ai/gateway-routing/fallback-failover/#circuit-breaker-behavior'
- alert: OculisNoHealthyUpstream
expr: sum(oculis_upstreams_healthy) by (route) == 0
for: 1m
labels: { severity: critical }
annotations:
summary: 'No healthy upstream for route {{ $labels.route }}'
runbook: 'https://docs.selaware.ai/oculis/reference/error-index/#err-oculis-no-healthy-upstream'
- alert: OculisKVCachePressure
expr: avg(oculis_kv_cache_utilization) by (upstream) > 0.9
for: 15m
labels: { severity: warning }
annotations:
summary: 'KV cache above 90% on {{ $labels.upstream }} — preemption likely'
runbook: 'https://docs.selaware.ai/ai/capacity-planning/'
- alert: OculisRetrievalSilentlyDegraded
expr: rate(oculis_retrieval_fail_open_total[10m]) > 0
for: 5m
labels: { severity: warning }
annotations:
summary: 'Serving ungrounded answers — vector store unreachable'
runbook: 'https://docs.selaware.ai/ai/rag-vector-databases/#connection-health-and-failure-modes'

That last rule catches a failure that is otherwise invisible: retrieval failing open produces fluent, confident, ungrounded answers rather than errors.

oculis-config.yaml
oculis:
observability:
cost_tracking:
enabled: true
attribute_by: [tenant, route, model]
emit_per_request: true # adds cost as a span attribute
Terminal window
oculis cost report --since 30d --group-by tenant --format table
Example output
tenant requests input_tokens output_tokens est_cost_usd
acme 418,204 512,884,102 41,002,884 1,568.82
globex 102,881 118,204,881 9,884,201 392.14
internal-batch 1,204,882 2,884,102,884 102,884,102 0.00*
--------- ------------- ------------- ------------
total 1,725,967 3,515,191,867 153,771,187 1,960.96
* self-hosted; see pricing.infra_cost_per_hour_usd for amortized attribution

Verification

Confirms metrics are scrapeable, traces reach the collector with the expected span structure, and cost attribution is recording. Check the trace end to end — an exporter that silently drops spans looks identical to one that works.

1. Metrics are exposed and populated.

Terminal window
curl -s http://localhost:9090/metrics | grep -c '^oculis_'
Expected output
47

A 0 here means the exporter is disabled or bound to a different interface.

2. A trace actually reaches the collector.

Terminal window
oculis trace test --emit-sample
Expected output
[INFO] Emitting synthetic trace to http://otel-collector.observability.svc:4317
[INFO] trace_id=4bf92f3577b34da6a3ce929d0e0e4736
[INFO] Exporter acknowledged 11 spans in 34ms
[PASS] OTLP export verified end to end.

3. The per-hop breakdown resolves for a real request.

Terminal window
oculis trace get req_01HQ8Z3K4M5N6P7Q8R9S --format tree

The output should match the span structure shown above. If oculis.queue.wait is missing, tracing is enabled but the router is not instrumented — check that sample_rate did not exclude this request.

4. Cost tracking is recording.

Terminal window
curl -s http://localhost:9090/metrics | grep oculis_cost_usd_total
Expected output
oculis_cost_usd_total{tenant="acme",route="default",model="gpt-4o-mini"} 12.4081
  • Telemetry export never blocks a response. Spans are buffered and dropped if the exporter falls behind. Watch oculis_telemetry_dropped_total — silent drops make traces misleading.
  • Sampling is per trace, not per span. A sampled request keeps all its spans; an unsampled one keeps none. There are no partial traces.
  • log_format: text is not machine-parseable. Use json anywhere logs are shipped.
  • Streaming requests report duration at stream close, so a long stream appears as one long span rather than incremental progress. TTFT is recorded separately for exactly this reason.
  • Cost is computed at response time using the price table loaded then. Changing prices does not retroactively restate history.