From dcc17adf0f5174e27d923bfe54fb8c3e7ac35454 Mon Sep 17 00:00:00 2001 From: Pixnop <77785313+Pixnop@users.noreply.github.com> Date: Thu, 3 Sep 2026 20:13:06 +0200 Subject: [PATCH] Trim what the audit found The README no longer names a release version in its install lines, since the stable people download and the prereleases stacked on dev are not the same number; the day-one survey carries a historical banner listing where the shipped mod overtook it; the Stryker scaffolding that no job ran is gone; the engine window constant is declared once with the sampling cadence derived from it, which also removes a doubled summary tag; the two OTLP scenarios share one wait helper and the attribution scenario reads its share line through the scrape helper it already had; and the mutation script restores once instead of on every run. --- .config/dotnet-tools.json | 13 --------- Pulse.Mutation.slnx | 7 ----- Pulse.Otlp.Scenarios/Exports.cs | 27 +++++++++++++++++++ Pulse.Otlp.Scenarios/OtlpExportScenarios.cs | 21 ++------------- .../OtlpGrpcExportScenarios.cs | 21 ++------------- Pulse.Scenarios/AttributionScenarios.cs | 9 ++----- Pulse.Scenarios/Scrape.cs | 3 ++- Pulse/PulseModSystem.cs | 18 ++++++------- README.md | 16 +++++------ docs/metrics-feasibility.md | 9 +++++++ stryker-config.json | 21 --------------- tools/mutation-check.sh | 6 ++++- 12 files changed, 66 insertions(+), 105 deletions(-) delete mode 100644 .config/dotnet-tools.json delete mode 100644 Pulse.Mutation.slnx create mode 100644 Pulse.Otlp.Scenarios/Exports.cs delete mode 100644 stryker-config.json diff --git a/.config/dotnet-tools.json b/.config/dotnet-tools.json deleted file mode 100644 index 010a8c1..0000000 --- a/.config/dotnet-tools.json +++ /dev/null @@ -1,13 +0,0 @@ -{ - "version": 1, - "isRoot": true, - "tools": { - "dotnet-stryker": { - "version": "4.16.0", - "commands": [ - "dotnet-stryker" - ], - "rollForward": false - } - } -} \ No newline at end of file diff --git a/Pulse.Mutation.slnx b/Pulse.Mutation.slnx deleted file mode 100644 index 39ba031..0000000 --- a/Pulse.Mutation.slnx +++ /dev/null @@ -1,7 +0,0 @@ - - - - - diff --git a/Pulse.Otlp.Scenarios/Exports.cs b/Pulse.Otlp.Scenarios/Exports.cs new file mode 100644 index 0000000..a46dde7 --- /dev/null +++ b/Pulse.Otlp.Scenarios/Exports.cs @@ -0,0 +1,27 @@ +using System.Diagnostics; + +namespace Pulse.Otlp.Scenarios; + +/// The one wait both collector scenarios share: pump the world until an export lands. +internal static class Exports +{ + /// Pumps the world until hands back an export, or the + /// deadline passes. + /// The bound is wall clock rather than a tick count, which is why this is not + /// World.Until: the exporter waits on a real timer on its own thread, and it owes the + /// game loop nothing. Ticking is how the scenario passes that time without sleeping the thread + /// the world runs on. + public static async Task WaitFor(Func first, Func pump, TimeSpan deadline, int port) + where T : class + { + Stopwatch clock = Stopwatch.StartNew(); + while (first() == null && clock.Elapsed < deadline) + { + await pump(); + } + + return first() + ?? throw new InvalidOperationException( + $"no export reached the collector on port {port} within {deadline.TotalSeconds:0}s"); + } +} diff --git a/Pulse.Otlp.Scenarios/OtlpExportScenarios.cs b/Pulse.Otlp.Scenarios/OtlpExportScenarios.cs index c079fa5..a1233f4 100644 --- a/Pulse.Otlp.Scenarios/OtlpExportScenarios.cs +++ b/Pulse.Otlp.Scenarios/OtlpExportScenarios.cs @@ -1,4 +1,3 @@ -using System.Diagnostics; using System.Text; using Atlas.Api; using Atlas.XUnit; @@ -75,22 +74,6 @@ public async Task Server_Keeps_Ticking_WhileExporting() Assert.Equal("game:chest-east", World.BlockAt(pos).Code.ToString()); } - /// Pumps the world until the collector has an export in hand. - /// The bound is wall clock rather than a tick count, which is why this is not - /// World.Until: the exporter waits on a real 5 s timer on its own thread, and it owes - /// the game loop nothing. Ticking is how the scenario passes that time without sleeping the - /// thread the world runs on. - private async Task WaitForExport() - { - TimeSpan deadline = ExportInterval * 6; - Stopwatch clock = Stopwatch.StartNew(); - while (collector.First == null && clock.Elapsed < deadline) - { - await World.Ticks(10); - } - - return collector.First - ?? throw new InvalidOperationException( - $"no export reached the collector on port {CollectorPort} within {deadline.TotalSeconds:0}s"); - } + private Task WaitForExport() + => Exports.WaitFor(() => collector.First, () => World.Ticks(10), ExportInterval * 6, CollectorPort); } diff --git a/Pulse.Otlp.Scenarios/OtlpGrpcExportScenarios.cs b/Pulse.Otlp.Scenarios/OtlpGrpcExportScenarios.cs index c7a9c16..dca7f18 100644 --- a/Pulse.Otlp.Scenarios/OtlpGrpcExportScenarios.cs +++ b/Pulse.Otlp.Scenarios/OtlpGrpcExportScenarios.cs @@ -1,4 +1,3 @@ -using System.Diagnostics; using System.Text; using Atlas.XUnit; using Xunit; @@ -76,22 +75,6 @@ public async Task Exporter_Pushes_PulsesMetrics_OverGrpc() Assert.Contains("pulse-atlas-grpc", body, StringComparison.Ordinal); } - /// Pumps the world until the collector has an export in hand. - /// The bound is wall clock rather than a tick count, which is why this is not - /// World.Until: the exporter waits on a real 5 s timer on its own thread, and it owes - /// the game loop nothing. Ticking is how the scenario passes that time without sleeping the - /// thread the world runs on. - private async Task WaitForExport() - { - TimeSpan deadline = ExportInterval * 12; - Stopwatch clock = Stopwatch.StartNew(); - while (collector.First == null && clock.Elapsed < deadline) - { - await World.Ticks(10); - } - - return collector.First - ?? throw new InvalidOperationException( - $"no export reached the collector on port {CollectorPort} within {deadline.TotalSeconds:0}s"); - } + private Task WaitForExport() + => Exports.WaitFor(() => collector.First, () => World.Ticks(10), ExportInterval * 12, CollectorPort); } diff --git a/Pulse.Scenarios/AttributionScenarios.cs b/Pulse.Scenarios/AttributionScenarios.cs index 729c733..f2a5779 100644 --- a/Pulse.Scenarios/AttributionScenarios.cs +++ b/Pulse.Scenarios/AttributionScenarios.cs @@ -44,14 +44,9 @@ private static async Task Burst(IWorldSession world) return body; } - /// Reads one labelled sample line, of which there is exactly one per mod. + /// One mod's share line, of which there is exactly one per mod. private static double Share(string exposition, string modid) - { - string name = $"pulse_mod_tick_share{{modid=\"{modid}\"}}"; - string? line = exposition.Split('\n').FirstOrDefault(l => l.StartsWith(name + " ", StringComparison.Ordinal)); - Assert.True(line != null, $"{name} is not in the exposition:\n{exposition}"); - return double.Parse(line![(name.Length + 1)..], CultureInfo.InvariantCulture); - } + => Scrape.Value(exposition, $"pulse_mod_tick_share{{modid=\"{modid}\"}}"); [AtlasScenario] public async Task Attribution_Serves_ItsFamilies_FromBoot() diff --git a/Pulse.Scenarios/Scrape.cs b/Pulse.Scenarios/Scrape.cs index 284a899..c1ca959 100644 --- a/Pulse.Scenarios/Scrape.cs +++ b/Pulse.Scenarios/Scrape.cs @@ -23,7 +23,8 @@ public static async Task Metrics(int port) return await response.Content.ReadAsStringAsync(); } - /// Reads one unlabelled sample line out of an exposition body. + /// Reads one sample line out of an exposition body by its exact name, labels + /// included when the series has any. public static double Value(string exposition, string name) { foreach (string line in exposition.Split('\n')) diff --git a/Pulse/PulseModSystem.cs b/Pulse/PulseModSystem.cs index 90ec181..19d60d8 100644 --- a/Pulse/PulseModSystem.cs +++ b/Pulse/PulseModSystem.cs @@ -15,9 +15,15 @@ public sealed class PulseModSystem : ModSystem private const string ConfigFile = "pulse.json"; private const double SnapshotIntervalSeconds = 1.0; - /// The engine rotates its statistics buckets every two seconds, so sampling them any - /// faster only re-reads the same window. - private const int EngineSampleIntervalMs = 2000; + /// The engine rotates its statistics ring every two seconds, a constant wired into + /// the tick loop, so a completed bucket nominally spans this long. A bucket cut short around a + /// suspend makes the rate read low for one window; the engine's own /stats has the same + /// approximation. + private const double EngineWindowSeconds = 2.0; + + /// Sampling the ring any faster than it rotates only re-reads the same window, so the + /// engine listener runs at exactly that cadence. + private const int EngineSampleIntervalMs = (int)(EngineWindowSeconds * 1000); /// How many entity codes get a series of their own before the rest are lumped into /// one bucket. Ten covers the animals and the drifters on any world worth looking at. @@ -576,12 +582,6 @@ private void OnServerResume() /// Both windowed network families read the same sample once, so their two channels /// always describe the same two seconds. - /// The engine rotates its statistics ring every two seconds, a constant wired into - /// the tick loop, so a completed bucket nominally spans this long. A bucket cut short around a - /// suspend makes the rate read low for one window; the engine's own /stats has the same - /// approximation. - private const double EngineWindowSeconds = 2.0; - private IEnumerable> PacketMeasurements() { EngineSample? sample = engine; diff --git a/README.md b/README.md index 913b6ef..3b2da23 100644 --- a/README.md +++ b/README.md @@ -193,8 +193,8 @@ a call tree. Lithos Probe's sampling profiler is the tool for the second pass. ## Install -Drop `pulse_0.1.0.zip` into your server's `Mods/` folder and start the server. Add -`pulseotlp_0.1.0.zip` beside it if you want OTLP push as well; the base mod works on its own and +Drop `pulse_x.x.x.zip` into your server's `Mods/` folder and start the server. Add +`pulseotlp_x.x.x.zip` beside it if you want OTLP push as well; the base mod works on its own and the OTLP one does not. On first boot Pulse writes `ModConfig/pulse.json` with its defaults: ```json @@ -263,7 +263,7 @@ and read `/metrics`, the server sends its metrics to a collector on a timer, in every major observability backend accepts. Grafana Cloud, Honeycomb, Datadog, New Relic and an `otel-collector` you run yourself all take the same payload. -It ships as a second mod, `pulseotlp_0.1.0.zip`, and both zips go in `Mods/`. The base mod stays +It ships as a second mod, `pulseotlp_x.x.x.zip`, and both zips go in `Mods/`. The base mod stays a single dll with no dependencies; the OTLP one carries the OpenTelemetry SDK and its `Microsoft.Extensions.*` fan-out, eighteen dlls in all. That split is not tidiness. The game's mod loader puts every root-level dll of every mod into one shared assembly context with no @@ -360,8 +360,8 @@ references only; neither is copied into the mod, which still ships as one file. export VINTAGE_STORY=/path/to/vintagestory dotnet build Pulse.slnx -c Release dotnet test # unit tests, then the Atlas scenarios -dotnet build Pulse/Pulse.csproj -c Release -t:PackageMod # artifacts/pulse_0.1.0.zip -dotnet build Pulse.Otlp/Pulse.Otlp.csproj -c Release -t:PackageMod # artifacts/pulseotlp_0.1.0.zip +dotnet build Pulse/Pulse.csproj -c Release -t:PackageMod # artifacts/pulse_x.x.x.zip +dotnet build Pulse.Otlp/Pulse.Otlp.csproj -c Release -t:PackageMod # artifacts/pulseotlp_x.x.x.zip ``` The scenarios in `Pulse.Scenarios` boot a real headless server in-process through @@ -386,9 +386,9 @@ aggregates, the entity top-ten with its series retirement rule, and the suspend them needs a server. `Pulse.Otlp.Tests` covers the config translation, which is where the OTLP mod's only non-obvious logic lives. Mutation verification over those files runs through `tools/mutation-check.sh`, which applies representative mutations one at a time and requires the -suite to fail on every one; CI runs it on each push. A `stryker-config.json` sits ready for -`dotnet stryker`, which currently finds the tests but runs mutants against the unmutated -assembly on the .NET 10 SDK. +suite to fail on every one; CI runs it on each push. Stryker itself is parked: on the .NET 10 SDK +it finds the tests but runs every mutant against the unmutated assembly, so the script stays +until a release of it reports a real score here (tracked in the issues). ## Where this is going diff --git a/docs/metrics-feasibility.md b/docs/metrics-feasibility.md index 3ad446c..890f3c5 100644 --- a/docs/metrics-feasibility.md +++ b/docs/metrics-feasibility.md @@ -1,5 +1,14 @@ # Pulse metrics feasibility report +**Historical document.** This is the day-one survey, written on 1 September 2026 before any +mod code existed, and kept as the record of what was known then. Where it disagrees with the +README, the README describes what shipped. The main places it has been overtaken: the join and +leave counters were never built, the suspend window ships as two counters rather than a +histogram, the packaging question closed as one dll plus a separate optional OTLP mod, the +engine's frame profiler turned out to be the per-mod attribution source (it is dismissed below +as a tick-time source, which is still true), and Stratum's `StratumEntityBehaviorTimings` is +not readable from another mod, so it is not the V2 route this survey imagined. + Survey of Vintage Story 1.22.7 server internals, done before writing any mod code. Method: the public API sources at 1.22.7 (GitHub master, which matches the shipped build; the stable branch lags at 1.20.11), the shipped `VintagestoryAPI.xml`, and decompilation of the closed `VintagestoryLib.dll` where the engine hides the interesting parts. Line references below point at the 1.22.7 sources or at decompiled engine types. Raw survey notes live in `.survey/` (not committed). ## Verdict diff --git a/stryker-config.json b/stryker-config.json deleted file mode 100644 index 63a407f..0000000 --- a/stryker-config.json +++ /dev/null @@ -1,21 +0,0 @@ -{ - "stryker-config": { - "solution": "Pulse.Mutation.slnx", - "project": "Pulse.csproj", - "mutate": [ - "**/PrometheusText.cs", - "**/MetricsAggregator.cs" - ], - "coverage-analysis": "off", - "reporters": [ - "progress", - "cleartext", - "json" - ], - "thresholds": { - "high": 100, - "low": 100, - "break": 100 - } - } -} diff --git a/tools/mutation-check.sh b/tools/mutation-check.sh index a71551b..c7c9876 100755 --- a/tools/mutation-check.sh +++ b/tools/mutation-check.sh @@ -20,8 +20,12 @@ TOTAL=0 # judged by the OTLP tests rather than by a suite that cannot see it. TEST_PROJECT="Pulse.Tests/Pulse.Tests.csproj" +# One restore up front; every test run after it skips the restore, which is most of the idle +# time in a loop that rebuilds the same projects dozens of times. +dotnet restore Pulse.slnx --nologo -v q >/dev/null 2>&1 + run_tests() { - dotnet test "$TEST_PROJECT" -c Release --nologo -v q >/dev/null 2>&1 + dotnet test "$TEST_PROJECT" -c Release --no-restore --nologo -v q >/dev/null 2>&1 return $? }