diff --git a/.github/workflows/benchmark-report.yml b/.github/workflows/benchmark-report.yml
new file mode 100644
index 00000000..51e5fca4
--- /dev/null
+++ b/.github/workflows/benchmark-report.yml
@@ -0,0 +1,21 @@
+name: .NET SDK benchmark report
+
+on:
+ workflow_run:
+ workflows: ['.NET SDK benchmarks']
+ types: [completed]
+
+permissions: {}
+
+jobs:
+ report:
+ permissions:
+ contents: read
+ actions: read
+ pull-requests: write
+ uses: PostHog/.github/.github/workflows/sdk-benchmark-report.yml@2a6111d08d784c0ca84f7f97f32a13fdd6d31449 # SDK benchmark reporting
+ with:
+ workflow-path: .github/workflows/benchmarks.yml
+ artifact-name: dotnet-benchmark-results
+ report-key: posthog-dotnet
+ sdk: posthog-dotnet
diff --git a/.github/workflows/benchmarks.yml b/.github/workflows/benchmarks.yml
new file mode 100644
index 00000000..2055a22e
--- /dev/null
+++ b/.github/workflows/benchmarks.yml
@@ -0,0 +1,89 @@
+name: .NET SDK benchmarks
+
+on: [pull_request]
+
+permissions: {}
+
+concurrency:
+ group: ${{ github.workflow }}-${{ github.event.pull_request.number }}
+ cancel-in-progress: true
+
+jobs:
+ benchmark:
+ runs-on: ubuntu-24.04
+ timeout-minutes: 12
+ permissions:
+ contents: read
+ steps:
+ - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
+ with:
+ ref: ${{ github.event.pull_request.head.sha }}
+ fetch-depth: 0
+ persist-credentials: false
+
+ - uses: actions/setup-dotnet@a98b56852c35b8e3190ac28c8c2271da59106c68 # v6.0.0
+ with:
+ dotnet-version: '9.0.109'
+
+ - uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0
+ with:
+ python-version: '3.12.12'
+
+ - name: Prepare pinned baseline
+ env:
+ TARGET_SHA: ${{ github.event.pull_request.base.sha }}
+ HEAD_SHA: ${{ github.event.pull_request.head.sha }}
+ run: |
+ set -euo pipefail
+ export BASE_WORKTREE="$RUNNER_TEMP/dotnet-benchmark-base"
+ export RESULTS="$RUNNER_TEMP/dotnet-benchmark-results"
+ printf 'BASE_WORKTREE=%s\nRESULTS=%s\n' "$BASE_WORKTREE" "$RESULTS" >> "$GITHUB_ENV"
+ BASE_SHA=$(git merge-base "$TARGET_SHA" "$HEAD_SHA")
+ git worktree add --detach "$BASE_WORKTREE" "$BASE_SHA"
+ mkdir -p "$RESULTS"
+ python -c 'import json, os, pathlib, sys; pathlib.Path(os.environ["RESULTS"], "revisions.json").write_text(json.dumps({"target": os.environ["TARGET_SHA"], "head": os.environ["HEAD_SHA"], "base": sys.argv[1]}, indent=2))' "$BASE_SHA"
+
+ - name: Test coordinator controls
+ run: python -B -m unittest discover -s benchmarks -p 'test_*.py'
+
+ - name: Build, test native controls and measure paired base and head
+ id: measure
+ timeout-minutes: 10
+ run: |
+ python -B benchmarks/run.py \
+ --base "$BASE_WORKTREE" \
+ --head "$GITHUB_WORKSPACE" \
+ --build-directory "$RUNNER_TEMP/dotnet-benchmark-build" \
+ --output "$RESULTS" \
+ --repetitions 6
+
+ - name: Render benchmark comparison
+ if: ${{ !cancelled() && (steps.measure.outcome == 'success' || steps.measure.outcome == 'failure') }}
+ uses: PostHog/.github/.github/actions/benchmark-report@04f8421ec4daa7f4066eaeb4c53621ba79ddc3ca # SDK benchmark reporting
+ with:
+ mode: render
+ source-status: ${{ steps.measure.outcome }}
+ base-results: ${{ runner.temp }}/dotnet-benchmark-results/base.json
+ head-results: ${{ runner.temp }}/dotnet-benchmark-results/head.json
+ output-directory: ${{ runner.temp }}/dotnet-benchmark-report
+
+ - name: Upload rendered comparison
+ if: always()
+ uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
+ with:
+ name: dotnet-benchmark-report
+ path: ${{ runner.temp }}/dotnet-benchmark-report
+ if-no-files-found: ignore
+ retention-days: 14
+
+ - name: Upload raw benchmark results
+ if: always()
+ uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
+ with:
+ name: dotnet-benchmark-results
+ path: |
+ ${{ runner.temp }}/dotnet-benchmark-results/base.json
+ ${{ runner.temp }}/dotnet-benchmark-results/head.json
+ ${{ runner.temp }}/dotnet-benchmark-results/revisions.json
+ if-no-files-found: warn
+ retention-days: 14
diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md
index 58a8f434..9ddf9921 100644
--- a/CONTRIBUTING.md
+++ b/CONTRIBUTING.md
@@ -45,6 +45,113 @@ The test projects target both `net8.0` and `netcoreapp3.1`. While .NET Core 3.1
This testing approach ensures broad compatibility without requiring users to install legacy runtimes in production.
+## Performance benchmarks
+
+Every opened, reopened, or updated pull request runs `.github/workflows/benchmarks.yml`.
+It compares the merge base with the exact PR head, using the head's benchmark code for
+both revisions. Results are informational: slow measurements do not fail the job, but
+build, execution, fixture correctness, or delivery failures do. The separate trusted
+`workflow_run` publisher posts a sticky PR comment once its workflow is on the default
+branch. Reporting is provided by [PostHog/.github](https://github.com/PostHog/.github/blob/main/.github/workflows/sdk-benchmark-report.md).
+
+The 20 workloads follow the shared [SDK benchmark specification](https://github.com/PostHog/.github/blob/main/.github/workflows/sdk-benchmark-specification.md)
+(`suite.version=2`, `suite.specification=sdk-benchmarks/v1`). They cover nine single-flag
+cases (16,000 evaluations each), all-flags at 10/100/1,000 definitions (1,600/160/160
+evaluations), 1,600 load evaluations using at most eight ThreadPool tasks, 40 definition
+reloads, and six capture workloads with 1,000 events each. Capture uses 32-byte and
+4-KiB payloads for enqueue, steady delivery (explicit flush every 100), and burst
+delivery (one final explicit flush). Enqueue has timing/allocation passes; both delivery
+patterns have timing/allocation/memory passes. Single/all-flags have timing/allocation
+passes; load and refresh have memory passes. Flag cases include percentage rollouts,
+variants, properties, regexes, cohorts, early/late/no-match groups and inactive flags.
+The suite exercises supported legacy getters; it does not measure the newer
+`EvaluateFlagsAsync` snapshot API.
+
+### Run locally
+
+Install .NET SDK 9.0.109 (including runtime 9.0.8) and Python 3.12. The coordinator uses
+only Python's standard library; all instrumentation and SDK workloads execute in C#.
+Use a fresh build directory for each invocation:
+
+```bash
+python3 -B -m unittest discover -s benchmarks -p 'test_*.py'
+RUN=$(mktemp -d)
+python3 -B benchmarks/run.py \
+ --base /path/to/baseline-checkout \
+ --head "$PWD" \
+ --build-directory "$RUN/build" \
+ --output "$RUN/results" \
+ --repetitions 6
+```
+
+Use `--smoke --repetitions 1` for correctness checks across all 20 workloads: 160
+single-flag evaluations, 16 all-flags evaluations, 32 load evaluations, four refreshes,
+and 200 events per capture workload. Smoke is not repeatability evidence. Native
+controls run automatically before pairs and check the inventory, fixtures, phase reset,
+flush schedules, reload generations, deterministic checkpoints and failure detection.
+For a same-revision noise control, point both `--base` and `--head` at the same checkout.
+Run repeatability checks on an otherwise idle machine; concurrent validation is only
+correctness evidence. Do not compare results from different hosts or languages.
+
+The coordinator copies the same standalone benchmark project outside the checkout,
+builds it separately against each revision's public SDK project with locked NuGet
+restore, and fingerprints the loaded SDK assembly and dependency graph. It uses the
+SDK's `net8.0` target on pinned .NET 9.0.8. Benchmark workers use workstation GC and
+optimized, non-tiered JIT compilation to avoid tier transitions during measurements.
+This is one explicit runtime configuration, not a claim about all .NET deployments.
+Each workload/repetition/pass starts a fresh process; base/head order alternates.
+Warmup is 1,024 single evaluations, 16 all/load evaluations, two refreshes, or 100
+captures, followed by a 25-ms native settling delay and full GC. Warmup captures are
+flushed and verified before counters and bounded sequence state reset. Every measured
+flag workload starts with generation 0 reloaded and verified. Full flag correctness
+sweeps occur outside measurement, including after the memory post-settling snapshot;
+measured refreshes only reload definitions and check that each made a request.
+
+### Measurement boundaries
+
+- Elapsed time uses `Stopwatch`; CPU uses whole-process `TotalProcessorTime`. Values
+ are expressed in nanoseconds, but CPU-counter resolution is OS-dependent.
+- Allocation volume uses `GC.GetTotalAllocatedBytes(precise: true)`, including managed
+ objects collected during the operation. It excludes native allocations and does not
+ provide exact object counts; counts are reported as unsupported, never zero.
+- Idle and post-settling managed heap use `GC.GetTotalMemory(false)` after explicit
+ full collections. These are retained-heap estimates, not allocation volume.
+- RSS checkpoints follow every 32 completed load evaluations, each refresh, or every
+ 100 captures, including the final operation if needed. Steady checkpoints follow the
+ scheduled flush; burst checkpoints precede the final flush. `observed_workload_peak_rss`
+ is the maximum of before-work, checkpoints and after-work readings, not a continuous
+ maximum. `process_lifetime_peak_rss` (`PeakWorkingSet64`) includes startup and warmup.
+ `idle_process_rss`, `post_settling_process_rss` and `post_settling_heap_used` retain their
+ distinct scopes. No forced GC occurs within measured work or checkpoint callbacks;
+ full collections bracket memory measurement. GC heap bytes and RSS are not
+ interchangeable; releasing objects need not reduce RSS.
+- Public `IHttpClientFactory` injection supplies offline definitions and acknowledges
+ batches. Measurements include in-process transport JSON parsing and validation, with
+ compression disabled through public options. This is not a network or remote
+ ingestion benchmark. No project credentials are required.
+- Caller fixtures are constructed outside timing/allocation boundaries. Event `benchmark`
+ uses 16 rotating identities and separate per-event property dictionaries containing
+ ASCII `payload`, `plan=pro` and integer `sequence`, starting at zero for each phase.
+ The receiver checks exact payload, plan and sequence-to-identity mapping using bounded,
+ preallocated uniqueness state: balanced duplicate/loss fails; order is not required.
+- Queue capacity (100,001), automatic flush count (100,000), and maximum count-based
+ batch size (100,000) exceed the workload; polling and flush intervals are one hour.
+ Native background consumers
+ and size-based transport behavior remain active. The patterns specify caller flushes,
+ not HTTP request counts. Enqueue measures synchronous `Capture` API return
+ (`completion_boundary=api_return`), excluding its later flush and verification;
+ process-wide CPU/allocation counters may include concurrent background work. Delivery
+ includes capture preparation, serialization and public `FlushAsync` completion
+ (`completion_boundary=completed_delivery`). Verification never adds a rescuing flush.
+
+CI keeps the suite on one runner, with a 12-minute job deadline and a 10-minute
+build/measurement step deadline to leave time for reporting and artifact upload.
+Each worker has a 120-second deadline and a 512-MiB **managed heap** limit, not an RSS
+limit. After two worker timeouts the coordinator finishes the current pair, saves
+partial diagnostics and fails the run. A killed process is not automatically classified
+as OOM. Raw `base.json` and `head.json` retain statuses, metadata, repetitions and memory
+checkpoints; the PR report suppresses numeric comparisons when the source run failed.
+
## Public API changes
Public API is hard to change once it ships, so agree on it before writing the implementation. Our [SDK guidelines](https://posthog.com/handbook/engineering/sdks/guidelines) explain how we design it.
diff --git a/benchmarks/.gitignore b/benchmarks/.gitignore
new file mode 100644
index 00000000..c18dd8d8
--- /dev/null
+++ b/benchmarks/.gitignore
@@ -0,0 +1 @@
+__pycache__/
diff --git a/benchmarks/Benchmark.csproj b/benchmarks/Benchmark.csproj
new file mode 100644
index 00000000..5c46b6d7
--- /dev/null
+++ b/benchmarks/Benchmark.csproj
@@ -0,0 +1,18 @@
+
+
+
+ Exe
+ net9.0
+ 9.0.8
+ Disable
+ enable
+ enable
+ true
+ false
+ false
+ false
+
+
+
+
+
diff --git a/benchmarks/Controls.cs b/benchmarks/Controls.cs
new file mode 100644
index 00000000..1f595288
--- /dev/null
+++ b/benchmarks/Controls.cs
@@ -0,0 +1,241 @@
+using System.Diagnostics;
+using System.Runtime.CompilerServices;
+using System.Text.Json;
+
+namespace PostHog.Benchmarks;
+
+internal static class Controls
+{
+ [MethodImpl(MethodImplOptions.NoInlining)]
+ static void AllocateChurn()
+ {
+ for (var i = 0; i < 32; i++) GC.KeepAlive(new byte[1024 * 1024]);
+ }
+
+ sealed class ExtendedFlagOptions : FeatureFlagOptions
+ {
+ public bool UnrelatedDefault { get; init; }
+ }
+
+ static void CheckFixtureIdentity()
+ {
+ var spec = Fixtures.Workloads(smoke: true)[0];
+ var baseline = Fixtures.FixtureDigest(spec);
+ var extended = Fixtures.Options.Select(option => new ExtendedFlagOptions
+ {
+ PersonProperties = new(option.PersonProperties!),
+ OnlyEvaluateLocally = option.OnlyEvaluateLocally,
+ SendFeatureFlagEvents = option.SendFeatureFlagEvents,
+ }).ToArray();
+ using var sdkJson = JsonDocument.Parse(JsonSerializer.Serialize(extended, Program.Json));
+ Program.Require(sdkJson.RootElement[0].TryGetProperty("unrelated_default", out _), "Extension control must change the serialized SDK schema");
+ Program.Require(Fixtures.FixtureDigest(spec, extended) == baseline, "SDK schema extension changed explicit fixture identity");
+
+ using var inputs = JsonDocument.Parse(Fixtures.FixtureInputs());
+ string[] expectedKeys = ["distinct_id", "person_properties", "only_evaluate_locally", "send_feature_flag_events"];
+ foreach (var input in inputs.RootElement.EnumerateArray())
+ {
+ Program.Require(input.EnumerateObject().Select(p => p.Name).Order().SequenceEqual(expectedKeys.Order()),
+ "Fixture identity must contain only benchmark-owned explicit inputs");
+ }
+ extended[0].PersonProperties!["plan"] = "changed-plan";
+ Program.Require(Fixtures.FixtureDigest(spec, extended) != baseline, "Person-property change did not change fixture identity");
+ Program.Require(Fixtures.FixtureDigest(spec with { FlagCount = spec.FlagCount + 1 }) != baseline,
+ "Definition change did not change fixture identity");
+ }
+
+ static void CheckInventory()
+ {
+ foreach (var smoke in new[] { false, true })
+ {
+ var workloads = Fixtures.Workloads(smoke);
+ var expectedIds = new[] { "boolean", "rollout", "variant", "property", "regex", "cohort", "early", "late", "no-match" }
+ .Select(kind => $"flags.single.{kind}")
+ .Concat(new[] { "flags.all.10", "flags.all.100", "flags.all.1000", "flags.load.1000", "flags.refresh.1000",
+ "capture.enqueue.32", "capture.enqueue.4096", "capture.drain.steady.32", "capture.drain.steady.4096",
+ "capture.drain.burst.32", "capture.drain.burst.4096" }).ToArray();
+ Program.Require(workloads.Select(w => w.Id).SequenceEqual(expectedIds), "Incorrect workload inventory");
+ foreach (var spec in workloads)
+ {
+ var expectedOperations = spec.Mode switch
+ {
+ "single" => smoke ? 160 : 16000,
+ "refresh" => smoke ? 4 : 40,
+ "all" when spec.Concurrency == 8 => smoke ? 32 : 1600,
+ "all" => smoke ? 16 : spec.FlagCount == 10 ? 1600 : 160,
+ _ => smoke ? 200 : 1000,
+ };
+ string[] expectedPasses = spec.Mode == "drain" ? ["timing", "allocation", "memory"]
+ : spec.Mode == "refresh" || spec.Concurrency == 8 ? ["memory"] : ["timing", "allocation"];
+ Program.Require(spec.Operations == expectedOperations && spec.Passes.SequenceEqual(expectedPasses), "Incorrect operations/passes");
+ Program.Require(spec.Concurrency == (spec.Id == "flags.load.1000" ? 8 : 1), "Incorrect concurrency");
+ if (spec.Mode == "single")
+ Program.Require(spec.FlagCount == 10 && Fixtures.Cases[spec.FlagIndex] == spec.Id.Split('.')[2], "Incorrect single fixture");
+ else if (spec.FlagCount > 0)
+ Program.Require(spec.FlagCount == int.Parse(spec.Id.Split('.')[2]), "Incorrect flag count");
+ if (spec.PayloadBytes > 0)
+ {
+ Program.Require(spec.PayloadBytes == int.Parse(spec.Id.Split('.')[^1]), "Incorrect payload size");
+ Program.Require(spec.CompletionBoundary == (spec.Mode == "capture" ? "api_return" : "completed_delivery"), "Incorrect completion boundary");
+ Program.Require(spec.FlushEvery == (spec.DrainStrategy == "steady" ? 100 : spec.Mode == "drain" ? spec.Operations : null), "Incorrect flush interval");
+ Program.Require(spec.DrainStrategy == (spec.Mode == "drain" ? spec.Id.Split('.')[2] : null), "Incorrect drain strategy");
+ }
+ }
+ }
+ }
+
+ static JsonElement Event(int sequence, int payloadBytes = 32, string plan = "pro", string? user = null) =>
+ JsonSerializer.SerializeToElement(new
+ {
+ @event = Fixtures.EventName,
+ properties = new Dictionary
+ {
+ ["distinct_id"] = user ?? Fixtures.Users[(sequence % 16 + 16) % 16],
+ ["payload"] = new string('x', payloadBytes),
+ ["plan"] = plan,
+ ["sequence"] = sequence,
+ },
+ });
+
+ static void Reject(Action action, string message)
+ {
+ var rejected = false;
+ try { action(); }
+ catch (InvalidOperationException) { rejected = true; }
+ Program.Require(rejected, message);
+ }
+
+ static void CheckReceiver()
+ {
+ using var transport = new Transport(Fixtures.Workloads(true).Single(s => s.Id == "capture.enqueue.32"));
+ transport.ResetDelivery(32);
+ for (var i = 31; i >= 0; i--) transport.ValidateEvent(Event(i));
+ Program.Require(transport.Delivered == 32, "Receiver must accept reordered delivery");
+ transport.ResetDelivery(32);
+ // Replacing sequence 16 with 0 preserves both total and per-user counts.
+ for (var i = 0; i < 16; i++) transport.ValidateEvent(Event(i));
+ Reject(() => transport.ValidateEvent(Event(0)), "Balanced duplicate/loss accepted");
+ foreach (var invalid in new[] { Event(-1), Event(32), Event(16, plan: "free"), Event(16, payloadBytes: 31), Event(16, user: Fixtures.Users[1]) })
+ Reject(() => transport.ValidateEvent(invalid), "Invalid capture fixture accepted");
+ var properties = Fixtures.CaptureProperties(32, 32);
+ properties[1]["sequence"] = 999;
+ Program.Require((int)properties[0]["sequence"] == 0 && (int)properties[2]["sequence"] == 2, "Caller property dictionaries are shared");
+ }
+
+ static async Task CheckSchedulesAndRefresh()
+ {
+ foreach (var smoke in new[] { false, true })
+ {
+ foreach (var spec in Fixtures.Workloads(smoke).Where(s => s.Mode == "drain"))
+ {
+ await using var scenario = new Scenario(spec);
+ await scenario.Prepare();
+ Program.Require(scenario.Transport.Delivered == 0 && scenario.Transport.BatchRequests == 0 && scenario.ExplicitFlushes == 0,
+ "Warmup delivery phase did not reset");
+ var flushes = new List();
+ var checkpoints = new List();
+ await scenario.Execute(spec.Operations, completed =>
+ {
+ checkpoints.Add(completed);
+ Program.Require(scenario.ExplicitFlushes == (spec.DrainStrategy == "steady" ? completed / 100 : 0),
+ "Checkpoint on wrong side of explicit flush");
+ }, flushes.Add);
+ var expectedCheckpoints = Enumerable.Range(1, spec.Operations / 100).Select(i => i * 100).ToArray();
+ Program.Require(checkpoints.SequenceEqual(expectedCheckpoints), "Incorrect capture checkpoints");
+ Program.Require(flushes.SequenceEqual(spec.DrainStrategy == "steady" ? expectedCheckpoints : [spec.Operations]), "Incorrect explicit flush schedule");
+ var count = scenario.ExplicitFlushes;
+ await scenario.VerifyCompletion(spec.Operations);
+ Program.Require(scenario.ExplicitFlushes == count, "Delivery verification performed a rescuing flush");
+ }
+ }
+ await using var refresh = new Scenario(Fixtures.Workloads(true).Single(s => s.Mode == "refresh"));
+ await refresh.Prepare();
+ Program.Require(refresh.Transport.Generation == 0, "Measured flags did not start at generation zero");
+ for (var generation = 1; generation <= 4; generation++)
+ {
+ var requests = refresh.Transport.DefinitionRequests;
+ await refresh.Execute(1);
+ Program.Require(refresh.Transport.Generation == generation && refresh.Transport.DefinitionRequests == requests + 1, "Incorrect reload generation/request count");
+ await refresh.VerifyFlags();
+ }
+ }
+
+ static async Task CheckFailedDelivery()
+ {
+ foreach (var strategy in new[] { "steady", "burst" })
+ {
+ await using var dropped = new Scenario(Fixtures.Workloads(true).Single(s => s.Id == $"capture.drain.{strategy}.32"));
+ await dropped.Prepare();
+ dropped.Transport.DropEvents = true;
+ await dropped.Execute(dropped.Spec.Operations);
+ dropped.Transport.DropEvents = false;
+ var flushes = dropped.ExplicitFlushes;
+ var rejected = false;
+ try { await dropped.VerifyCompletion(dropped.Spec.Operations); }
+ catch (InvalidOperationException) { rejected = true; }
+ Program.Require(rejected && dropped.ExplicitFlushes == flushes, "Incomplete measured delivery rescued/accepted by verification");
+ }
+ }
+
+ internal static async Task Run()
+ {
+ CheckFixtureIdentity();
+ CheckInventory();
+ CheckReceiver();
+ await CheckSchedulesAndRefresh();
+ await CheckFailedDelivery();
+ foreach (var spec in Fixtures.Workloads(smoke: true))
+ {
+ foreach (var pass in spec.Passes)
+ {
+ // Each pass normally gets a fresh worker; use a fresh scenario here too.
+ await using var measured = new Scenario(spec);
+ Program.Require(measured.Options.FlushAt == 100000 && measured.Options.MaxQueueSize == 100001 &&
+ measured.Options.MaxBatchSize == 100000, "Incorrect controlled count limits");
+ Program.Require(!measured.Options.EnableCompression && measured.Options.FlushInterval > TimeSpan.FromSeconds(120) &&
+ measured.Options.FeatureFlagPollInterval > TimeSpan.FromSeconds(120), "Incorrect compression or automatic polling/flush configuration");
+ var result = await Measurement.Run(measured, pass);
+ Program.Require((string)result["status"] == "ok", "Control workload failed");
+ if (pass == "allocation") Program.Require((double)result["allocated_bytes_per_op"] > 0, "Missing allocation signal");
+ if (pass == "timing")
+ {
+ Program.Require((double)result["elapsed_ns"] > 0, "Missing elapsed-time signal");
+ Program.Require((double)result["process_cpu_time_per_op"] >= 0, "Invalid CPU signal");
+ }
+ if (pass == "memory")
+ {
+ var points = JsonSerializer.SerializeToElement(result["checkpoints"]);
+ var positions = points.EnumerateArray().Select(p => p.GetProperty("operations").GetInt32()).ToArray();
+ Program.Require(positions.SequenceEqual(positions.Distinct().Order()), "Checkpoint positions must increase strictly");
+ var interval = spec.Mode == "refresh" ? 1 : spec.Mode == "drain" ? 100 : 32;
+ var expected = Enumerable.Range(1, spec.Operations).Where(i => i % interval == 0 || i == spec.Operations);
+ Program.Require(positions.SequenceEqual(expected), "Incorrect deterministic checkpoints");
+ Program.Require((long)result["post_settling_heap_used"] > 0, "Missing retained-heap signal");
+ }
+ }
+ }
+ Measurement.Collect();
+ var before = GC.GetTotalAllocatedBytes(true);
+ AllocateChurn();
+ Measurement.Collect();
+ Program.Require(GC.GetTotalAllocatedBytes(true) - before >= 32 * 1024 * 1024, "Allocation counter lost collected objects");
+ var heap = GC.GetTotalMemory(false);
+ var retained = new byte[32 * 1024 * 1024];
+ Measurement.Collect();
+ Program.Require(GC.GetTotalMemory(false) - heap >= retained.Length, "Retained-heap counter missed live data");
+ GC.KeepAlive(retained);
+ using var process = Process.GetCurrentProcess();
+ Program.Require(Measurement.Rss(process) > 0, "RSS unavailable");
+
+ await using var dropped = new Scenario(Fixtures.Workloads(true).Single(s => s.Id == "capture.enqueue.32"));
+ await dropped.Prepare();
+ dropped.Transport.DropEvents = true;
+ await dropped.Execute(1);
+ await dropped.Client.FlushAsync();
+ var rejected = false;
+ try { dropped.VerifyDelivery(1); }
+ catch (InvalidOperationException) { rejected = true; }
+ Program.Require(rejected, "Delivery control accepted missing events");
+ Console.WriteLine("20 workloads: inventory, fixture identity, receiver uniqueness, flush schedules, phase reset, refresh generations, checkpoints, failed delivery, allocation churn, retained heap and RSS controls passed.");
+ }
+}
diff --git a/benchmarks/Fixtures.cs b/benchmarks/Fixtures.cs
new file mode 100644
index 00000000..c2e45155
--- /dev/null
+++ b/benchmarks/Fixtures.cs
@@ -0,0 +1,120 @@
+using System.Globalization;
+using System.Security.Cryptography;
+using System.Text;
+using System.Text.Json;
+
+namespace PostHog.Benchmarks;
+
+internal record Workload(string Id, string Mode, int Operations, string[] Passes,
+ int FlagCount = 0, int FlagIndex = 0, int Concurrency = 1, int PayloadBytes = 0,
+ string? DrainStrategy = null, int? FlushEvery = null, string? CompletionBoundary = null);
+
+internal static class Fixtures
+{
+ internal const string EventName = "benchmark";
+ internal static readonly string[] Cases =
+ ["boolean", "rollout", "variant", "property", "regex", "cohort", "early", "late", "no-match", "inactive"];
+ internal static readonly string[] Users = Enumerable.Range(0, 16).Select(i => $"benchmark-user-{i}").ToArray();
+ internal static readonly FeatureFlagOptions[] Options = Enumerable.Range(0, 16).Select(i => new FeatureFlagOptions
+ {
+ OnlyEvaluateLocally = true,
+ SendFeatureFlagEvents = false,
+ PersonProperties = new() { ["plan"] = i % 2 == 0 ? "pro" : "free", ["email"] = $"user-{i}@example.test", ["score"] = i },
+ }).ToArray();
+
+ internal static string Key(int i) => $"{Cases[i % Cases.Length]}-{i}";
+ internal static string Digest(byte[] bytes) => Convert.ToHexStringLower(SHA256.HashData(bytes));
+ internal static string Digest(string value) => Digest(Encoding.UTF8.GetBytes(value));
+ internal static string FixtureInputs(IEnumerable? options = null) => JsonSerializer.Serialize(
+ (options ?? Options).Select((option, i) => new
+ {
+ DistinctId = Users[i],
+ option.PersonProperties,
+ option.OnlyEvaluateLocally,
+ option.SendFeatureFlagEvents,
+ }), Program.Json);
+
+ internal static string FixtureDigest(Workload spec, IEnumerable? options = null) =>
+ Digest(Definitions(spec.FlagCount) + FixtureInputs(options) + JsonSerializer.Serialize(spec, Program.Json) +
+ (spec.PayloadBytes > 0 ? EventName + JsonSerializer.Serialize(CaptureProperties(spec.PayloadBytes, spec.Operations), Program.Json) : ""));
+
+ internal static Dictionary[] CaptureProperties(int payloadBytes, int operations)
+ {
+ var payload = new string('x', payloadBytes);
+ return Enumerable.Range(0, operations).Select(i => new Dictionary
+ {
+ ["payload"] = payload,
+ ["plan"] = "pro",
+ ["sequence"] = i,
+ }).ToArray();
+ }
+
+ static object Property(string key, object value, string op = "exact") => new { key, value, @operator = op, type = "person" };
+ static object Group(object[] properties, int rollout = 100) => new { properties, rollout_percentage = rollout };
+
+ internal static string Definitions(int count, int generation = 0)
+ {
+ var flags = Enumerable.Range(0, count).Select(i =>
+ {
+ var kind = Cases[i % Cases.Length];
+ var match = Group([Property("plan", new[] { "pro" })]);
+ var miss = Group([Property("plan", new[] { "never" })]);
+ object[] groups = kind switch
+ {
+ "rollout" => [Group([], 50)],
+ "property" => [match],
+ "regex" => [Group([Property("email", $"^(user|refresh-{generation})-[0-9]+@example[.]test$", "regex")])],
+ "cohort" => [Group([new { key = "id", value = 1, type = "cohort" }])],
+ "early" => [match, .. Enumerable.Repeat(miss, 7)],
+ "late" => [.. Enumerable.Repeat(miss, 7), match],
+ "no-match" => Enumerable.Repeat(miss, 8).ToArray(),
+ _ => [Group([])],
+ };
+ var filters = new Dictionary { ["groups"] = groups };
+ if (kind == "variant")
+ filters["multivariate"] = new { variants = new[] { new { key = "control", rollout_percentage = 50 }, new { key = "test", rollout_percentage = 50 } } };
+ return new { id = i + 1, key = Key(i), name = Key(i), active = kind != "inactive" && (kind != "boolean" || generation % 2 == 0), filters };
+ }).ToArray();
+ return JsonSerializer.Serialize(new
+ {
+ flags,
+ group_type_mapping = new Dictionary(),
+ cohorts = new Dictionary
+ {
+ ["1"] = new { type = "AND", values = new[] { Property("plan", new[] { "pro" }), Property("score", generation % 2 == 0 ? -1 : 100, "gt") } },
+ },
+ });
+ }
+
+ // Independent fixture oracle: no SDK evaluator or private SDK state is used.
+ static double Bucket(string key, string user, string salt = "")
+ {
+ var hash = Convert.ToHexString(SHA1.HashData(Encoding.UTF8.GetBytes($"{key}.{user}{salt}")));
+ return long.Parse(hash[..15], NumberStyles.HexNumber, CultureInfo.InvariantCulture) / Math.Pow(2, 60);
+ }
+
+ internal static object Expected(int index, int user, int generation = 0) => Cases[index % Cases.Length] switch
+ {
+ "rollout" => Bucket(Key(index), Users[user]) < 0.5,
+ "variant" => Bucket(Key(index), Users[user], "variant") < 0.5 ? "control" : "test",
+ "boolean" => generation % 2 == 0,
+ "cohort" => generation % 2 == 0 && user % 2 == 0,
+ "property" or "early" or "late" => user % 2 == 0,
+ "no-match" or "inactive" => false,
+ _ => true,
+ };
+
+ internal static Workload[] Workloads(bool smoke) =>
+ [
+ .. Cases.Take(9).Select((kind, i) => new Workload($"flags.single.{kind}", "single", smoke ? 160 : 16000, ["timing", "allocation"], 10, i)),
+ .. new[] { 10, 100, 1000 }.Select(count => new Workload($"flags.all.{count}", "all", smoke ? 16 : Math.Max(160, 16000 / count), ["timing", "allocation"], count)),
+ new("flags.load.1000", "all", smoke ? 32 : 1600, ["memory"], 1000, Concurrency: 8),
+ new("flags.refresh.1000", "refresh", smoke ? 4 : 40, ["memory"], 1000),
+ .. new[] { 32, 4096 }.Select(size => new Workload($"capture.enqueue.{size}", "capture", smoke ? 200 : 1000,
+ ["timing", "allocation"], PayloadBytes: size, CompletionBoundary: "api_return")),
+ .. new[] { "steady", "burst" }.SelectMany(strategy => new[] { 32, 4096 }.Select(size =>
+ new Workload($"capture.drain.{strategy}.{size}", "drain", smoke ? 200 : 1000,
+ ["timing", "allocation", "memory"], PayloadBytes: size, DrainStrategy: strategy,
+ FlushEvery: strategy == "steady" ? 100 : smoke ? 200 : 1000, CompletionBoundary: "completed_delivery"))),
+ ];
+}
diff --git a/benchmarks/Measurement.cs b/benchmarks/Measurement.cs
new file mode 100644
index 00000000..078866f7
--- /dev/null
+++ b/benchmarks/Measurement.cs
@@ -0,0 +1,78 @@
+using System.Diagnostics;
+
+namespace PostHog.Benchmarks;
+
+internal static class Measurement
+{
+ internal static void Collect()
+ {
+ GC.Collect(GC.MaxGeneration, GCCollectionMode.Forced, blocking: true, compacting: true);
+ GC.WaitForPendingFinalizers();
+ GC.Collect(GC.MaxGeneration, GCCollectionMode.Forced, blocking: true, compacting: true);
+ }
+
+ internal static long Rss(Process process)
+ {
+ process.Refresh();
+ var bytes = process.WorkingSet64;
+ Program.Require(bytes > 0, "Current RSS is unavailable on this platform");
+ return bytes;
+ }
+
+ internal static async Task> Run(Scenario scenario, string pass)
+ {
+ await scenario.Prepare();
+ var spec = scenario.Spec;
+ var result = new Dictionary
+ {
+ ["pass"] = pass, ["status"] = "ok", ["operations"] = spec.Operations,
+ ["warmup_operations"] = scenario.WarmupOperations,
+ };
+ using var process = Process.GetCurrentProcess();
+ if (pass == "timing")
+ {
+ var cpu = process.TotalProcessorTime;
+ var start = Stopwatch.GetTimestamp();
+ await scenario.Execute(spec.Operations);
+ var elapsed = Stopwatch.GetElapsedTime(start).TotalNanoseconds;
+ result["elapsed_ns"] = elapsed;
+ result["elapsed_time_per_op"] = elapsed / spec.Operations;
+ result["process_cpu_time_per_op"] = (process.TotalProcessorTime - cpu).TotalNanoseconds / spec.Operations;
+ }
+ else if (pass == "allocation")
+ {
+ var before = GC.GetTotalAllocatedBytes(precise: true);
+ await scenario.Execute(spec.Operations);
+ result["allocated_bytes_per_op"] = (GC.GetTotalAllocatedBytes(precise: true) - before) / (double)spec.Operations;
+ }
+ else if (pass == "memory")
+ {
+ result["idle_managed_heap_bytes"] = GC.GetTotalMemory(forceFullCollection: false);
+ result["idle_process_rss"] = Rss(process);
+ var checkpoints = new List