From 07b499afe2473e9b9d8e59477c12018cae50ffab Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9=20Silva?= <2493377+askpt@users.noreply.github.com> Date: Sat, 4 Jul 2026 13:50:30 +0100 Subject: [PATCH 1/8] feat: Add OpenTelemetryCollector package to Community and AppHost projects --- Directory.Packages.props | 1 + src/Garage.AppHost/Garage.AppHost.csproj | 1 + 2 files changed, 2 insertions(+) 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/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 @@ + From be0f899f7253242e0bebf20db11bfb035431efe6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9=20Silva?= <2493377+askpt@users.noreply.github.com> Date: Sat, 4 Jul 2026 13:58:32 +0100 Subject: [PATCH 2/8] feat: Integrate OpenTelemetry collector and add configuration file --- src/Garage.AppHost/Program.cs | 6 ++++- src/Garage.AppHost/otel/config.yaml | 35 +++++++++++++++++++++++++++++ 2 files changed, 40 insertions(+), 1 deletion(-) create mode 100644 src/Garage.AppHost/otel/config.yaml diff --git a/src/Garage.AppHost/Program.cs b/src/Garage.AppHost/Program.cs index c13d1e0..8d128d9 100644 --- a/src/Garage.AppHost/Program.cs +++ b/src/Garage.AppHost/Program.cs @@ -8,6 +8,11 @@ var builder = DistributedApplication.CreateBuilder(args); +// Add collector for OpenTelemetry signals +var collector = builder.AddOpenTelemetryCollector("opentelemetry-collector") + .WithConfig("otel/config.yaml") + .WithAppForwarding(); + // Add Azure Container App Environment for publishing var containerAppEnvironment = builder .AddAzureContainerAppEnvironment("cae"); @@ -59,7 +64,6 @@ var flagsApi = builder.AddGoApp("flagsapi", "../Garage.FeatureFlags/") .WithHttpEndpoint(env: "PORT") .WithExternalHttpEndpoints() - .WithEnvironment("OTEL_EXPORTER_OTLP_PROTOCOL", "http/protobuf") .PublishAsDockerFile(); if (!builder.ExecutionContext.IsPublishMode) diff --git a/src/Garage.AppHost/otel/config.yaml b/src/Garage.AppHost/otel/config.yaml new file mode 100644 index 0000000..5ec9aee --- /dev/null +++ b/src/Garage.AppHost/otel/config.yaml @@ -0,0 +1,35 @@ +receivers: + otlp: + protocols: + grpc: + endpoint: 0.0.0.0:4317 + http: + endpoint: 0.0.0.0:4318 + +processors: + batch: + +exporters: + debug: + verbosity: detailed + otlp/aspire: + endpoint: ${env:ASPIRE_ENDPOINT} + headers: + x-otlp-api-key: ${env:ASPIRE_API_KEY} + tls: + insecure: true + +service: + pipelines: + traces: + receivers: [otlp] + processors: [batch] + exporters: [otlp/aspire] + metrics: + receivers: [otlp] + processors: [batch] + exporters: [otlp/aspire] + logs: + receivers: [otlp] + processors: [batch] + exporters: [otlp/aspire] From 74d2f342fac4009a5b79b90ca6361c5393844c4c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9=20Silva?= <2493377+askpt@users.noreply.github.com> Date: Sat, 4 Jul 2026 14:17:12 +0100 Subject: [PATCH 3/8] feat: Add Grafana LGTM stack and update OpenTelemetry configuration for metrics and logs --- README.md | 1 + src/Garage.AppHost/Program.cs | 8 +++++++- src/Garage.AppHost/otel/config.yaml | 10 +++++++--- 3 files changed, 15 insertions(+), 4 deletions(-) diff --git a/README.md b/README.md index a66d300..eaca753 100644 --- a/README.md +++ b/README.md @@ -127,6 +127,7 @@ 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) (`admin` / `admin`) ### 6. OpenAPI and Scalar Documentation (Development) diff --git a/src/Garage.AppHost/Program.cs b/src/Garage.AppHost/Program.cs index 8d128d9..edaae1c 100644 --- a/src/Garage.AppHost/Program.cs +++ b/src/Garage.AppHost/Program.cs @@ -8,10 +8,16 @@ var builder = DistributedApplication.CreateBuilder(args); +// 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") + .WithExternalHttpEndpoints(); + // Add collector for OpenTelemetry signals var collector = builder.AddOpenTelemetryCollector("opentelemetry-collector") .WithConfig("otel/config.yaml") - .WithAppForwarding(); + .WithAppForwarding() + .WaitFor(lgtm); // Add Azure Container App Environment for publishing var containerAppEnvironment = builder diff --git a/src/Garage.AppHost/otel/config.yaml b/src/Garage.AppHost/otel/config.yaml index 5ec9aee..8950796 100644 --- a/src/Garage.AppHost/otel/config.yaml +++ b/src/Garage.AppHost/otel/config.yaml @@ -18,18 +18,22 @@ exporters: 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] + exporters: [otlp/aspire, otlp/lgtm] metrics: receivers: [otlp] processors: [batch] - exporters: [otlp/aspire] + exporters: [otlp/aspire, otlp/lgtm] logs: receivers: [otlp] processors: [batch] - exporters: [otlp/aspire] + exporters: [otlp/aspire, otlp/lgtm] From 276fbb08bfc8ba33df52940af2a3d458ce7b084e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9=20Silva?= <2493377+askpt@users.noreply.github.com> Date: Sat, 4 Jul 2026 14:22:04 +0100 Subject: [PATCH 4/8] feat: Add Grafana dashboard provisioning and initial OpenFeature overview dashboard --- README.md | 20 ++++ src/Garage.AppHost/Program.cs | 3 + .../provisioning/dashboards/custom.yaml | 12 ++ .../custom/openfeature-overview.json | 103 ++++++++++++++++++ 4 files changed, 138 insertions(+) create mode 100644 src/Garage.AppHost/grafana/provisioning/dashboards/custom.yaml create mode 100644 src/Garage.AppHost/grafana/provisioning/dashboards/custom/openfeature-overview.json diff --git a/README.md b/README.md index eaca753..472e67f 100644 --- a/README.md +++ b/README.md @@ -129,6 +129,26 @@ aspire run - Aspire Dashboard: [https://localhost:15888](https://localhost:15888) - Grafana (LGTM): [http://localhost:3000](http://localhost:3000) (`admin` / `admin`) +### 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) The API service exports an OpenAPI document in Development and serves a Scalar UI for interactive exploration. diff --git a/src/Garage.AppHost/Program.cs b/src/Garage.AppHost/Program.cs index edaae1c..fc4fdd5 100644 --- a/src/Garage.AppHost/Program.cs +++ b/src/Garage.AppHost/Program.cs @@ -8,9 +8,12 @@ var builder = DistributedApplication.CreateBuilder(args); +var grafanaProvisioningPath = Path.Combine(builder.AppHostDirectory, "grafana", "provisioning", "dashboards"); + // 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 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/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": "" +} From 17f8d26367034c5753f0e97c8d0faf05a3396cda Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9=20Silva?= <2493377+askpt@users.noreply.github.com> Date: Sat, 4 Jul 2026 14:47:49 +0100 Subject: [PATCH 5/8] feat: Add Grafana dashboard for feature flag evaluation metrics --- .../provisioning/dashboards/custom/flags.json | 327 ++++++++++++++++++ 1 file changed, 327 insertions(+) create mode 100644 src/Garage.AppHost/grafana/provisioning/dashboards/custom/flags.json 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": "" +} From 52d737ffe8ee78972b97fd24e9622751cc0250ab Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9=20Silva?= <2493377+askpt@users.noreply.github.com> Date: Sat, 4 Jul 2026 14:55:51 +0100 Subject: [PATCH 6/8] feat: Update Grafana provisioning path and add OpenTelemetry collector configuration --- README.md | 2 +- src/Garage.AppHost/Program.cs | 35 ++++++++++++++++++++++------------- 2 files changed, 23 insertions(+), 14 deletions(-) diff --git a/README.md b/README.md index 472e67f..2a36bd7 100644 --- a/README.md +++ b/README.md @@ -127,7 +127,7 @@ 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) (`admin` / `admin`) +- Grafana (LGTM): [http://localhost:3000](http://localhost:3000) ### Provisioned Grafana Dashboards diff --git a/src/Garage.AppHost/Program.cs b/src/Garage.AppHost/Program.cs index fc4fdd5..c4af132 100644 --- a/src/Garage.AppHost/Program.cs +++ b/src/Garage.AppHost/Program.cs @@ -8,19 +8,27 @@ var builder = DistributedApplication.CreateBuilder(args); -var grafanaProvisioningPath = Path.Combine(builder.AppHostDirectory, "grafana", "provisioning", "dashboards"); - -// 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") - .WithAppForwarding() - .WaitFor(lgtm); +// Add local Grafana provisioning path for dashboards (only used in local development, not in Azure deployment) +if (!builder.ExecutionContext.IsPublishMode) +{ + 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") + .WithAppForwarding() + .WaitFor(lgtm); +} // Add Azure Container App Environment for publishing var containerAppEnvironment = builder @@ -111,6 +119,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"); From 24806812060b237cdd9dd88131568e6a02557c47 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9=20Silva?= <2493377+askpt@users.noreply.github.com> Date: Sat, 4 Jul 2026 15:40:02 +0100 Subject: [PATCH 7/8] feat: Refactor Grafana provisioning and enhance OpenTelemetry configuration with CORS settings --- src/Garage.AppHost/Program.cs | 55 +++++++++++++++++------------ src/Garage.AppHost/otel/config.yaml | 15 ++++++++ 2 files changed, 47 insertions(+), 23 deletions(-) diff --git a/src/Garage.AppHost/Program.cs b/src/Garage.AppHost/Program.cs index c4af132..c97d1e9 100644 --- a/src/Garage.AppHost/Program.cs +++ b/src/Garage.AppHost/Program.cs @@ -8,28 +8,6 @@ var builder = DistributedApplication.CreateBuilder(args); -// Add local Grafana provisioning path for dashboards (only used in local development, not in Azure deployment) -if (!builder.ExecutionContext.IsPublishMode) -{ - 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") - .WithAppForwarding() - .WaitFor(lgtm); -} - // Add Azure Container App Environment for publishing var containerAppEnvironment = builder .AddAzureContainerAppEnvironment("cae"); @@ -57,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") @@ -85,11 +63,37 @@ 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); @@ -102,6 +106,11 @@ var tunnel = builder.AddDevTunnel("tunnel") .WithReference(flagd) .WithAnonymousAccess(); + + webFrontend = webFrontend + .WithBrowserLogs() + .WithOtlpExporter() + .WaitFor(collector); } else { diff --git a/src/Garage.AppHost/otel/config.yaml b/src/Garage.AppHost/otel/config.yaml index 8950796..97f93dc 100644 --- a/src/Garage.AppHost/otel/config.yaml +++ b/src/Garage.AppHost/otel/config.yaml @@ -5,6 +5,21 @@ receivers: 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 + max_age: 86400 processors: batch: From 1e8d4a80d8941204a39361bd76254228b9953629 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9=20Silva?= <2493377+askpt@users.noreply.github.com> Date: Sat, 4 Jul 2026 16:03:50 +0100 Subject: [PATCH 8/8] feat: Update OpenTelemetry configuration for browser telemetry and add Playwright test artifacts to .gitignore --- .gitignore | 3 +++ src/Garage.AppHost/Program.cs | 5 +++++ src/Garage.AppHost/otel/config.yaml | 2 ++ 3 files changed, 10 insertions(+) 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/src/Garage.AppHost/Program.cs b/src/Garage.AppHost/Program.cs index c97d1e9..5b5931b 100644 --- a/src/Garage.AppHost/Program.cs +++ b/src/Garage.AppHost/Program.cs @@ -107,9 +107,14 @@ .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 diff --git a/src/Garage.AppHost/otel/config.yaml b/src/Garage.AppHost/otel/config.yaml index 97f93dc..c2e9099 100644 --- a/src/Garage.AppHost/otel/config.yaml +++ b/src/Garage.AppHost/otel/config.yaml @@ -19,6 +19,8 @@ receivers: - 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: