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

SCRIPE Observability Stack
SCRIPE Backend — OpenTelemetry SDK
Prometheus — Scrapes /metrics
Jaeger — OTLP gRPC :4317
Loki — Serilog push API
Grafana — Unified dashboards

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.

TracingBehavior.cs — AstraFlow mediator Pipeline TracingC#
/// <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 TagExample ValuePurpose
mediatr.request_typeGetAdminsQueryIdentify the handler being executed
mediatr.kindQuery / CommandDistingscripeh read vs write operations
mediatr.moduleIdentity / EntitlementsFilter traces by module in Jaeger UI
mediatr.duration_ms45Performance profiling and SLA monitoring
otel.status_codeOK / ERRORError 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.

prometheus.yml — Scrape ConfigurationYAML
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']
MetricTypeDescription
http_server_request_duration_secondsHistogramHTTP request duration broken down by method, path, and status code
http_server_active_requestsGaugeNumber of HTTP requests currently being processed
dotnet_gc_collections_totalCounterGC collection count by generation (0, 1, 2)
process_cpu_seconds_totalCounterTotal CPU time consumed by the process
process_working_set_bytesGaugeCurrent 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.

ServiceExtensions.cs — Serilog + Loki ConfigurationC#
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"
            ]);
    }
});
EnrichmentSourcePurpose
MachineNameSystem.EnvironmentIdentify which server/container/pod emitted the log
EnvironmentNameASPNETCORE_ENVIRONMENTDistingscripeh Development/Staging/Production logs
CorrelationIdX-Correlation-ID headerTrace a single request across ALL log entries and services
TenantIdJWT claims (auto-extracted)Filter logs by tenant in multi-tenant deployment
ModuleTagAuto-detected from endpoint/namespaceFilter logs by module (Identity, Entitlements, Products, etc.)
ApplicationStatic enrichmentFixed 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.

AlertSeverityConditionAction
HighErrorRateCritical>5% of requests return 5xx for 5 minutesPage on-call engineer immediately
DatabaseDownCriticalHealth check fails for 1 minuteAuto-failover if configured, otherwise escalate
HighP99LatencyCriticalP99 response time >5s for 10 minutesScale up pods or investigate slow queries
ServiceDownCriticalNo metrics received for 2 minutesRestart container, check deployment
HighP95LatencyWarningP95 response time >2s for 5 minutesMonitor trend, investigate if persists
AuthSpikeAnomalyWarning>50 auth failures in 5 minutesCheck for brute force attack, verify IP blocking
HighMemoryUsageWarning>85% memory utilization for 10 minutesInvestigate memory leak, check GC metrics
HighCPUUsageWarning>90% CPU usage for 5 minutesScale horizontally, profile hot paths
DiskSpaceLowWarning<10% free disk spaceClean 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 Monitoring StackShell
# 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 down

Observability Configuration

appsettings.Development.json — Development (Console only)JSON
{
  "Observability": {
    "ExporterType": "Console",
    "ServiceName": "SCRIPE-Dev",
    "OtlpEndpoint": "http://localhost:4317",
    "TraceSampleRatio": 1.0,
    "EnablePrometheus": true,
    "Enabled": true
  },
  "Loki": {
    "Url": ""
  }
}
appsettings.Production.json — Production (Full stack)JSON
{
  "Observability": {
    "ExporterType": "Otlp",
    "ServiceName": "SCRIPE-Production",
    "OtlpEndpoint": "http://jaeger:4317",
    "TraceSampleRatio": 0.1,
    "EnablePrometheus": true,
    "Enabled": true
  },
  "Loki": {
    "Url": "http://loki:3100"
  }
}
EnvironmentExporterTypeTraceSampleRatioLoki:UrlNotes
DevelopmentConsole1.0 (100%)Empty (disabled)All traces to console, no external deps needed
Docker Compose (local)Otlp1.0 (100%)http://loki:3100Full stack via monitoring compose, 100% sampling
StagingOtlp0.5 (50%)http://loki:3100Half traces sampled, validates production config
ProductionOtlp0.1 (10%)http://loki:310010% sampling to reduce overhead, all logs still pushed
IIS (Windows)Otlp0.1 (10%)http://loki-server:3100Point to your Grafana stack server hostname