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: