OpenTelemetry Distributed Observability
On this page
Status
Reconciled 2026-08-08 (#1207): the pipeline landed as ONE commit (db918e6c, 2026-03-22, "feat: OpenTelemetry distributed observability" — pre-dating the epic/issue-per-step workflow, hence the single evidence SHA below) and was later enriched by #1160 (db-pool metrics + the connection-budget doctrine) and #1186/ADR-061 (health-check rework — see the Step 4 supersession note in §Steps).
| Step | Description | Status |
|---|---|---|
1 |
Dependencies & telemetry core (craig-common) |
Done (2026-03-22) — db918e6c: |
2 |
HTTP middleware & metrics (craig-api) |
Done (2026-03-22) — db918e6c: |
3 |
RabbitMQ trace propagation (craig-mq) |
Done (2026-03-22) — db918e6c: |
4 |
Per-service changes (main.rs updates, MqHealth, cross-service headers) |
Done (2026-03-22) — db918e6c: |
5 |
Devstack infrastructure (Jaeger, Prometheus, Grafana) |
Done (2026-03-22) — db918e6c: jaeger (now pinned 2.17.0) + prometheus + grafana under the compose |
Epic: none — landed pre-epic-workflow as db918e6c; enrichments rode #1160 / #1186
Issues: #1207 (this reconcile); #1160, #1186 (enrichments)
Branch: feature/opentelemetry-observability (merged)
Context
The CRAIG codebase has 8 microservices communicating via HTTP and RabbitMQ, but zero distributed tracing, zero metrics collection, and no trace correlation across service boundaries. The current observability stack is:
-
tracing+tracing-subscriberwith JSON formatting (structured logs only) -
tower_http::trace::TraceLayerfor inbound HTTP request logging -
GET /healthzreturning{"status": "ok"}with no dependency checks
A referral-to-placement workflow touches 4+ services with no way to correlate logs. Cross-service HTTP calls (craig-cases→craig-rules, craig-web→all backends) and RabbitMQ event chains produce disconnected log entries. Production debugging requires manually grepping logs across 8 containers by timestamp.
This plan adds the three observability pillars — traces, metrics, and enriched health checks — while keeping all complexity in the shared crates so individual services get observability "for free."
Scope
In scope:
-
Distributed traces via OpenTelemetry (OTLP export to Jaeger)
-
Prometheus metrics endpoint (
/metrics) on every service -
Enriched health checks (DB connectivity, RabbitMQ status, uptime)
-
Devstack observability UI (Jaeger, Prometheus, Grafana)
-
Graceful degradation when OTEL env vars are unset
-
W3C TraceContext propagation across HTTP and RabbitMQ boundaries
Out of scope:
-
Production deployment configuration (Datadog, New Relic, etc.)
-
Custom business metrics (counters per domain event)
-
Log aggregation (ELK/Loki) — separate concern
Design
As-built corrections (#1207, 2026-08-08). The landed pipeline differs
from the prescriptions below in four recorded ways: (1) runtime gating is the
single OTEL_EXPORTER_OTLP_ENDPOINT variable — set ⇒ OTLP traces + Prometheus
metrics with the service name as the OTel resource, unset ⇒ JSON logging only
(the compile-time otel feature stays default-on); (2) the devstack
observability stack (jaeger 2.17.0 / prometheus / grafana) is an OPT-IN compose
observability profile, not always-on services with hardcoded ports; (3)
outbound HTTP injection consolidated into craig_common::telemetry::
send_with_metrics (#485) — injection + the outbound latency histogram at every
S2S call site, not per-site inject_trace_headers wrapping; (4) Step 4’s
MqHealth Extension channel was superseded by #1186/ADR-061 (AppState.mq,
/livez//readyz probes) — see the Step 4 note. The step bodies below are the
as-planned record; the idempotency-middleware note already carries its own
supersession (#1194/ADR-062 §B).
|
What This Enables
-
Distributed traces: A single request produces a connected trace across all services it touches (HTTP + RabbitMQ)
-
Metrics dashboards: Request latency (p50/p95/p99), error rates, active requests per service
-
Enriched health checks: DB connectivity, RabbitMQ status, uptime — not just
{"status": "ok"} -
Devstack observability UI: Jaeger (traces), Prometheus (metrics), Grafana (dashboards) in the devstack
-
Graceful degradation: If OTEL env vars are unset, services behave exactly as today
Key Design Notes
craig-web has a different initialization pattern from the backend services. It does NOT use ApiServer::router() or bootstrap() — instead it builds its own Axum router with i18n state, locale middleware (middleware::set_locale), session management (tower_sessions::SessionManagerLayer), and OIDC auth. It uses tracing_subscriber::fmt() directly in main(), not craig_common::telemetry::init(). The OTEL telemetry init and trace header injection must be wired into craig-web/src/main.rs directly, not via the shared ApiServer path. The api_client.rs HTTP methods (get, post, put, delete, post_multipart, get_bytes) are the single change point for outbound trace header propagation.
|
(Middleware-era, historical — the idempotency middleware was deleted in B2 #1194/ADR-062 §B; the layering rationale below is retained as the as-was record.) The idempotency middleware (craig-api::idempotency_middleware) caches and replays responses for requests with matching Idempotency-Key headers. When OTEL is active, a replayed cached response will still carry the original request’s trace context. This is correct behavior — the cached response was produced under the original trace. The OTEL propagation middleware should be layered outside (below in Axum layer order) the idempotency middleware so that trace context extraction happens before the idempotency check. This ensures every inbound request gets a span, even if the response is served from cache.
|
TraceLayer from tower_http already exists in ApiServer::router() at crates/craig-api/src/lib.rs:108. The new OTEL propagation middleware must be layered before TraceLayer (i.e., added below it in the .layer() chain, since Axum layers execute bottom-to-top). This ensures the OTEL span context is established before TraceLayer creates its HTTP-level span, allowing TraceLayer spans to be children of the OTEL parent context from upstream callers.
|
The bootstrap() function in crates/craig-api/src/bootstrap.rs already receives service_name as a parameter but currently only uses it for the info! log message. The telemetry init call at line 39 (craig_common::telemetry::init(&settings.log_level)) should be updated to pass service_name through. This means the bootstrap() function signature does NOT change — only the call to telemetry::init() inside it changes. All 8 backend services that call bootstrap() get the new behavior for free.
|
Steps
Step 1: Dependencies & Telemetry Core (craig-common)
Files: Cargo.toml (workspace root), crates/craig-common/Cargo.toml, crates/craig-common/src/telemetry.rs
Problem
crates/craig-common/src/telemetry.rs currently initializes only tracing-subscriber with JSON formatting. No OpenTelemetry, no metrics registry.
Current code (EXACT, from crates/craig-common/src/telemetry.rs)
// SPDX-License-Identifier: AGPL-3.0-or-later
use tracing_subscriber::{EnvFilter, fmt, layer::SubscriberExt, util::SubscriberInitExt};
/// Initialize structured JSON logging with the given default level.
///
/// Respects `RUST_LOG` for fine-grained control. Falls back to `default_level`
/// if `RUST_LOG` is not set.
pub fn init(default_level: &str) {
let filter =
EnvFilter::try_from_default_env().unwrap_or_else(|_| EnvFilter::new(default_level));
tracing_subscriber::registry()
.with(filter)
.with(fmt::layer().json().flatten_event(true))
.init();
}
Note: The current code uses tracing_subscriber::registry() with .with(fmt::layer().json().flatten_event(true)), NOT tracing_subscriber::fmt().json().flatten_event(true). The replacement must follow the same registry() + .with() pattern to compose the OTEL layer alongside the fmt layer.
Solution
Add workspace dependencies to Cargo.toml root [workspace.dependencies] section (which already has tracing = "0.1" and tracing-subscriber = { version = "0.3", features = ["env-filter", "json"] }):
# Add to [workspace.dependencies] section:
opentelemetry = "0.29"
opentelemetry_sdk = { version = "0.29", features = ["rt-tokio"] }
opentelemetry-otlp = { version = "0.29", features = ["tonic"] }
opentelemetry-semantic-conventions = "0.29"
tracing-opentelemetry = "0.30"
opentelemetry-prometheus = "0.29"
prometheus = "0.13"
| Verify these are the latest stable versions at implementation time. The OTEL Rust ecosystem moves fast. |
Add to crates/craig-common/Cargo.toml (current deps include: anyhow, axum, chrono, config, serde, serde_json, thiserror, tracing, tracing-subscriber, utoipa, uuid):
# Add to [dependencies]:
opentelemetry = { workspace = true, optional = true }
opentelemetry_sdk = { workspace = true, optional = true }
opentelemetry-otlp = { workspace = true, optional = true }
tracing-opentelemetry = { workspace = true, optional = true }
opentelemetry-prometheus = { workspace = true, optional = true }
prometheus = { workspace = true, optional = true }
# Add new section:
[features]
default = ["otel"]
otel = [
"dep:opentelemetry",
"dep:opentelemetry_sdk",
"dep:opentelemetry-otlp",
"dep:tracing-opentelemetry",
"dep:opentelemetry-prometheus",
"dep:prometheus",
]
Replace the ENTIRE content of crates/craig-common/src/telemetry.rs with:
// SPDX-License-Identifier: AGPL-3.0-or-later
use tracing_subscriber::{EnvFilter, fmt, layer::SubscriberExt, util::SubscriberInitExt};
/// Guard that flushes OTEL providers on drop. Hold this in `main()`.
///
/// When OTEL is not configured, the fields are `None` and drop is a no-op.
pub struct TelemetryGuard {
#[cfg(feature = "otel")]
_trace_provider: Option<opentelemetry_sdk::trace::SdkTracerProvider>,
#[cfg(feature = "otel")]
_meter_provider: Option<opentelemetry_sdk::metrics::SdkMeterProvider>,
}
#[cfg(feature = "otel")]
impl Drop for TelemetryGuard {
fn drop(&mut self) {
if let Some(tp) = self._trace_provider.take() {
if let Err(e) = tp.shutdown() {
eprintln!("error shutting down trace provider: {e}");
}
}
if let Some(mp) = self._meter_provider.take() {
if let Err(e) = mp.shutdown() {
eprintln!("error shutting down meter provider: {e}");
}
}
}
}
#[cfg(feature = "otel")]
static METRICS_REGISTRY: std::sync::OnceLock<prometheus::Registry> = std::sync::OnceLock::new();
/// Returns the Prometheus metrics registry, if OTEL was initialized.
#[cfg(feature = "otel")]
pub fn metrics_registry() -> Option<&'static prometheus::Registry> {
METRICS_REGISTRY.get()
}
/// Returns `None` when compiled without the `otel` feature.
#[cfg(not(feature = "otel"))]
pub fn metrics_registry() -> Option<&'static ()> {
None
}
/// Initialize telemetry. If the `otel` feature is enabled AND
/// `OTEL_EXPORTER_OTLP_ENDPOINT` is set, enables OpenTelemetry trace export
/// and Prometheus metrics. Otherwise, JSON logging only (same as before).
///
/// The `service_name` parameter is used as the OTEL resource service name
/// and is ignored when OTEL is not active.
///
/// Returns a `TelemetryGuard` that MUST be held alive for the duration of
/// `main()`. Dropping it flushes pending spans and metrics.
pub fn init(default_level: &str, service_name: &str) -> TelemetryGuard {
let filter =
EnvFilter::try_from_default_env().unwrap_or_else(|_| EnvFilter::new(default_level));
#[cfg(feature = "otel")]
{
let otel_endpoint = std::env::var("OTEL_EXPORTER_OTLP_ENDPOINT").ok();
if let Some(endpoint) = otel_endpoint {
// -- OTLP trace exporter --
let exporter = opentelemetry_otlp::SpanExporter::builder()
.with_tonic()
.with_endpoint(&endpoint)
.build()
.expect("failed to build OTLP span exporter");
let trace_provider = opentelemetry_sdk::trace::SdkTracerProvider::builder()
.with_batch_exporter(exporter)
.with_resource(
opentelemetry_sdk::Resource::builder()
.with_service_name(service_name.to_owned())
.build(),
)
.build();
opentelemetry::global::set_tracer_provider(trace_provider.clone());
// -- Prometheus metrics exporter --
let registry = prometheus::Registry::new();
let prometheus_exporter = opentelemetry_prometheus::exporter()
.with_registry(registry.clone())
.build()
.expect("failed to build Prometheus exporter");
let meter_provider = opentelemetry_sdk::metrics::SdkMeterProvider::builder()
.with_reader(prometheus_exporter)
.build();
opentelemetry::global::set_meter_provider(meter_provider.clone());
METRICS_REGISTRY.set(registry).ok();
// -- W3C TraceContext propagator --
opentelemetry::global::set_text_map_propagator(
opentelemetry_sdk::propagation::TraceContextPropagator::new(),
);
// -- Compose subscriber: JSON fmt + OpenTelemetry layer --
let otel_layer = tracing_opentelemetry::OpenTelemetryLayer::new(
trace_provider.tracer(service_name.to_owned()),
);
tracing_subscriber::registry()
.with(filter)
.with(fmt::layer().json().flatten_event(true))
.with(otel_layer)
.init();
return TelemetryGuard {
_trace_provider: Some(trace_provider),
_meter_provider: Some(meter_provider),
};
}
}
// No OTEL — behave exactly as today (JSON structured logging)
tracing_subscriber::registry()
.with(filter)
.with(fmt::layer().json().flatten_event(true))
.init();
TelemetryGuard {
#[cfg(feature = "otel")]
_trace_provider: None,
#[cfg(feature = "otel")]
_meter_provider: None,
}
}
/// Inject the current span's W3C trace context headers into an outbound
/// `reqwest::RequestBuilder`. No-op when OTEL is not active.
///
/// Usage: `let req = inject_trace_headers(client.get(url).bearer_auth(token));`
pub fn inject_trace_headers(builder: reqwest::RequestBuilder) -> reqwest::RequestBuilder {
#[cfg(feature = "otel")]
{
use tracing_opentelemetry::OpenTelemetrySpanExt;
let cx = tracing::Span::current().context();
let mut carrier = std::collections::HashMap::new();
opentelemetry::global::get_text_map_propagator(|p| {
p.inject_context(&cx, &mut carrier);
});
let mut builder = builder;
for (key, value) in carrier {
if let Ok(name) = reqwest::header::HeaderName::from_bytes(key.as_bytes()) {
builder = builder.header(name, value);
}
}
builder
}
#[cfg(not(feature = "otel"))]
{
builder
}
}
The inject_trace_headers function requires reqwest as a dependency. Add to crates/craig-common/Cargo.toml:
|
# Add to [dependencies]:
reqwest = { workspace = true }
Verify that reqwest is in the workspace dependencies of the root Cargo.toml (it will be, since craig-test-lib and services use it). If not present, add reqwest = { version = "0.12", features = ["json"] }.
Cargo.toml changes summary
crates/craig-common/Cargo.toml after changes:
# SPDX-License-Identifier: AGPL-3.0-or-later
[package]
name = "craig-common"
version.workspace = true
edition.workspace = true
license.workspace = true
[dependencies]
anyhow = { workspace = true }
axum = { workspace = true }
chrono = { workspace = true }
config = { workspace = true }
reqwest = { workspace = true }
serde = { workspace = true }
serde_json = { workspace = true }
thiserror = { workspace = true }
tracing = { workspace = true }
tracing-subscriber = { workspace = true }
utoipa = { workspace = true }
uuid = { workspace = true }
# OTEL (optional, default = on)
opentelemetry = { workspace = true, optional = true }
opentelemetry_sdk = { workspace = true, optional = true }
opentelemetry-otlp = { workspace = true, optional = true }
tracing-opentelemetry = { workspace = true, optional = true }
opentelemetry-prometheus = { workspace = true, optional = true }
prometheus = { workspace = true, optional = true }
[dev-dependencies]
tokio = { workspace = true }
[features]
default = ["otel"]
otel = [
"dep:opentelemetry",
"dep:opentelemetry_sdk",
"dep:opentelemetry-otlp",
"dep:tracing-opentelemetry",
"dep:opentelemetry-prometheus",
"dep:prometheus",
]
Step 2: HTTP Middleware & Metrics (craig-api)
Files: crates/craig-api/src/otel.rs (NEW), crates/craig-api/src/lib.rs, crates/craig-api/src/bootstrap.rs, crates/craig-api/Cargo.toml
2a. Trace context propagation middleware
Create crates/craig-api/src/otel.rs (NEW FILE):
// SPDX-License-Identifier: AGPL-3.0-or-later
//! OpenTelemetry middleware for W3C TraceContext propagation on inbound HTTP
//! requests. When OTEL is not configured, the global propagator is a no-op
//! and this middleware adds zero overhead.
use axum::extract::Request;
use axum::middleware::Next;
use axum::response::Response;
/// Axum middleware: extracts W3C `traceparent`/`tracestate` from inbound
/// request headers and sets as parent context for the current tracing span.
///
/// Layer this BELOW `TraceLayer` in the `.layer()` chain (Axum layers execute
/// bottom-to-top) so that OTEL context extraction runs first, then TraceLayer
/// creates its HTTP span as a child of the extracted parent.
pub async fn otel_propagation(request: Request, next: Next) -> Response {
#[cfg(feature = "otel")]
{
use tracing::Instrument;
use tracing_opentelemetry::OpenTelemetrySpanExt;
let headers = request.headers();
let parent_cx = opentelemetry::global::get_text_map_propagator(|propagator| {
propagator.extract(&opentelemetry_http::HeaderExtractor(headers))
});
let span = tracing::info_span!(
"http_request",
otel.kind = "server",
http.method = %request.method(),
http.route = %request.uri().path(),
);
span.set_parent(parent_cx);
next.run(request).instrument(span).await
}
#[cfg(not(feature = "otel"))]
{
next.run(request).await
}
}
Add mod otel; to crates/craig-api/src/lib.rs (after mod bootstrap;):
mod bootstrap;
pub mod idempotency;
mod otel;
Current middleware stack in ApiServer::router() (EXACT, from crates/craig-api/src/lib.rs lines 81-112)
pub fn router(
state: AppState,
service_routes: Router<AppState>,
opts: ServerOptions,
api_doc: Option<utoipa::openapi::OpenApi>,
) -> Router {
let cors = build_cors(&opts.cors_origins);
let idempotency_cache = IdempotencyCache::new();
let auth_layer = axum::middleware::from_fn_with_state(state.auth.clone(), auth_middleware);
let idempotency_layer =
axum::middleware::from_fn_with_state(idempotency_cache, idempotency_middleware);
// Layers execute bottom-to-top: auth runs first (inserts Claims),
// then idempotency checks for cached responses.
let protected = service_routes.layer(idempotency_layer).layer(auth_layer);
let mut router = Router::new()
.nest("/v1", protected)
.route("/healthz", get(health_check));
if let Some(doc) = api_doc {
router = router.merge(SwaggerUi::new("/swagger-ui").url("/api-doc/openapi.json", doc));
}
router
.layer(DefaultBodyLimit::max(opts.body_limit))
.layer(TraceLayer::new_for_http())
.layer(CompressionLayer::new())
.layer(cors)
.with_state(state)
}
Updated middleware stack
Replace the router building section (from let mut router = Router::new() to .with_state(state)) with:
let mut router = Router::new()
.nest("/v1", protected)
.route("/healthz", get(health_check))
.route("/metrics", get(metrics_handler));
if let Some(doc) = api_doc {
router = router.merge(SwaggerUi::new("/swagger-ui").url("/api-doc/openapi.json", doc));
}
router
.layer(DefaultBodyLimit::max(opts.body_limit))
.layer(TraceLayer::new_for_http())
.layer(axum::middleware::from_fn(otel::otel_propagation))
.layer(CompressionLayer::new())
.layer(cors)
.with_state(state)
Why OTEL goes below TraceLayer: Axum layers execute bottom-to-top. The OTEL propagation middleware (added below TraceLayer) runs first, extracting the traceparent header and setting the parent context on the current span. Then TraceLayer runs second, creating its HTTP-level span as a child of that parent. This links inbound requests to the caller’s trace.
Why OTEL goes outside idempotency: The idempotency middleware is applied per-route on service_routes (lines 96), inside the /v1 nest. The OTEL middleware is applied on the top-level router, so it wraps everything — including /healthz and /metrics. Every inbound request gets a span regardless of whether the response is served from the idempotency cache.
2b. /metrics endpoint
Add this handler in crates/craig-api/src/lib.rs (near the existing health_check handler):
async fn metrics_handler() -> axum::response::Response {
use axum::http::header;
use axum::response::IntoResponse;
match craig_common::telemetry::metrics_registry() {
Some(registry) => {
use prometheus::Encoder;
let encoder = prometheus::TextEncoder::new();
let mut buffer = Vec::new();
let _ = encoder.encode(®istry.gather(), &mut buffer);
(
[(header::CONTENT_TYPE, "text/plain; charset=utf-8")],
buffer,
)
.into_response()
}
None => (
[(header::CONTENT_TYPE, "text/plain; charset=utf-8")],
Vec::<u8>::new(),
)
.into_response(),
}
}
Add prometheus to crates/craig-api/Cargo.toml:
# Add to [dependencies]:
prometheus = { workspace = true }
opentelemetry = { workspace = true }
opentelemetry-http = { workspace = true }
tracing-opentelemetry = { workspace = true }
Also add opentelemetry-http to workspace dependencies in root Cargo.toml:
# Add to [workspace.dependencies]:
opentelemetry-http = "0.29"
2c. Enriched health check
Current health check (EXACT, from crates/craig-api/src/lib.rs lines 157-164)
#[derive(Serialize)]
struct HealthResponse {
status: &'static str,
}
async fn health_check() -> Json<HealthResponse> {
Json(HealthResponse { status: "ok" })
}
Replacement health check
Replace the HealthResponse struct and health_check function with:
/// Shared start time for uptime calculation.
static START_TIME: std::sync::OnceLock<std::time::Instant> = std::sync::OnceLock::new();
/// Call once at server startup (inside `router()`) to record the start time.
fn record_start_time() {
START_TIME.get_or_init(std::time::Instant::now);
}
#[derive(Serialize)]
struct HealthResponse {
status: &'static str,
uptime_seconds: u64,
checks: HealthChecks,
}
#[derive(Serialize)]
struct HealthChecks {
database: CheckResult,
rabbitmq: CheckResult,
}
#[derive(Serialize)]
struct CheckResult {
status: &'static str,
#[serde(skip_serializing_if = "Option::is_none")]
message: Option<String>,
}
/// RabbitMQ health wrapper. Services without MQ (craig-web) omit this Extension
/// and the health check reports "n/a".
#[derive(Clone)]
pub struct MqHealth(pub std::sync::Arc<lapin::Connection>);
async fn health_check(
State(state): State<AppState>,
mq: Option<Extension<MqHealth>>,
) -> Json<HealthResponse> {
let uptime = START_TIME
.get()
.map(|t| t.elapsed().as_secs())
.unwrap_or(0);
// Check database: run a trivial query
let db_check = match sqlx::query("SELECT 1")
.execute(state.db.pool())
.await
{
Ok(_) => CheckResult { status: "ok", message: None },
Err(e) => CheckResult { status: "error", message: Some(e.to_string()) },
};
// Check RabbitMQ connection status
let mq_check = match mq {
Some(Extension(ref health)) => {
if health.0.status().connected() {
CheckResult { status: "ok", message: None }
} else {
CheckResult { status: "error", message: Some("disconnected".into()) }
}
}
None => CheckResult { status: "n/a", message: None },
};
let overall = if db_check.status == "ok"
&& (mq_check.status == "ok" || mq_check.status == "n/a")
{
"ok"
} else {
"degraded"
};
Json(HealthResponse {
status: overall,
uptime_seconds: uptime,
checks: HealthChecks {
database: db_check,
rabbitmq: mq_check,
},
})
}
Add record_start_time(); as the first line inside ApiServer::router().
The health check handler now takes State(state): State<AppState> (for DB pool access) and Option<Extension<MqHealth>> (for RabbitMQ). The health_check route already has access to AppState because it’s inside the router that calls .with_state(state). The MqHealth Extension is added per-service in Step 4b.
Superseded (#1186 / ADR-061, 2026-07-31): the Option<Extension<MqHealth>> channel this step prescribed never reached the outer-router health routes (probes silently saw None); MQ health now rides AppState.mq as MqRequirement::Required and the handlers take State(AppState) only. The prescription below is the historical as-planned record.
|
The health_check function signature changes from async fn health_check() → Json<HealthResponse> to async fn health_check(State(state): State<AppState>, mq: Option<Extension<MqHealth>>) → Json<HealthResponse>. This requires adding these imports to crates/craig-api/src/lib.rs:
|
use axum::extract::{Extension, State};
Also add sqlx and lapin to crates/craig-api/Cargo.toml:
# Add to [dependencies]:
sqlx = { workspace = true }
lapin = { workspace = true }
Always return HTTP 200. Docker healthchecks use curl -sf which only checks status code. The status field differentiates "ok" vs "degraded".
|
The health_check route is currently defined as .route("/healthz", get(health_check)). The existing E2E docker healthchecks all use curl -sf http://localhost:{PORT}/healthz and check only the HTTP status code (200). Since we always return 200, no healthcheck changes are needed.
|
The state.db.pool() call assumes craig_db::DbPool exposes a pool() method returning a &sqlx::PgPool. Check the actual DbPool API — if it derefs or exposes the pool differently, adjust accordingly.
|
2d. Request metrics middleware (optional, low priority)
Record http.server.request.duration histogram and http.server.active_requests gauge using opentelemetry::global::meter("craig"). Add as axum::middleware::from_fn in the router stack. This is automatically provided by the tracing-opentelemetry layer when spans complete, so explicit metrics middleware may not be needed. Verify after Step 1 whether the OTEL layer produces http.server.request.duration metrics automatically.
Step 3: RabbitMQ Trace Propagation (craig-mq)
Files: crates/craig-mq/src/envelope.rs, crates/craig-mq/src/publisher.rs, crates/craig-mq/src/subscriber.rs, crates/craig-mq/Cargo.toml
Problem
Events flow through RabbitMQ with no trace context. A case creation event published by craig-cases and consumed by craig-rules produces disconnected traces.
Current EventEnvelope (EXACT, from crates/craig-mq/src/envelope.rs)
// SPDX-License-Identifier: AGPL-3.0-or-later
use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
use uuid::Uuid;
/// Standard message envelope for all events on the message bus.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct EventEnvelope {
/// Unique message ID.
pub id: Uuid,
/// Timestamp when the event was produced.
pub timestamp: DateTime<Utc>,
/// Service that produced the event.
pub source_service: String,
/// Event type used as routing key (e.g. "case.created").
pub event_type: String,
/// Event-specific payload.
pub payload: serde_json::Value,
}
impl EventEnvelope {
pub fn new(
source_service: impl Into<String>,
event_type: impl Into<String>,
payload: serde_json::Value,
) -> Self {
Self {
id: Uuid::now_v7(),
timestamp: Utc::now(),
source_service: source_service.into(),
event_type: event_type.into(),
payload,
}
}
}
Updated EventEnvelope
Replace the struct definition and new() impl (keep existing tests, they still pass because trace_context defaults to None):
// SPDX-License-Identifier: AGPL-3.0-or-later
use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
use uuid::Uuid;
/// W3C TraceContext carried inside event envelopes for cross-service
/// trace correlation through RabbitMQ.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct TraceContext {
pub traceparent: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub tracestate: Option<String>,
}
/// Standard message envelope for all events on the message bus.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct EventEnvelope {
/// Unique message ID.
pub id: Uuid,
/// Timestamp when the event was produced.
pub timestamp: DateTime<Utc>,
/// Service that produced the event.
pub source_service: String,
/// Event type used as routing key (e.g. "case.created").
pub event_type: String,
/// Event-specific payload.
pub payload: serde_json::Value,
/// Optional W3C trace context for distributed tracing across MQ boundaries.
/// Backward compatible: old messages without this field deserialize as None.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub trace_context: Option<TraceContext>,
}
impl EventEnvelope {
pub fn new(
source_service: impl Into<String>,
event_type: impl Into<String>,
payload: serde_json::Value,
) -> Self {
Self {
id: Uuid::now_v7(),
timestamp: Utc::now(),
source_service: source_service.into(),
event_type: event_type.into(),
payload,
trace_context: None,
}
}
}
Backward compatible: #[serde(default)] means old messages without trace_context deserialize as None. skip_serializing_if means no extra JSON when OTEL is off. The EventEnvelope::new() constructor sets trace_context: None — the publisher injects it at publish time.
Current Publisher::publish (EXACT, from crates/craig-mq/src/publisher.rs)
/// Publish an event envelope to the topic exchange.
///
/// The `event_type` field of the envelope is used as the routing key.
pub async fn publish(&self, envelope: &EventEnvelope) -> Result<(), lapin::Error> {
let payload =
serde_json::to_vec(envelope).expect("EventEnvelope serialization should not fail");
let routing_key = &envelope.event_type;
self.channel
.basic_publish(
EVENTS_EXCHANGE.into(),
routing_key.as_str().into(),
BasicPublishOptions::default(),
&payload,
BasicProperties::default()
.with_content_type("application/json".into())
.with_delivery_mode(2), // persistent
)
.await?
.await?;
debug!(routing_key, id = %envelope.id, "event published");
Ok(())
}
Updated Publisher::publish
/// Publish an event envelope to the topic exchange.
///
/// The `event_type` field of the envelope is used as the routing key.
/// If OTEL is active, injects the current span's W3C trace context into
/// the envelope so consumers can link their spans to the publisher's trace.
pub async fn publish(&self, envelope: &EventEnvelope) -> Result<(), lapin::Error> {
let mut envelope = envelope.clone();
envelope.trace_context = Self::inject_trace_context();
let payload =
serde_json::to_vec(&envelope).expect("EventEnvelope serialization should not fail");
let routing_key = &envelope.event_type;
self.channel
.basic_publish(
EVENTS_EXCHANGE.into(),
routing_key.as_str().into(),
BasicPublishOptions::default(),
&payload,
BasicProperties::default()
.with_content_type("application/json".into())
.with_delivery_mode(2), // persistent
)
.await?
.await?;
debug!(routing_key, id = %envelope.id, "event published");
Ok(())
}
/// Extract the current span's W3C trace context for injection into the
/// event envelope. Returns `None` when OTEL is not configured or the
/// current span has no valid trace context.
#[cfg(feature = "otel")]
fn inject_trace_context() -> Option<crate::envelope::TraceContext> {
use tracing_opentelemetry::OpenTelemetrySpanExt;
let cx = tracing::Span::current().context();
let mut carrier = std::collections::HashMap::new();
opentelemetry::global::get_text_map_propagator(|p| {
p.inject_context(&cx, &mut carrier);
});
carrier.get("traceparent").map(|tp| crate::envelope::TraceContext {
traceparent: tp.clone(),
tracestate: carrier.get("tracestate").cloned(),
})
}
#[cfg(not(feature = "otel"))]
fn inject_trace_context() -> Option<crate::envelope::TraceContext> {
None
}
Updated Subscriber (consumer span extraction)
In crates/craig-mq/src/subscriber.rs, update the consumer loop inside subscribe_inner(). The current code at line 125-127 is:
match serde_json::from_slice::<EventEnvelope>(&delivery.data) {
Ok(envelope) => {
if let Err(e) = handler(envelope).await {
Replace with:
match serde_json::from_slice::<EventEnvelope>(&delivery.data) {
Ok(envelope) => {
let result = {
let _span_guard = create_consumer_span(&envelope).entered();
handler(envelope).await
};
if let Err(e) = result {
Add this helper function at the bottom of subscriber.rs (before closing }):
/// Create a tracing span for event processing, optionally linked to the
/// publisher's trace context for distributed trace correlation.
fn create_consumer_span(envelope: &EventEnvelope) -> tracing::Span {
let span = tracing::info_span!(
"event_process",
messaging.system = "rabbitmq",
messaging.operation = "process",
messaging.destination.name = %envelope.event_type,
craig.event_id = %envelope.id,
craig.source_service = %envelope.source_service,
);
#[cfg(feature = "otel")]
if let Some(ref tc) = envelope.trace_context {
use tracing_opentelemetry::OpenTelemetrySpanExt;
let mut carrier = std::collections::HashMap::new();
carrier.insert("traceparent".to_string(), tc.traceparent.clone());
if let Some(ref ts) = tc.tracestate {
carrier.insert("tracestate".to_string(), ts.clone());
}
let parent_cx = opentelemetry::global::get_text_map_propagator(|p| p.extract(&carrier));
span.set_parent(parent_cx);
}
span
}
craig-mq Cargo.toml changes
Current crates/craig-mq/Cargo.toml dependencies: anyhow, craig-common, futures-lite, lapin, serde, serde_json, tokio, tracing, uuid, chrono.
Add:
# Add to [dependencies]:
opentelemetry = { workspace = true, optional = true }
tracing-opentelemetry = { workspace = true, optional = true }
# Add new section:
[features]
default = ["otel"]
otel = ["dep:opentelemetry", "dep:tracing-opentelemetry"]
Step 4: Per-Service Changes
Files: crates/craig-api/src/bootstrap.rs, all 8 backend service main.rs files, services/craig-web/src/main.rs, services/craig-web/src/api_client.rs, services/craig-cases/src/api/investigations.rs, services/craig-intake/src/api/internal.rs
4a. Update bootstrap() to pass service_name to telemetry::init()
Current bootstrap telemetry call (EXACT, from crates/craig-api/src/bootstrap.rs lines 31-45)
pub async fn bootstrap(
prefix: &str,
service_name: &str,
) -> anyhow::Result<(ServiceSettings, BootstrapResult)> {
dotenvy::dotenv().ok();
let settings = ServiceSettings::load(prefix).context("failed to load service settings")?;
craig_common::telemetry::init(&settings.log_level);
info!(
port = settings.port,
jurisdiction = %settings.jurisdiction,
"starting {service_name} service"
);
Updated bootstrap telemetry call
Change line 39 from:
craig_common::telemetry::init(&settings.log_level);
To:
let _telemetry = craig_common::telemetry::init(&settings.log_level, service_name);
The _telemetry guard must be returned from bootstrap() so it lives for the duration of main(). Update the BootstrapResult struct:
|
/// Result of the common bootstrap sequence shared by all standard CRAIG services.
pub struct BootstrapResult {
pub db: DbPool,
pub auth: AuthLayer,
pub publisher: Publisher,
pub subscriber: Subscriber,
/// Holds OTEL providers alive. Drop flushes pending spans/metrics.
pub _telemetry: craig_common::telemetry::TelemetryGuard,
}
And update the return value at the end of bootstrap():
Ok((
settings,
BootstrapResult {
db,
auth,
publisher,
subscriber,
_telemetry,
},
))
This means ALL 8 backend services get the telemetry guard for free via their existing bootstrap() call. No changes needed in individual service main.rs files for the telemetry init — the BootstrapResult destructuring just gains a new field.
Backend service main.rs pattern (EXACT, from services/craig-rules/src/main.rs lines 17-26)
#[tokio::main]
async fn main() -> anyhow::Result<()> {
let (
settings,
BootstrapResult {
db,
auth,
publisher,
subscriber,
},
) = bootstrap("CRAIG_RULES", "craig-rules").await?;
Updated backend service main.rs pattern
#[tokio::main]
async fn main() -> anyhow::Result<()> {
let (
settings,
BootstrapResult {
db,
auth,
publisher,
subscriber,
_telemetry,
},
) = bootstrap("CRAIG_RULES", "craig-rules").await?;
The only change is adding _telemetry, to the destructuring pattern. This must be done in ALL 8 backend services:
| File | Prefix / service_name (already correct, no change) |
|---|---|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
craig-web special case (EXACT current init, from services/craig-web/src/main.rs lines 30-43)
craig-web does NOT use bootstrap(). Its current tracing initialization is:
#[tokio::main]
async fn main() -> anyhow::Result<()> {
dotenvy::dotenv().ok();
let settings = WebSettings::load()?;
tracing_subscriber::fmt()
.with_env_filter(
EnvFilter::try_from_default_env()
.unwrap_or_else(|_| EnvFilter::new(&settings.log_level)),
)
.init();
info!(?settings, "starting craig-web");
Replace with:
#[tokio::main]
async fn main() -> anyhow::Result<()> {
dotenvy::dotenv().ok();
let settings = WebSettings::load()?;
let _telemetry = craig_common::telemetry::init(&settings.log_level, "craig-web");
info!(?settings, "starting craig-web");
This replaces the direct tracing_subscriber::fmt() call with the shared telemetry::init(). Remove the now-unused import of tracing_subscriber::EnvFilter from the top of the file (line 24: use tracing_subscriber::EnvFilter;).
Everything else in craig-web’s main() stays the same: i18n loading (line 46-48), AppState construction (line 50-54), session layer (line 57-61), locale middleware (line 364-367), session layer (line 368). The _telemetry binding keeps the guard alive until main() returns.
craig-web also needs the OTEL propagation middleware on its router. The current final router assembly (lines 356-369) is:
let app = Router::new()
.merge(auth_routes)
.merge(public_report_routes)
.merge(protected_routes)
.merge(locale_route)
.nest_service("/static", static_service)
.route("/healthz", get(healthz))
.fallback(not_found)
.layer(axum::middleware::from_fn_with_state(
state.clone(),
middleware::set_locale,
))
.layer(session_layer)
.with_state(state);
Add a /metrics route and OTEL propagation middleware. The updated version:
let app = Router::new()
.merge(auth_routes)
.merge(public_report_routes)
.merge(protected_routes)
.merge(locale_route)
.nest_service("/static", static_service)
.route("/healthz", get(healthz))
.route("/metrics", get(metrics_handler))
.fallback(not_found)
.layer(axum::middleware::from_fn_with_state(
state.clone(),
middleware::set_locale,
))
.layer(session_layer)
.with_state(state);
Add the metrics_handler function to services/craig-web/src/main.rs:
async fn metrics_handler() -> impl IntoResponse {
match craig_common::telemetry::metrics_registry() {
Some(registry) => {
use prometheus::Encoder;
let encoder = prometheus::TextEncoder::new();
let mut buffer = Vec::new();
let _ = encoder.encode(®istry.gather(), &mut buffer);
(
[(axum::http::header::CONTENT_TYPE, "text/plain; charset=utf-8")],
buffer,
)
.into_response()
}
None => (
[(axum::http::header::CONTENT_TYPE, "text/plain; charset=utf-8")],
Vec::<u8>::new(),
)
.into_response(),
}
}
Add prometheus and craig-common (already present) to services/craig-web/Cargo.toml.
4b. Add MqHealth extension (backend services only)
In crates/craig-api/src/bootstrap.rs, the RabbitMQ connection is created at line 70:
let mq_conn = craig_mq::connect(&settings.rabbitmq_url)
.await
.context("failed to connect to RabbitMQ")?;
After this line, create the MqHealth wrapper and add it to BootstrapResult:
let mq_health = MqHealth(std::sync::Arc::new(mq_conn));
But wait — mq_conn is moved into create_channel() calls below. Instead, the MqHealth should wrap the connection before channels are created, and channels should be created from a clone of the Arc. However, lapin::Connection::create_channel() takes &self, so we can share via Arc:
Actually, looking at the code again, mq_conn is used only for create_channel() which takes &self. So we can wrap it in Arc first:
let mq_conn = craig_mq::connect(&settings.rabbitmq_url)
.await
.context("failed to connect to RabbitMQ")?;
let mq_conn = std::sync::Arc::new(mq_conn);
let mq_health = crate::MqHealth(mq_conn.clone());
let pub_channel = mq_conn
.create_channel()
.await
.context("failed to create publisher channel")?;
let publisher = Publisher::new(pub_channel);
let sub_channel = mq_conn
.create_channel()
.await
.context("failed to create subscriber channel")?;
let subscriber = Subscriber::new(sub_channel);
Add mq_health to BootstrapResult:
pub struct BootstrapResult {
pub db: DbPool,
pub auth: AuthLayer,
pub publisher: Publisher,
pub subscriber: Subscriber,
pub mq_health: crate::MqHealth,
pub _telemetry: craig_common::telemetry::TelemetryGuard,
}
Each backend service then adds the MqHealth as a layer extension. In the ApiServer::router() function, the caller passes it via the AppState or as a layer. The simplest approach: add it as a router extension. In each service’s main.rs, after constructing the router:
// After `ApiServer::router(...)`:
let router = router.layer(Extension(mq_health));
But since ApiServer::router() returns a Router (not Router<AppState>), we can add the extension layer directly.
Alternatively, add mq_health: Option<MqHealth> to AppState. However, AppState is defined in craig-api and used by all services, so adding an optional field is cleaner. The choice is left to the implementer — either approach works. The Extension approach is simpler and doesn’t change AppState.
4c. Cross-service HTTP trace header injection
The inject_trace_headers() function was added to craig-common in Step 1. Apply it at these 3 call sites:
1. services/craig-cases/src/api/investigations.rs (rules engine call)
Current code (around line 301-304):
let eval_response = rules_client
.client
.post(format!("{}/v1/rules/evaluate", rules_client.base_url))
.header("Authorization", &auth_header)
.json(&serde_json::json!({
Updated:
let eval_response = craig_common::telemetry::inject_trace_headers(
rules_client
.client
.post(format!("{}/v1/rules/evaluate", rules_client.base_url))
.header("Authorization", &auth_header)
)
.json(&serde_json::json!({
2. services/craig-intake/src/api/internal.rs (cases HTTP call)
Current code (around line 301-305):
let client = reqwest::Client::new();
let resp = client
.post(format!("{}/v1/cases/referrals", cases_url.0))
.bearer_auth(bearer_token)
.json(&referral_body)
.send()
Updated:
let client = reqwest::Client::new();
let resp = craig_common::telemetry::inject_trace_headers(
client
.post(format!("{}/v1/cases/referrals", cases_url.0))
.bearer_auth(bearer_token)
)
.json(&referral_body)
.send()
3. services/craig-web/src/api_client.rs (ALL backend calls)
This is the single change point for craig-web’s outbound HTTP calls. The ApiClient has 6 methods that make HTTP calls: get, post, put, delete, post_multipart, get_bytes.
For each method, wrap the request builder with inject_trace_headers. Example for get (current, lines 29-38):
pub async fn get(&self, base_url: &str, path: &str, token: &str) -> Result<Value> {
let url = format!("{base_url}{path}");
let resp = self
.http
.get(&url)
.bearer_auth(token)
.send()
.await
.with_context(|| format!("GET {url}"))?;
self.handle_response(resp, "GET", &url).await
}
Updated:
pub async fn get(&self, base_url: &str, path: &str, token: &str) -> Result<Value> {
let url = format!("{base_url}{path}");
let resp = craig_common::telemetry::inject_trace_headers(
self.http.get(&url).bearer_auth(token)
)
.send()
.await
.with_context(|| format!("GET {url}"))?;
self.handle_response(resp, "GET", &url).await
}
Apply the same pattern to all 6 methods: wrap self.http.{method}(&url).bearer_auth(token) (or the equivalent builder chain) with craig_common::telemetry::inject_trace_headers(…) before calling .send().
4d. Environment variables in docker-compose.yml
Add to each CRAIG service’s environment: block in docker-compose.yml:
OTEL_EXPORTER_OTLP_ENDPOINT: http://jaeger:4317
Services to update: craig-rules, craig-cases, craig-placement, craig-exchange, craig-financial, craig-reporting, craig-security, craig-intake, craig-intake-standalone, craig-web.
Also add depends_on for jaeger to each service (after existing depends_on entries):
jaeger:
condition: service_healthy
craig-intake-standalone should also get the OTEL env var.
|
Step 5: Devstack Infrastructure
Files: docker-compose.yml, devstack/prometheus/Dockerfile, devstack/prometheus/prometheus.yml, devstack/grafana/Dockerfile, devstack/grafana/provisioning/datasources/datasources.yml, devstack/grafana/provisioning/dashboards/dashboard.yml, devstack/grafana/provisioning/dashboards/json/craig-overview.json, xtask/src/main.rs
5a. Jaeger (trace storage + UI)
Add to docker-compose.yml BEFORE the # — CRAIG services -- comment (after the garage service, matching the pattern of infrastructure services first):
jaeger:
image: jaegertracing/jaeger:2
ports:
- "16686:16686" # Jaeger UI
- "4317:4317" # OTLP gRPC receiver
environment:
COLLECTOR_OTLP_ENABLED: "true"
healthcheck:
test: ["CMD-SHELL", "wget --spider -q http://localhost:16686 || exit 1"]
interval: 5s
timeout: 3s
retries: 5
Jaeger 2.x accepts OTLP natively (no separate collector). In-memory storage is fine for devstack.
5b. Prometheus (metrics scraping)
Create devstack/prometheus/prometheus.yml:
global:
scrape_interval: 15s
scrape_configs:
- job_name: 'craig-rules'
metrics_path: '/metrics'
static_configs:
- targets: ['craig-rules:8001']
- job_name: 'craig-cases'
metrics_path: '/metrics'
static_configs:
- targets: ['craig-cases:8002']
- job_name: 'craig-placement'
metrics_path: '/metrics'
static_configs:
- targets: ['craig-placement:8003']
- job_name: 'craig-exchange'
metrics_path: '/metrics'
static_configs:
- targets: ['craig-exchange:8004']
- job_name: 'craig-financial'
metrics_path: '/metrics'
static_configs:
- targets: ['craig-financial:8005']
- job_name: 'craig-reporting'
metrics_path: '/metrics'
static_configs:
- targets: ['craig-reporting:8006']
- job_name: 'craig-security'
metrics_path: '/metrics'
static_configs:
- targets: ['craig-security:8007']
- job_name: 'craig-intake'
metrics_path: '/metrics'
static_configs:
- targets: ['craig-intake:8008']
- job_name: 'craig-web'
metrics_path: '/metrics'
static_configs:
- targets: ['craig-web:8080']
Create devstack/prometheus/Dockerfile:
FROM prom/prometheus:latest
COPY prometheus.yml /etc/prometheus/prometheus.yml
Add to docker-compose.yml (after jaeger, before # — CRAIG services --):
prometheus:
build: ./devstack/prometheus
ports:
- "9090:9090"
depends_on:
craig-rules:
condition: service_healthy
craig-cases:
condition: service_healthy
craig-placement:
condition: service_healthy
craig-exchange:
condition: service_healthy
craig-financial:
condition: service_healthy
craig-reporting:
condition: service_healthy
craig-security:
condition: service_healthy
craig-intake:
condition: service_healthy
craig-web:
condition: service_healthy
healthcheck:
test: ["CMD", "wget", "--spider", "-q", "http://localhost:9090/-/healthy"]
interval: 10s
timeout: 3s
retries: 5
profiles:
- observability
Prometheus depends on all CRAIG services being healthy because it scrapes their /metrics endpoints. Using the observability profile means these containers are NOT started by default with docker compose up — only when explicitly requested with --profile observability. This keeps the default devstack lightweight.
|
5c. Grafana (dashboards)
Create devstack/grafana/Dockerfile:
FROM grafana/grafana:latest
COPY provisioning/ /etc/grafana/provisioning/
Create devstack/grafana/provisioning/datasources/datasources.yml:
apiVersion: 1
datasources:
- name: Prometheus
type: prometheus
access: proxy
url: http://prometheus:9090
isDefault: true
- name: Jaeger
type: jaeger
access: proxy
url: http://jaeger:16686
Create devstack/grafana/provisioning/dashboards/dashboard.yml:
apiVersion: 1
providers:
- name: 'default'
orgId: 1
folder: ''
type: file
disableDeletion: false
editable: true
options:
path: /etc/grafana/provisioning/dashboards/json
foldersFromFilesStructure: false
Create devstack/grafana/provisioning/dashboards/json/craig-overview.json:
{
"annotations": { "list": [] },
"editable": true,
"fiscalYearStartMonth": 0,
"graphTooltip": 1,
"id": null,
"links": [],
"liveNow": false,
"panels": [
{
"title": "Request Rate by Service",
"type": "timeseries",
"datasource": { "type": "prometheus", "uid": "prometheus" },
"gridPos": { "h": 8, "w": 12, "x": 0, "y": 0 },
"targets": [
{
"expr": "rate(http_server_request_duration_seconds_count[5m])",
"legendFormat": "{{job}}",
"refId": "A"
}
],
"fieldConfig": {
"defaults": {
"custom": { "drawStyle": "line", "lineWidth": 2, "fillOpacity": 10 },
"unit": "reqps"
},
"overrides": []
},
"options": { "legend": { "displayMode": "table", "placement": "bottom" } }
},
{
"title": "p95 Latency by Service",
"type": "timeseries",
"datasource": { "type": "prometheus", "uid": "prometheus" },
"gridPos": { "h": 8, "w": 12, "x": 12, "y": 0 },
"targets": [
{
"expr": "histogram_quantile(0.95, rate(http_server_request_duration_seconds_bucket[5m]))",
"legendFormat": "{{job}}",
"refId": "A"
}
],
"fieldConfig": {
"defaults": {
"custom": { "drawStyle": "line", "lineWidth": 2, "fillOpacity": 10 },
"unit": "s"
},
"overrides": []
},
"options": { "legend": { "displayMode": "table", "placement": "bottom" } }
},
{
"title": "Error Rate (5xx) by Service",
"type": "timeseries",
"datasource": { "type": "prometheus", "uid": "prometheus" },
"gridPos": { "h": 8, "w": 12, "x": 0, "y": 8 },
"targets": [
{
"expr": "rate(http_server_request_duration_seconds_count{http_status_code=~\"5..\"}[5m])",
"legendFormat": "{{job}}",
"refId": "A"
}
],
"fieldConfig": {
"defaults": {
"custom": { "drawStyle": "line", "lineWidth": 2, "fillOpacity": 10 },
"color": { "mode": "fixed", "fixedColor": "red" },
"unit": "reqps"
},
"overrides": []
},
"options": { "legend": { "displayMode": "table", "placement": "bottom" } }
},
{
"title": "Active Spans (Gauge)",
"type": "stat",
"datasource": { "type": "prometheus", "uid": "prometheus" },
"gridPos": { "h": 8, "w": 12, "x": 12, "y": 8 },
"targets": [
{
"expr": "sum by (job) (http_server_active_requests)",
"legendFormat": "{{job}}",
"refId": "A"
}
],
"fieldConfig": {
"defaults": { "unit": "short" },
"overrides": []
},
"options": {
"reduceOptions": { "calcs": ["lastNotNull"] },
"textMode": "auto",
"colorMode": "value",
"graphMode": "area"
}
}
],
"refresh": "10s",
"schemaVersion": 39,
"tags": ["craig", "overview"],
"templating": { "list": [] },
"time": { "from": "now-1h", "to": "now" },
"timepicker": {},
"timezone": "",
"title": "CRAIG Overview",
"uid": "craig-overview",
"version": 1
}
The metric names (http_server_request_duration_seconds, http_server_active_requests) are the OpenTelemetry semantic convention names. The actual metric names produced by tracing-opentelemetry + opentelemetry-prometheus may differ slightly depending on version. After deploying, verify the actual metric names by checking curl localhost:8001/metrics and adjust the Grafana queries accordingly.
|
Add to docker-compose.yml (after prometheus):
grafana:
build: ./devstack/grafana
ports:
- "3001:3000"
environment:
GF_SECURITY_ADMIN_PASSWORD: admin
GF_AUTH_ANONYMOUS_ENABLED: "true"
GF_AUTH_ANONYMOUS_ORG_ROLE: Admin
depends_on:
prometheus:
condition: service_healthy
healthcheck:
test: ["CMD", "wget", "--spider", "-q", "http://localhost:3000/api/health"]
interval: 10s
timeout: 3s
retries: 5
profiles:
- observability
Port 3001 on the host (mapped to container port 3000) to avoid conflicts with other dev servers.
5d. Devstack script updates
Update cargo xtask dev infrastructure:
-
Add jaeger to the infrastructure service list (always started — lightweight)
-
Add prometheus, grafana to an optional
--observabilityflag or theobservabilityprofile -
Print Jaeger UI URL (
http://localhost:16686) and Grafana URL (http://localhost:3001) on startup -
Add health check waits for jaeger
Files Touched
New files
| File | Purpose |
|---|---|
|
Trace propagation middleware + |
|
Prometheus container (COPY config) |
|
Scrape config for all 9 CRAIG services |
|
Grafana container (COPY provisioning) |
|
Prometheus + Jaeger datasources |
|
Dashboard provider config |
|
4-panel overview dashboard (request rate, p95 latency, error rate, active spans) |
Modified files
| File | Change |
|---|---|
|
Add 8 OpenTelemetry workspace deps ( |
|
Add otel + reqwest deps with |
|
Full rewrite: |
|
Add |
|
Add |
|
Update |
|
Add |
|
Add |
|
Add |
|
Add |
|
Add |
|
Same as craig-rules |
|
Wrap rules engine HTTP call with |
|
Add |
|
Same pattern |
|
Same pattern |
|
Same pattern |
|
Same pattern |
|
Same pattern |
|
Wrap cases HTTP call with |
|
Replace |
|
Wrap all 6 HTTP methods with |
|
Add jaeger, prometheus, grafana services; add |
|
Add observability services to health checks + status output |
Testing Strategy
-
Unit tests: No breakage expected. The
telemetry::init()signature changes (newservice_nameparam andTelemetryGuardreturn), butbootstrap()absorbs this change — onlyBootstrapResultdestructuring adds a field.EventEnvelopechanges are backward compatible via#[serde(default)]. Existing envelope tests pass without modification becausetrace_contextdefaults toNone. -
Integration tests: The
craig-test-lib::TestHarness(incrates/craig-test-lib/src/harness.rs) does NOT callbootstrap()ortelemetry::init()— it createsreqwest::Clientinstances andKeycloakTokenProviderdirectly. Tests do not injecttraceparentheaders, which is fine — services generate new traces for requests without a parent. Health check response shape changes from{"status":"ok"}to{"status":"ok","uptime_seconds":N,"checks":{…}}butdevstack_available()and docker healthchecks only check HTTP 200 status, not body shape. -
E2E tests: No changes — observability is infrastructure-only, no user-facing behavior changes.
-
New optional test: A single integration test verifying trace context propagates across craig-cases→craig-rules HTTP call (query Jaeger API for connected spans). Low priority — manual verification via Jaeger UI is sufficient.
Verification
-
cargo fmt --all+cargo clippy --workspace --locked— zero warnings -
cargo nextest run --workspace --lib— unit tests pass (including existing EventEnvelope serde tests) -
cargo xtask dev restart— all services + jaeger healthy -
cargo nextest run --workspace— integration tests pass -
cargo xtask e2e— E2E tests pass -
Open Jaeger UI (
http://localhost:16686) — select any service, see traces with spans -
Trigger a cross-service workflow (e.g., create referral → safety assessment via craig-web) — verify connected trace spanning craig-web → craig-cases → craig-rules
-
Start observability stack:
docker compose --profile observability up -d -
Open Grafana (
http://localhost:3001) — verify CRAIG Overview dashboard shows request rates and latencies -
curl localhost:8001/metrics— verify Prometheus text output -
curl localhost:8001/healthz— verify enriched response withdatabase,rabbitmqchecks, anduptime_seconds
Documentation Updates
-
.claude/docs/services.md— add/metricsendpoint to all services -
CHANGELOG.adoc— entry under== Unreleased -
.claude/docs/local-dev.md— add Jaeger/Prometheus/Grafana URLs to devstack reference -
.claude/docs/architecture.md— add observability section -
docs/modules/ROOT/pages/devstack.adoc— document new container URLs and--profile observability