Observability & Monitoring
OpenTelemetry distributed tracing, Prometheus metrics, Grafana Loki centralized logging, and Jaeger trace visualization with pre-built alert rules.
SCRIPE implements a complete observability stack built on open standards: OpenTelemetry for distributed tracing, Prometheus for metrics collection, Grafana Loki for centralized logging, and Jaeger for trace visualization. Every SCRIPE request handler is automatically traced, every HTTP request generates metrics, and every log entry is enriched with CorrelationId, TenantId, and ModuleTag. The entire stack is opt-in — in development you can run with console-only output and zero external dependencies.
Observability Stack Architecture
Distributed Tracing (OpenTelemetry)
The TracingBehavior SCRIPE mediator pipeline creates an OpenTelemetry span for every command and query handler. Spans include auto-detected module names, request types, and duration measurements. Errors are automatically recorded with exception details. Traces flow to Jaeger via OTLP gRPC protocol (:4317) for visualization and analysis.
/// <summary>
/// Creates an OpenTelemetry span for every SCRIPE request handler.
/// Auto-detects the module from the handler's namespace.
/// Pipeline order: Exception → Validation → Auth → FeatureCheck →
/// Cache → TRACING → Audit → Handler
/// </summary>
public class TracingBehavior<TRequest, TResponse>
: IPipelineBehavior<TRequest, TResponse>
{
private static readonly ActivitySource Source = new("SCRIPE.Mediator");
public async Task<TResponse> Handle(
TRequest request, RequestHandlerDelegate<TResponse> next,
CancellationToken ct)
{
var requestName = typeof(TRequest).Name;
var kind = requestName.EndsWith("Query")
? "Query" : "Command";
var module = DetectModule(typeof(TRequest).Namespace);
using var activity = Source.StartActivity(
$"AstraFlow mediator {kind}: {requestName}",
ActivityKind.Internal);
activity?.SetTag("mediatr.request_type", requestName);
activity?.SetTag("mediatr.kind", kind);
activity?.SetTag("mediatr.module", module);
try
{
var response = await next();
activity?.SetStatus(ActivityStatusCode.Ok);
return response;
}
catch (Exception ex)
{
activity?.SetStatus(ActivityStatusCode.Error, ex.Message);
activity?.RecordException(ex);
throw;
}
}
}| Span Tag | Example Value | Purpose |
|---|---|---|
| mediatr.request_type | GetAdminsQuery | Identify the handler being executed |
| mediatr.kind | Query / Command | Distingscripeh read vs write operations |
| mediatr.module | Identity / Entitlements | Filter traces by module in Jaeger UI |
| mediatr.duration_ms | 45 | Performance profiling and SLA monitoring |
| otel.status_code | OK / ERROR | Error attribution and alert triggering |
Prometheus Metrics
The /metrics endpoint exposes OpenTelemetry metrics in Prometheus text format. Prometheus scrapes this endpoint at 15-second intervals, collecting HTTP request durations (histogram), active requests (gauge), GC collections, CPU time, and working set memory. In monolith mode, a single scrape target is needed. In microservice mode, configure one scrape job per module service.
global:
scrape_interval: 15s
evaluation_interval: 15s
# Load alert rules
rule_files:
- /etc/prometheus/alerts/*.yml
scrape_configs:
# ── Monolith Mode (single target) ──
- job_name: 'scripe-backend'
static_configs:
- targets: ['host.docker.internal:5035']
metrics_path: '/metrics'
scrape_interval: 15s
# ── Microservice Mode (one target per module) ──
# Uncomment and configure per deployed module:
# - job_name: 'scripe-identity'
# static_configs:
# - targets: ['identity-service:5001']
# - job_name: 'scripe-entitlements'
# static_configs:
# - targets: ['entitlements-service:5004']
# - job_name: 'scripe-gateway'
# static_configs:
# - targets: ['gateway-service:5010']| Metric | Type | Description |
|---|---|---|
| http_server_request_duration_seconds | Histogram | HTTP request duration broken down by method, path, and status code |
| http_server_active_requests | Gauge | Number of HTTP requests currently being processed |
| dotnet_gc_collections_total | Counter | GC collection count by generation (0, 1, 2) |
| process_cpu_seconds_total | Counter | Total CPU time consumed by the process |
| process_working_set_bytes | Gauge | Current working set memory in bytes |
Centralized Logging (Serilog + Loki)
Serilog enriches every log entry with machine name, environment, correlation ID, tenant ID, and module tag. When Loki is configured (Loki:Url is set), logs are pushed in real-time via the GrafanaLoki sink. When Loki:Url is empty, logging falls back to console only — this is the default in Development. The 'Application' label is always set to 'SCRIPE' to distingscripeh from other services in a shared Loki instance.
builder.Host.UseSerilog((context, loggerConfig) =>
{
loggerConfig
.ReadFrom.Configuration(context.Configuration)
.Enrich.FromLogContext()
.Enrich.WithMachineName()
.Enrich.WithEnvironmentName()
.Enrich.WithProperty("Application", "SCRIPE");
// Loki sink — opt-in via configuration
// Leave Loki:Url empty in dev to disable (logs go to console only)
// Set Loki:Url in staging/production to enable centralized logging
var lokiUrl = context.Configuration["Loki:Url"];
if (!string.IsNullOrEmpty(lokiUrl))
{
loggerConfig.WriteTo.GrafanaLoki(lokiUrl,
labels: [
new LokiLabel { Key = "app", Value = "scripe" },
new LokiLabel { Key = "environment",
Value = context.HostingEnvironment.EnvironmentName },
],
propertiesAsLabels: [
"Application", "CorrelationId",
"TenantId", "ModuleTag"
]);
}
});| Enrichment | Source | Purpose |
|---|---|---|
| MachineName | System.Environment | Identify which server/container/pod emitted the log |
| EnvironmentName | ASPNETCORE_ENVIRONMENT | Distingscripeh Development/Staging/Production logs |
| CorrelationId | X-Correlation-ID header | Trace a single request across ALL log entries and services |
| TenantId | JWT claims (auto-extracted) | Filter logs by tenant in multi-tenant deployment |
| ModuleTag | Auto-detected from endpoint/namespace | Filter logs by module (Identity, Entitlements, Products, etc.) |
| Application | Static enrichment | Fixed label 'SCRIPE' — distingscripeh from other apps in shared Loki |
Alert Rules
Pre-configured Prometheus alert rules detect critical and warning conditions. Critical alerts fire immediately for high error rates, database outages, and extreme latency. Warning alerts track P95 degradation, auth anomalies, memory pressure, CPU spikes, and disk space. Alert rules are stored in infrastructure/monitoring/prometheus/alerts/ and auto-loaded by Prometheus.
| Alert | Severity | Condition | Action |
|---|---|---|---|
| HighErrorRate | Critical | >5% of requests return 5xx for 5 minutes | Page on-call engineer immediately |
| DatabaseDown | Critical | Health check fails for 1 minute | Auto-failover if configured, otherwise escalate |
| HighP99Latency | Critical | P99 response time >5s for 10 minutes | Scale up pods or investigate slow queries |
| ServiceDown | Critical | No metrics received for 2 minutes | Restart container, check deployment |
| HighP95Latency | Warning | P95 response time >2s for 5 minutes | Monitor trend, investigate if persists |
| AuthSpikeAnomaly | Warning | >50 auth failures in 5 minutes | Check for brute force attack, verify IP blocking |
| HighMemoryUsage | Warning | >85% memory utilization for 10 minutes | Investigate memory leak, check GC metrics |
| HighCPUUsage | Warning | >90% CPU usage for 5 minutes | Scale horizontally, profile hot paths |
| DiskSpaceLow | Warning | <10% free disk space | Clean old logs/snapshots, extend volume |
Docker Monitoring Stack
A pre-built Docker Compose file (infrastructure/monitoring/docker-compose.monitoring.yml) launches the complete monitoring stack: Prometheus v3.2.1, Grafana v11.5.2, Loki v3.4.2, and Jaeger v2.4.0. All datasources, dashboards, and alert rules are auto-provisioned via Grafana's provisioning system. Grafana runs on port 3001 to avoid conflicts with the Next.js dev server on 3000.
# Start the full monitoring stack (from SCRIPE root directory)
docker compose -f infrastructure/monitoring/docker-compose.monitoring.yml up -d
# ═══════════════════════════════════════════════════════════
# Access Points:
# ═══════════════════════════════════════════════════════════
# Grafana: http://localhost:3001 (admin / scripe-admin)
# Prometheus: http://localhost:9090 (metrics store + alerts)
# Jaeger: http://localhost:16686 (distributed tracing UI)
# Loki: http://localhost:3100 (log aggregation API only)
#
# Your app exposes:
# /metrics → Prometheus scrapes this every 15s
# OTLP :4317 → Jaeger receives traces via gRPC
# Serilog → Pushes logs to Loki (when Loki:Url is set)
# ═══════════════════════════════════════════════════════════
# Verify everything is running
docker ps --filter name=scripe- --format "table {{.Names}}\t{{.Status}}\t{{.Ports}}"
# View Prometheus targets (should show "UP")
curl http://localhost:9090/api/v1/targets | jq '.data.activeTargets[].health'
# Stop the monitoring stack
docker compose -f infrastructure/monitoring/docker-compose.monitoring.yml downObservability Configuration
{
"Observability": {
"ExporterType": "Console",
"ServiceName": "SCRIPE-Dev",
"OtlpEndpoint": "http://localhost:4317",
"TraceSampleRatio": 1.0,
"EnablePrometheus": true,
"Enabled": true
},
"Loki": {
"Url": ""
}
}{
"Observability": {
"ExporterType": "Otlp",
"ServiceName": "SCRIPE-Production",
"OtlpEndpoint": "http://jaeger:4317",
"TraceSampleRatio": 0.1,
"EnablePrometheus": true,
"Enabled": true
},
"Loki": {
"Url": "http://loki:3100"
}
}| Environment | ExporterType | TraceSampleRatio | Loki:Url | Notes |
|---|---|---|---|---|
| Development | Console | 1.0 (100%) | Empty (disabled) | All traces to console, no external deps needed |
| Docker Compose (local) | Otlp | 1.0 (100%) | http://loki:3100 | Full stack via monitoring compose, 100% sampling |
| Staging | Otlp | 0.5 (50%) | http://loki:3100 | Half traces sampled, validates production config |
| Production | Otlp | 0.1 (10%) | http://loki:3100 | 10% sampling to reduce overhead, all logs still pushed |
| IIS (Windows) | Otlp | 0.1 (10%) | http://loki-server:3100 | Point to your Grafana stack server hostname |