Skip to content

Commit 49ec752

Browse files
yashikakhuranaYashika KhuranaYashika Khurana
authored
feat(nimbus): Grafana proxy view (#15433)
Because -Grafana is behind Google IAP, which sets session cookies on` yardstick.mozilla.org.` Browsers block these as third-party cookies when the dashboard is embedded in an iframe on a different origin, causing it to fail to load. This commit - Adds a server-side GrafanaProxyView that fetches Grafana content via internal k8s DNS (grafana.grafana-prod.svc.cluster.local:8080), bypassing IAP entirely - Rewrites <base href> and appUrl in HTML responses so Grafana's assets and API calls also route through the proxy - Adds `GRAFANA_INTERNAL_URL` and `GRAFANA_SERVICE_ACCOUNT_TOKEN` settings for the internal endpoint and service account auth - Adds feature_monitoring_proxy_path on NimbusFeatureConfig to extract the path/query from the monitoring URL Updates the feature monitoring iframe to use `/nimbus/grafana-proxy/ `instead of the direct Grafana URL Fixes #15342 mozilla/webservices-infra#10728 --------- Co-authored-by: Yashika Khurana <yashikakhurana@Yashikas-MBP.home.local> Co-authored-by: Yashika Khurana <yashikakhurana@Yashikas-MacBook-Pro.local>
1 parent a0ca757 commit 49ec752

6 files changed

Lines changed: 170 additions & 51 deletions

File tree

experimenter/experimenter/nimbus_ui/templates/nimbus_experiments/feature_monitoring.html

Lines changed: 0 additions & 42 deletions
This file was deleted.

experimenter/experimenter/nimbus_ui/templates/nimbus_experiments/features.html

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -526,6 +526,34 @@ <h5 class="fw-semibold mt-3 d-flex align-items-center">
526526
</div>
527527
</div>
528528
</div>
529+
{% if selected_feature_config %}
530+
<!-- Feature Monitoring Card -->
531+
<div id="feature-monitoring-card"
532+
class="card shadow-sm border-0 px-3 pt-3 h-100 rounded-3 bg-primary-subtle mb-4">
533+
<div class="d-flex align-items-center justify-content-between mt-3 mb-2">
534+
<h5 class="fw-semibold mb-0 d-flex align-items-center">
535+
<i class="fa-solid fa-chart-line me-2"></i>
536+
Feature Monitoring
537+
</h5>
538+
<a href="{{ selected_feature_config.feature_monitoring_url }}"
539+
target="_blank"
540+
rel="noopener noreferrer"
541+
class="btn btn-outline-primary btn-sm">
542+
<i class="fa-solid fa-arrow-up-right-from-square me-1"></i>
543+
Open in Grafana
544+
</a>
545+
</div>
546+
<div class="card shadow-sm border-0 rounded-3 mb-3">
547+
<div class="card-body p-0">
548+
<iframe src="/nimbus/grafana-proxy/?slug={{ selected_feature_config.slug }}"
549+
width="100%"
550+
height="800"
551+
frameborder="0"
552+
title="Feature Monitoring Dashboard"></iframe>
553+
</div>
554+
</div>
555+
</div>
556+
{% endif %}
529557
</div>
530558
</div>
531559
{% endblock %}

experimenter/experimenter/nimbus_ui/tests/test_views.py

Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
import json
44
from unittest.mock import patch
55

6+
import requests
67
from django.conf import settings
78
from django.core.files.uploadedfile import SimpleUploadedFile
89
from django.test import TestCase, override_settings
@@ -5754,6 +5755,73 @@ def test_post_clears_all_tags_when_none_selected(self):
57545755
self.assertEqual(experiment.tags.count(), 0)
57555756

57565757

5758+
class TestGrafanaProxyView(AuthTestCase):
5759+
def _dashboard_url(self, slug):
5760+
return reverse("nimbus-ui-grafana-proxy") + f"?slug={slug}"
5761+
5762+
@patch("experimenter.nimbus_ui.views.requests.get")
5763+
def test_proxy_returns_grafana_response(self, mock_get):
5764+
feature = NimbusFeatureConfigFactory.create(
5765+
slug="my-feature",
5766+
application=NimbusExperiment.Application.DESKTOP,
5767+
)
5768+
mock_get.return_value.status_code = 200
5769+
mock_get.return_value.headers = {"Content-Type": "text/html; charset=utf-8"}
5770+
mock_get.return_value.content = b"<html></html>"
5771+
5772+
response = self.client.get(self._dashboard_url(feature.slug))
5773+
5774+
self.assertEqual(response.status_code, 200)
5775+
mock_get.assert_called_once()
5776+
call_url = mock_get.call_args[0][0]
5777+
self.assertIn("nimbus-feature-monitoring", call_url)
5778+
call_params = mock_get.call_args[1]["params"]
5779+
self.assertEqual(call_params["var-feature"], "my-feature")
5780+
self.assertEqual(call_params["var-application"], "firefox_desktop")
5781+
5782+
@patch(
5783+
"experimenter.nimbus_ui.views.requests.get",
5784+
side_effect=requests.exceptions.ConnectionError("connection refused"),
5785+
)
5786+
def test_proxy_returns_503_when_grafana_unavailable(self, mock_get):
5787+
feature = NimbusFeatureConfigFactory.create(
5788+
slug="my-feature",
5789+
application=NimbusExperiment.Application.DESKTOP,
5790+
)
5791+
response = self.client.get(self._dashboard_url(feature.slug))
5792+
5793+
self.assertEqual(response.status_code, 503)
5794+
5795+
def test_proxy_requires_login(self):
5796+
self.client.defaults.pop(settings.OPENIDC_EMAIL_HEADER)
5797+
response = self.client.get(reverse("nimbus-ui-grafana-proxy"))
5798+
self.assertNotEqual(response.status_code, 200)
5799+
5800+
@override_settings(GRAFANA_SERVICE_ACCOUNT_TOKEN="test-token")
5801+
@patch("experimenter.nimbus_ui.views.requests.get")
5802+
def test_proxy_sends_service_account_token(self, mock_get):
5803+
feature = NimbusFeatureConfigFactory.create(
5804+
slug="my-feature",
5805+
application=NimbusExperiment.Application.DESKTOP,
5806+
)
5807+
mock_get.return_value.status_code = 200
5808+
mock_get.return_value.headers = {"Content-Type": "text/html"}
5809+
mock_get.return_value.content = b""
5810+
5811+
self.client.get(self._dashboard_url(feature.slug))
5812+
5813+
call_headers = mock_get.call_args[1]["headers"]
5814+
self.assertEqual(call_headers["Authorization"], "Bearer test-token")
5815+
5816+
def test_proxy_returns_400_when_slug_missing(self):
5817+
response = self.client.get(reverse("nimbus-ui-grafana-proxy"))
5818+
self.assertEqual(response.status_code, 400)
5819+
5820+
def test_proxy_returns_404_when_slug_invalid(self):
5821+
response = self.client.get(self._dashboard_url("nonexistent-feature"))
5822+
self.assertEqual(response.status_code, 404)
5823+
5824+
57575825
class TestNewOverviewUpdateView(AuthTestCase):
57585826
url_name = "nimbus-ui-new-update-overview"
57595827

experimenter/experimenter/nimbus_ui/urls.py

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@
2323
DraftToReviewView,
2424
EditOutcomeSummaryView,
2525
FeatureSubscribersUpdateView,
26+
GrafanaProxyView,
2627
LiveToCompleteView,
2728
LiveToEndEnrollmentView,
2829
LiveToUpdateRolloutView,
@@ -43,7 +44,6 @@
4344
NimbusExperimentsListTableView,
4445
NimbusExperimentsPromoteToRolloutView,
4546
NimbusExperimentsSidebarCloneView,
46-
NimbusFeatureMonitoringView,
4747
NimbusFeaturesView,
4848
NimbusRolloutDetailView,
4949
OverviewUpdateView,
@@ -82,9 +82,9 @@
8282
name="nimbus-ui-features",
8383
),
8484
re_path(
85-
r"^feature-monitoring/(?P<pk>\d+)/$",
86-
NimbusFeatureMonitoringView.as_view(),
87-
name="nimbus-ui-feature-monitoring",
85+
r"^grafana-proxy/$",
86+
GrafanaProxyView.as_view(),
87+
name="nimbus-ui-grafana-proxy",
8888
),
8989
re_path(
9090
r"^tags/manage/$",

experimenter/experimenter/nimbus_ui/views.py

Lines changed: 61 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,15 @@
11
import json
22

3+
import requests
34
from deepdiff import DeepDiff
45
from django.conf import settings
6+
from django.contrib.auth.mixins import LoginRequiredMixin
57
from django.core.paginator import Paginator
68
from django.db.models import Q
79
from django.http import HttpResponse, HttpResponseRedirect
8-
from django.shortcuts import render
10+
from django.shortcuts import get_object_or_404, render
911
from django.urls import reverse
12+
from django.views import View
1013
from django.views.generic import CreateView, DetailView, TemplateView
1114
from django.views.generic.edit import UpdateView
1215
from django_filters.views import FilterView
@@ -1137,10 +1140,63 @@ def get_context_data(self, **kwargs):
11371140
return context
11381141

11391142

1140-
class NimbusFeatureMonitoringView(DetailView):
1141-
template_name = "nimbus_experiments/feature_monitoring.html"
1142-
model = NimbusFeatureConfig
1143-
context_object_name = "feature_config"
1143+
# Grafana is behind Google IAP, which blocks third-party cookies in iframes.
1144+
# Both Experimenter and Grafana run in the same k8s cluster, so the backend
1145+
# can reach Grafana via internal DNS (bypassing IAP) and proxy it to the browser.
1146+
class GrafanaProxyView(LoginRequiredMixin, View):
1147+
_SAFE_RESPONSE_HEADERS = ("Cache-Control", "ETag", "Last-Modified", "Content-Type")
1148+
1149+
def get(self, request):
1150+
slug = request.GET.get("slug")
1151+
if not slug:
1152+
return HttpResponse("Missing slug parameter", status=400)
1153+
1154+
feature_config = get_object_or_404(NimbusFeatureConfig, slug=slug)
1155+
1156+
# Construct the Grafana URL entirely from known-safe components.
1157+
# User input (slug) is validated against the DB before use;
1158+
# the host and dashboard path come from settings only.
1159+
application = (feature_config.application or "").replace("-", "_")
1160+
target = "{base}/{path}".format(
1161+
base=settings.GRAFANA_INTERNAL_URL.rstrip("/"),
1162+
path=settings.GRAFANA_FEATURE_MONITORING_DASHBOARD_PATH,
1163+
)
1164+
params = {
1165+
"orgId": "1",
1166+
"from": "now-30d",
1167+
"to": "now",
1168+
"timezone": "utc",
1169+
"var-application": application,
1170+
"var-feature": feature_config.slug,
1171+
"kiosk": "",
1172+
}
1173+
1174+
headers = {}
1175+
if settings.GRAFANA_SERVICE_ACCOUNT_TOKEN:
1176+
headers["Authorization"] = f"Bearer {settings.GRAFANA_SERVICE_ACCOUNT_TOKEN}"
1177+
1178+
try:
1179+
upstream = requests.get(
1180+
target,
1181+
params=params,
1182+
headers=headers,
1183+
timeout=30,
1184+
)
1185+
except requests.exceptions.RequestException:
1186+
return HttpResponse("Grafana is unavailable", status=503)
1187+
1188+
response = HttpResponse(
1189+
upstream.content,
1190+
content_type=upstream.headers.get("Content-Type", "application/octet-stream"),
1191+
status=upstream.status_code,
1192+
)
1193+
1194+
for header in self._SAFE_RESPONSE_HEADERS:
1195+
if value := upstream.headers.get(header):
1196+
response[header] = value
1197+
1198+
response["X-Frame-Options"] = "SAMEORIGIN"
1199+
return response
11441200

11451201

11461202
class NimbusExperimentsHomeView(FilterView):

experimenter/experimenter/settings.py

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -440,6 +440,15 @@
440440
"https://yardstick.mozilla.org/d/dtfz7xv/nimbus-feature-monitoring"
441441
"?orgId=1&var-feature_slug={slug}&var-application={application}"
442442
)
443+
GRAFANA_INTERNAL_URL = config(
444+
"GRAFANA_INTERNAL_URL",
445+
default="http://grafana.grafana-prod.svc.cluster.local:8080",
446+
)
447+
GRAFANA_FEATURE_MONITORING_DASHBOARD_PATH = config(
448+
"GRAFANA_FEATURE_MONITORING_DASHBOARD_PATH",
449+
default="d/dtfz7xv/nimbus-feature-monitoring",
450+
)
451+
GRAFANA_SERVICE_ACCOUNT_TOKEN = config("GRAFANA_SERVICE_ACCOUNT_TOKEN", default="")
443452

444453
# Statsd via Markus
445454
STATSD_BACKEND = config(

0 commit comments

Comments
 (0)