FS: Add OpenFeature scaffolding and local setup (#117904)

* add openfeature handling + local env to frontend-service

* remove react18 manifest that was accidentally added

* fix and add some more unit tests

* review changes

* remove comment from frontend_service

* translations...
This commit is contained in:
Ashley Harrison 2026-02-13 18:14:36 +00:00 committed by GitHub
parent 234760f61e
commit aaaeb13d32
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
12 changed files with 221 additions and 157 deletions

View file

@ -71,6 +71,7 @@ dc_resource("tempo", labels=["observability"])
dc_resource("postgres", labels=["misc"])
dc_resource("tempo-init", labels=["misc"])
dc_resource("goff", labels=["misc"])
# paths in tilt files are confusing....
# - if tilt is dealing with the path, it is relative to the Tiltfile

View file

@ -105,6 +105,7 @@ server {
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header baggage "namespace=stacks-11";
}
}

View file

@ -78,10 +78,23 @@ services:
OTEL_SERVICE_NAME: frontend-service
GF_TRACING_OPENTELEMETRY_OTLP_ADDRESS: 'alloy:4317'
GF_TRACING_OPENTELEMETRY_OTLP_PROPAGATION: jaeger,w3c
GF_FEATURE_TOGGLES_OPENFEATURE_PROVIDER: ofrep
GF_FEATURE_TOGGLES_OPENFEATURE_URL: http://goff:1031
# Faro (Grafana JavaScript Agent) configuration - sends frontend observability data to Loki via Alloy
GF_LOG_FRONTEND_ENABLED: true
GF_LOG_FRONTEND_CUSTOM_ENDPOINT: http://localhost:12347/collect
goff:
image: gofeatureflag/go-feature-flag:latest
command: /go-feature-flag --config /config/config.yaml
ports:
- 1031:1031/tcp
configs:
- source: goff_config
target: /config/config.yaml
- source: goff_flags
target: /flags/flags.yaml
postgres:
image: postgres:16.1-alpine3.19@sha256:17eb369d9330fe7fbdb2f705418c18823d66322584c77c2b43cc0e1851d01de7
environment:
@ -155,6 +168,20 @@ services:
tempo-init:
condition: service_completed_successfully
configs:
goff_config:
content: |
logLevel: debug
logFormat: logfmt
listen: 1031
evaluationContextEnrichment:
cluster: localdev
retrievers:
- kind: "file"
path: "/flags/flags.yaml"
goff_flags:
file: ./goff-flags.yaml
volumes:
backend-data:
postgres-data:

View file

@ -0,0 +1,6 @@
flagName:
variations:
enabled: true
disabled: false
defaultRule:
variation: disabled

View file

