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]