Collect experiment outcomes to enable statistical analysis and publication-ready reports. The ExperimentFramework.Data package provides thread-safe, high-performance outcome recording.
Data collection captures:
- Binary outcomes: Success/failure events (conversions, clicks, purchases)
- Continuous outcomes: Numeric measurements (revenue, scores, ratings)
- Count outcomes: Integer counts (page views, items added, errors)
- Duration outcomes: Time measurements (load time, session duration)
dotnet add package ExperimentFramework.Datausing ExperimentFramework.Data;
var builder = WebApplication.CreateBuilder(args);
// Register data collection services
builder.Services.AddExperimentDataCollection();
var app = builder.Build();public class CheckoutController : ControllerBase
{
private readonly IOutcomeRecorder _recorder;
public CheckoutController(IOutcomeRecorder recorder)
{
_recorder = recorder;
}
[HttpPost]
public async Task<IActionResult> Checkout(string userId, string experimentTrial)
{
var stopwatch = Stopwatch.StartNew();
try
{
// Process checkout...
var success = await ProcessCheckout(userId);
stopwatch.Stop();
// Record binary outcome (conversion)
await _recorder.RecordBinaryAsync(
experimentName: "checkout-v2",
trialKey: experimentTrial,
subjectId: userId,
metricName: "purchase_completed",
success: success);
// Record duration
await _recorder.RecordDurationAsync(
experimentName: "checkout-v2",
trialKey: experimentTrial,
subjectId: userId,
metricName: "checkout_duration",
duration: stopwatch.Elapsed);
return Ok();
}
catch (Exception)
{
// Record failure
await _recorder.RecordBinaryAsync(
experimentName: "checkout-v2",
trialKey: experimentTrial,
subjectId: userId,
metricName: "purchase_completed",
success: false);
throw;
}
}
}For success/failure events:
// Conversion tracking
await recorder.RecordBinaryAsync(
"pricing-test",
"variant-a",
userId,
"converted",
success: true);
// Click tracking
await recorder.RecordBinaryAsync(
"cta-test",
"red-button",
userId,
"button_clicked",
success: true);
// Error tracking
await recorder.RecordBinaryAsync(
"api-migration",
"new-api",
userId,
"request_succeeded",
success: response.IsSuccessStatusCode);For numeric measurements:
// Revenue
await recorder.RecordContinuousAsync(
"checkout-v2",
"streamlined",
userId,
"order_value",
value: 149.99);
// Scores
await recorder.RecordContinuousAsync(
"recommendation-test",
"ml-model-v2",
userId,
"relevance_score",
value: 0.87);
// Ratings
await recorder.RecordContinuousAsync(
"onboarding-test",
"interactive",
userId,
"satisfaction_rating",
value: 4.5);For integer counts:
// Items in cart
await recorder.RecordCountAsync(
"cart-optimization",
"suggested-items",
userId,
"items_added",
count: 3);
// Page views
await recorder.RecordCountAsync(
"layout-test",
"single-page",
userId,
"pages_viewed",
count: 1);
// Errors encountered
await recorder.RecordCountAsync(
"error-handling-test",
"retry-strategy",
userId,
"retry_attempts",
count: 2);For time measurements:
// Response time
await recorder.RecordDurationAsync(
"api-optimization",
"cached",
userId,
"response_time",
duration: TimeSpan.FromMilliseconds(45));
// Session duration
await recorder.RecordDurationAsync(
"engagement-test",
"gamified",
userId,
"session_length",
duration: TimeSpan.FromMinutes(12.5));
// Time to first interaction
await recorder.RecordDurationAsync(
"onboarding-test",
"tutorial",
userId,
"time_to_first_action",
duration: TimeSpan.FromSeconds(8.3));Include additional context for segmented analysis:
await recorder.RecordBinaryAsync(
"checkout-v2",
"streamlined",
userId,
"purchase_completed",
success: true,
metadata: new Dictionary<string, object>
{
["device"] = "mobile",
["platform"] = "ios",
["region"] = "us-west",
["user_segment"] = "power_user",
["cart_value_tier"] = "high"
});Metadata enables:
- Segmented analysis: Compare results by user type, device, region
- Covariate adjustment: Control for confounding variables
- Debugging: Trace issues in specific conditions
Thread-safe storage for development and testing:
services.AddExperimentDataCollection();Implement IOutcomeStore for production:
public class PostgresOutcomeStore : IOutcomeStore
{
private readonly NpgsqlDataSource _dataSource;
public PostgresOutcomeStore(NpgsqlDataSource dataSource)
{
_dataSource = dataSource;
}
public async ValueTask RecordAsync(
ExperimentOutcome outcome,
CancellationToken cancellationToken = default)
{
await using var conn = await _dataSource.OpenConnectionAsync(cancellationToken);
await using var cmd = conn.CreateCommand();
cmd.CommandText = """
INSERT INTO experiment_outcomes
(id, experiment_name, trial_key, subject_id, outcome_type,
metric_name, value, timestamp, metadata)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)
""";
cmd.Parameters.AddWithValue(outcome.Id);
cmd.Parameters.AddWithValue(outcome.ExperimentName);
cmd.Parameters.AddWithValue(outcome.TrialKey);
cmd.Parameters.AddWithValue(outcome.SubjectId);
cmd.Parameters.AddWithValue(outcome.OutcomeType.ToString());
cmd.Parameters.AddWithValue(outcome.MetricName);
cmd.Parameters.AddWithValue(outcome.Value);
cmd.Parameters.AddWithValue(outcome.Timestamp);
cmd.Parameters.AddWithValue(
outcome.Metadata != null
? JsonSerializer.Serialize(outcome.Metadata)
: DBNull.Value);
await cmd.ExecuteNonQueryAsync(cancellationToken);
}
public async ValueTask<IReadOnlyList<ExperimentOutcome>> QueryAsync(
OutcomeQuery query,
CancellationToken cancellationToken = default)
{
// Implementation...
}
public async ValueTask<IReadOnlyDictionary<string, OutcomeAggregation>> GetAggregationsAsync(
string experimentName,
string metricName,
CancellationToken cancellationToken = default)
{
// Implementation...
}
}
// Register custom store
services.AddExperimentDataCollection<PostgresOutcomeStore>();Zero-overhead when data collection should be disabled:
// Production environment without analysis
if (env.IsProduction() && !config.GetValue<bool>("EnableExperimentAnalysis"))
{
services.AddExperimentDataCollectionNoop();
}
else
{
services.AddExperimentDataCollection();
}public class AnalysisService
{
private readonly IOutcomeStore _store;
public AnalysisService(IOutcomeStore store)
{
_store = store;
}
public async Task<IReadOnlyList<ExperimentOutcome>> GetConversions(
string experimentName)
{
var query = new OutcomeQuery
{
ExperimentName = experimentName,
MetricName = "purchase_completed"
};
return await _store.QueryAsync(query);
}
}var query = new OutcomeQuery
{
ExperimentName = "checkout-v2",
TrialKey = "streamlined",
MetricName = "purchase_completed",
FromTimestamp = DateTimeOffset.UtcNow.AddDays(-7),
ToTimestamp = DateTimeOffset.UtcNow
};
var recentOutcomes = await store.QueryAsync(query);Get summary statistics per trial:
var aggregations = await store.GetAggregationsAsync(
"checkout-v2",
"purchase_completed");
foreach (var (trialKey, agg) in aggregations)
{
Console.WriteLine($"{trialKey}: {agg.SuccessCount}/{agg.Count} = {agg.ConversionRate:P1}");
}
// Output:
// control: 145/500 = 29.0%
// streamlined: 187/500 = 37.4%services.AddExperimentDataCollection(options =>
{
// Auto-generate IDs if not provided (default: true)
options.AutoGenerateIds = true;
// Automatically set timestamps if not provided (default: true)
options.AutoSetTimestamps = true;
// Collect invocation duration automatically via decorator (default: true)
options.CollectDuration = true;
// Collect error information automatically via decorator (default: true)
options.CollectErrors = true;
// Custom metric names
options.DurationMetricName = "duration_seconds";
options.SuccessMetricName = "success";
options.ErrorMetricName = "error";
// Enable batching for high-throughput scenarios (default: false)
options.EnableBatching = false;
options.MaxBatchSize = 100;
options.BatchFlushInterval = TimeSpan.FromSeconds(5);
});Record outcomes immediately when the action occurs:
// Good - record immediately
public async Task<IActionResult> Purchase(string userId)
{
var result = await _checkout.ProcessAsync(userId);
await _recorder.RecordBinaryAsync(
"checkout-v2", trialKey, userId,
"purchase_completed", result.Success);
return Ok(result);
}
// Bad - deferred recording can lose data
public async Task<IActionResult> Purchase(string userId)
{
var result = await _checkout.ProcessAsync(userId);
_pendingOutcomes.Add(new PendingOutcome(...)); // May be lost
return Ok(result);
}Use stable identifiers for subjects:
// Good - stable user ID
await recorder.RecordBinaryAsync("test", trial, user.Id, "converted", true);
// Bad - session ID changes between visits
await recorder.RecordBinaryAsync("test", trial, HttpContext.Session.Id, "converted", true);Use descriptive, hierarchical metric names:
// Good
"checkout.purchase_completed"
"checkout.cart_abandoned"
"search.results_clicked"
// Bad
"converted"
"success"
"metric1"Always record outcomes, even on failure:
try
{
await ProcessPayment();
await recorder.RecordBinaryAsync(..., success: true);
}
catch (PaymentException)
{
await recorder.RecordBinaryAsync(..., success: false);
throw;
}Verify outcomes are recorded correctly:
[Fact]
public async Task Checkout_RecordsPurchaseOutcome()
{
// Arrange
var store = new InMemoryOutcomeStore();
var recorder = new OutcomeRecorder(store);
var controller = new CheckoutController(recorder);
// Act
await controller.Checkout("user-123", "variant-a");
// Assert
var outcomes = await store.QueryAsync(new OutcomeQuery
{
ExperimentName = "checkout-v2",
SubjectId = "user-123"
});
Assert.Single(outcomes);
Assert.Equal(1.0, outcomes[0].Value); // Success
}Data collection feeds into the Science package for analysis:
// Collect data
services.AddExperimentDataCollection();
services.AddExperimentScience();
// Later, analyze
var analyzer = serviceProvider.GetRequiredService<IExperimentAnalyzer>();
var report = await analyzer.AnalyzeAsync("checkout-v2", new AnalysisOptions
{
Alpha = 0.05,
TargetPower = 0.80,
ApplyMultipleComparisonCorrection = true,
CorrectionMethod = MultipleComparisonMethod.BenjaminiHochberg
});
// Generate report
var reporter = serviceProvider.GetRequiredService<IExperimentReporter>();
var markdown = await reporter.GenerateAsync(report);- Statistical Analysis - Analyze collected data
- Hypothesis Testing - Define and test hypotheses
- Power Analysis - Calculate required sample sizes
- Metrics - Real-time operational metrics