@ -6,7 +6,10 @@ import (
"github.com/grafana/grafana/pkg/infra/log"
"github.com/grafana/grafana/pkg/infra/tracing"
"github.com/open-feature/go-sdk/openfeature"
"k8s.io/apiserver/pkg/endpoints/request"
"go.opentelemetry.io/otel/baggage"
"go.opentelemetry.io/otel/trace"
"github.com/grafana/grafana/pkg/services/contexthandler/ctxkey"
@ -37,8 +40,9 @@ func setRequestContext(ctx context.Context, w http.ResponseWriter, r *http.Reque
ctx, span := tracing.Start(ctx, "setRequestContext")
defer span.End()
webCtx := web.FromContext(ctx)
reqContext := &contextmodel.ReqContext{
Context: web.FromContext(ctx),
Context: webCtx,
Logger: log.New("context"),
SignedInUser: &user.SignedInUser{},
}
@ -48,7 +52,9 @@ func setRequestContext(ctx context.Context, w http.ResponseWriter, r *http.Reque
// Set the context for the http.Request.Context
// This modifies both r and reqContext.Req since they point to the same value
*reqContext.Req = *reqContext.Req.WithContext(ctx)
if webCtx != nil {
*reqContext.Req = *reqContext.Req.WithContext(ctx)
}
// add traceID to logger context
traceID := tracing.TraceIDFromContext(ctx, false)
@ -64,5 +70,27 @@ func setRequestContext(ctx context.Context, w http.ResponseWriter, r *http.Reque
reqContext.Logger = reqContext.Logger.New("hostname", hostname)
}
// Parse namespace from W3C baggage header
var namespace string
if baggageHeader := r.Header.Get("baggage"); baggageHeader != "" {
if bag, err := baggage.Parse(baggageHeader); err == nil {
if member := bag.Member("namespace"); member.Value() != "" {
namespace = member.Value()
}
}
}
ctx = request.WithNamespace(ctx, namespace)
// Note: OpenFeature is already initialized by target.go before this service starts.
// The frontend service only needs to set evaluation context per request
openFeatureNamespace := "default"
if namespace != "" {
openFeatureNamespace = namespace
}
evalCtx := openfeature.NewEvaluationContext(openFeatureNamespace, map[string]any{
"namespace": openFeatureNamespace,
})
ctx = openfeature.MergeTransactionContext(ctx, evalCtx)
return ctx
}

View file

@ -0,0 +1,96 @@
package frontend
import (
"net/http"
"net/http/httptest"
"testing"
"github.com/stretchr/testify/assert"
"k8s.io/apiserver/pkg/endpoints/request"
)
func TestContextMiddleware(t *testing.T) {
t.Run("calls next handler with context", func(t *testing.T) {
service := &frontendService{}
nextCalled := false
var capturedRequest *http.Request
nextHandler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
nextCalled = true
capturedRequest = r
})
middleware := service.contextMiddleware()
handler := middleware(nextHandler)
req := httptest.NewRequest(http.MethodGet, "/", nil)
recorder := httptest.NewRecorder()
handler.ServeHTTP(recorder, req)
assert.True(t, nextCalled, "next handler should be called")
assert.NotNil(t, capturedRequest, "request should be passed to next handler")
assert.NotNil(t, capturedRequest.Context(), "request context should be set")
})
}
func TestSetRequestContext(t *testing.T) {
t.Run("parses namespace from baggage header", func(t *testing.T) {
req := httptest.NewRequest(http.MethodGet, "/", nil)
req.Header.Set("baggage", "namespace=stacks-123")
rec := httptest.NewRecorder()
ctx := setRequestContext(req.Context(), rec, req)
namespace, ok := request.NamespaceFrom(ctx)
assert.True(t, ok, "Namespace should be present in context")
assert.Equal(t, "stacks-123", namespace, "Namespace should match baggage value")
})
t.Run("handles baggage header with multiple members", func(t *testing.T) {
req := httptest.NewRequest(http.MethodGet, "/", nil)
req.Header.Set("baggage", "trace-id=abc123,namespace=tenant-456,user-id=xyz")
rec := httptest.NewRecorder()
ctx := setRequestContext(req.Context(), rec, req)
namespace, ok := request.NamespaceFrom(ctx)
assert.True(t, ok, "Namespace should be present in context")
assert.Equal(t, "tenant-456", namespace, "Namespace should match baggage value")
})
t.Run("defaults to empty namespace when baggage header is missing", func(t *testing.T) {
req := httptest.NewRequest(http.MethodGet, "/", nil)
rec := httptest.NewRecorder()
ctx := setRequestContext(req.Context(), rec, req)
namespace, ok := request.NamespaceFrom(ctx)
assert.True(t, ok, "Namespace should be present in context")
assert.Empty(t, namespace, "Namespace should be empty when not provided")
})
t.Run("handles invalid baggage header gracefully", func(t *testing.T) {
req := httptest.NewRequest(http.MethodGet, "/", nil)
req.Header.Set("baggage", "invalid-baggage-format;;;")
rec := httptest.NewRecorder()
ctx := setRequestContext(req.Context(), rec, req)
namespace, ok := request.NamespaceFrom(ctx)
assert.True(t, ok, "Namespace should be present in context")
assert.Empty(t, namespace, "Namespace should be empty when baggage is invalid")
})
t.Run("handles baggage header without namespace member", func(t *testing.T) {
req := httptest.NewRequest(http.MethodGet, "/", nil)
req.Header.Set("baggage", "other-key=other-value")
rec := httptest.NewRecorder()
ctx := setRequestContext(req.Context(), rec, req)
namespace, ok := request.NamespaceFrom(ctx)
assert.True(t, ok, "Namespace should be present in context")
assert.Empty(t, namespace, "Namespace should be empty when namespace member not in baggage")
})
}

