diff --git a/.gitignore b/.gitignore index 769a701..9cb4d46 100644 --- a/.gitignore +++ b/.gitignore @@ -351,3 +351,6 @@ src/Garage.FeatureFlags/flagsapi # Python virtual environments src/Garage.ChatService/.venv/ src/Garage.ChatService/prompts.egg-info/ + +# Playwright test artifacts +.playwright-mcp diff --git a/Directory.Packages.props b/Directory.Packages.props index 200f812..27ad416 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -24,6 +24,7 @@ + diff --git a/README.md b/README.md index a66d300..2a36bd7 100644 --- a/README.md +++ b/README.md @@ -127,6 +127,27 @@ aspire run - Web Frontend: [https://localhost:7070](https://localhost:7070) - API Service: [https://localhost:7071](https://localhost:7071) - Aspire Dashboard: [https://localhost:15888](https://localhost:15888) +- Grafana (LGTM): [http://localhost:3000](http://localhost:3000) + +### Provisioned Grafana Dashboards + +Grafana dashboard provisioning is configured in the AppHost and mounted into the LGTM container. + +- Provider config: `src/Garage.AppHost/grafana/provisioning/dashboards/custom.yaml` +- Dashboard JSON folder: `src/Garage.AppHost/grafana/provisioning/dashboards/custom/` + +To add an external dashboard: + +1. Export or download the dashboard JSON. +2. Place it in `src/Garage.AppHost/grafana/provisioning/dashboards/custom/`. +3. Restart Aspire (`aspire run`) so LGTM reloads provisioned dashboards. +4. Open Grafana and navigate to the **OpenFeature** folder. + +The starter dashboard includes datasource placeholder variables you can reuse in your panels: + +- `$metrics_ds` (Prometheus) +- `$logs_ds` (Loki) +- `$traces_ds` (Tempo) ### 6. OpenAPI and Scalar Documentation (Development) diff --git a/src/Garage.AppHost/Garage.AppHost.csproj b/src/Garage.AppHost/Garage.AppHost.csproj index c0b06e4..e91b37b 100644 --- a/src/Garage.AppHost/Garage.AppHost.csproj +++ b/src/Garage.AppHost/Garage.AppHost.csproj @@ -24,6 +24,7 @@ + diff --git a/src/Garage.AppHost/Program.cs b/src/Garage.AppHost/Program.cs index c13d1e0..5b5931b 100644 --- a/src/Garage.AppHost/Program.cs +++ b/src/Garage.AppHost/Program.cs @@ -35,7 +35,7 @@ var migrations = apiService.AddEFMigrations("api-migrations", "Garage.ApiModel.Data.GarageDbContext"); -var webFrontend = builder.AddJavaScriptApp("web", "../Garage.Web/").WithBrowserLogs(); +var webFrontend = builder.AddJavaScriptApp("web", "../Garage.Web/"); // Add Python chat service using Uvicorn (FastAPI/ASGI) var chatService = builder.AddUvicornApp("chatservice", "../Garage.ChatService/", "main:app") @@ -59,16 +59,41 @@ var flagsApi = builder.AddGoApp("flagsapi", "../Garage.FeatureFlags/") .WithHttpEndpoint(env: "PORT") .WithExternalHttpEndpoints() - .WithEnvironment("OTEL_EXPORTER_OTLP_PROTOCOL", "http/protobuf") .PublishAsDockerFile(); if (!builder.ExecutionContext.IsPublishMode) { + // Add local Grafana provisioning path for dashboards + var grafanaProvisioningPath = Path.Combine(builder.AppHostDirectory, "grafana", "provisioning", "dashboards"); + if (!Directory.Exists(grafanaProvisioningPath)) + { + throw new DirectoryNotFoundException($"Grafana provisioning path not found: {grafanaProvisioningPath}"); + } + + // Add Grafana LGTM stack (Loki, Tempo, Prometheus, Pyroscope, Grafana) + var lgtm = builder.AddContainer("lgtm", "grafana/otel-lgtm", "latest") + .WithHttpEndpoint(port: 3000, targetPort: 3000, name: "grafana") + .WithBindMount(grafanaProvisioningPath, "/otel-lgtm/grafana/conf/provisioning/dashboards", isReadOnly: true) + .WithExternalHttpEndpoints(); + + // Add collector for OpenTelemetry signals + var collector = builder.AddOpenTelemetryCollector("opentelemetry-collector") + .WithConfig("otel/config.yaml") + .WithExternalHttpEndpoints() + .WithAppForwarding() + .WaitFor(lgtm); + // Local development: flagd reads from host filesystem via bind mount var flagsPath = Path.Combine(builder.AppHostDirectory, "flags", "flagd.json"); flagd.WithBindFileSync(Path.GetDirectoryName(flagsPath)!); + flagd = flagd.WaitFor(collector); + chatService = chatService.WaitFor(collector); + apiService = apiService.WaitFor(collector); + migrations = migrations.WaitFor(collector); + flagsApi = flagsApi + .WaitFor(collector) .WithEnvironment("FLAGS_FILE_PATH", flagsPath) .WaitFor(flagd); @@ -81,6 +106,16 @@ var tunnel = builder.AddDevTunnel("tunnel") .WithReference(flagd) .WithAnonymousAccess(); + + // Browser telemetry is sent directly from the browser to the collector, so it + // must target the collector's HTTP OTLP endpoint (port 4318) which has CORS + // configured — browsers can't use gRPC. Setting the protocol to "http" makes + // WithAppForwarding route to the collector's "http" endpoint instead of "grpc". + webFrontend = webFrontend + .WithBrowserLogs() + .WithOtlpExporter() + .WithEnvironment("OTEL_EXPORTER_OTLP_PROTOCOL", "http") + .WaitFor(collector); } else { @@ -98,6 +133,7 @@ flagsApi = flagsApi .WithReference(flagsBlobs) + .WithEnvironment("OTEL_EXPORTER_OTLP_PROTOCOL", "http/protobuf") .WithEnvironment("AZURE_STORAGE_ACCOUNT", accountName) .WithEnvironment("FLAGS_BLOB_CONTAINER", "flags") .WithEnvironment("FLAGS_BLOB_NAME", "flagd.json"); diff --git a/src/Garage.AppHost/grafana/provisioning/dashboards/custom.yaml b/src/Garage.AppHost/grafana/provisioning/dashboards/custom.yaml new file mode 100644 index 0000000..54c5a26 --- /dev/null +++ b/src/Garage.AppHost/grafana/provisioning/dashboards/custom.yaml @@ -0,0 +1,12 @@ +apiVersion: 1 + +providers: + - name: "OpenFeature Dashboards" + orgId: 1 + folder: "OpenFeature" + type: file + disableDeletion: false + allowUiUpdates: true + options: + path: /otel-lgtm/grafana/conf/provisioning/dashboards/custom + foldersFromFilesStructure: false diff --git a/src/Garage.AppHost/grafana/provisioning/dashboards/custom/flags.json b/src/Garage.AppHost/grafana/provisioning/dashboards/custom/flags.json new file mode 100644 index 0000000..198b880 --- /dev/null +++ b/src/Garage.AppHost/grafana/provisioning/dashboards/custom/flags.json @@ -0,0 +1,327 @@ +{ + "id": null, + "uid": "feature-flags", + "title": "Feature Flags", + "description": "Feature flag evaluation metrics.", + "tags": [ + "openfeature", + "feature-flags", + "lgtm" + ], + "timezone": "browser", + "schemaVersion": 39, + "version": 1, + "editable": true, + "refresh": "30s", + "panels": [ + { + "id": 9, + "type": "stat", + "title": "Active Feature Flags", + "description": "Total number of currently active feature flags", + "gridPos": { "h": 4, "w": 6, "x": 0, "y": 0 }, + "datasource": "$metrics_ds", + "targets": [ + { + "refId": "A", + "expr": "sum(feature_flag_evaluation_active_count{feature_flag_key=~\"$feature_flag_key\"})", + "instant": true, + "legendFormat": "Active Flags" + } + ], + "fieldConfig": { + "defaults": { + "unit": "short", + "color": { "mode": "fixed", "fixedColor": "blue" } + }, + "overrides": [] + }, + "options": { + "reduceOptions": { "calcs": ["lastNotNull"], "fields": "", "values": false }, + "orientation": "auto", + "colorMode": "background", + "graphMode": "none", + "justifyMode": "auto", + "textMode": "auto" + } + }, + { + "id": 10, + "type": "stat", + "title": "Evaluation Request Rate", + "description": "Total evaluation requests per second", + "gridPos": { "h": 4, "w": 6, "x": 6, "y": 0 }, + "datasource": "$metrics_ds", + "targets": [ + { + "refId": "A", + "expr": "sum(rate(feature_flag_evaluation_requests_total{}[$__rate_interval]))", + "instant": true, + "legendFormat": "Requests/s" + } + ], + "fieldConfig": { + "defaults": { + "unit": "reqps", + "color": { "mode": "fixed", "fixedColor": "green" } + }, + "overrides": [] + }, + "options": { + "reduceOptions": { "calcs": ["lastNotNull"], "fields": "", "values": false }, + "orientation": "auto", + "colorMode": "background", + "graphMode": "area", + "justifyMode": "auto", + "textMode": "auto" + } + }, + { + "id": 11, + "type": "stat", + "title": "Evaluation Success Rate", + "description": "Total successful evaluations per second", + "gridPos": { "h": 4, "w": 6, "x": 12, "y": 0 }, + "datasource": "$metrics_ds", + "targets": [ + { + "refId": "A", + "expr": "sum(rate(feature_flag_evaluation_success_total{}[$__rate_interval]))", + "instant": true, + "legendFormat": "Successes/s" + } + ], + "fieldConfig": { + "defaults": { + "unit": "reqps", + "color": { "mode": "fixed", "fixedColor": "green" } + }, + "overrides": [] + }, + "options": { + "reduceOptions": { "calcs": ["lastNotNull"], "fields": "", "values": false }, + "orientation": "auto", + "colorMode": "background", + "graphMode": "area", + "justifyMode": "auto", + "textMode": "auto" + } + }, + { + "id": 12, + "type": "stat", + "title": "Evaluation Error Rate", + "description": "Total evaluation errors per second", + "gridPos": { "h": 4, "w": 6, "x": 18, "y": 0 }, + "datasource": "$metrics_ds", + "targets": [ + { + "refId": "A", + "expr": "sum(rate(feature_flag_evaluation_error_total{}[$__rate_interval]))", + "instant": true, + "legendFormat": "Errors/s" + } + ], + "fieldConfig": { + "defaults": { + "unit": "reqps", + "color": { "mode": "thresholds" }, + "thresholds": { + "mode": "absolute", + "steps": [ + { "value": 0, "color": "green" }, + { "value": 0.01, "color": "red" } + ] + } + }, + "overrides": [] + }, + "options": { + "reduceOptions": { "calcs": ["lastNotNull"], "fields": "", "values": false }, + "orientation": "auto", + "colorMode": "background", + "graphMode": "area", + "justifyMode": "auto", + "textMode": "auto" + } + }, + { + "id": 13, + "type": "timeseries", + "title": "Request Rate by Feature Flag", + "description": "Evaluation requests per second broken down by flag key", + "gridPos": { "h": 8, "w": 12, "x": 0, "y": 4 }, + "datasource": "$metrics_ds", + "targets": [ + { + "refId": "A", + "expr": "sum by(feature_flag_key) (rate(feature_flag_evaluation_requests_total{}[$__rate_interval]))", + "legendFormat": "{{feature_flag_key}}" + } + ], + "fieldConfig": { + "defaults": { + "unit": "reqps", + "color": { "mode": "palette-classic" } + }, + "overrides": [] + }, + "options": { + "legend": { "displayMode": "table", "placement": "right" }, + "tooltip": { "mode": "multi", "sort": "none" } + } + }, + { + "id": 14, + "type": "timeseries", + "title": "Success Rate by Flag and Reason", + "description": "Successful evaluations per second by flag key and result reason", + "gridPos": { "h": 8, "w": 12, "x": 12, "y": 4 }, + "datasource": "$metrics_ds", + "targets": [ + { + "refId": "A", + "expr": "sum by(feature_flag_key, feature_flag_result_reason) (rate(feature_flag_evaluation_success_total{}[$__rate_interval]))", + "legendFormat": "{{feature_flag_key}} - {{feature_flag_result_reason}}" + } + ], + "fieldConfig": { + "defaults": { + "unit": "reqps", + "color": { "mode": "palette-classic" } + }, + "overrides": [] + }, + "options": { + "legend": { "displayMode": "table", "placement": "right" }, + "tooltip": { "mode": "multi", "sort": "none" } + } + }, + { + "id": 15, + "type": "timeseries", + "title": "Error Rate by Flag and Exception", + "description": "Evaluation errors per second by flag key and exception type", + "gridPos": { "h": 8, "w": 12, "x": 0, "y": 12 }, + "datasource": "$metrics_ds", + "targets": [ + { + "refId": "A", + "expr": "sum by(feature_flag_key, exception) (rate(feature_flag_evaluation_error_total{}[$__rate_interval]))", + "legendFormat": "{{feature_flag_key}} - {{exception}}" + } + ], + "fieldConfig": { + "defaults": { + "unit": "reqps", + "color": { "mode": "fixed", "fixedColor": "red" } + }, + "overrides": [] + }, + "options": { + "legend": { "displayMode": "table", "placement": "right" }, + "tooltip": { "mode": "multi", "sort": "none" } + } + }, + { + "id": 16, + "type": "table", + "title": "Active Flag Count by Key", + "description": "Current active evaluation count per feature flag key, sorted descending", + "gridPos": { "h": 8, "w": 12, "x": 12, "y": 12 }, + "datasource": "$metrics_ds", + "targets": [ + { + "refId": "A", + "expr": "sort_desc(sum by(feature_flag_key) (feature_flag_evaluation_active_count{feature_flag_key=~\"$feature_flag_key\"}))", + "instant": true, + "legendFormat": "{{feature_flag_key}}" + } + ], + "options": { + "showHeader": true, + "sortBy": [ + { "displayName": "Value", "desc": true } + ] + }, + "fieldConfig": { + "defaults": { + "unit": "short" + }, + "overrides": [] + } + } + ], + "templating": { + "list": [ + { + "name": "metrics_ds", + "label": "Metrics datasource", + "type": "datasource", + "query": "prometheus", + "current": { "text": "Prometheus", "value": "Prometheus" }, + "refresh": 1 + }, + { + "name": "logs_ds", + "label": "Logs datasource", + "type": "datasource", + "query": "loki", + "current": { "text": "Loki", "value": "Loki" }, + "refresh": 1 + }, + { + "name": "traces_ds", + "label": "Traces datasource", + "type": "datasource", + "query": "tempo", + "current": { "text": "Tempo", "value": "Tempo" }, + "refresh": 1 + }, + { + "name": "feature_flag_key", + "label": "Feature Flag", + "type": "query", + "datasource": "$metrics_ds", + "query": "label_values(feature_flag_evaluation_requests_total, feature_flag_key)", + "includeAll": true, + "allValue": ".*", + "multi": false, + "refresh": 1, + "current": { "text": "All", "value": ".*" } + } + ] + }, + "annotations": { + "list": [ + { + "builtIn": 1, + "datasource": "-- Grafana --", + "enable": true, + "hide": true, + "iconColor": "rgba(0, 211, 255, 1)", + "name": "Annotations & Alerts", + "type": "dashboard" + } + ] + }, + "time": { + "from": "now-6h", + "to": "now" + }, + "timepicker": { + "refresh_intervals": [ + "5s", + "10s", + "30s", + "1m", + "5m", + "15m", + "30m", + "1h", + "2h", + "1d" + ] + }, + "weekStart": "" +} diff --git a/src/Garage.AppHost/grafana/provisioning/dashboards/custom/openfeature-overview.json b/src/Garage.AppHost/grafana/provisioning/dashboards/custom/openfeature-overview.json new file mode 100644 index 0000000..5cee180 --- /dev/null +++ b/src/Garage.AppHost/grafana/provisioning/dashboards/custom/openfeature-overview.json @@ -0,0 +1,103 @@ +{ + "id": null, + "uid": "openfeature-overview", + "title": "OpenFeature Overview", + "tags": [ + "openfeature", + "aspire", + "lgtm" + ], + "timezone": "browser", + "schemaVersion": 39, + "version": 1, + "editable": true, + "refresh": "10s", + "panels": [ + { + "id": 1, + "type": "text", + "title": "About", + "gridPos": { + "h": 8, + "w": 24, + "x": 0, + "y": 0 + }, + "options": { + "mode": "markdown", + "content": "# OpenFeature Aspire Dashboard\\n\\nThis dashboard is provisioned from the repository and loaded automatically by LGTM.\\n\\nDatasource placeholders are available as dashboard variables:\\n- `$metrics_ds` (Prometheus)\\n- `$logs_ds` (Loki)\\n- `$traces_ds` (Tempo)\\n\\nUse these variables when adding or cloning panels so dashboards remain portable across environments." + } + }, + { + "id": 2, + "type": "timeseries", + "title": "Service Up (Sample)", + "gridPos": { + "h": 8, + "w": 24, + "x": 0, + "y": 8 + }, + "datasource": "$metrics_ds", + "targets": [ + { + "refId": "A", + "expr": "sum(up)", + "legendFormat": "sum(up)" + } + ], + "options": { + "legend": { + "displayMode": "table", + "placement": "bottom" + } + } + } + ], + "templating": { + "list": [ + { + "name": "metrics_ds", + "label": "Metrics datasource", + "type": "datasource", + "query": "prometheus", + "current": { + "text": "Prometheus", + "value": "Prometheus" + }, + "refresh": 1 + }, + { + "name": "logs_ds", + "label": "Logs datasource", + "type": "datasource", + "query": "loki", + "current": { + "text": "Loki", + "value": "Loki" + }, + "refresh": 1 + }, + { + "name": "traces_ds", + "label": "Traces datasource", + "type": "datasource", + "query": "tempo", + "current": { + "text": "Tempo", + "value": "Tempo" + }, + "refresh": 1 + } + ] + }, + "annotations": { + "list": [] + }, + "time": { + "from": "now-15m", + "to": "now" + }, + "timepicker": {}, + "weekStart": "" +} diff --git a/src/Garage.AppHost/otel/config.yaml b/src/Garage.AppHost/otel/config.yaml new file mode 100644 index 0000000..c2e9099 --- /dev/null +++ b/src/Garage.AppHost/otel/config.yaml @@ -0,0 +1,56 @@ +receivers: + otlp: + protocols: + grpc: + endpoint: 0.0.0.0:4317 + http: + endpoint: 0.0.0.0:4318 + cors: + allowed_origins: + - http://localhost:* + - https://localhost:* + # Important, make sure you whitelists all "unsafe" headers + allowed_headers: + - Authorization + - X-Requested-With + - Accept + - Accept-Language + - Content-Language + - Content-Type + - Range + - Access-Control-Allow-Origin + # Sent by the browser OTLP exporter (from OTEL_EXPORTER_OTLP_HEADERS) + - x-otlp-api-key + max_age: 86400 + +processors: + batch: + +exporters: + debug: + verbosity: detailed + otlp/aspire: + endpoint: ${env:ASPIRE_ENDPOINT} + headers: + x-otlp-api-key: ${env:ASPIRE_API_KEY} + tls: + insecure: true + otlp/lgtm: + endpoint: lgtm:4317 + tls: + insecure: true + +service: + pipelines: + traces: + receivers: [otlp] + processors: [batch] + exporters: [otlp/aspire, otlp/lgtm] + metrics: + receivers: [otlp] + processors: [batch] + exporters: [otlp/aspire, otlp/lgtm] + logs: + receivers: [otlp] + processors: [batch] + exporters: [otlp/aspire, otlp/lgtm]