-
Notifications
You must be signed in to change notification settings - Fork 1
Expand file tree
/
Copy pathfeature_flags.go
More file actions
408 lines (354 loc) · 15.4 KB
/
feature_flags.go
File metadata and controls
408 lines (354 loc) · 15.4 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
package reverseproxy
import (
"context"
"errors"
"fmt"
"log/slog"
"net/http"
"reflect"
"sort"
"github.com/GoCodeAlone/modular"
)
// FeatureFlagEvaluator defines the interface for evaluating feature flags.
// This allows for different implementations of feature flag services while
// providing a consistent interface for the reverseproxy module.
//
// Evaluators may return special sentinel errors to control aggregation behavior:
// - ErrNoDecision: Evaluator abstains and evaluation continues to next evaluator
// - ErrEvaluatorFatal: Fatal error that stops evaluation chain immediately
type FeatureFlagEvaluator interface {
// EvaluateFlag evaluates a feature flag for the given context and request.
// Returns true if the feature flag is enabled, false otherwise.
// The tenantID parameter can be empty if no tenant context is available.
//
// Special error handling:
// - Returning ErrNoDecision allows evaluation to continue to next evaluator
// - Returning ErrEvaluatorFatal stops evaluation chain immediately
// - Other errors are treated as non-fatal and evaluation continues
EvaluateFlag(ctx context.Context, flagID string, tenantID modular.TenantID, req *http.Request) (bool, error)
// EvaluateFlagWithDefault evaluates a feature flag with a default value.
// If evaluation fails or the flag doesn't exist, returns the default value.
EvaluateFlagWithDefault(ctx context.Context, flagID string, tenantID modular.TenantID, req *http.Request, defaultValue bool) bool
}
// WeightedEvaluator is an optional interface that FeatureFlagEvaluator implementations
// can implement to specify their priority in the evaluation chain.
// Lower weight values indicate higher priority (evaluated first).
// Default weight for evaluators that don't implement this interface is 100.
// The built-in file evaluator has weight 1000 (lowest priority/last fallback).
type WeightedEvaluator interface {
FeatureFlagEvaluator
// Weight returns the priority weight for this evaluator.
// Lower values = higher priority. Default is 100 if not implemented.
Weight() int
}
// FileBasedFeatureFlagEvaluator implements a feature flag evaluator that integrates
// with the Modular framework's tenant-aware configuration system.
type FileBasedFeatureFlagEvaluator struct {
// app provides access to the application and its services
app modular.Application
// tenantAwareConfig provides tenant-aware access to feature flag configuration
// Can be nil if no tenant service is available
tenantAwareConfig *modular.TenantAwareConfig
// defaultConfigProvider is used as fallback when tenantAwareConfig is nil
defaultConfigProvider modular.ConfigProvider
// logger for debug and error logging
logger *slog.Logger
}
// NewFileBasedFeatureFlagEvaluator creates a new tenant-aware feature flag evaluator.
func NewFileBasedFeatureFlagEvaluator(ctx context.Context, app modular.Application, logger *slog.Logger) (*FileBasedFeatureFlagEvaluator, error) {
// Validate parameters
if app == nil {
return nil, ErrApplicationNil
}
if logger == nil {
return nil, ErrLoggerNil
}
// Get tenant service
var tenantService modular.TenantService
if err := app.GetService("tenantService", &tenantService); err != nil {
logger.WarnContext(ctx, "TenantService not available, feature flags will use default configuration only", "error", err)
tenantService = nil
}
// Get the default configuration from the application
var defaultConfigProvider modular.ConfigProvider
if configProvider, err := app.GetConfigSection("reverseproxy"); err == nil {
defaultConfigProvider = configProvider
} else {
// Fallback to empty config if no section is registered
defaultConfigProvider = modular.NewStdConfigProvider(&ReverseProxyConfig{})
}
// Create tenant-aware config for feature flags if tenant service is available
// This will use the "reverseproxy" section from configurations
var tenantAwareConfig *modular.TenantAwareConfig
if tenantService != nil {
tenantAwareConfig = modular.NewTenantAwareConfig(
defaultConfigProvider,
tenantService,
"reverseproxy",
)
// Validate that tenant-aware config was created successfully
if tenantAwareConfig == nil {
return nil, ErrTenantAwareConfigCreation
}
} else {
// When no tenant service is available, we'll use defaultConfigProvider directly
logger.WarnContext(ctx, "No tenant service available, using default config provider only")
tenantAwareConfig = nil
}
return &FileBasedFeatureFlagEvaluator{
app: app,
tenantAwareConfig: tenantAwareConfig,
defaultConfigProvider: defaultConfigProvider,
logger: logger,
}, nil
}
// EvaluateFlag evaluates a feature flag using tenant-aware configuration.
// It follows the standard Modular framework pattern where:
// 1. Default flags come from the main configuration
// 2. Tenant-specific overrides come from tenant configuration files
// 3. During request processing, tenant context determines which configuration to use
//
//nolint:contextcheck // Skipping context check because this code intentionally creates a new tenant context if one does not exist, enabling tenant-aware configuration lookup.
func (f *FileBasedFeatureFlagEvaluator) EvaluateFlag(ctx context.Context, flagID string, tenantID modular.TenantID, req *http.Request) (bool, error) {
// Create context with tenant ID if provided and not already a tenant context
if tenantID != "" {
if _, hasTenant := modular.GetTenantIDFromContext(ctx); !hasTenant {
ctx = modular.NewTenantContext(ctx, tenantID)
}
}
// Get configuration - use tenant-aware if available, otherwise use default
var config *ReverseProxyConfig
if f.tenantAwareConfig != nil {
// Get tenant-aware configuration
rawConfig := f.tenantAwareConfig.GetConfigWithContext(ctx)
if rawConfig == nil {
f.logger.DebugContext(ctx, "No configuration available from tenant-aware config", "flag", flagID)
return false, fmt.Errorf("feature flag %s not found: %w", flagID, ErrFeatureFlagNotFound)
}
var ok bool
config, ok = rawConfig.(*ReverseProxyConfig)
if !ok {
f.logger.DebugContext(ctx, "Configuration is not of expected type", "flag", flagID, "type", fmt.Sprintf("%T", rawConfig))
return false, fmt.Errorf("%w: %s", ErrInvalidFeatureFlagConfigType, flagID)
}
} else {
// Fall back to default config provider when no tenant service is available
f.logger.DebugContext(ctx, "Using default config provider (no tenant service available)", "flag", flagID)
if f.defaultConfigProvider == nil {
f.logger.DebugContext(ctx, "No default config provider available", "flag", flagID)
return false, fmt.Errorf("%w: %s", ErrNoFeatureFlagConfigProvider, flagID)
}
rawConfig := f.defaultConfigProvider.GetConfig()
if rawConfig == nil {
f.logger.DebugContext(ctx, "No configuration available from default provider", "flag", flagID)
return false, fmt.Errorf("feature flag %s not found: %w", flagID, ErrFeatureFlagNotFound)
}
var ok bool
config, ok = rawConfig.(*ReverseProxyConfig)
if !ok {
f.logger.DebugContext(ctx, "Default configuration is not of expected type", "flag", flagID, "type", fmt.Sprintf("%T", rawConfig))
return false, fmt.Errorf("%w: %s", ErrInvalidDefaultFeatureFlagConfig, flagID)
}
}
if config == nil {
f.logger.DebugContext(ctx, "No feature flag configuration available", "flag", flagID)
return false, fmt.Errorf("feature flag %s not found: %w", flagID, ErrFeatureFlagNotFound)
}
// Check if feature flags are enabled
if !config.FeatureFlags.Enabled {
f.logger.DebugContext(ctx, "Feature flags are disabled", "flag", flagID)
return false, fmt.Errorf("feature flags disabled: %w", ErrFeatureFlagNotFound)
}
// Look up the flag value
if config.FeatureFlags.Flags != nil {
if value, exists := config.FeatureFlags.Flags[flagID]; exists {
f.logger.DebugContext(ctx, "Feature flag evaluated",
"flag", flagID,
"tenant", tenantID,
"value", value)
return value, nil
}
}
f.logger.DebugContext(ctx, "Feature flag not found in configuration",
"flag", flagID,
"tenant", tenantID)
return false, fmt.Errorf("feature flag %s not found: %w", flagID, ErrFeatureFlagNotFound)
}
// EvaluateFlagWithDefault evaluates a feature flag with a default value.
func (f *FileBasedFeatureFlagEvaluator) EvaluateFlagWithDefault(ctx context.Context, flagID string, tenantID modular.TenantID, req *http.Request, defaultValue bool) bool {
value, err := f.EvaluateFlag(ctx, flagID, tenantID, req)
if err != nil {
return defaultValue
}
return value
}
// FeatureFlagAggregator implements FeatureFlagEvaluator by aggregating multiple
// evaluators and calling them in priority order (weight-based).
// It discovers evaluators from the service registry by name prefix pattern.
type FeatureFlagAggregator struct {
app modular.Application
logger *slog.Logger
}
// weightedEvaluatorInstance holds an evaluator with its resolved weight
type weightedEvaluatorInstance struct {
evaluator FeatureFlagEvaluator
weight int
name string
}
// NewFeatureFlagAggregator creates a new aggregator that discovers and coordinates
// multiple feature flag evaluators from the application's service registry.
func NewFeatureFlagAggregator(app modular.Application, logger *slog.Logger) *FeatureFlagAggregator {
return &FeatureFlagAggregator{
app: app,
logger: logger,
}
}
// discoverEvaluators finds all FeatureFlagEvaluator services by matching interface implementation
// and assigns unique names. The name doesn't matter for matching, only for uniqueness.
func (a *FeatureFlagAggregator) discoverEvaluators() []weightedEvaluatorInstance {
var evaluators []weightedEvaluatorInstance
nameCounters := make(map[string]int) // Track name usage for uniqueness
// Use interface-based discovery to find all FeatureFlagEvaluator services
evaluatorType := reflect.TypeOf((*FeatureFlagEvaluator)(nil)).Elem()
entries := a.app.GetServicesByInterface(evaluatorType)
for _, entry := range entries {
// Check if it's the same instance as ourselves (prevent self-ingestion)
if entry.Service == a {
continue
}
// Skip the aggregator itself to prevent recursion
if entry.ActualName == "featureFlagEvaluator" {
continue
}
// Skip the internal file evaluator to prevent double evaluation
// (it will be included via separate discovery)
if entry.ActualName == "featureFlagEvaluator.file" {
continue
}
// Already confirmed to be FeatureFlagEvaluator by interface discovery
evaluator := entry.Service.(FeatureFlagEvaluator)
// Generate unique name using enhanced service registry information
uniqueName := a.generateUniqueNameWithModuleInfo(entry, nameCounters)
// Determine weight
weight := 100 // default weight
if weightedEvaluator, ok := evaluator.(WeightedEvaluator); ok {
weight = weightedEvaluator.Weight()
}
evaluators = append(evaluators, weightedEvaluatorInstance{
evaluator: evaluator,
weight: weight,
name: uniqueName,
})
a.logger.Debug("Discovered feature flag evaluator",
"originalName", entry.OriginalName, "actualName", entry.ActualName,
"uniqueName", uniqueName, "moduleName", entry.ModuleName,
"weight", weight, "type", fmt.Sprintf("%T", evaluator))
}
// Also include the file evaluator with weight 1000 (lowest priority)
var fileEvaluator FeatureFlagEvaluator
if err := a.app.GetService("featureFlagEvaluator.file", &fileEvaluator); err == nil && fileEvaluator != nil {
evaluators = append(evaluators, weightedEvaluatorInstance{
evaluator: fileEvaluator,
weight: 1000, // Lowest priority - fallback evaluator
name: "featureFlagEvaluator.file",
})
} else if err != nil {
a.logger.Debug("File evaluator not found", "error", err)
}
// Sort by weight (ascending - lower weight = higher priority)
sort.Slice(evaluators, func(i, j int) bool {
return evaluators[i].weight < evaluators[j].weight
})
return evaluators
}
// generateUniqueNameWithModuleInfo creates a unique name for a feature flag evaluator service
// using the enhanced service registry information that tracks module associations.
// This replaces the previous heuristic-based approach with precise module information.
func (a *FeatureFlagAggregator) generateUniqueNameWithModuleInfo(entry *modular.ServiceRegistryEntry, nameCounters map[string]int) string {
// Try original name first
originalName := entry.OriginalName
if nameCounters[originalName] == 0 {
nameCounters[originalName] = 1
return originalName
}
// Name conflicts exist - use module information for disambiguation
if entry.ModuleName != "" {
// Try with module name
moduleBasedName := fmt.Sprintf("%s.%s", originalName, entry.ModuleName)
if nameCounters[moduleBasedName] == 0 {
nameCounters[moduleBasedName] = 1
return moduleBasedName
}
}
// Try with module type name if available
if entry.ModuleType != nil {
typeName := entry.ModuleType.Elem().Name()
if typeName == "" {
typeName = entry.ModuleType.String()
}
typeBasedName := fmt.Sprintf("%s.%s", originalName, typeName)
if nameCounters[typeBasedName] == 0 {
nameCounters[typeBasedName] = 1
return typeBasedName
}
}
// Final fallback: append incrementing counter
counter := nameCounters[originalName]
nameCounters[originalName] = counter + 1
return fmt.Sprintf("%s.%d", originalName, counter)
}
// EvaluateFlag implements FeatureFlagEvaluator by calling discovered evaluators
// in weight order until one returns a decision or all have been tried.
func (a *FeatureFlagAggregator) EvaluateFlag(ctx context.Context, flagID string, tenantID modular.TenantID, req *http.Request) (bool, error) {
evaluators := a.discoverEvaluators()
if len(evaluators) == 0 {
a.logger.Debug("No feature flag evaluators found", "flag", flagID)
return false, fmt.Errorf("%w for %s", ErrNoEvaluatorsAvailable, flagID)
}
// Try each evaluator in weight order
for _, eval := range evaluators {
// Safety check to ensure evaluator is not nil
if eval.evaluator == nil {
a.logger.Warn("Skipping nil evaluator", "name", eval.name)
continue
}
a.logger.Debug("Trying feature flag evaluator",
"evaluator", eval.name, "weight", eval.weight, "flag", flagID)
result, err := eval.evaluator.EvaluateFlag(ctx, flagID, tenantID, req)
// Handle different error conditions
if err != nil {
if errors.Is(err, ErrNoDecision) {
// Evaluator abstains, continue to next
a.logger.Debug("Evaluator abstained",
"evaluator", eval.name, "flag", flagID)
continue
}
if errors.Is(err, ErrEvaluatorFatal) {
// Fatal error, abort evaluation chain
a.logger.Error("Evaluator fatal error, aborting evaluation",
"evaluator", eval.name, "flag", flagID, "error", err)
return false, fmt.Errorf("fatal error from evaluator %s: %w", eval.name, err)
}
// Non-fatal error, log and continue
a.logger.Warn("Evaluator error, continuing to next",
"evaluator", eval.name, "flag", flagID, "error", err)
continue
}
// Got a decision, return it
a.logger.Debug("Feature flag evaluated",
"evaluator", eval.name, "flag", flagID, "result", result)
return result, nil
}
// No evaluator provided a decision
a.logger.Debug("No evaluator provided decision for flag", "flag", flagID)
return false, fmt.Errorf("%w %s", ErrNoEvaluatorDecision, flagID)
}
// EvaluateFlagWithDefault implements FeatureFlagEvaluator by calling EvaluateFlag
// and returning the default value if evaluation fails.
func (a *FeatureFlagAggregator) EvaluateFlagWithDefault(ctx context.Context, flagID string, tenantID modular.TenantID, req *http.Request, defaultValue bool) bool {
result, err := a.EvaluateFlag(ctx, flagID, tenantID, req)
if err != nil {
return defaultValue
}
return result
}