View file

@ -19,7 +19,6 @@ import (
"github.com/grafana/grafana/pkg/middleware/loggermw"
"github.com/grafana/grafana/pkg/middleware/requestmeta"
"github.com/grafana/grafana/pkg/services/featuremgmt"
fswebassets "github.com/grafana/grafana/pkg/services/frontend/webassets"
"github.com/grafana/grafana/pkg/services/hooks"
"github.com/grafana/grafana/pkg/services/licensing"
publicdashboardsapi "github.com/grafana/grafana/pkg/services/publicdashboards/api"
@ -56,12 +55,8 @@ type frontendService struct {
func ProvideFrontendService(cfg *setting.Cfg, features featuremgmt.FeatureToggles, promGatherer prometheus.Gatherer, promRegister prometheus.Registerer, license licensing.Licensing, hooksService *hooks.HooksService) (*frontendService, error) {
logger := log.New("frontend-service")
assetsManifest, err := fswebassets.GetWebAssets(cfg, license)
if err != nil {
return nil, err
}
index, err := NewIndexProvider(cfg, assetsManifest, license, hooksService)
index, err := NewIndexProvider(cfg, license, hooksService)
if err != nil {
return nil, err
}

View file

@ -12,6 +12,7 @@ import (
"github.com/grafana/grafana/pkg/api/dtos"
"github.com/grafana/grafana/pkg/services/contexthandler"
contextmodel "github.com/grafana/grafana/pkg/services/contexthandler/model"
fswebassets "github.com/grafana/grafana/pkg/services/frontend/webassets"
"github.com/grafana/grafana/pkg/services/hooks"
"github.com/grafana/grafana/pkg/services/licensing"
"github.com/grafana/grafana/pkg/setting"
@ -22,7 +23,7 @@ type IndexProvider struct {
index *template.Template
hooksService *hooks.HooksService
config *setting.Cfg
assets dtos.EntryPointAssets // Includes CDN info
license licensing.Licensing
}
type IndexViewData struct {
@ -53,7 +54,7 @@ var (
htmlTemplates = template.Must(template.New("html").Delims("[[", "]]").ParseFS(templatesFS, `*.html`))
)
func NewIndexProvider(cfg *setting.Cfg, assetsManifest dtos.EntryPointAssets, license licensing.Licensing, hooksService *hooks.HooksService) (*IndexProvider, error) {
func NewIndexProvider(cfg *setting.Cfg, license licensing.Licensing, hooksService *hooks.HooksService) (*IndexProvider, error) {
t := htmlTemplates.Lookup("index.html")
if t == nil {
return nil, fmt.Errorf("missing index template")
@ -69,7 +70,7 @@ func NewIndexProvider(cfg *setting.Cfg, assetsManifest dtos.EntryPointAssets, li
index: t,
hooksService: hooksService,
config: cfg,
assets: assetsManifest,
license: license,
}, nil
}
@ -89,6 +90,13 @@ func (p *IndexProvider) HandleRequest(writer http.ResponseWriter, request *http.
return
}
assetsManifest, err := fswebassets.GetWebAssets(ctx, p.config, p.license)
if err != nil {
p.log.Error("unable to get web assets", "err", err)
http.Error(writer, "Internal Server Error", http.StatusInternalServerError)
return
}
reqCtx := contexthandler.FromContext(ctx)
// make a copy of the settings
@ -98,7 +106,7 @@ func (p *IndexProvider) HandleRequest(writer http.ResponseWriter, request *http.
AppTitle: "Grafana",
AppSubUrl: p.config.AppSubURL,
IsDevelopmentEnv: p.config.Env == setting.Dev,
Assets: p.assets,
Assets: assetsManifest,
DefaultUser: dtos.CurrentUser{},
Nonce: reqCtx.RequestNonce,
PublicDashboardAccessToken: reqCtx.PublicDashboardAccessToken,

View file

@ -3,7 +3,6 @@ package frontend
import (
"net/http"
"go.opentelemetry.io/otel/baggage"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"k8s.io/apiserver/pkg/endpoints/request"
@ -29,17 +28,6 @@ func RequestConfigMiddleware(cfg *setting.Cfg, license licensing.Licensing, sett
ctx, span := tracing.Start(r.Context(), "frontend.RequestConfigMiddleware")
defer span.End()
// Parse namespace from W3C baggage header
var namespace string
if baggageHeader := r.Header.Get("baggage"); baggageHeader != "" {
if bag, err := baggage.Parse(baggageHeader); err == nil {
if member := bag.Member("namespace"); member.Value() != "" {
namespace = member.Value()
}
}
}
ctx = request.WithNamespace(ctx, namespace)
reqCtx := contexthandler.FromContext(ctx)
logger := reqCtx.Logger
@ -48,28 +36,30 @@ func RequestConfigMiddleware(cfg *setting.Cfg, license licensing.Licensing, sett
requestConfig := NewFSRequestConfig(cfg, license)
// Fetch tenant-specific configuration if namespace is present
if namespace != "" && settingsService != nil {
// Fetch tenant overrides for relevant sections only
selector := metav1.LabelSelector{
MatchExpressions: []metav1.LabelSelectorRequirement{{
Key: "section",
Operator: metav1.LabelSelectorOpIn,
Values: []string{"security"}, // TODO: get correct list
}, {
// don't return values from defaults.ini as they conflict with the services's own defaults
Key: "source",
Operator: metav1.LabelSelectorOpNotIn,
Values: []string{"defaults"},
}},
}
if namespace, ok := request.NamespaceFrom(ctx); ok {
if settingsService != nil {
// Fetch tenant overrides for relevant sections only
selector := metav1.LabelSelector{
MatchExpressions: []metav1.LabelSelectorRequirement{{
Key: "section",
Operator: metav1.LabelSelectorOpIn,
Values: []string{"security"}, // TODO: get correct list
}, {
// don't return values from defaults.ini as they conflict with the services's own defaults
Key: "source",
Operator: metav1.LabelSelectorOpNotIn,
Values: []string{"defaults"},
}},
}
settings, err := settingsService.ListAsIni(ctx, selector)
if err != nil {
logger.Error("failed to fetch tenant settings", "namespace", namespace, "err", err)
// Fall back to base config
} else {
// Merge tenant overrides with base config
requestConfig.ApplyOverrides(settings, logger)
settings, err := settingsService.ListAsIni(ctx, selector)
if err != nil {
logger.Error("failed to fetch tenant settings", "namespace", namespace, "err", err)
// Fall back to base config
} else {
// Merge tenant overrides with base config
requestConfig.ApplyOverrides(settings, logger)
}
}
}

View file

@ -8,6 +8,7 @@ import (
"gopkg.in/ini.v1"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"k8s.io/apiserver/pkg/endpoints/request"
"github.com/grafana/grafana/pkg/infra/log"
"github.com/grafana/grafana/pkg/services/contexthandler/ctxkey"
@ -22,13 +23,16 @@ import (
)
// setupTestContext creates a request with a proper context that includes a logger
func setupTestContext(r *http.Request) *http.Request {
func setupTestContext(r *http.Request, namespace string) *http.Request {
logger := log.NewNopLogger()
reqCtx := &contextmodel.ReqContext{
Context: &web.Context{Req: r},
Logger: logger,
}
ctx := ctxkey.Set(r.Context(), reqCtx)
if namespace != "" {
ctx = request.WithNamespace(ctx, namespace)
}
return r.WithContext(ctx)
}
@ -56,7 +60,7 @@ func TestRequestConfigMiddleware(t *testing.T) {
handler := middleware(testHandler)
req := httptest.NewRequest("GET", "/", nil)
req = setupTestContext(req)
req = setupTestContext(req, "stacks-123")
recorder := httptest.NewRecorder()
handler.ServeHTTP(recorder, req)
@ -88,7 +92,7 @@ func TestRequestConfigMiddleware(t *testing.T) {
handler := middleware(testHandler)
req := httptest.NewRequest("GET", "/", nil)
req = setupTestContext(req)
req = setupTestContext(req, "stacks-123")
recorder := httptest.NewRecorder()
handler.ServeHTTP(recorder, req)
@ -128,8 +132,7 @@ func TestRequestConfigMiddleware(t *testing.T) {
handler := middleware(testHandler)
req := httptest.NewRequest("GET", "/", nil)
req.Header.Set("baggage", "namespace=stacks-123")
req = setupTestContext(req)
req = setupTestContext(req, "stacks-123")
recorder := httptest.NewRecorder()
handler.ServeHTTP(recorder, req)
@ -175,8 +178,7 @@ func TestRequestConfigMiddleware(t *testing.T) {
handler := middleware(testHandler)
req := httptest.NewRequest("GET", "/", nil)
req.Header.Set("baggage", "namespace=stacks-123")
req = setupTestContext(req)
req = setupTestContext(req, "stacks-123")
recorder := httptest.NewRecorder()
handler.ServeHTTP(recorder, req)
@ -192,7 +194,7 @@ func TestRequestConfigMiddleware(t *testing.T) {
assert.True(t, mockSettingsService.called)
})
t.Run("should not call settings service without namespace header", func(t *testing.T) {
t.Run("should not call settings service when no namespace is present", func(t *testing.T) {
mockSettingsService := &mockSettingsService{}
license := &licensing.OSSLicensingService{}
@ -211,7 +213,7 @@ func TestRequestConfigMiddleware(t *testing.T) {
handler := middleware(testHandler)
req := httptest.NewRequest("GET", "/", nil)
req = setupTestContext(req)
req = setupTestContext(req, "")
// No baggage header
recorder := httptest.NewRecorder()
@ -220,103 +222,6 @@ func TestRequestConfigMiddleware(t *testing.T) {
assert.Equal(t, http.StatusOK, recorder.Code)
assert.False(t, mockSettingsService.called)
})
t.Run("should parse namespace from baggage header with multiple values", func(t *testing.T) {
mockSettingsService := &mockSettingsService{
settings: []*settingservice.Setting{
{Section: "security", Key: "content_security_policy", Value: "true"},
},
}
license := &licensing.OSSLicensingService{}
cfg := &setting.Cfg{
Raw: ini.Empty(),
HTTPPort: "1234",
CSPEnabled: false, // Base config has CSP disabled
}
middleware := RequestConfigMiddleware(cfg, license, mockSettingsService)
var capturedConfig FSRequestConfig
testHandler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
var err error
capturedConfig, err = FSRequestConfigFromContext(r.Context())
require.NoError(t, err)
w.WriteHeader(http.StatusOK)
})
handler := middleware(testHandler)
req := httptest.NewRequest("GET", "/", nil)
// Baggage header with multiple key-value pairs
req.Header.Set("baggage", "trace-id=abc123,namespace=tenant-456,user-id=xyz")
req = setupTestContext(req)
recorder := httptest.NewRecorder()
handler.ServeHTTP(recorder, req)
assert.Equal(t, http.StatusOK, recorder.Code)
assert.True(t, capturedConfig.CSPEnabled, "Should apply tenant overrides when namespace is present")
assert.True(t, mockSettingsService.called, "Should call settings service when namespace is in baggage")
})
t.Run("should not call settings service with malformed baggage header", func(t *testing.T) {
mockSettingsService := &mockSettingsService{}
license := &licensing.OSSLicensingService{}
cfg := &setting.Cfg{
Raw: ini.Empty(),
HTTPPort: "1234",
}
middleware := RequestConfigMiddleware(cfg, license, mockSettingsService)
testHandler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusOK)
})
handler := middleware(testHandler)
req := httptest.NewRequest("GET", "/", nil)
// Malformed baggage header
req.Header.Set("baggage", "invalid baggage format;;;")
req = setupTestContext(req)
recorder := httptest.NewRecorder()
handler.ServeHTTP(recorder, req)
assert.Equal(t, http.StatusOK, recorder.Code)
assert.False(t, mockSettingsService.called, "Should not call settings service with malformed baggage")
})
t.Run("should not call settings service when baggage has no namespace", func(t *testing.T) {
mockSettingsService := &mockSettingsService{}
license := &licensing.OSSLicensingService{}
cfg := &setting.Cfg{
Raw: ini.Empty(),
HTTPPort: "1234",
}
middleware := RequestConfigMiddleware(cfg, license, mockSettingsService)
testHandler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusOK)
})
handler := middleware(testHandler)
req := httptest.NewRequest("GET", "/", nil)
// Baggage header without namespace key
req.Header.Set("baggage", "trace-id=abc123,user-id=xyz")
req = setupTestContext(req)
recorder := httptest.NewRecorder()
handler.ServeHTTP(recorder, req)
assert.Equal(t, http.StatusOK, recorder.Code)
assert.False(t, mockSettingsService.called, "Should not call settings service when namespace is not in baggage")
})
}
// mockSettingsService is a simple mock for testing

View file

@ -1,6 +1,7 @@
package fswebassets
import (
"context"
"path/filepath"
"github.com/grafana/grafana/pkg/api/dtos"
@ -34,8 +35,9 @@ func getCDNRoot(cfg *setting.Cfg, license licensing.Licensing) string {
}
// New codepath for retrieving web assets URLs for the frontend-service
func GetWebAssets(cfg *setting.Cfg, license licensing.Licensing) (dtos.EntryPointAssets, error) {
assetsManifest, err := webassets.ReadWebAssetsFromFile(filepath.Join(cfg.StaticRootPath, "build", "assets-manifest.json"))
func GetWebAssets(ctx context.Context, cfg *setting.Cfg, license licensing.Licensing) (dtos.EntryPointAssets, error) {
assetsFilename := "assets-manifest.json"
assetsManifest, err := webassets.ReadWebAssetsFromFile(filepath.Join(cfg.StaticRootPath, "build", assetsFilename))
if err != nil {
return dtos.EntryPointAssets{}, err
}

View file

@ -1,6 +1,7 @@
package fswebassets_test
import (
"context"
"net/url"
"testing"
@ -16,8 +17,9 @@ func TestGetWebAssets_WithoutCDNConfigured(t *testing.T) {
}
license := licensingtest.NewFakeLicensing()
license.On("ContentDeliveryPrefix").Return("grafana")
ctx := context.Background()
assets, err := fswebassets.GetWebAssets(cfg, license)
assets, err := fswebassets.GetWebAssets(ctx, cfg, license)
assert.NoError(t, err)
assert.NotNil(t, assets)
@ -33,8 +35,9 @@ func TestGetWebAssets_PrefixFromLicense(t *testing.T) {
}
license := licensingtest.NewFakeLicensing()
license.On("ContentDeliveryPrefix").Return("grafana-pro-max")
ctx := context.Background()
assets, err := fswebassets.GetWebAssets(cfg, license)
assets, err := fswebassets.GetWebAssets(ctx, cfg, license)
assert.NoError(t, err)
assert.NotNil(t, assets)
@ -49,8 +52,9 @@ func TestGetWebAssets_PrefixFromConfig(t *testing.T) {
}
license := licensingtest.NewFakeLicensing()
license.On("ContentDeliveryPrefix").Return("should-not-be-used")
ctx := context.Background()
assets, err := fswebassets.GetWebAssets(cfg, license)
assets, err := fswebassets.GetWebAssets(ctx, cfg, license)
assert.NoError(t, err)
assert.NotNil(t, assets)
@ -66,8 +70,9 @@ func TestGetWebAssets_PrefixFromConfigTrailingSlash(t *testing.T) {
}
license := licensingtest.NewFakeLicensing()
license.On("ContentDeliveryPrefix").Return("should-not-be-used")
ctx := context.Background()
assets, err := fswebassets.GetWebAssets(cfg, license)
assets, err := fswebassets.GetWebAssets(ctx, cfg, license)
assert.NoError(t, err)
assert.NotNil(t, assets)