Skip to content

Commit 7721e95

Browse files
authored
Merge pull request #2 from JerrettDavis/feature/add-enterprise-features
feat: add enterprise resilience features and comprehensive documentation
2 parents 010cc22 + 08ad512 commit 7721e95

File tree

46 files changed

+5069
-152
lines changed

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

46 files changed

+5069
-152
lines changed

ExperimentFramework.slnx

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,8 @@
77
<Folder Name="/src/">
88
<Project Path="benchmarks/ExperimentFramework.Benchmarks/ExperimentFramework.Benchmarks.csproj" />
99
<Project Path="src/ExperimentFramework.Generators/ExperimentFramework.Generators.csproj" />
10+
<Project Path="src/ExperimentFramework.Metrics.Exporters/ExperimentFramework.Metrics.Exporters.csproj" />
11+
<Project Path="src/ExperimentFramework.Resilience/ExperimentFramework.Resilience.csproj" />
1012
<Project Path="src/ExperimentFramework/ExperimentFramework.csproj" />
1113
</Folder>
1214
<Folder Name="/tests/">

README.md

Lines changed: 107 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -1,31 +1,30 @@
11
# ExperimentFramework
22

3-
A .NET framework for runtime-switchable A/B testing, feature flags, trial fallback, and comprehensive observability.
3+
A .NET library for routing service calls through configurable trials based on feature flags, configuration values, or custom routing logic.
44

5-
**Version 0.1.0** - Production-ready with source-generated zero-overhead proxies
5+
## Features
66

7-
## Key Features
8-
9-
**Multiple Selection Modes**
7+
**Selection Modes**
108
- Boolean feature flags (`true`/`false` keys)
119
- Configuration values (string variants)
1210
- Variant feature flags (IVariantFeatureManager integration)
13-
- Sticky routing (deterministic user/session-based A/B testing)
11+
- Sticky routing (deterministic user/session-based routing)
12+
13+
**Resilience**
14+
- Timeout enforcement with fallback
15+
- Circuit breaker (Polly integration)
16+
- Kill switch for disabling experiments at runtime
1417

15-
**Enterprise Observability**
16-
- OpenTelemetry distributed tracing support
18+
**Observability**
19+
- OpenTelemetry tracing
20+
- Metrics collection (Prometheus, OpenTelemetry)
1721
- Built-in benchmarking and error logging
18-
- Zero overhead when telemetry disabled
1922

20-
**Flexible Configuration**
21-
- Custom naming conventions
23+
**Configuration**
2224
- Error policies with fallback strategies
25+
- Custom naming conventions
2326
- Decorator pipeline for cross-cutting concerns
24-
25-
**Type-Safe & DI-Friendly**
26-
- Composition-root driven registration
27-
- Full dependency injection integration
28-
- Strongly-typed builder API
27+
- Dependency injection integration
2928

3029
## Quick Start
3130

@@ -230,6 +229,98 @@ Tries ordered list of fallback trials:
230229
// Fine-grained control over fallback strategy
231230
```
232231

232+
## Timeout Enforcement
233+
234+
Prevent slow trials from degrading system performance:
235+
236+
```csharp
237+
var experiments = ExperimentFrameworkBuilder.Create()
238+
.Define<IMyDatabase>(c => c
239+
.UsingFeatureFlag("UseCloudDb")
240+
.AddDefaultTrial<LocalDb>("false")
241+
.AddTrial<CloudDb>("true")
242+
.OnErrorRedirectAndReplayDefault())
243+
.WithTimeout(TimeSpan.FromSeconds(5), TimeoutAction.FallbackToDefault)
244+
.UseDispatchProxy();
245+
```
246+
247+
**Actions:**
248+
- `TimeoutAction.ThrowException` - Throw `TimeoutException` when trial exceeds timeout
249+
- `TimeoutAction.FallbackToDefault` - Automatically fallback to default trial on timeout
250+
251+
See [Timeout Enforcement Guide](docs/user-guide/timeout-enforcement.md) for detailed examples.
252+
253+
## Circuit Breaker
254+
255+
Automatically disable failing trials using Polly:
256+
257+
```bash
258+
dotnet add package ExperimentFramework.Resilience
259+
```
260+
261+
```csharp
262+
var experiments = ExperimentFrameworkBuilder.Create()
263+
.Define<IMyService>(c => c
264+
.UsingFeatureFlag("UseNewService")
265+
.AddDefaultTrial<StableService>("false")
266+
.AddTrial<NewService>("true")
267+
.OnErrorRedirectAndReplayDefault())
268+
.WithCircuitBreaker(options =>
269+
{
270+
options.FailureRatioThreshold = 0.5; // Open after 50% failure rate
271+
options.MinimumThroughput = 10; // Need 10 calls to assess
272+
options.SamplingDuration = TimeSpan.FromSeconds(30);
273+
options.BreakDuration = TimeSpan.FromSeconds(60);
274+
options.OnCircuitOpen = CircuitBreakerAction.FallbackToDefault;
275+
})
276+
.UseDispatchProxy();
277+
```
278+
279+
See [Circuit Breaker Guide](docs/user-guide/circuit-breaker.md) for advanced configuration.
280+
281+
## Metrics Collection
282+
283+
Track experiment performance with Prometheus or OpenTelemetry:
284+
285+
```bash
286+
dotnet add package ExperimentFramework.Metrics.Exporters
287+
```
288+
289+
```csharp
290+
var prometheusMetrics = new PrometheusExperimentMetrics();
291+
var experiments = ExperimentFrameworkBuilder.Create()
292+
.Define<IMyService>(c => c.UsingFeatureFlag("MyFeature")...)
293+
.WithMetrics(prometheusMetrics)
294+
.UseDispatchProxy();
295+
296+
app.MapGet("/metrics", () => prometheusMetrics.GeneratePrometheusOutput());
297+
```
298+
299+
**Collected Metrics:**
300+
- `experiment_invocations_total` (counter) - Total invocations per experiment/trial
301+
- `experiment_duration_seconds` (histogram) - Duration of each invocation
302+
303+
See [Metrics Guide](docs/user-guide/metrics.md) for OpenTelemetry integration and Grafana dashboards.
304+
305+
## Kill Switch
306+
307+
Emergency shutdown for problematic experiments:
308+
309+
```csharp
310+
var killSwitch = new InMemoryKillSwitchProvider();
311+
312+
var experiments = ExperimentFrameworkBuilder.Create()
313+
.Define<IMyDatabase>(c => c.UsingFeatureFlag("UseCloudDb")...)
314+
.WithKillSwitch(killSwitch)
315+
.UseDispatchProxy();
316+
317+
// Emergency disable
318+
killSwitch.DisableExperiment(typeof(IMyDatabase));
319+
killSwitch.DisableTrial(typeof(IMyDatabase), "cloud");
320+
```
321+
322+
See [Kill Switch Guide](docs/user-guide/kill-switch.md) for distributed scenarios with Redis.
323+
233324
## Custom Naming Conventions
234325

235326
Replace default selector naming:

benchmarks/ExperimentFramework.Benchmarks/ProxyOverheadBenchmarks.cs

Lines changed: 8 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -247,7 +247,7 @@ public string[] Direct_RepeatedSync_10Calls()
247247
using var scope = _directServiceProvider.CreateScope();
248248
var service = scope.ServiceProvider.GetRequiredService<ISimpleService>();
249249
var results = new string[10];
250-
for (int i = 0; i < 10; i++)
250+
for (var i = 0; i < 10; i++)
251251
{
252252
results[i] = service.GetValue();
253253
}
@@ -260,7 +260,7 @@ public string[] Proxied_Configuration_RepeatedSync_10Calls()
260260
using var scope = _proxiedConfigurationServiceProvider.CreateScope();
261261
var service = scope.ServiceProvider.GetRequiredService<ISimpleService>();
262262
var results = new string[10];
263-
for (int i = 0; i < 10; i++)
263+
for (var i = 0; i < 10; i++)
264264
{
265265
results[i] = service.GetValue();
266266
}
@@ -273,7 +273,7 @@ public string[] Direct_RepeatedSync_100Calls()
273273
using var scope = _directServiceProvider.CreateScope();
274274
var service = scope.ServiceProvider.GetRequiredService<ISimpleService>();
275275
var results = new string[100];
276-
for (int i = 0; i < 100; i++)
276+
for (var i = 0; i < 100; i++)
277277
{
278278
results[i] = service.GetValue();
279279
}
@@ -286,7 +286,7 @@ public string[] Proxied_Configuration_RepeatedSync_100Calls()
286286
using var scope = _proxiedConfigurationServiceProvider.CreateScope();
287287
var service = scope.ServiceProvider.GetRequiredService<ISimpleService>();
288288
var results = new string[100];
289-
for (int i = 0; i < 100; i++)
289+
for (var i = 0; i < 100; i++)
290290
{
291291
results[i] = service.GetValue();
292292
}
@@ -299,7 +299,7 @@ public async Task<string[]> Direct_RepeatedAsync_10Calls()
299299
using var scope = _directServiceProvider.CreateScope();
300300
var service = scope.ServiceProvider.GetRequiredService<ISimpleService>();
301301
var results = new string[10];
302-
for (int i = 0; i < 10; i++)
302+
for (var i = 0; i < 10; i++)
303303
{
304304
results[i] = await service.GetValueAsync();
305305
}
@@ -312,7 +312,7 @@ public async Task<string[]> Proxied_Configuration_RepeatedAsync_10Calls()
312312
using var scope = _proxiedConfigurationServiceProvider.CreateScope();
313313
var service = scope.ServiceProvider.GetRequiredService<ISimpleService>();
314314
var results = new string[10];
315-
for (int i = 0; i < 10; i++)
315+
for (var i = 0; i < 10; i++)
316316
{
317317
results[i] = await service.GetValueAsync();
318318
}
@@ -325,7 +325,7 @@ public async Task<string[]> Direct_RepeatedAsync_100Calls()
325325
using var scope = _directServiceProvider.CreateScope();
326326
var service = scope.ServiceProvider.GetRequiredService<ISimpleService>();
327327
var results = new string[100];
328-
for (int i = 0; i < 100; i++)
328+
for (var i = 0; i < 100; i++)
329329
{
330330
results[i] = await service.GetValueAsync();
331331
}
@@ -338,7 +338,7 @@ public async Task<string[]> Proxied_Configuration_RepeatedAsync_100Calls()
338338
using var scope = _proxiedConfigurationServiceProvider.CreateScope();
339339
var service = scope.ServiceProvider.GetRequiredService<ISimpleService>();
340340
var results = new string[100];
341-
for (int i = 0; i < 100; i++)
341+
for (var i = 0; i < 100; i++)
342342
{
343343
results[i] = await service.GetValueAsync();
344344
}

benchmarks/ExperimentFramework.Benchmarks/RealWorldScenarioBenchmarks.cs

Lines changed: 8 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -259,7 +259,7 @@ public string[] Direct_CPUBound_Repeated_10Calls()
259259
using var scope = _directCacheProvider.CreateScope();
260260
var cache = scope.ServiceProvider.GetRequiredService<ICache>();
261261
var results = new string[10];
262-
for (int i = 0; i < 10; i++)
262+
for (var i = 0; i < 10; i++)
263263
{
264264
results[i] = cache.ComputeHash($"test-data-{i}");
265265
}
@@ -272,7 +272,7 @@ public string[] Proxied_CPUBound_Repeated_10Calls()
272272
using var scope = _proxiedCacheProvider.CreateScope();
273273
var cache = scope.ServiceProvider.GetRequiredService<ICache>();
274274
var results = new string[10];
275-
for (int i = 0; i < 10; i++)
275+
for (var i = 0; i < 10; i++)
276276
{
277277
results[i] = cache.ComputeHash($"test-data-{i}");
278278
}
@@ -285,7 +285,7 @@ public string[] Direct_CPUBound_Repeated_100Calls()
285285
using var scope = _directCacheProvider.CreateScope();
286286
var cache = scope.ServiceProvider.GetRequiredService<ICache>();
287287
var results = new string[100];
288-
for (int i = 0; i < 100; i++)
288+
for (var i = 0; i < 100; i++)
289289
{
290290
results[i] = cache.ComputeHash($"test-data-{i}");
291291
}
@@ -298,7 +298,7 @@ public string[] Proxied_CPUBound_Repeated_100Calls()
298298
using var scope = _proxiedCacheProvider.CreateScope();
299299
var cache = scope.ServiceProvider.GetRequiredService<ICache>();
300300
var results = new string[100];
301-
for (int i = 0; i < 100; i++)
301+
for (var i = 0; i < 100; i++)
302302
{
303303
results[i] = cache.ComputeHash($"test-data-{i}");
304304
}
@@ -311,7 +311,7 @@ public string[] Proxied_CPUBound_Repeated_100Calls()
311311
using var scope = _directDatabaseProvider.CreateScope();
312312
var db = scope.ServiceProvider.GetRequiredService<IDatabase>();
313313
var results = new Customer?[10];
314-
for (int i = 0; i < 10; i++)
314+
for (var i = 0; i < 10; i++)
315315
{
316316
results[i] = await db.GetCustomerByIdAsync(1);
317317
}
@@ -324,7 +324,7 @@ public string[] Proxied_CPUBound_Repeated_100Calls()
324324
using var scope = _proxiedDatabaseProvider.CreateScope();
325325
var db = scope.ServiceProvider.GetRequiredService<IDatabase>();
326326
var results = new Customer?[10];
327-
for (int i = 0; i < 10; i++)
327+
for (var i = 0; i < 10; i++)
328328
{
329329
results[i] = await db.GetCustomerByIdAsync(1);
330330
}
@@ -337,7 +337,7 @@ public string[] Proxied_CPUBound_Repeated_100Calls()
337337
using var scope = _directDatabaseProvider.CreateScope();
338338
var db = scope.ServiceProvider.GetRequiredService<IDatabase>();
339339
var results = new Customer?[100];
340-
for (int i = 0; i < 100; i++)
340+
for (var i = 0; i < 100; i++)
341341
{
342342
results[i] = await db.GetCustomerByIdAsync(1);
343343
}
@@ -350,7 +350,7 @@ public string[] Proxied_CPUBound_Repeated_100Calls()
350350
using var scope = _proxiedDatabaseProvider.CreateScope();
351351
var db = scope.ServiceProvider.GetRequiredService<IDatabase>();
352352
var results = new Customer?[100];
353-
for (int i = 0; i < 100; i++)
353+
for (var i = 0; i < 100; i++)
354354
{
355355
results[i] = await db.GetCustomerByIdAsync(1);
356356
}

0 commit comments

Comments
 (0)