Simulate is a lightweight .NET framework for running and measuring scenarios with one or many independent virtual users. It supports structured logging, metrics, and tracing to help you analyze simulation outcomes and performance with full observability.
- Run a scenario with any number of virtual users, each with its own state and its own loop (no lock-step between users)
- Ramp-up, think time, warm-up, a duration and/or a number of iterations per user
- Per-user setup and teardown (for example a logged in browser), with limited parallel setup
- Supports:
System.Diagnostics.Metricsfor metricsSystem.Diagnostics.ActivitySourcefor tracingILoggerfor structured logging
- Emits OpenTelemetry-compatible metrics and spans; every iteration is its own trace
- Easy to test and extend
Add the package via NuGet:
dotnet package add SimulateA scenario without state runs once by default:
var results = await new Simulation("example", async logger =>
{
logger.LogInformation("Relevant information");
await Task.Delay(100); // Simulated task
return true; // Successful simulation result
})
.RunFor(TimeSpan.FromSeconds(5))
.Run();
Console.WriteLine($"Successes: {results.Successes}");
Console.WriteLine($"Failures: {results.Failures}");Use Simulation<TUser> when every user carries state between iterations (a logged in browser, a session) and
users should work independently:
var results = await new Simulation<IPage>("view assessment",
setup: async (index, logger) => await LoginAsync(index), // once per user, not measured
scenario: async (page, logger) =>
{
await page.GotoAsync("/dashboard");
return true;
},
teardown: async page => await page.CloseAsync()) // always runs
.WithUsers(10)
.RampUp(TimeSpan.FromSeconds(30)) // user i starts after i/10 of 30 s
.ThinkTime(TimeSpan.FromSeconds(1), TimeSpan.FromSeconds(3)) // random pause between iterations
.Warmup(1) // first iteration per user is not recorded
.MaxParallelSetup(2) // do not flood a login server
.RunFor(TimeSpan.FromMinutes(5))
.Run();- Every user runs its own loop; there is no barrier between users, so a slow iteration of one user does not hold back the others.
- Without
RunFor, every user runs the scenario once.RunFor(duration)counts from the end of the setup phase and includes the ramp-up; iterations in flight complete.RunFor(iterationsPerUser)can be combined with a duration: whichever comes first. - Every iteration is its own trace (
simulate.userandsimulate.iterationtags, error status on failure), soActivity.Currentinside the scenario is the iteration to propagate, for example as atraceparentheader. results.Resultsholds one entry per recorded iteration including theUserindex (also inToCSV). Warm-up iterations are not included.- If a user cannot be set up,
Runthrows after tearing down the users that were set up. Cancelling the token passed toRunstops the users after their current iteration and returns the results so far. - Need several load levels? Run one simulation per level and give each its own name.
Simulate emits three main metrics:
| Metric Name | Type | Description |
|---|---|---|
simulations.ok |
Counter | Total number of successful runs |
simulations.fail |
Counter | Total number of failed runs |
simulations.duration |
Histogram | Time taken per iteration (ms) |
All metrics are tagged with:
scenario– the name of the simulation
simulations.duration is also tagged with success (true or false).
You can integrate OpenTelemetry to export traces, metrics, and logs from Simulate to the console or any OpenTelemetry-compatible backend (like Jaeger, Prometheus, or OTLP).
using OpenTelemetry;
using OpenTelemetry.Trace;
using OpenTelemetry.Metrics;
using OpenTelemetry.Logs;
using Microsoft.Extensions.Logging;
var serviceName = "Simulate";
var serviceVersion = "0.1.0";
// Configure logging
using var loggerFactory = LoggerFactory.Create(builder =>
{
builder.AddOpenTelemetry(options =>
{
options.IncludeScopes = true;
options.ParseStateValues = true;
options.AddConsoleExporter();
});
});
// Configure tracing
using var tracerProvider = Sdk.CreateTracerProviderBuilder()
.AddSource(serviceName)
.SetResourceBuilder(ResourceBuilder.CreateDefault()
.AddService(serviceName: serviceName, serviceVersion: serviceVersion))
.AddConsoleExporter()
.Build();
// Configure metrics
using var meterProvider = Sdk.CreateMeterProviderBuilder()
.AddMeter(serviceName)
.SetResourceBuilder(ResourceBuilder.CreateDefault()
.AddService(serviceName: serviceName, serviceVersion: serviceVersion))
.AddConsoleExporter()
.Build();Place this configuration before running your simulation:
await new Simulation("example", async _ =>
{
await Task.Delay(100);
return true;
}, loggerFactory)
.WithUsers(5)
.RunFor(TimeSpan.FromSeconds(3))
.Run();Use MeterListener, ActivityListener, and structured log capturing to test your simulation results.
[TestMethod]
public async Task Should_Record_Metrics()
{
var metrics = new List<string>();
using var listener = new MeterListener();
listener.InstrumentPublished = (inst, _) => listener.EnableMeasurementEvents(inst);
listener.SetMeasurementEventCallback<long>((inst, measurement, tags, _) =>
{
metrics.Add($"{inst.Name}: {measurement}");
});
listener.Start();
var results = await new Simulation("test", async _ =>
{
await Task.Delay(10);
return true;
})
.RunFor(TimeSpan.FromSeconds(1))
.Run();
Assert.IsTrue(results.Successes > 0);
Assert.IsTrue(metrics.Count > 0);
}This project is licensed under the MIT License. See LICENSE for details.
Contributions, suggestions, and issues are welcome. Please open a PR or file an issue in GitHub.
Questions? Reach out on GitHub or submit an issue.