From 4dd51eb6c2c23cc46b04a231b52e7762f33d2a65 Mon Sep 17 00:00:00 2001 From: Microck Date: Sat, 10 Oct 2026 14:01:38 +0200 Subject: [PATCH 01/10] feat: add opt-in Sentry reporting for handled Akron errors --- .github/workflows/ci.yml | 22 ++ .github/workflows/release.yml | 33 ++- CHANGELOG.md | 4 + Source/Actions/akron-startpos-persistence.cs | 2 + Source/Akron.csproj | 6 + Source/Core/akron-telemetry.cs | 261 +++++++++++++++++++ Source/Module/AkronModule.cs | 8 + Source/Module/AkronModuleSettings.cs | 2 + Source/Module/akron-module-menu.cs | 6 + Source/Module/akron-module-telemetry.cs | 29 +++ Source/Overlay/akron-imgui-renderer.cs | 2 + Source/Overlay/akron-overlay-copy.cs | 1 + Source/Overlay/akron-overlay-entries.cs | 11 + Source/SaveLoad/AkronSaveLoad.cs | 2 + Source/packages.lock.json | 8 +- docs/docs.json | 1 + docs/feature-guide/interface.mdx | 1 + docs/reference/error-reporting.mdx | 46 ++++ licenses/third-party-notices.txt | 5 + tests/akron-tests.csproj | 1 + tests/module-settings-tests.cs | 18 ++ tests/telemetry/akron-telemetry-tests.csproj | 16 ++ tests/telemetry/packages.lock.json | 112 ++++++++ tests/telemetry/telemetry-tests.cs | 207 +++++++++++++++ 24 files changed, 802 insertions(+), 2 deletions(-) create mode 100644 Source/Core/akron-telemetry.cs create mode 100644 Source/Module/akron-module-telemetry.cs create mode 100644 docs/reference/error-reporting.mdx create mode 100644 tests/telemetry/akron-telemetry-tests.csproj create mode 100644 tests/telemetry/packages.lock.json create mode 100644 tests/telemetry/telemetry-tests.cs diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 20687e84..c6ff3ecc 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -15,6 +15,23 @@ concurrency: cancel-in-progress: true jobs: + telemetry-tests: + name: Telemetry consent and privacy tests + runs-on: ubuntu-24.04 + steps: + - name: Checkout + uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 + with: + persist-credentials: false + - name: Setup .NET + uses: actions/setup-dotnet@67a3573c9a986a3f9c594539f4ab511d57bb3ce9 # v4 + with: + dotnet-version: 8.0.418 + - name: Restore locked telemetry tests + run: dotnet restore tests/telemetry/akron-telemetry-tests.csproj --locked-mode --nologo + - name: Verify consent, sanitized SDK envelopes, deduplication and shutdown + run: dotnet test tests/telemetry/akron-telemetry-tests.csproj --configuration Release --no-restore --nologo + build-and-test: name: Build and test runs-on: ubuntu-24.04 @@ -66,6 +83,11 @@ jobs: fi unzip -Z1 Akron.zip | grep -Fx LICENSE >/dev/null unzip -Z1 Akron.zip | grep -Fx ThirdPartyNotices.txt >/dev/null + unzip -Z1 Akron.zip | grep -Fx bin/Sentry.dll >/dev/null + if unzip -Z1 Akron.zip | grep -E '(^|/)(sentry-cli[^/]*|sentry\.dll|libsentry[^/]*)(/|$)'; then + echo "::error::Player package must not contain Sentry CLI or native crash handlers." + exit 1 + fi cmp LICENSE <(unzip -p Akron.zip LICENSE) cmp licenses/third-party-notices.txt <(unzip -p Akron.zip ThirdPartyNotices.txt) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 6680a907..40d5cba5 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -109,11 +109,37 @@ jobs: run: dotnet restore Akron.sln --locked-mode --nologo -p:ContinuousIntegrationBuild=true - name: Build - run: dotnet build Akron.sln --configuration Release --no-restore --nologo -p:ContinuousIntegrationBuild=true + env: + AKRON_SENTRY_DSN: ${{ vars.AKRON_SENTRY_DSN }} + run: dotnet build Akron.sln --configuration Release --no-restore --nologo -p:ContinuousIntegrationBuild=true -p:AkronSentryDsn="$AKRON_SENTRY_DSN" - name: Test run: dotnet test tests/akron-tests.csproj --configuration Release --no-build --nologo + - name: Upload private Sentry symbols and release identity + env: + SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }} + SENTRY_ORG: ${{ vars.SENTRY_ORG }} + SENTRY_PROJECT: ${{ vars.SENTRY_PROJECT }} + AKRON_SENTRY_DSN: ${{ vars.AKRON_SENTRY_DSN }} + run: | + set -euo pipefail + if [ -z "$AKRON_SENTRY_DSN" ]; then + echo "Sentry is not configured; skipping symbol upload." + exit 0 + fi + if [ -z "$SENTRY_AUTH_TOKEN" ] || [ -z "$SENTRY_ORG" ] || [ -z "$SENTRY_PROJECT" ]; then + echo "::error::Configured error reporting requires SENTRY_AUTH_TOKEN, SENTRY_ORG and SENTRY_PROJECT for readable release stacks." + exit 1 + fi + # The pinned Sentry NuGet package includes this CLI. It is never copied into Akron.zip. + sentry_cli="${NUGET_PACKAGES:-$HOME/.nuget/packages}/sentry/6.12.0/tools/sentry-cli-Linux-x86_64" + chmod +x "$sentry_cli" + "$sentry_cli" debug-files upload --include-sources bin/Akron.dll bin/Akron.pdb + "$sentry_cli" releases new "akron@${RELEASE_TAG#v}" + "$sentry_cli" releases set-commits "akron@${RELEASE_TAG#v}" --commit "Microck/akron@$(git rev-parse HEAD)" + "$sentry_cli" releases finalize "akron@${RELEASE_TAG#v}" + - name: Verify package contents run: | set -euo pipefail @@ -124,6 +150,11 @@ jobs: fi unzip -Z1 Akron.zip | grep -Fx LICENSE >/dev/null unzip -Z1 Akron.zip | grep -Fx ThirdPartyNotices.txt >/dev/null + unzip -Z1 Akron.zip | grep -Fx bin/Sentry.dll >/dev/null + if unzip -Z1 Akron.zip | grep -E '(^|/)(sentry-cli[^/]*|sentry\.dll|libsentry[^/]*)(/|$)'; then + echo "::error::Player package must not contain Sentry CLI or native crash handlers." + exit 1 + fi cmp LICENSE <(unzip -p Akron.zip LICENSE) cmp licenses/third-party-notices.txt <(unzip -p Akron.zip ThirdPartyNotices.txt) cp Akron.zip "Akron-${RELEASE_TAG}.zip" diff --git a/CHANGELOG.md b/CHANGELOG.md index a73fe760..c5c80dfb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,10 @@ This project uses version tags that match the mod version in `everest.yaml`, whi ## Unreleased +### Added + +- Add optional Automatic Error Reports for selected Akron startup, overlay, settings and StartPos failures. Reporting is off by default, requires a configured Sentry destination, and sends only bounded Akron stack details and the mod version. Local logging and Send diagnostics keep their existing behavior. + ## Akron Beta 84 ### Fixed diff --git a/Source/Actions/akron-startpos-persistence.cs b/Source/Actions/akron-startpos-persistence.cs index 8289ef60..517252e3 100644 --- a/Source/Actions/akron-startpos-persistence.cs +++ b/Source/Actions/akron-startpos-persistence.cs @@ -838,6 +838,7 @@ Engine.Scene is Level level && // Completion applies file and metadata changes on the game // thread. One failure must not strand later leases or prevent // Shutdown from saving metadata and removing its hook. + AkronTelemetry.Capture(exception, AkronFailurePhase.StartPosApply); AkronLog.Warn(nameof(AkronStartPosPersistence), "Could not apply a StartPos restart copy: " + exception); } finally { @@ -1295,6 +1296,7 @@ private static void RunWorker() { error = AkronSnapshotPacing.CancelledMessage; } catch (Exception exception) { result = AkronSaveLoadResult.Failed; + AkronTelemetry.Capture(exception, AkronFailurePhase.StartPosPersist); error = exception.GetType().Name + ": " + exception.Message; // Diagnostic, not Warn: the short form travels out through the // completion and is already reported at Warn when the slot is diff --git a/Source/Akron.csproj b/Source/Akron.csproj index f59bb7d7..5157b64e 100644 --- a/Source/Akron.csproj +++ b/Source/Akron.csproj @@ -8,6 +8,8 @@ true true true + portable + true ..\..\.. ..\lib-stripped ..\everest.yaml;..\bin\**\*.* @@ -19,6 +21,8 @@ + + @@ -34,6 +38,8 @@ + + diff --git a/Source/Core/akron-telemetry.cs b/Source/Core/akron-telemetry.cs new file mode 100644 index 00000000..daf13da5 --- /dev/null +++ b/Source/Core/akron-telemetry.cs @@ -0,0 +1,261 @@ +using System; +using System.Collections.Generic; +using System.Diagnostics; +using System.Globalization; +using System.IO; +using System.Linq; +using System.Net; +using System.Net.Http; +using System.Reflection; +using System.Text.Json; +using System.Text.RegularExpressions; +using System.Threading; +using System.Threading.Tasks; +using Sentry; +using Sentry.Protocol; + +namespace Celeste.Mod.Akron; + +internal enum AkronFailurePhase { + Startup, + Initialize, + Content, + Overlay, + Settings, + DeferredAction, + StartPosPersist, + StartPosApply, + StartPosCapture, + StartPosRestore +} + +// This client belongs to Akron, not the Celeste process. Never use SentrySdk.Init here: +// other mods own their exceptions, scopes, native handlers and process lifetime. +internal static class AkronTelemetry { + private static readonly object Sync = new object(); + private static AkronErrorReporter reporter; + internal static string Destination { get; private set; } = string.Empty; + internal static bool IsConfigured => !string.IsNullOrEmpty(Destination); + internal static bool IsEnabled { get { lock (Sync) return reporter != null; } } + + internal static void Configure(bool consent, string dsn, string release, HttpMessageHandler transport = null) { + Stop(flush: false); + Destination = AkronErrorReporter.TryGetDestination(dsn, out string host) ? host : string.Empty; + if (!consent || !IsConfigured) return; + try { + lock (Sync) { + reporter = new AkronErrorReporter(dsn, release, transport); + } + } catch (Exception) { + // Reporting must never prevent the mod from loading or leak its configuration to a log. + } + } + + internal static string ResolveDsn() { + string configured = Environment.GetEnvironmentVariable("AKRON_SENTRY_DSN"); + return configured ?? typeof(AkronTelemetry).Assembly + .GetCustomAttributes() + .FirstOrDefault(attribute => attribute.Key == "AkronSentryDsn")?.Value ?? string.Empty; + } + + internal static void Capture(Exception exception, AkronFailurePhase phase) { + AkronErrorReporter current; + lock (Sync) current = reporter; + current?.Capture(exception, phase); + } + + internal static void Stop(bool flush = true) { + AkronErrorReporter previous; + lock (Sync) { + previous = reporter; + reporter = null; + } + previous?.Stop(flush); + } +} + +// Kept independent of live game objects so consent and the actual SDK payload can be tested headlessly. +internal sealed class AkronErrorReporter { + internal const int MaxReportsPerSession = 32; + internal static readonly TimeSpan ShutdownTimeout = TimeSpan.FromMilliseconds(500); + private static readonly Regex Identifier = new Regex(@"^[A-Za-z0-9_.+`<> {}(),\[\]:&?=-]{1,512}$", RegexOptions.CultureInvariant); + private static readonly Regex HexIdentifier = new Regex(@"^[A-Fa-f0-9-]{1,128}$", RegexOptions.CultureInvariant); + private readonly object sync = new object(); + private readonly HashSet reported = new HashSet(StringComparer.Ordinal); + private readonly CancellationTokenSource revoked = new CancellationTokenSource(); + private readonly SentryClient client; + private readonly string release; + private bool stopped; + + internal AkronErrorReporter(string dsn, string version, HttpMessageHandler transport = null) { + if (!TryGetDestination(dsn, out _)) throw new ArgumentException("A valid HTTPS Sentry DSN is required."); + release = "akron@" + SafeVersion(version); + SentryOptions options = new SentryOptions { + Dsn = dsn, + Release = release, + Environment = "production", + SendDefaultPii = false, + IsEnvironmentUser = false, + AutoSessionTracking = false, + AttachStacktrace = false, + ReportAssembliesMode = ReportAssembliesMode.None, + StackTraceMode = StackTraceMode.Original, + MaxBreadcrumbs = 0, + MaxQueueItems = MaxReportsPerSession, + TracesSampleRate = 0, + EnableLogs = false, + CaptureFailedRequests = false, + SendClientReports = false, + DisableFileWrite = true, + DisableSentryHttpMessageHandler = true, + ShutdownTimeout = ShutdownTimeout, + FlushTimeout = ShutdownTimeout, + CreateHttpMessageHandler = () => new ConsentHandler(revoked.Token, transport ?? new HttpClientHandler { AllowAutoRedirect = false }) + }; + options.AddInAppInclude("Celeste.Mod.Akron."); + options.SetBeforeSend(Sanitize); + // The standalone client does not register SDK integrations or change the global hub. + client = new SentryClient(options); + } + + internal SentryId Capture(Exception exception, AkronFailurePhase phase) { + if (exception == null || exception is OperationCanceledException || !Enum.IsDefined(phase)) return SentryId.Empty; + try { + lock (sync) { + if (stopped || reported.Count >= MaxReportsPerSession) return SentryId.Empty; + string frame = new StackTrace(exception, false).GetFrames()? + .Select(value => value.GetMethod()) + .FirstOrDefault(method => method?.DeclaringType?.Namespace?.StartsWith("Celeste.Mod.Akron", StringComparison.Ordinal) == true)?.Name ?? string.Empty; + string key = phase + ":" + exception.GetType().FullName + ":" + frame; + if (!reported.Add(key)) return SentryId.Empty; + SentryEvent entry = new SentryEvent(exception); + entry.SetTag("akron.phase", phase.ToString()); + return client.CaptureEvent(entry); + } + } catch (Exception) { + // No recursive reporting and no change to the caller's recovery path. + return SentryId.Empty; + } + } + + internal void Stop(bool flush) { + lock (sync) { + if (stopped) return; + stopped = true; + if (!flush) revoked.Cancel(); + } + // Revocation prevents queued or subsequent sends immediately. Already-sent requests + // cannot be recalled. Do not wait for network delivery while the player changes consent. + if (!flush) { + _ = Task.Run(DisposeClient); + return; + } + DisposeClient(); + } + + private void DisposeClient() { + try { client.Dispose(); } + catch (Exception) { } + finally { revoked.Cancel(); } + } + + internal Task FlushAsync() => client.FlushAsync(ShutdownTimeout); + + // A new event is an allowlist, not a blacklist: exception messages/data, user identity, + // request data, breadcrumbs, host/device context, other mods and raw paths cannot survive. + internal SentryEvent Sanitize(SentryEvent source) { + if (revoked.IsCancellationRequested || !source.Tags.TryGetValue("akron.phase", out string phase) || + !Enum.TryParse(phase, out AkronFailurePhase parsed) || !Enum.IsDefined(parsed)) return null; + // The public constructor generates a new ID. FromJson is the SDK's public API + // for preserving identity; seed it with these two trusted scalar fields only. + using JsonDocument identity = JsonDocument.Parse(JsonSerializer.Serialize(new { + event_id = source.EventId.ToString(), + timestamp = source.Timestamp.ToString("O", CultureInfo.InvariantCulture) + })); + SentryEvent clean = SentryEvent.FromJson(identity.RootElement); + clean.Level = SentryLevel.Error; + clean.Logger = "Akron"; + clean.Release = release; + clean.Environment = "production"; + clean.Sdk.Name = source.Sdk.Name; + clean.Sdk.Version = source.Sdk.Version; + clean.SetTag("akron.phase", parsed.ToString()); + Dictionary imageIndexes = new Dictionary(); + if (source.DebugImages != null) { + clean.DebugImages = new List(); + for (int index = 0; index < source.DebugImages.Count; index++) { + DebugImage image = source.DebugImages[index]; + if (!string.Equals(FileName(image.CodeFile), "Akron.dll", StringComparison.OrdinalIgnoreCase)) continue; + imageIndexes["rel:" + index.ToString(CultureInfo.InvariantCulture)] = "rel:" + clean.DebugImages.Count.ToString(CultureInfo.InvariantCulture); + clean.DebugImages.Add(new DebugImage { + Type = "pe_dotnet", + CodeFile = "Akron.dll", + DebugFile = "Akron.pdb", + DebugId = SafeHex(image.DebugId), + DebugChecksum = SafeHex(image.DebugChecksum), + CodeId = SafeHex(image.CodeId) + }); + } + } + List exceptions = new List(); + foreach (SentryException error in source.SentryExceptions?.Take(8) ?? Enumerable.Empty()) { + SentryStackTrace stack = new SentryStackTrace(); + foreach (SentryStackFrame frame in error.Stacktrace?.Frames?.TakeLast(64) ?? Enumerable.Empty()) { + if (frame.Module?.StartsWith("Celeste.Mod.Akron.", StringComparison.Ordinal) != true) continue; + string addressMode = null; + if (frame.AddressMode != null) imageIndexes.TryGetValue(frame.AddressMode, out addressMode); + stack.Frames.Add(new SentryStackFrame { + Module = SafeIdentifier(frame.Module), + Function = SafeIdentifier(frame.Function), + Package = "Akron", + FileName = FileName(frame.FileName), + LineNumber = frame.LineNumber, + ColumnNumber = frame.ColumnNumber, + InApp = true, + AddressMode = addressMode, + FunctionId = addressMode == null ? null : frame.FunctionId, + InstructionAddress = addressMode == null ? null : frame.InstructionAddress + }); + } + exceptions.Add(new SentryException { + Type = SafeIdentifier(error.Type), + Value = "Akron " + parsed + " failed.", + Stacktrace = stack.Frames.Count == 0 ? null : stack, + Mechanism = new Mechanism { Type = "akron", Handled = true } + }); + } + clean.SentryExceptions = exceptions; + return clean; + } + + internal static bool TryGetDestination(string dsn, out string host) { + host = string.Empty; + if (string.IsNullOrWhiteSpace(dsn) || !Uri.TryCreate(dsn, UriKind.Absolute, out Uri uri) || + uri.Scheme != Uri.UriSchemeHttps || string.IsNullOrEmpty(uri.IdnHost) || + !Regex.IsMatch(uri.UserInfo, @"^[A-Za-z0-9]+$", RegexOptions.CultureInvariant) || + !Regex.IsMatch(uri.AbsolutePath, @"^/(?:[A-Za-z0-9_-]+/)*[0-9]+$", RegexOptions.CultureInvariant) || + !string.IsNullOrEmpty(uri.Query) || !string.IsNullOrEmpty(uri.Fragment)) return false; + host = uri.IdnHost; + return true; + } + + private static string SafeVersion(string value) => + !string.IsNullOrWhiteSpace(value) && Regex.IsMatch(value, @"^[A-Za-z0-9_.+-]{1,96}$", RegexOptions.CultureInvariant) ? value : "unknown"; + private static string SafeIdentifier(string value) => value != null && Identifier.IsMatch(value) ? value : "unknown"; + private static string SafeHex(string value) => value != null && HexIdentifier.IsMatch(value) ? value : null; + private static string FileName(string value) { + if (string.IsNullOrEmpty(value)) return null; + string name = value.Replace('\\', '/').Split('/').Last(); + return Regex.IsMatch(name, @"^[A-Za-z0-9_.-]{1,128}$", RegexOptions.CultureInvariant) ? name : null; + } + + private sealed class ConsentHandler : DelegatingHandler { + private readonly CancellationToken revoked; + internal ConsentHandler(CancellationToken revoked, HttpMessageHandler inner) : base(inner) { this.revoked = revoked; } + protected override async Task SendAsync(HttpRequestMessage request, CancellationToken cancellationToken) { + if (revoked.IsCancellationRequested) return new HttpResponseMessage(HttpStatusCode.OK); + using CancellationTokenSource linked = CancellationTokenSource.CreateLinkedTokenSource(revoked, cancellationToken); + return await base.SendAsync(request, linked.Token).ConfigureAwait(false); + } + } +} diff --git a/Source/Module/AkronModule.cs b/Source/Module/AkronModule.cs index 7f3087e9..c53fe10c 100644 --- a/Source/Module/AkronModule.cs +++ b/Source/Module/AkronModule.cs @@ -133,6 +133,7 @@ public AkronModule() { } public override void Load() { + ConfigureErrorReporting(); renderedStartPosFrameGeneration = AkronActions.StartPosFrameGeneration; AkronModuleSettings.EnsureCurrentKeybindDefaults(Settings); AkronModuleSettings.DropUnkeyedAutomationAreas(Settings); @@ -147,6 +148,7 @@ public override void Load() { AkronStartPosPersistence.Start(); AkronScreenshotScanner.Load(); } catch (Exception exception) { + AkronTelemetry.Capture(exception, AkronFailurePhase.Startup); Logger.Log(LogLevel.Error, nameof(AkronModule), "Akron startup helper initialization failed; continuing so the module menu and overlay can still load: " + exception); } if (Engine.Instance != null) { @@ -263,6 +265,7 @@ public override void Initialize() { AkronMotionSmoothingInterop.ApplyAkronSettings(); AkronAudioSplitter.Initialize(); } catch (Exception exception) { + AkronTelemetry.Capture(exception, AkronFailurePhase.Initialize); Logger.Log(LogLevel.Error, nameof(AkronModule), "Akron startup helper initialization failed during Initialize; continuing so the module menu and overlay can still load: " + exception); } } @@ -271,11 +274,13 @@ public override void LoadContent(bool firstLoad) { try { AkronImGuiRenderer.WarmUp(); } catch (Exception exception) { + AkronTelemetry.Capture(exception, AkronFailurePhase.Content); Logger.Log(LogLevel.Error, nameof(AkronModule), "Akron startup helper initialization failed during LoadContent; continuing so the module menu and overlay can still load: " + exception); } } public override void Unload() { + AkronTelemetry.Stop(); AkronDiagnosticsMenu.CloseActive(); if (Engine.Instance != null) { Engine.Instance.Exiting -= EngineOnExiting; @@ -390,6 +395,7 @@ public override void Unload() { } private static void EngineOnExiting(object sender, EventArgs eventArgs) { + AkronTelemetry.Stop(); AkronStartPosPersistence.Shutdown(); AkronActions.ClearPendingStartPosState(); // Unload does not run on a normal quit, and an FFmpeg process that is killed with the @@ -739,6 +745,7 @@ internal bool TrySaveSettings() { // Everest saves every installed module's settings from one background thread and // stops at the first exception, so throwing here would cost every other mod its // save as well. + AkronTelemetry.Capture(exception, AkronFailurePhase.Settings); AkronLog.Warn(nameof(AkronModule), "Could not save Akron settings: " + exception); return false; } @@ -886,6 +893,7 @@ private static void RunAfterEngineUpdateActions() { try { afterEngineUpdateActions.Dequeue().Invoke(); } catch (Exception exception) { + AkronTelemetry.Capture(exception, AkronFailurePhase.DeferredAction); Logger.Log(LogLevel.Error, nameof(AkronModule), "Deferred engine-update action failed: " + exception); } diff --git a/Source/Module/AkronModuleSettings.cs b/Source/Module/AkronModuleSettings.cs index 70b9b4d0..50c9854c 100644 --- a/Source/Module/AkronModuleSettings.cs +++ b/Source/Module/AkronModuleSettings.cs @@ -138,6 +138,8 @@ public void ApplySubmissionMode(bool enabled) { public int OverlayScale { get; set; } = 100; public int OverlayBlur { get; set; } public bool Logging { get; set; } = true; + // Machine-local consent: intentionally absent from AkronSetupState and .akr setup packs. + public bool ErrorReportingEnabled { get; set; } = false; public AkronLoggingLevel LoggingLevel { get; set; } = AkronLoggingLevel.Diagnostic; public bool LoggingMirrorWarningsToEverest { get; set; } = true; public int LoggingMaxFileSizeMb { get; set; } = 5; diff --git a/Source/Module/akron-module-menu.cs b/Source/Module/akron-module-menu.cs index 14b3601c..bdc16d14 100644 --- a/Source/Module/akron-module-menu.cs +++ b/Source/Module/akron-module-menu.cs @@ -24,6 +24,12 @@ public override void CreateModMenuSection(TextMenu menu, bool inGame, EventInsta menu.Add(new TextMenu.OnOff("Safe Mode", Settings.SafeMode).Change(value => Settings.SafeMode = value)); menu.Add(new TextMenu.SubHeader("Support")); + AddWrappedModMenuSubHeaders(menu, AkronTelemetry.IsConfigured + ? "Automatic error reports send Akron stack frames and its version to Sentry at " + AkronTelemetry.Destination + ". No logs, exception messages, names, save contents or full paths are included." + : "Automatic error reports are not configured in this build."); + menu.Add(new TextMenu.OnOff("Automatic Error Reports", Settings.ErrorReportingEnabled) { + Disabled = !AkronTelemetry.IsConfigured && !Settings.ErrorReportingEnabled + }.Change(SetErrorReportingEnabled)); menu.Add(new TextMenu.Button("Send diagnostics").Pressed(() => AkronDiagnosticsMenu.Open(menu))); } diff --git a/Source/Module/akron-module-telemetry.cs b/Source/Module/akron-module-telemetry.cs new file mode 100644 index 00000000..1a535363 --- /dev/null +++ b/Source/Module/akron-module-telemetry.cs @@ -0,0 +1,29 @@ +using System; +using Monocle; + +namespace Celeste.Mod.Akron; + +public partial class AkronModule { + private static void ConfigureErrorReporting() { + try { + AkronTelemetry.Configure(TryGetSettings()?.ErrorReportingEnabled == true, + AkronTelemetry.ResolveDsn(), Instance?.Metadata?.VersionString); + } catch (Exception) { + // A broken telemetry configuration must not break the mod. + } + } + + internal static void SetErrorReportingEnabled(bool enabled) { + AkronModuleSettings settings = TryGetSettings(); + if (settings == null || (enabled && !AkronTelemetry.IsConfigured)) return; + settings.ErrorReportingEnabled = enabled; + if (!enabled) AkronTelemetry.Stop(flush: false); + if (!SaveAkronSettingsNow("error reporting consent")) { + settings.ErrorReportingEnabled = false; + AkronTelemetry.Stop(flush: false); + Engine.Scene?.Add(new AkronToast("Error reports are off. Could not save this preference; check it after restarting.")); + return; + } + if (enabled) ConfigureErrorReporting(); + } +} diff --git a/Source/Overlay/akron-imgui-renderer.cs b/Source/Overlay/akron-imgui-renderer.cs index 0e4110d8..bbac6129 100644 --- a/Source/Overlay/akron-imgui-renderer.cs +++ b/Source/Overlay/akron-imgui-renderer.cs @@ -161,6 +161,7 @@ public static bool Render(Action drawLayout, bool exposeCapture = true) { lastFailure = string.Empty; return true; } catch (Exception exception) { + AkronTelemetry.Capture(exception, AkronFailurePhase.Overlay); EndInputSession(); initializationFailed = true; initializationRetryFrames = 120; @@ -189,6 +190,7 @@ public static void WarmUp() { instance.EnsureBufferCapacity(8192, 16384); instance.effect ??= new BasicEffect(instance.graphicsDevice); } catch (Exception exception) { + AkronTelemetry.Capture(exception, AkronFailurePhase.Overlay); initializationFailed = true; initializationRetryFrames = 120; lastFailure = exception.GetType().Name + ": " + exception.Message; diff --git a/Source/Overlay/akron-overlay-copy.cs b/Source/Overlay/akron-overlay-copy.cs index f54017aa..f1d41b63 100644 --- a/Source/Overlay/akron-overlay-copy.cs +++ b/Source/Overlay/akron-overlay-copy.cs @@ -333,6 +333,7 @@ public sealed partial class AkronOverlay { ["Opacity"] = "Set Akron overlay background opacity.", ["Streamer Mode"] = "Show filenames instead of full local filesystem paths.", ["Logging"] = "Write local Akron diagnostic logs for debugging.", + ["Automatic Error Reports"] = "Send selected Akron errors to Sentry. Off by default; reports contain Akron stack frames and the mod version, without logs, exception messages, names, save contents or full paths.", ["Room Capture"] = "Capture overlapping tiles across the current room.", ["Map Capture"] = "Capture each room in the current map.", ["Search Autofocus"] = "Focus Akron search automatically when the overlay opens.", diff --git a/Source/Overlay/akron-overlay-entries.cs b/Source/Overlay/akron-overlay-entries.cs index d130b1bf..f0dbf1d2 100644 --- a/Source/Overlay/akron-overlay-entries.cs +++ b/Source/Overlay/akron-overlay-entries.cs @@ -259,6 +259,7 @@ private static List BuildEntriesForTab(string tab, Level level) { Toggle("Block Gameplay Input", () => AkronModule.Settings.ConsumeGameplayInputInMenu, value => AkronModule.Settings.ConsumeGameplayInputInMenu = value, "input", "madeline", "overlay", "consume"), Toggle("Streamer Mode", () => AkronModule.Settings.StreamerMode, value => AkronModule.Settings.StreamerMode = value), LoggingToggle(), + ErrorReportingToggle(), Action("Send diagnostics", () => Engine.Scene != null, AkronDiagnosticsMenu.DescribeAction, () => AkronDiagnosticsMenu.Open(), "logs", "support", "report", "upload", "performance"), Toggle("Search Autofocus", () => AkronModule.Settings.SearchAutofocus, value => AkronModule.Settings.SearchAutofocus = value), SearchInput() @@ -555,6 +556,16 @@ private static OverlayEntry Toggle(string label, AkronFeatureKind? featureKind, return new OverlayEntry(label, () => true, () => getter() ? "On" : "Off", () => setter(!getter()), BuildSearchTerms(label, tags), true, OverlayEntryControl.Toggle, featureKind, active: getter); } + private static OverlayEntry ErrorReportingToggle() { + return new OverlayEntry( + "Automatic Error Reports", + () => AkronTelemetry.IsConfigured || AkronModule.Settings.ErrorReportingEnabled, + () => AkronTelemetry.IsConfigured ? (AkronModule.Settings.ErrorReportingEnabled ? "On" : "Off") : "Not configured", + () => AkronModule.SetErrorReportingEnabled(!AkronModule.Settings.ErrorReportingEnabled), + BuildSearchTerms("Automatic Error Reports", new[] { "sentry", "errors", "support", "privacy", "consent" }), + true, OverlayEntryControl.Toggle, null, active: () => AkronModule.Settings.ErrorReportingEnabled); + } + private static OverlayEntry LoggingToggle() { return new OverlayEntry( "Logging", diff --git a/Source/SaveLoad/AkronSaveLoad.cs b/Source/SaveLoad/AkronSaveLoad.cs index 098c951d..1987fe69 100644 --- a/Source/SaveLoad/AkronSaveLoad.cs +++ b/Source/SaveLoad/AkronSaveLoad.cs @@ -1577,6 +1577,7 @@ bool allowDeadPlayer preserveInspectorState: true); } catch (Exception exception) { CurrentSlotName = slotName; + AkronTelemetry.Capture(exception, AkronFailurePhase.StartPosCapture); LastPersistentSnapshotError = "could not capture pre-load state: " + exception.GetType().Name + ": " + exception.Message; return AkronSaveLoadResult.Failed; } @@ -1599,6 +1600,7 @@ bool allowDeadPlayer // refusal still names the type and what it is about, and that is what // names the missing mod or the map that stopped placing the entity. AkronReconstructionException refusal = exception as AkronReconstructionException; + if (refusal == null) AkronTelemetry.Capture(exception, AkronFailurePhase.StartPosRestore); SetPersistentSnapshotFailure( exception.GetType().Name + ": " + exception.Message, refusal?.RefusedTypeName ?? string.Empty, diff --git a/Source/packages.lock.json b/Source/packages.lock.json index ba1b8b83..8e8e6146 100644 --- a/Source/packages.lock.json +++ b/Source/packages.lock.json @@ -62,6 +62,12 @@ "resolved": "13.0.3", "contentHash": "HrC5BXdl00IP9zeV+0Z848QWPAoCr9P3bDEZguI+gkLcBKAOxix/tLEAAHC+UvDNPv4a2d18lOReHMOagPa+zQ==" }, + "Sentry": { + "type": "Direct", + "requested": "[6.12.0, )", + "resolved": "6.12.0", + "contentHash": "HMD1MxszbFMkQmSNnEwdJa7cjz/vLdn9vKqzVpSa+7PvVeZZqTFOHVrg5dteYlcF7sWjVNRA06D2/jSYkf/sww==" + }, "AsmResolver": { "type": "Transitive", "resolved": "6.0.0-beta.1", @@ -811,4 +817,4 @@ } } } -} \ No newline at end of file +} diff --git a/docs/docs.json b/docs/docs.json index 5e09d52c..cbb9f877 100644 --- a/docs/docs.json +++ b/docs/docs.json @@ -133,6 +133,7 @@ "reference/akr-archives", "reference/community-pack-catalog", "reference/debug-commands", + "reference/error-reporting", "reference/file-locations", "reference/release-configuration" ] diff --git a/docs/feature-guide/interface.mdx b/docs/feature-guide/interface.mdx index ed31e543..eab4762d 100644 --- a/docs/feature-guide/interface.mdx +++ b/docs/feature-guide/interface.mdx @@ -18,6 +18,7 @@ The Interface tab controls the Akron overlay itself and the public `.akr` pack w | Block Gameplay Input | Toggle | Keep gameplay input from reaching Madeline while the overlay is open. On by default. | None | | Streamer Mode | Toggle | Replace full local filesystem paths with filenames. | None | | Logging | Toggle | Write local Akron diagnostic logs to `Saves/AkronLogs`. | Goldberry/Hardlist clear | +| Automatic Error Reports | Toggle | Opt in to selected Akron error reports through Sentry. Off by default; unavailable without a configured destination. See [error reporting](/reference/error-reporting) for the exact data sent. | None | | Send diagnostics | Action | Describe a problem, review consent, and send bounded logs, mod versions and system specs to Akron's private Discord support channel. | None | | Search Autofocus | Toggle | Focus Akron search automatically when the overlay opens. | None | | Search | Text input | Filter visible Akron rows by label or search alias. | None | diff --git a/docs/reference/error-reporting.mdx b/docs/reference/error-reporting.mdx new file mode 100644 index 00000000..2468f45f --- /dev/null +++ b/docs/reference/error-reporting.mdx @@ -0,0 +1,46 @@ +--- +title: Error reporting +description: Choose whether Akron sends selected errors to Sentry and configure reporting for maintained builds. +--- + +## Player choice + +**Automatic Error Reports** is off by default. Enable it in the Interface tab or Akron's mod options under Support. Mod options show the configured destination. The control is unavailable when no valid Sentry destination is configured. + +The preference is stored only in your local mod settings as `ErrorReportingEnabled`. Importing a `.akr` pack cannot enable it or replace your choice. Turning it off immediately stops new sends and cancels pending requests; a report already sent cannot be recalled. + +Automatic reports contain the failure phase, exception type, Akron version, Akron source filenames and method/line details, and identifiers used to resolve that build's debug symbols. They exclude exception messages and data, local directory paths, usernames, machine names, map/save content, other mods' stack frames, HTTP bodies and URLs, breadcrumbs, and log attachments. The receiving server necessarily sees the network connection's IP address; the SDK does not add it as a user field. + +Reporting covers selected failures already caught by Akron: startup helpers, ImGui initialization/rendering, settings saves, deferred actions, StartPos persistence and completion, and unexpected persistent restore failures. Expected cancellation and explicit reconstruction refusals are not reported. Repeated failures from the same phase, exception type and Akron method are reported once per reporting session, with at most 32 reports per session. The client does not install global exception or native crash handlers, record gameplay frames, trace requests, or track sessions. + +Local Logging and the explicit **Send diagnostics** flow remain separate. Automatic reporting never uploads those logs or support descriptions. Keep using Send diagnostics when a maintainer needs reproduction details. + +## Maintainer configuration + +The SDK is pinned to Sentry .NET `6.12.0`. An absent DSN keeps reporting inert, including in development and CI. Never put an auth token in a build or player setting. + +For a local build, set the public ingest DSN through the MSBuild property `AkronSentryDsn`. For a local run, `AKRON_SENTRY_DSN` overrides the compiled value; setting that environment variable to an empty string disables the destination. Only HTTPS DSNs with a public key and numeric project ID are accepted. A configured destination still requires the player's explicit opt-in. + +For official releases, configure these values in GitHub's `release-build` environment: + +| Name | Kind | Purpose | +|---|---|---| +| `AKRON_SENTRY_DSN` | Variable | Public DSN compiled into the mod. | +| `SENTRY_ORG` | Variable | Sentry organization slug. | +| `SENTRY_PROJECT` | Variable | Sentry project slug for the mod. | +| `SENTRY_AUTH_TOKEN` | Secret | CI-only token authorized to upload debug files and manage releases. | + +The release workflow uploads `Akron.dll` and its portable PDB privately to Sentry, includes embedded source information, and associates release `akron@` with the exact Git commit. The player ZIP continues to exclude PDB files and the Sentry CLI. Symbol upload is skipped without a DSN; a configured release fails if its symbol-upload credentials are incomplete. + +## Verification + +Run the game-independent suite with a fake HTTP handler; it does not send events to Sentry or need Celeste reference assemblies: + +```bash +dotnet restore tests/telemetry/akron-telemetry-tests.csproj --locked-mode +dotnet test tests/telemetry/akron-telemetry-tests.csproj --configuration Release --no-restore +``` + +The suite checks disabled reporting, payload redaction through the actual SDK, stable event identity, debug-image index remapping, renderer deduplication, session limits, consent revocation, and bounded shutdown. The full mod build and settings tests still require the project's verified Celeste reference archive. + +Before release, a maintainer should test the visible controls in Celeste/Everest, confirm a controlled handled error in a dedicated Sentry test project, verify its source line against the release symbols, turn reporting off and verify no further requests, and check both normal quit and mod unload. Do not manufacture a gameplay crash to test reporting. diff --git a/licenses/third-party-notices.txt b/licenses/third-party-notices.txt index a62174b1..fdb070e4 100644 --- a/licenses/third-party-notices.txt +++ b/licenses/third-party-notices.txt @@ -40,6 +40,11 @@ Scope: bin/Newtonsoft.Json.dll in the player package Upstream: https://github.com/JamesNK/Newtonsoft.Json Copyright (c) 2007 James Newton-King +Sentry .NET SDK 6.12.0 +Scope: bin/Sentry.dll in the player package +Upstream: https://github.com/getsentry/sentry-dotnet/tree/6.12.0 +Copyright (c) 2018 Sentry + The following MIT License applies to each component listed in this section: Permission is hereby granted, free of charge, to any person obtaining a copy diff --git a/tests/akron-tests.csproj b/tests/akron-tests.csproj index d00f2700..b1941da7 100644 --- a/tests/akron-tests.csproj +++ b/tests/akron-tests.csproj @@ -15,6 +15,7 @@ + diff --git a/tests/module-settings-tests.cs b/tests/module-settings-tests.cs index 3ce8f505..d5a94c47 100644 --- a/tests/module-settings-tests.cs +++ b/tests/module-settings-tests.cs @@ -465,6 +465,24 @@ public void DeathParticleCustomShapeNormalizesToEightByEightMask() Assert.All(normalized, character => Assert.Contains(character, new[] { '0', '1' })); } + [Fact] + public void AutomaticErrorReportingDefaultsOff() + { + Assert.False(new AkronModuleSettings().ErrorReportingEnabled); + } + + [Theory] + [InlineData(false)] + [InlineData(true)] + public void SetupPacksCannotReplaceErrorReportingConsent(bool consent) + { + AkronModuleSettings source = new AkronModuleSettings { ErrorReportingEnabled = !consent }; + AkronSetupPack pack = AkronSetupPacks.Capture(source, session: null, "Consent test"); + AkronModuleSettings target = new AkronModuleSettings { ErrorReportingEnabled = consent }; + AkronSetupPacks.Apply(target, session: null, pack); + Assert.Equal(consent, target.ErrorReportingEnabled); + } + [Fact] public void LoggingDefaultsToPlaytesterSafeDiagnosticsWithBoundedRetention() { diff --git a/tests/telemetry/akron-telemetry-tests.csproj b/tests/telemetry/akron-telemetry-tests.csproj new file mode 100644 index 00000000..ae747243 --- /dev/null +++ b/tests/telemetry/akron-telemetry-tests.csproj @@ -0,0 +1,16 @@ + + + net8.0 + Akron.Telemetry.Tests + false + true + latest + + + + + + + + + diff --git a/tests/telemetry/packages.lock.json b/tests/telemetry/packages.lock.json new file mode 100644 index 00000000..1cd97197 --- /dev/null +++ b/tests/telemetry/packages.lock.json @@ -0,0 +1,112 @@ +{ + "version": 1, + "dependencies": { + "net8.0": { + "Microsoft.NET.Test.Sdk": { + "type": "Direct", + "requested": "[17.12.0, )", + "resolved": "17.12.0", + "contentHash": "kt/PKBZ91rFCWxVIJZSgVLk+YR+4KxTuHf799ho8WNiK5ZQpJNAEZCAWX86vcKrs+DiYjiibpYKdGZP6+/N17w==", + "dependencies": { + "Microsoft.CodeCoverage": "17.12.0", + "Microsoft.TestPlatform.TestHost": "17.12.0" + } + }, + "Sentry": { + "type": "Direct", + "requested": "[6.12.0, )", + "resolved": "6.12.0", + "contentHash": "HMD1MxszbFMkQmSNnEwdJa7cjz/vLdn9vKqzVpSa+7PvVeZZqTFOHVrg5dteYlcF7sWjVNRA06D2/jSYkf/sww==" + }, + "xunit": { + "type": "Direct", + "requested": "[2.9.2, )", + "resolved": "2.9.2", + "contentHash": "7LhFS2N9Z6Xgg8aE5lY95cneYivRMfRI8v+4PATa4S64D5Z/Plkg0qa8dTRHSiGRgVZ/CL2gEfJDE5AUhOX+2Q==", + "dependencies": { + "xunit.analyzers": "1.16.0", + "xunit.assert": "2.9.2", + "xunit.core": "[2.9.2]" + } + }, + "xunit.runner.visualstudio": { + "type": "Direct", + "requested": "[2.8.2, )", + "resolved": "2.8.2", + "contentHash": "vm1tbfXhFmjFMUmS4M0J0ASXz3/U5XvXBa6DOQUL3fEz4Vt6YPhv+ESCarx6M6D+9kJkJYZKCNvJMas1+nVfmQ==" + }, + "Microsoft.CodeCoverage": { + "type": "Transitive", + "resolved": "17.12.0", + "contentHash": "4svMznBd5JM21JIG2xZKGNanAHNXplxf/kQDFfLHXQ3OnpJkayRK/TjacFjA+EYmoyuNXHo/sOETEfcYtAzIrA==" + }, + "Microsoft.TestPlatform.ObjectModel": { + "type": "Transitive", + "resolved": "17.12.0", + "contentHash": "TDqkTKLfQuAaPcEb3pDDWnh7b3SyZF+/W9OZvWFp6eJCIiiYFdSB6taE2I6tWrFw5ywhzOb6sreoGJTI6m3rSQ==", + "dependencies": { + "System.Reflection.Metadata": "1.6.0" + } + }, + "Microsoft.TestPlatform.TestHost": { + "type": "Transitive", + "resolved": "17.12.0", + "contentHash": "MiPEJQNyADfwZ4pJNpQex+t9/jOClBGMiCiVVFuELCMSX2nmNfvUor3uFVxNNCg30uxDP8JDYfPnMXQzsfzYyg==", + "dependencies": { + "Microsoft.TestPlatform.ObjectModel": "17.12.0", + "Newtonsoft.Json": "13.0.1" + } + }, + "Newtonsoft.Json": { + "type": "Transitive", + "resolved": "13.0.1", + "contentHash": "ppPFpBcvxdsfUonNcvITKqLl3bqxWbDCZIzDWHzjpdAHRFfZe0Dw9HmA0+za13IdyrgJwpkDTDA9fHaxOrt20A==" + }, + "System.Reflection.Metadata": { + "type": "Transitive", + "resolved": "1.6.0", + "contentHash": "COC1aiAJjCoA5GBF+QKL2uLqEBew4JsCkQmoHKbN3TlOZKa2fKLz5CpiRQKDz0RsAOEGsVKqOD5bomsXq/4STQ==" + }, + "xunit.abstractions": { + "type": "Transitive", + "resolved": "2.0.3", + "contentHash": "pot1I4YOxlWjIb5jmwvvQNbTrZ3lJQ+jUGkGjWE3hEFM0l5gOnBWS+H3qsex68s5cO52g+44vpGzhAt+42vwKg==" + }, + "xunit.analyzers": { + "type": "Transitive", + "resolved": "1.16.0", + "contentHash": "hptYM7vGr46GUIgZt21YHO4rfuBAQS2eINbFo16CV/Dqq+24Tp+P5gDCACu1AbFfW4Sp/WRfDPSK8fmUUb8s0Q==" + }, + "xunit.assert": { + "type": "Transitive", + "resolved": "2.9.2", + "contentHash": "QkNBAQG4pa66cholm28AxijBjrmki98/vsEh4Sx5iplzotvPgpiotcxqJQMRC8d7RV7nIT8ozh97957hDnZwsQ==" + }, + "xunit.core": { + "type": "Transitive", + "resolved": "2.9.2", + "contentHash": "O6RrNSdmZ0xgEn5kT927PNwog5vxTtKrWMihhhrT0Sg9jQ7iBDciYOwzBgP2krBEk5/GBXI18R1lKvmnxGcb4w==", + "dependencies": { + "xunit.extensibility.core": "[2.9.2]", + "xunit.extensibility.execution": "[2.9.2]" + } + }, + "xunit.extensibility.core": { + "type": "Transitive", + "resolved": "2.9.2", + "contentHash": "Ol+KlBJz1x8BrdnhN2DeOuLrr1I/cTwtHCggL9BvYqFuVd/TUSzxNT5O0NxCIXth30bsKxgMfdqLTcORtM52yQ==", + "dependencies": { + "xunit.abstractions": "2.0.3" + } + }, + "xunit.extensibility.execution": { + "type": "Transitive", + "resolved": "2.9.2", + "contentHash": "rKMpq4GsIUIJibXuZoZ8lYp5EpROlnYaRpwu9Zr0sRZXE7JqJfEEbCsUriZqB+ByXCLFBJyjkTRULMdC+U566g==", + "dependencies": { + "xunit.extensibility.core": "[2.9.2]" + } + } + } + } +} diff --git a/tests/telemetry/telemetry-tests.cs b/tests/telemetry/telemetry-tests.cs new file mode 100644 index 00000000..21f278b5 --- /dev/null +++ b/tests/telemetry/telemetry-tests.cs @@ -0,0 +1,207 @@ +using System; +using System.Collections.Concurrent; +using System.Collections.Generic; +using System.Diagnostics; +using System.IO; +using System.IO.Compression; +using System.Linq; +using System.Net; +using System.Net.Http; +using System.Text; +using System.Text.Json; +using System.Threading; +using System.Threading.Tasks; +using Sentry; +using Sentry.Protocol; +using Xunit; + +[assembly: CollectionBehavior(DisableTestParallelization = true)] + +namespace Celeste.Mod.Akron.Tests; + +public sealed class TelemetryTests { + private const string Dsn = "https://publickey@example.invalid/1"; + + [Theory] + [InlineData(false, Dsn)] + [InlineData(true, "")] + [InlineData(true, "http://publickey@example.invalid/1")] + [InlineData(true, "https://publickey:secret@example.invalid/1")] + [InlineData(true, "https://publickey@example.invalid/1?secret=value")] + public void MissingConsentOrValidDsnNeverStartsReporting(bool consent, string dsn) { + RecordingHandler transport = new RecordingHandler(); + try { + AkronTelemetry.Configure(consent, dsn, "1.2.3", transport); + AkronTelemetry.Capture(BuildFailure(), AkronFailurePhase.Startup); + Assert.False(AkronTelemetry.IsEnabled); + Assert.Empty(transport.Bodies); + } finally { + AkronTelemetry.Stop(flush: false); + } + } + + [Fact] + public void SanitizerPreservesIdentityAndOnlyAllowsAkronDiagnosticFields() { + AkronErrorReporter reporter = new AkronErrorReporter(Dsn, "1.2.3", new RecordingHandler()); + try { + SentryEvent source = new SentryEvent(BuildFailure()) { + ServerName = "private-machine", + Release = "private-version", + Environment = "private-environment", + Message = "private-message", + User = new SentryUser { Username = "private-user", Email = "private@example.invalid" }, + Request = new SentryRequest { Url = "https://private.invalid/token", Data = "private-body" }, + SentryExceptions = new[] { + new SentryException { + Type = "IOException", Value = "private exception value", + Stacktrace = new SentryStackTrace { Frames = new List { + new SentryStackFrame { Module = "Other.Mod", Function = "private-method" }, + new SentryStackFrame { + Module = "Celeste.Mod.Akron.AkronSaveLoadService", Function = "Restore", + Package = "private-assembly", FileName = "/home/private-user/AkronSaveLoad.cs", + AbsolutePath = "/home/private-user/AkronSaveLoad.cs", ContextLine = "private-source", + LineNumber = 123, AddressMode = "rel:1", FunctionId = 5, InstructionAddress = 7 + } + } } + } + }, + DebugImages = new List { + new DebugImage { CodeFile = "/private/Other.dll", DebugId = "abcd" }, + new DebugImage { CodeFile = @"C:\Users\private-user\Akron.dll", DebugFile = @"C:\private\Akron.pdb", DebugId = "abcd-1234", CodeId = "1234", DebugChecksum = "abcd" } + } + }; + source.SetTag("akron.phase", AkronFailurePhase.StartPosRestore.ToString()); + source.SetTag("private-tag", "private-value"); + source.SetExtra("private-extra", "private-data"); + source.AddBreadcrumb(new Breadcrumb("private-breadcrumb", "default")); + source.Contexts["private-context"] = "private-value"; + SentryEvent clean = reporter.Sanitize(source); + Assert.Equal(source.EventId, clean.EventId); + Assert.Equal(source.Timestamp, clean.Timestamp); + string json = Serialize(clean); + Assert.DoesNotContain("private", json, StringComparison.OrdinalIgnoreCase); + Assert.DoesNotContain("Other.Mod", json); + Assert.DoesNotContain("Other.dll", json); + Assert.Contains("AkronSaveLoad.cs", json); + Assert.Contains("akron@1.2.3", json); + SentryStackFrame frame = Assert.Single(Assert.Single(clean.SentryExceptions).Stacktrace.Frames); + Assert.Equal("rel:0", frame.AddressMode); + Assert.Equal(5, frame.FunctionId); + Assert.Equal(7, frame.InstructionAddress); + Assert.Equal("Akron.dll", Assert.Single(clean.DebugImages).CodeFile); + } finally { reporter.Stop(flush: false); } + } + + [Fact] + public async Task ActualSdkEnvelopeIsScrubbedAndUsesTheReturnedEventId() { + RecordingHandler transport = new RecordingHandler(); + bool globalWasEnabled = SentrySdk.IsEnabled; + AkronErrorReporter reporter = new AkronErrorReporter(Dsn, "1.2.3", transport); + try { + SentryId id = reporter.Capture(BuildFailure(), AkronFailurePhase.StartPosPersist); + await reporter.FlushAsync(); + string body = Assert.Single(transport.Bodies); + Assert.NotEqual(SentryId.Empty, id); + Assert.Contains(id.ToString(), body); + Assert.Contains("StartPosPersist", body); + Assert.DoesNotContain("private", body, StringComparison.OrdinalIgnoreCase); + Assert.Equal(globalWasEnabled, SentrySdk.IsEnabled); + } finally { reporter.Stop(flush: false); } + } + + [Fact] + public async Task RepeatedRendererFailuresAndCancellationDoNotFloodReports() { + RecordingHandler transport = new RecordingHandler(); + AkronErrorReporter reporter = new AkronErrorReporter(Dsn, "1.2.3", transport); + try { + Assert.NotEqual(SentryId.Empty, reporter.Capture(BuildFailure(), AkronFailurePhase.Overlay)); + for (int index = 0; index < 100; index++) { + Assert.Equal(SentryId.Empty, reporter.Capture(BuildFailure(), AkronFailurePhase.Overlay)); + } + Assert.Equal(SentryId.Empty, reporter.Capture(new OperationCanceledException(), AkronFailurePhase.StartPosPersist)); + Assert.NotEqual(SentryId.Empty, reporter.Capture(BuildFailure(), AkronFailurePhase.StartPosRestore)); + await reporter.FlushAsync(); + Assert.Equal(2, transport.Bodies.Count); + } finally { reporter.Stop(flush: false); } + } + + [Fact] + public async Task IndependentFailuresHaveASessionLimit() { + RecordingHandler transport = new RecordingHandler(); + AkronErrorReporter reporter = new AkronErrorReporter(Dsn, "1.2.3", transport); + try { + foreach (AkronFailurePhase phase in Enum.GetValues()) { + foreach (Exception exception in new Exception[] { new IOException(), new ArgumentException(), new InvalidOperationException(), new NotSupportedException() }) { + reporter.Capture(exception, phase); + } + } + await reporter.FlushAsync(); + Assert.Equal(AkronErrorReporter.MaxReportsPerSession, transport.Bodies.Count); + } finally { reporter.Stop(flush: false); } + } + + [Fact] + public async Task RevokingConsentCancelsInFlightAndPreventsQueuedSends() { + BlockingHandler transport = new BlockingHandler(); + AkronErrorReporter reporter = new AkronErrorReporter(Dsn, "1.2.3", transport); + reporter.Capture(BuildFailure(), AkronFailurePhase.Overlay); + await transport.Started.Task.WaitAsync(TimeSpan.FromSeconds(5)); + reporter.Capture(BuildFailure(), AkronFailurePhase.StartPosRestore); + reporter.Stop(flush: false); + await transport.Canceled.Task.WaitAsync(TimeSpan.FromSeconds(5)); + await reporter.FlushAsync(); + Assert.Equal(SentryId.Empty, reporter.Capture(BuildFailure(), AkronFailurePhase.Content)); + Assert.Equal(1, transport.StartCount); + } + + [Fact] + public async Task ShutdownIsBoundedAndIdempotent() { + BlockingHandler transport = new BlockingHandler(); + AkronErrorReporter reporter = new AkronErrorReporter(Dsn, "1.2.3", transport); + reporter.Capture(BuildFailure(), AkronFailurePhase.Overlay); + await transport.Started.Task.WaitAsync(TimeSpan.FromSeconds(5)); + Stopwatch timer = Stopwatch.StartNew(); + reporter.Stop(flush: true); + reporter.Stop(flush: true); + Assert.True(timer.Elapsed < TimeSpan.FromSeconds(2)); + await transport.Canceled.Task.WaitAsync(TimeSpan.FromSeconds(5)); + } + + private static Exception BuildFailure() { + try { throw new IOException("private-user /home/private-user/file secret=private-token"); } + catch (Exception exception) { + exception.Data["private-key"] = "private-value"; + return exception; + } + } + + private static string Serialize(SentryEvent entry) { + using MemoryStream stream = new MemoryStream(); + using (Utf8JsonWriter writer = new Utf8JsonWriter(stream)) entry.WriteTo(writer, null); + return Encoding.UTF8.GetString(stream.ToArray()); + } + + private sealed class RecordingHandler : HttpMessageHandler { + internal ConcurrentQueue Bodies { get; } = new ConcurrentQueue(); + protected override async Task SendAsync(HttpRequestMessage request, CancellationToken token) { + using Stream content = await request.Content.ReadAsStreamAsync(token); + using Stream decoded = request.Content.Headers.ContentEncoding.Contains("gzip") ? new GZipStream(content, CompressionMode.Decompress) : content; + using StreamReader reader = new StreamReader(decoded); + Bodies.Enqueue(await reader.ReadToEndAsync(token)); + return new HttpResponseMessage(HttpStatusCode.OK) { Content = new StringContent("{}") }; + } + } + + private sealed class BlockingHandler : HttpMessageHandler { + internal TaskCompletionSource Started { get; } = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + internal TaskCompletionSource Canceled { get; } = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + internal int StartCount; + protected override async Task SendAsync(HttpRequestMessage request, CancellationToken token) { + Interlocked.Increment(ref StartCount); + Started.TrySetResult(true); + try { await Task.Delay(Timeout.Infinite, token); } + catch (OperationCanceledException) { Canceled.TrySetResult(true); throw; } + return new HttpResponseMessage(HttpStatusCode.OK); + } + } +} From 7145d2250e27088392cec4d83702c91e699fbbe7 Mon Sep 17 00:00:00 2001 From: Microck Date: Sat, 10 Oct 2026 14:06:58 +0200 Subject: [PATCH 02/10] ci: collect NuGet lock and formatter evidence for draft review --- .github/workflows/ci.yml | 41 ++++++++++++++++++++++++++++++++++++++++ 1 file changed, 41 insertions(+) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index c6ff3ecc..1082a788 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -27,8 +27,30 @@ jobs: uses: actions/setup-dotnet@67a3573c9a986a3f9c594539f4ab511d57bb3ce9 # v4 with: dotnet-version: 8.0.418 + - name: Regenerate telemetry lock metadata for this draft + run: | + dotnet restore tests/telemetry/akron-telemetry-tests.csproj --force-evaluate --use-lock-file --nologo + echo 'TELEMETRY_RESTORED_LOCK_BEGIN' + cat tests/telemetry/packages.lock.json + echo 'TELEMETRY_RESTORED_LOCK_END' - name: Restore locked telemetry tests run: dotnet restore tests/telemetry/akron-telemetry-tests.csproj --locked-mode --nologo + - name: Capture targeted telemetry formatting evidence for this draft + run: | + mkdir -p validation + format_status=0 + dotnet format tests/telemetry/akron-telemetry-tests.csproj --no-restore --include Source/Core/akron-telemetry.cs tests/telemetry/telemetry-tests.cs > validation/telemetry-format.log 2>&1 || format_status=$? + git diff -- Source/Core/akron-telemetry.cs tests/telemetry/telemetry-tests.cs > validation/telemetry-format.patch + git restore -- Source/Core/akron-telemetry.cs tests/telemetry/telemetry-tests.cs + cat validation/telemetry-format.log + exit "$format_status" + - name: Upload telemetry formatting evidence + if: always() + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4 + with: + name: telemetry-formatting + path: validation/telemetry-format.* + retention-days: 3 - name: Verify consent, sanitized SDK envelopes, deduplication and shutdown run: dotnet test tests/telemetry/akron-telemetry-tests.csproj --configuration Release --no-restore --nologo @@ -71,6 +93,25 @@ jobs: - name: Build run: dotnet build Akron.sln --configuration Release --no-restore --nologo -p:ContinuousIntegrationBuild=true + - name: Capture targeted mod formatting evidence for this draft + run: | + mkdir -p validation + changed_files=(Source/Core/akron-telemetry.cs Source/Module/akron-module-telemetry.cs Source/Module/AkronModuleSettings.cs Source/Module/AkronModule.cs Source/Actions/akron-startpos-persistence.cs Source/Overlay/akron-imgui-renderer.cs Source/SaveLoad/AkronSaveLoad.cs Source/Module/akron-module-menu.cs Source/Overlay/akron-overlay-entries.cs Source/Overlay/akron-overlay-copy.cs tests/module-settings-tests.cs) + format_status=0 + dotnet format Akron.sln --no-restore --include "${changed_files[@]}" > validation/mod-format.log 2>&1 || format_status=$? + git diff -- "${changed_files[@]}" > validation/mod-format.patch + git restore -- "${changed_files[@]}" + cat validation/mod-format.log + exit "$format_status" + + - name: Upload mod formatting evidence + if: always() + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4 + with: + name: mod-formatting + path: validation/mod-format.* + retention-days: 3 + - name: Test run: dotnet test tests/akron-tests.csproj --configuration Release --no-build --nologo From 6cb73a9f0acf1766c55fe58e5d1ab5f62ecb857c Mon Sep 17 00:00:00 2001 From: Microck Date: Sat, 10 Oct 2026 14:10:49 +0200 Subject: [PATCH 03/10] fix: use restored NuGet content hashes and format telemetry sources --- .github/workflows/ci.yml | 22 ------ Source/Core/akron-telemetry.cs | 104 +++++++++++++++++++---------- Source/packages.lock.json | 2 +- tests/telemetry/packages.lock.json | 2 +- tests/telemetry/telemetry-tests.cs | 88 ++++++++++++++++-------- 5 files changed, 131 insertions(+), 87 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 1082a788..7561dd46 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -27,30 +27,8 @@ jobs: uses: actions/setup-dotnet@67a3573c9a986a3f9c594539f4ab511d57bb3ce9 # v4 with: dotnet-version: 8.0.418 - - name: Regenerate telemetry lock metadata for this draft - run: | - dotnet restore tests/telemetry/akron-telemetry-tests.csproj --force-evaluate --use-lock-file --nologo - echo 'TELEMETRY_RESTORED_LOCK_BEGIN' - cat tests/telemetry/packages.lock.json - echo 'TELEMETRY_RESTORED_LOCK_END' - name: Restore locked telemetry tests run: dotnet restore tests/telemetry/akron-telemetry-tests.csproj --locked-mode --nologo - - name: Capture targeted telemetry formatting evidence for this draft - run: | - mkdir -p validation - format_status=0 - dotnet format tests/telemetry/akron-telemetry-tests.csproj --no-restore --include Source/Core/akron-telemetry.cs tests/telemetry/telemetry-tests.cs > validation/telemetry-format.log 2>&1 || format_status=$? - git diff -- Source/Core/akron-telemetry.cs tests/telemetry/telemetry-tests.cs > validation/telemetry-format.patch - git restore -- Source/Core/akron-telemetry.cs tests/telemetry/telemetry-tests.cs - cat validation/telemetry-format.log - exit "$format_status" - - name: Upload telemetry formatting evidence - if: always() - uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4 - with: - name: telemetry-formatting - path: validation/telemetry-format.* - retention-days: 3 - name: Verify consent, sanitized SDK envelopes, deduplication and shutdown run: dotnet test tests/telemetry/akron-telemetry-tests.csproj --configuration Release --no-restore --nologo diff --git a/Source/Core/akron-telemetry.cs b/Source/Core/akron-telemetry.cs index daf13da5..00101e89 100644 --- a/Source/Core/akron-telemetry.cs +++ b/Source/Core/akron-telemetry.cs @@ -16,7 +16,8 @@ namespace Celeste.Mod.Akron; -internal enum AkronFailurePhase { +internal enum AkronFailurePhase +{ Startup, Initialize, Content, @@ -31,42 +32,52 @@ internal enum AkronFailurePhase { // This client belongs to Akron, not the Celeste process. Never use SentrySdk.Init here: // other mods own their exceptions, scopes, native handlers and process lifetime. -internal static class AkronTelemetry { +internal static class AkronTelemetry +{ private static readonly object Sync = new object(); private static AkronErrorReporter reporter; internal static string Destination { get; private set; } = string.Empty; internal static bool IsConfigured => !string.IsNullOrEmpty(Destination); internal static bool IsEnabled { get { lock (Sync) return reporter != null; } } - internal static void Configure(bool consent, string dsn, string release, HttpMessageHandler transport = null) { + internal static void Configure(bool consent, string dsn, string release, HttpMessageHandler transport = null) + { Stop(flush: false); Destination = AkronErrorReporter.TryGetDestination(dsn, out string host) ? host : string.Empty; if (!consent || !IsConfigured) return; - try { - lock (Sync) { + try + { + lock (Sync) + { reporter = new AkronErrorReporter(dsn, release, transport); } - } catch (Exception) { + } + catch (Exception) + { // Reporting must never prevent the mod from loading or leak its configuration to a log. } } - internal static string ResolveDsn() { + internal static string ResolveDsn() + { string configured = Environment.GetEnvironmentVariable("AKRON_SENTRY_DSN"); return configured ?? typeof(AkronTelemetry).Assembly .GetCustomAttributes() .FirstOrDefault(attribute => attribute.Key == "AkronSentryDsn")?.Value ?? string.Empty; } - internal static void Capture(Exception exception, AkronFailurePhase phase) { + internal static void Capture(Exception exception, AkronFailurePhase phase) + { AkronErrorReporter current; lock (Sync) current = reporter; current?.Capture(exception, phase); } - internal static void Stop(bool flush = true) { + internal static void Stop(bool flush = true) + { AkronErrorReporter previous; - lock (Sync) { + lock (Sync) + { previous = reporter; reporter = null; } @@ -75,7 +86,8 @@ internal static void Stop(bool flush = true) { } // Kept independent of live game objects so consent and the actual SDK payload can be tested headlessly. -internal sealed class AkronErrorReporter { +internal sealed class AkronErrorReporter +{ internal const int MaxReportsPerSession = 32; internal static readonly TimeSpan ShutdownTimeout = TimeSpan.FromMilliseconds(500); private static readonly Regex Identifier = new Regex(@"^[A-Za-z0-9_.+`<> {}(),\[\]:&?=-]{1,512}$", RegexOptions.CultureInvariant); @@ -87,10 +99,12 @@ internal sealed class AkronErrorReporter { private readonly string release; private bool stopped; - internal AkronErrorReporter(string dsn, string version, HttpMessageHandler transport = null) { + internal AkronErrorReporter(string dsn, string version, HttpMessageHandler transport = null) + { if (!TryGetDestination(dsn, out _)) throw new ArgumentException("A valid HTTPS Sentry DSN is required."); release = "akron@" + SafeVersion(version); - SentryOptions options = new SentryOptions { + SentryOptions options = new SentryOptions + { Dsn = dsn, Release = release, Environment = "production", @@ -118,10 +132,13 @@ internal AkronErrorReporter(string dsn, string version, HttpMessageHandler trans client = new SentryClient(options); } - internal SentryId Capture(Exception exception, AkronFailurePhase phase) { + internal SentryId Capture(Exception exception, AkronFailurePhase phase) + { if (exception == null || exception is OperationCanceledException || !Enum.IsDefined(phase)) return SentryId.Empty; - try { - lock (sync) { + try + { + lock (sync) + { if (stopped || reported.Count >= MaxReportsPerSession) return SentryId.Empty; string frame = new StackTrace(exception, false).GetFrames()? .Select(value => value.GetMethod()) @@ -132,28 +149,34 @@ internal SentryId Capture(Exception exception, AkronFailurePhase phase) { entry.SetTag("akron.phase", phase.ToString()); return client.CaptureEvent(entry); } - } catch (Exception) { + } + catch (Exception) + { // No recursive reporting and no change to the caller's recovery path. return SentryId.Empty; } } - internal void Stop(bool flush) { - lock (sync) { + internal void Stop(bool flush) + { + lock (sync) + { if (stopped) return; stopped = true; if (!flush) revoked.Cancel(); } // Revocation prevents queued or subsequent sends immediately. Already-sent requests // cannot be recalled. Do not wait for network delivery while the player changes consent. - if (!flush) { + if (!flush) + { _ = Task.Run(DisposeClient); return; } DisposeClient(); } - private void DisposeClient() { + private void DisposeClient() + { try { client.Dispose(); } catch (Exception) { } finally { revoked.Cancel(); } @@ -163,12 +186,14 @@ private void DisposeClient() { // A new event is an allowlist, not a blacklist: exception messages/data, user identity, // request data, breadcrumbs, host/device context, other mods and raw paths cannot survive. - internal SentryEvent Sanitize(SentryEvent source) { + internal SentryEvent Sanitize(SentryEvent source) + { if (revoked.IsCancellationRequested || !source.Tags.TryGetValue("akron.phase", out string phase) || !Enum.TryParse(phase, out AkronFailurePhase parsed) || !Enum.IsDefined(parsed)) return null; // The public constructor generates a new ID. FromJson is the SDK's public API // for preserving identity; seed it with these two trusted scalar fields only. - using JsonDocument identity = JsonDocument.Parse(JsonSerializer.Serialize(new { + using JsonDocument identity = JsonDocument.Parse(JsonSerializer.Serialize(new + { event_id = source.EventId.ToString(), timestamp = source.Timestamp.ToString("O", CultureInfo.InvariantCulture) })); @@ -181,13 +206,16 @@ internal SentryEvent Sanitize(SentryEvent source) { clean.Sdk.Version = source.Sdk.Version; clean.SetTag("akron.phase", parsed.ToString()); Dictionary imageIndexes = new Dictionary(); - if (source.DebugImages != null) { + if (source.DebugImages != null) + { clean.DebugImages = new List(); - for (int index = 0; index < source.DebugImages.Count; index++) { + for (int index = 0; index < source.DebugImages.Count; index++) + { DebugImage image = source.DebugImages[index]; if (!string.Equals(FileName(image.CodeFile), "Akron.dll", StringComparison.OrdinalIgnoreCase)) continue; imageIndexes["rel:" + index.ToString(CultureInfo.InvariantCulture)] = "rel:" + clean.DebugImages.Count.ToString(CultureInfo.InvariantCulture); - clean.DebugImages.Add(new DebugImage { + clean.DebugImages.Add(new DebugImage + { Type = "pe_dotnet", CodeFile = "Akron.dll", DebugFile = "Akron.pdb", @@ -198,13 +226,16 @@ internal SentryEvent Sanitize(SentryEvent source) { } } List exceptions = new List(); - foreach (SentryException error in source.SentryExceptions?.Take(8) ?? Enumerable.Empty()) { + foreach (SentryException error in source.SentryExceptions?.Take(8) ?? Enumerable.Empty()) + { SentryStackTrace stack = new SentryStackTrace(); - foreach (SentryStackFrame frame in error.Stacktrace?.Frames?.TakeLast(64) ?? Enumerable.Empty()) { + foreach (SentryStackFrame frame in error.Stacktrace?.Frames?.TakeLast(64) ?? Enumerable.Empty()) + { if (frame.Module?.StartsWith("Celeste.Mod.Akron.", StringComparison.Ordinal) != true) continue; string addressMode = null; if (frame.AddressMode != null) imageIndexes.TryGetValue(frame.AddressMode, out addressMode); - stack.Frames.Add(new SentryStackFrame { + stack.Frames.Add(new SentryStackFrame + { Module = SafeIdentifier(frame.Module), Function = SafeIdentifier(frame.Function), Package = "Akron", @@ -217,7 +248,8 @@ internal SentryEvent Sanitize(SentryEvent source) { InstructionAddress = addressMode == null ? null : frame.InstructionAddress }); } - exceptions.Add(new SentryException { + exceptions.Add(new SentryException + { Type = SafeIdentifier(error.Type), Value = "Akron " + parsed + " failed.", Stacktrace = stack.Frames.Count == 0 ? null : stack, @@ -228,7 +260,8 @@ internal SentryEvent Sanitize(SentryEvent source) { return clean; } - internal static bool TryGetDestination(string dsn, out string host) { + internal static bool TryGetDestination(string dsn, out string host) + { host = string.Empty; if (string.IsNullOrWhiteSpace(dsn) || !Uri.TryCreate(dsn, UriKind.Absolute, out Uri uri) || uri.Scheme != Uri.UriSchemeHttps || string.IsNullOrEmpty(uri.IdnHost) || @@ -243,16 +276,19 @@ private static string SafeVersion(string value) => !string.IsNullOrWhiteSpace(value) && Regex.IsMatch(value, @"^[A-Za-z0-9_.+-]{1,96}$", RegexOptions.CultureInvariant) ? value : "unknown"; private static string SafeIdentifier(string value) => value != null && Identifier.IsMatch(value) ? value : "unknown"; private static string SafeHex(string value) => value != null && HexIdentifier.IsMatch(value) ? value : null; - private static string FileName(string value) { + private static string FileName(string value) + { if (string.IsNullOrEmpty(value)) return null; string name = value.Replace('\\', '/').Split('/').Last(); return Regex.IsMatch(name, @"^[A-Za-z0-9_.-]{1,128}$", RegexOptions.CultureInvariant) ? name : null; } - private sealed class ConsentHandler : DelegatingHandler { + private sealed class ConsentHandler : DelegatingHandler + { private readonly CancellationToken revoked; internal ConsentHandler(CancellationToken revoked, HttpMessageHandler inner) : base(inner) { this.revoked = revoked; } - protected override async Task SendAsync(HttpRequestMessage request, CancellationToken cancellationToken) { + protected override async Task SendAsync(HttpRequestMessage request, CancellationToken cancellationToken) + { if (revoked.IsCancellationRequested) return new HttpResponseMessage(HttpStatusCode.OK); using CancellationTokenSource linked = CancellationTokenSource.CreateLinkedTokenSource(revoked, cancellationToken); return await base.SendAsync(request, linked.Token).ConfigureAwait(false); diff --git a/Source/packages.lock.json b/Source/packages.lock.json index 8e8e6146..91168094 100644 --- a/Source/packages.lock.json +++ b/Source/packages.lock.json @@ -66,7 +66,7 @@ "type": "Direct", "requested": "[6.12.0, )", "resolved": "6.12.0", - "contentHash": "HMD1MxszbFMkQmSNnEwdJa7cjz/vLdn9vKqzVpSa+7PvVeZZqTFOHVrg5dteYlcF7sWjVNRA06D2/jSYkf/sww==" + "contentHash": "7IRSoMWSxzjD204mFrQs0+kU7/ZAQRcqNdiFB8tN9R8CxgwZvP/rrD2nuWmQnFleQwA3yZYcpR8m3EtGfP5x5w==" }, "AsmResolver": { "type": "Transitive", diff --git a/tests/telemetry/packages.lock.json b/tests/telemetry/packages.lock.json index 1cd97197..ed4ca481 100644 --- a/tests/telemetry/packages.lock.json +++ b/tests/telemetry/packages.lock.json @@ -16,7 +16,7 @@ "type": "Direct", "requested": "[6.12.0, )", "resolved": "6.12.0", - "contentHash": "HMD1MxszbFMkQmSNnEwdJa7cjz/vLdn9vKqzVpSa+7PvVeZZqTFOHVrg5dteYlcF7sWjVNRA06D2/jSYkf/sww==" + "contentHash": "7IRSoMWSxzjD204mFrQs0+kU7/ZAQRcqNdiFB8tN9R8CxgwZvP/rrD2nuWmQnFleQwA3yZYcpR8m3EtGfP5x5w==" }, "xunit": { "type": "Direct", diff --git a/tests/telemetry/telemetry-tests.cs b/tests/telemetry/telemetry-tests.cs index 21f278b5..8262fb5d 100644 --- a/tests/telemetry/telemetry-tests.cs +++ b/tests/telemetry/telemetry-tests.cs @@ -19,7 +19,8 @@ namespace Celeste.Mod.Akron.Tests; -public sealed class TelemetryTests { +public sealed class TelemetryTests +{ private const string Dsn = "https://publickey@example.invalid/1"; [Theory] @@ -28,23 +29,30 @@ public sealed class TelemetryTests { [InlineData(true, "http://publickey@example.invalid/1")] [InlineData(true, "https://publickey:secret@example.invalid/1")] [InlineData(true, "https://publickey@example.invalid/1?secret=value")] - public void MissingConsentOrValidDsnNeverStartsReporting(bool consent, string dsn) { + public void MissingConsentOrValidDsnNeverStartsReporting(bool consent, string dsn) + { RecordingHandler transport = new RecordingHandler(); - try { + try + { AkronTelemetry.Configure(consent, dsn, "1.2.3", transport); AkronTelemetry.Capture(BuildFailure(), AkronFailurePhase.Startup); Assert.False(AkronTelemetry.IsEnabled); Assert.Empty(transport.Bodies); - } finally { + } + finally + { AkronTelemetry.Stop(flush: false); } } [Fact] - public void SanitizerPreservesIdentityAndOnlyAllowsAkronDiagnosticFields() { + public void SanitizerPreservesIdentityAndOnlyAllowsAkronDiagnosticFields() + { AkronErrorReporter reporter = new AkronErrorReporter(Dsn, "1.2.3", new RecordingHandler()); - try { - SentryEvent source = new SentryEvent(BuildFailure()) { + try + { + SentryEvent source = new SentryEvent(BuildFailure()) + { ServerName = "private-machine", Release = "private-version", Environment = "private-environment", @@ -89,15 +97,18 @@ public void SanitizerPreservesIdentityAndOnlyAllowsAkronDiagnosticFields() { Assert.Equal(5, frame.FunctionId); Assert.Equal(7, frame.InstructionAddress); Assert.Equal("Akron.dll", Assert.Single(clean.DebugImages).CodeFile); - } finally { reporter.Stop(flush: false); } + } + finally { reporter.Stop(flush: false); } } [Fact] - public async Task ActualSdkEnvelopeIsScrubbedAndUsesTheReturnedEventId() { + public async Task ActualSdkEnvelopeIsScrubbedAndUsesTheReturnedEventId() + { RecordingHandler transport = new RecordingHandler(); bool globalWasEnabled = SentrySdk.IsEnabled; AkronErrorReporter reporter = new AkronErrorReporter(Dsn, "1.2.3", transport); - try { + try + { SentryId id = reporter.Capture(BuildFailure(), AkronFailurePhase.StartPosPersist); await reporter.FlushAsync(); string body = Assert.Single(transport.Bodies); @@ -106,42 +117,53 @@ public async Task ActualSdkEnvelopeIsScrubbedAndUsesTheReturnedEventId() { Assert.Contains("StartPosPersist", body); Assert.DoesNotContain("private", body, StringComparison.OrdinalIgnoreCase); Assert.Equal(globalWasEnabled, SentrySdk.IsEnabled); - } finally { reporter.Stop(flush: false); } + } + finally { reporter.Stop(flush: false); } } [Fact] - public async Task RepeatedRendererFailuresAndCancellationDoNotFloodReports() { + public async Task RepeatedRendererFailuresAndCancellationDoNotFloodReports() + { RecordingHandler transport = new RecordingHandler(); AkronErrorReporter reporter = new AkronErrorReporter(Dsn, "1.2.3", transport); - try { + try + { Assert.NotEqual(SentryId.Empty, reporter.Capture(BuildFailure(), AkronFailurePhase.Overlay)); - for (int index = 0; index < 100; index++) { + for (int index = 0; index < 100; index++) + { Assert.Equal(SentryId.Empty, reporter.Capture(BuildFailure(), AkronFailurePhase.Overlay)); } Assert.Equal(SentryId.Empty, reporter.Capture(new OperationCanceledException(), AkronFailurePhase.StartPosPersist)); Assert.NotEqual(SentryId.Empty, reporter.Capture(BuildFailure(), AkronFailurePhase.StartPosRestore)); await reporter.FlushAsync(); Assert.Equal(2, transport.Bodies.Count); - } finally { reporter.Stop(flush: false); } + } + finally { reporter.Stop(flush: false); } } [Fact] - public async Task IndependentFailuresHaveASessionLimit() { + public async Task IndependentFailuresHaveASessionLimit() + { RecordingHandler transport = new RecordingHandler(); AkronErrorReporter reporter = new AkronErrorReporter(Dsn, "1.2.3", transport); - try { - foreach (AkronFailurePhase phase in Enum.GetValues()) { - foreach (Exception exception in new Exception[] { new IOException(), new ArgumentException(), new InvalidOperationException(), new NotSupportedException() }) { + try + { + foreach (AkronFailurePhase phase in Enum.GetValues()) + { + foreach (Exception exception in new Exception[] { new IOException(), new ArgumentException(), new InvalidOperationException(), new NotSupportedException() }) + { reporter.Capture(exception, phase); } } await reporter.FlushAsync(); Assert.Equal(AkronErrorReporter.MaxReportsPerSession, transport.Bodies.Count); - } finally { reporter.Stop(flush: false); } + } + finally { reporter.Stop(flush: false); } } [Fact] - public async Task RevokingConsentCancelsInFlightAndPreventsQueuedSends() { + public async Task RevokingConsentCancelsInFlightAndPreventsQueuedSends() + { BlockingHandler transport = new BlockingHandler(); AkronErrorReporter reporter = new AkronErrorReporter(Dsn, "1.2.3", transport); reporter.Capture(BuildFailure(), AkronFailurePhase.Overlay); @@ -155,7 +177,8 @@ public async Task RevokingConsentCancelsInFlightAndPreventsQueuedSends() { } [Fact] - public async Task ShutdownIsBoundedAndIdempotent() { + public async Task ShutdownIsBoundedAndIdempotent() + { BlockingHandler transport = new BlockingHandler(); AkronErrorReporter reporter = new AkronErrorReporter(Dsn, "1.2.3", transport); reporter.Capture(BuildFailure(), AkronFailurePhase.Overlay); @@ -167,23 +190,28 @@ public async Task ShutdownIsBoundedAndIdempotent() { await transport.Canceled.Task.WaitAsync(TimeSpan.FromSeconds(5)); } - private static Exception BuildFailure() { + private static Exception BuildFailure() + { try { throw new IOException("private-user /home/private-user/file secret=private-token"); } - catch (Exception exception) { + catch (Exception exception) + { exception.Data["private-key"] = "private-value"; return exception; } } - private static string Serialize(SentryEvent entry) { + private static string Serialize(SentryEvent entry) + { using MemoryStream stream = new MemoryStream(); using (Utf8JsonWriter writer = new Utf8JsonWriter(stream)) entry.WriteTo(writer, null); return Encoding.UTF8.GetString(stream.ToArray()); } - private sealed class RecordingHandler : HttpMessageHandler { + private sealed class RecordingHandler : HttpMessageHandler + { internal ConcurrentQueue Bodies { get; } = new ConcurrentQueue(); - protected override async Task SendAsync(HttpRequestMessage request, CancellationToken token) { + protected override async Task SendAsync(HttpRequestMessage request, CancellationToken token) + { using Stream content = await request.Content.ReadAsStreamAsync(token); using Stream decoded = request.Content.Headers.ContentEncoding.Contains("gzip") ? new GZipStream(content, CompressionMode.Decompress) : content; using StreamReader reader = new StreamReader(decoded); @@ -192,11 +220,13 @@ protected override async Task SendAsync(HttpRequestMessage } } - private sealed class BlockingHandler : HttpMessageHandler { + private sealed class BlockingHandler : HttpMessageHandler + { internal TaskCompletionSource Started { get; } = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); internal TaskCompletionSource Canceled { get; } = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); internal int StartCount; - protected override async Task SendAsync(HttpRequestMessage request, CancellationToken token) { + protected override async Task SendAsync(HttpRequestMessage request, CancellationToken token) + { Interlocked.Increment(ref StartCount); Started.TrySetResult(true); try { await Task.Delay(Timeout.Infinite, token); } From 51863c0936202ab87dce1523c777054963ee5988 Mon Sep 17 00:00:00 2001 From: Microck Date: Sat, 10 Oct 2026 14:20:38 +0200 Subject: [PATCH 04/10] fix: own and dispose the mod telemetry queue and transport --- .github/workflows/ci.yml | 21 +--- Source/Core/akron-telemetry-worker.cs | 106 +++++++++++++++++++ Source/Core/akron-telemetry.cs | 29 ++++- Source/Module/akron-module-telemetry.cs | 19 ++-- docs/reference/error-reporting.mdx | 4 +- tests/telemetry/akron-telemetry-tests.csproj | 1 + tests/telemetry/telemetry-tests.cs | 76 +++++++++++++ 7 files changed, 225 insertions(+), 31 deletions(-) create mode 100644 Source/Core/akron-telemetry-worker.cs diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 7561dd46..e9153b13 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -29,6 +29,8 @@ jobs: dotnet-version: 8.0.418 - name: Restore locked telemetry tests run: dotnet restore tests/telemetry/akron-telemetry-tests.csproj --locked-mode --nologo + - name: Check telemetry formatting + run: dotnet format tests/telemetry/akron-telemetry-tests.csproj --no-restore --verify-no-changes --include Source/Core/akron-telemetry.cs Source/Core/akron-telemetry-worker.cs tests/telemetry/telemetry-tests.cs - name: Verify consent, sanitized SDK envelopes, deduplication and shutdown run: dotnet test tests/telemetry/akron-telemetry-tests.csproj --configuration Release --no-restore --nologo @@ -71,25 +73,6 @@ jobs: - name: Build run: dotnet build Akron.sln --configuration Release --no-restore --nologo -p:ContinuousIntegrationBuild=true - - name: Capture targeted mod formatting evidence for this draft - run: | - mkdir -p validation - changed_files=(Source/Core/akron-telemetry.cs Source/Module/akron-module-telemetry.cs Source/Module/AkronModuleSettings.cs Source/Module/AkronModule.cs Source/Actions/akron-startpos-persistence.cs Source/Overlay/akron-imgui-renderer.cs Source/SaveLoad/AkronSaveLoad.cs Source/Module/akron-module-menu.cs Source/Overlay/akron-overlay-entries.cs Source/Overlay/akron-overlay-copy.cs tests/module-settings-tests.cs) - format_status=0 - dotnet format Akron.sln --no-restore --include "${changed_files[@]}" > validation/mod-format.log 2>&1 || format_status=$? - git diff -- "${changed_files[@]}" > validation/mod-format.patch - git restore -- "${changed_files[@]}" - cat validation/mod-format.log - exit "$format_status" - - - name: Upload mod formatting evidence - if: always() - uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4 - with: - name: mod-formatting - path: validation/mod-format.* - retention-days: 3 - - name: Test run: dotnet test tests/akron-tests.csproj --configuration Release --no-build --nologo diff --git a/Source/Core/akron-telemetry-worker.cs b/Source/Core/akron-telemetry-worker.cs new file mode 100644 index 00000000..1550868a --- /dev/null +++ b/Source/Core/akron-telemetry-worker.cs @@ -0,0 +1,106 @@ +using System; +using System.Globalization; +using System.Net; +using System.Net.Http; +using System.Threading; +using System.Threading.Tasks; +using Sentry; +using Sentry.Extensibility; +using Sentry.Http; +using Sentry.Protocol.Envelopes; + +namespace Celeste.Mod.Akron; + +// SentryClient.Dispose only flushes its default worker. Own the public worker and +// transport extension points so an unloaded mod cannot leave an idle SDK task behind. +internal sealed class AkronTelemetryWorker : HttpTransportBase, IBackgroundWorker, IDisposable +{ + private readonly object sync = new object(); + private readonly HttpClient httpClient; + private readonly CancellationTokenSource shutdown = new CancellationTokenSource(); + private readonly int limit; + private Task tail = Task.CompletedTask; + private Task completion = Task.CompletedTask; + private int queued; + private bool stopped; + + internal AkronTelemetryWorker(SentryOptions options, HttpMessageHandler handler) : base(options) + { + limit = options.MaxQueueItems; + httpClient = new HttpClient(handler) { Timeout = TimeSpan.FromSeconds(10) }; + } + + public int QueuedItems => Volatile.Read(ref queued); + internal Task Completion { get { lock (sync) return completion; } } + + public bool EnqueueEnvelope(Envelope envelope) + { + lock (sync) + { + if (stopped || queued >= limit) return false; + Interlocked.Increment(ref queued); + Task previous = tail; + // Only accepted events schedule work. There is no task waiting on an empty queue. + tail = Task.Run(() => SendAfterAsync(previous, envelope)); + return true; + } + } + + private async Task SendAfterAsync(Task previous, Envelope envelope) + { + try + { + await previous.ConfigureAwait(false); + if (shutdown.IsCancellationRequested) return; + Envelope processed = ProcessEnvelope(envelope); + if (processed.Items.Count == 0) return; + using HttpRequestMessage request = CreateRequest(processed); + using HttpResponseMessage response = await httpClient.SendAsync(request, shutdown.Token).ConfigureAwait(false); + // The SDK's default HTTP handler handles plain 429s. Normalize them for + // the public transport base, which already applies Sentry rate-limit headers. + if (response.StatusCode == HttpStatusCode.TooManyRequests && !response.Headers.Contains("X-Sentry-Rate-Limits")) + { + double seconds = response.Headers.RetryAfter?.Delta?.TotalSeconds ?? + (response.Headers.RetryAfter?.Date - DateTimeOffset.UtcNow)?.TotalSeconds ?? 60; + string delay = Math.Ceiling(Math.Clamp(seconds, 0, int.MaxValue)).ToString(CultureInfo.InvariantCulture); + response.Headers.TryAddWithoutValidation("X-Sentry-Rate-Limits", delay + "::organization"); + } + await HandleResponseAsync(response, processed, shutdown.Token).ConfigureAwait(false); + } + catch (Exception) + { + // Reporting is best effort. Cancellation and failed requests never escape to gameplay. + } + finally + { + envelope.Dispose(); + Interlocked.Decrement(ref queued); + } + } + + public async Task FlushAsync(TimeSpan timeout) + { + Task pending; + lock (sync) pending = tail; + try { await pending.WaitAsync(timeout).ConfigureAwait(false); } + catch (TimeoutException) { } + } + + public void Dispose() + { + lock (sync) + { + if (stopped) return; + stopped = true; + shutdown.Cancel(); + httpClient.Dispose(); + completion = FinishDisposalAsync(tail); + } + } + + private async Task FinishDisposalAsync(Task pending) + { + try { await pending.ConfigureAwait(false); } + finally { shutdown.Dispose(); } + } +} diff --git a/Source/Core/akron-telemetry.cs b/Source/Core/akron-telemetry.cs index 00101e89..8326034b 100644 --- a/Source/Core/akron-telemetry.cs +++ b/Source/Core/akron-telemetry.cs @@ -96,7 +96,9 @@ internal sealed class AkronErrorReporter private readonly HashSet reported = new HashSet(StringComparer.Ordinal); private readonly CancellationTokenSource revoked = new CancellationTokenSource(); private readonly SentryClient client; + private readonly AkronTelemetryWorker worker; private readonly string release; + private Task shutdown = Task.CompletedTask; private bool stopped; internal AkronErrorReporter(string dsn, string version, HttpMessageHandler transport = null) @@ -123,13 +125,20 @@ internal AkronErrorReporter(string dsn, string version, HttpMessageHandler trans DisableFileWrite = true, DisableSentryHttpMessageHandler = true, ShutdownTimeout = ShutdownTimeout, - FlushTimeout = ShutdownTimeout, - CreateHttpMessageHandler = () => new ConsentHandler(revoked.Token, transport ?? new HttpClientHandler { AllowAutoRedirect = false }) + FlushTimeout = ShutdownTimeout }; + worker = new AkronTelemetryWorker(options, + new ConsentHandler(revoked.Token, transport ?? new HttpClientHandler { AllowAutoRedirect = false })); + options.BackgroundWorker = worker; options.AddInAppInclude("Celeste.Mod.Akron."); options.SetBeforeSend(Sanitize); // The standalone client does not register SDK integrations or change the global hub. - client = new SentryClient(options); + try { client = new SentryClient(options); } + catch + { + worker.Dispose(); + throw; + } } internal SentryId Capture(Exception exception, AkronFailurePhase phase) @@ -169,7 +178,7 @@ internal void Stop(bool flush) // cannot be recalled. Do not wait for network delivery while the player changes consent. if (!flush) { - _ = Task.Run(DisposeClient); + shutdown = Task.Run(DisposeClient); return; } DisposeClient(); @@ -179,11 +188,21 @@ private void DisposeClient() { try { client.Dispose(); } catch (Exception) { } - finally { revoked.Cancel(); } + finally + { + revoked.Cancel(); + worker.Dispose(); + } } internal Task FlushAsync() => client.FlushAsync(ShutdownTimeout); + internal async Task WaitForShutdownAsync() + { + await shutdown.ConfigureAwait(false); + await worker.Completion.ConfigureAwait(false); + } + // A new event is an allowlist, not a blacklist: exception messages/data, user identity, // request data, breadcrumbs, host/device context, other mods and raw paths cannot survive. internal SentryEvent Sanitize(SentryEvent source) diff --git a/Source/Module/akron-module-telemetry.cs b/Source/Module/akron-module-telemetry.cs index 1a535363..54ed91d8 100644 --- a/Source/Module/akron-module-telemetry.cs +++ b/Source/Module/akron-module-telemetry.cs @@ -3,22 +3,29 @@ namespace Celeste.Mod.Akron; -public partial class AkronModule { - private static void ConfigureErrorReporting() { - try { +public partial class AkronModule +{ + private static void ConfigureErrorReporting() + { + try + { AkronTelemetry.Configure(TryGetSettings()?.ErrorReportingEnabled == true, AkronTelemetry.ResolveDsn(), Instance?.Metadata?.VersionString); - } catch (Exception) { + } + catch (Exception) + { // A broken telemetry configuration must not break the mod. } } - internal static void SetErrorReportingEnabled(bool enabled) { + internal static void SetErrorReportingEnabled(bool enabled) + { AkronModuleSettings settings = TryGetSettings(); if (settings == null || (enabled && !AkronTelemetry.IsConfigured)) return; settings.ErrorReportingEnabled = enabled; if (!enabled) AkronTelemetry.Stop(flush: false); - if (!SaveAkronSettingsNow("error reporting consent")) { + if (!SaveAkronSettingsNow("error reporting consent")) + { settings.ErrorReportingEnabled = false; AkronTelemetry.Stop(flush: false); Engine.Scene?.Add(new AkronToast("Error reports are off. Could not save this preference; check it after restarting.")); diff --git a/docs/reference/error-reporting.mdx b/docs/reference/error-reporting.mdx index 2468f45f..d745d5ec 100644 --- a/docs/reference/error-reporting.mdx +++ b/docs/reference/error-reporting.mdx @@ -19,6 +19,8 @@ Local Logging and the explicit **Send diagnostics** flow remain separate. Automa The SDK is pinned to Sentry .NET `6.12.0`. An absent DSN keeps reporting inert, including in development and CI. Never put an auth token in a build or player setting. +Akron owns the SDK's queue and HTTP client through its public worker/transport extension points. Opt-out and unload cancel pending work and dispose those resources; the standalone SDK client does not create a retained background worker. Sentry rate-limit headers and plain HTTP 429 responses are honored. + For a local build, set the public ingest DSN through the MSBuild property `AkronSentryDsn`. For a local run, `AKRON_SENTRY_DSN` overrides the compiled value; setting that environment variable to an empty string disables the destination. Only HTTPS DSNs with a public key and numeric project ID are accepted. A configured destination still requires the player's explicit opt-in. For official releases, configure these values in GitHub's `release-build` environment: @@ -41,6 +43,6 @@ dotnet restore tests/telemetry/akron-telemetry-tests.csproj --locked-mode dotnet test tests/telemetry/akron-telemetry-tests.csproj --configuration Release --no-restore ``` -The suite checks disabled reporting, payload redaction through the actual SDK, stable event identity, debug-image index remapping, renderer deduplication, session limits, consent revocation, and bounded shutdown. The full mod build and settings tests still require the project's verified Celeste reference archive. +The suite checks disabled reporting, payload redaction through the actual SDK, stable event identity, debug-image index remapping, renderer deduplication, session limits, consent revocation, bounded shutdown, resource disposal across repeated reporting sessions, and server rate limits. The full mod build and settings tests still require the project's verified Celeste reference archive. Before release, a maintainer should test the visible controls in Celeste/Everest, confirm a controlled handled error in a dedicated Sentry test project, verify its source line against the release symbols, turn reporting off and verify no further requests, and check both normal quit and mod unload. Do not manufacture a gameplay crash to test reporting. diff --git a/tests/telemetry/akron-telemetry-tests.csproj b/tests/telemetry/akron-telemetry-tests.csproj index ae747243..69b0640a 100644 --- a/tests/telemetry/akron-telemetry-tests.csproj +++ b/tests/telemetry/akron-telemetry-tests.csproj @@ -8,6 +8,7 @@ + diff --git a/tests/telemetry/telemetry-tests.cs b/tests/telemetry/telemetry-tests.cs index 8262fb5d..315c7101 100644 --- a/tests/telemetry/telemetry-tests.cs +++ b/tests/telemetry/telemetry-tests.cs @@ -172,8 +172,10 @@ public async Task RevokingConsentCancelsInFlightAndPreventsQueuedSends() reporter.Stop(flush: false); await transport.Canceled.Task.WaitAsync(TimeSpan.FromSeconds(5)); await reporter.FlushAsync(); + await reporter.WaitForShutdownAsync().WaitAsync(TimeSpan.FromSeconds(5)); Assert.Equal(SentryId.Empty, reporter.Capture(BuildFailure(), AkronFailurePhase.Content)); Assert.Equal(1, transport.StartCount); + Assert.Equal(1, transport.DisposeCount); } [Fact] @@ -188,6 +190,50 @@ public async Task ShutdownIsBoundedAndIdempotent() reporter.Stop(flush: true); Assert.True(timer.Elapsed < TimeSpan.FromSeconds(2)); await transport.Canceled.Task.WaitAsync(TimeSpan.FromSeconds(5)); + await reporter.WaitForShutdownAsync().WaitAsync(TimeSpan.FromSeconds(5)); + Assert.Equal(1, transport.DisposeCount); + } + + [Theory] + [InlineData(false)] + [InlineData(true)] + public async Task RepeatedConsentAndUnloadCyclesReleaseOwnedResources(bool flush) + { + for (int cycle = 0; cycle < 3; cycle++) + { + RecordingHandler transport = new RecordingHandler(); + AkronErrorReporter reporter = new AkronErrorReporter(Dsn, "1.2.3", transport); + reporter.Capture(BuildFailure(), AkronFailurePhase.Overlay); + await reporter.FlushAsync(); + reporter.Stop(flush); + reporter.Stop(flush); + await reporter.WaitForShutdownAsync().WaitAsync(TimeSpan.FromSeconds(5)); + Assert.Equal(1, transport.DisposeCount); + Assert.Single(transport.Bodies); + Assert.Equal(SentryId.Empty, reporter.Capture(BuildFailure(), AkronFailurePhase.Content)); + } + } + + [Theory] + [InlineData(false)] + [InlineData(true)] + public async Task OwnedTransportHonorsSentryAndRetryAfterRateLimits(bool plain429) + { + RateLimitedHandler transport = new RateLimitedHandler(plain429); + AkronErrorReporter reporter = new AkronErrorReporter(Dsn, "1.2.3", transport); + try + { + reporter.Capture(BuildFailure(), AkronFailurePhase.Overlay); + await reporter.FlushAsync(); + reporter.Capture(BuildFailure(), AkronFailurePhase.Content); + await reporter.FlushAsync(); + Assert.Equal(1, transport.SendCount); + } + finally + { + reporter.Stop(flush: false); + await reporter.WaitForShutdownAsync().WaitAsync(TimeSpan.FromSeconds(5)); + } } private static Exception BuildFailure() @@ -209,7 +255,13 @@ private static string Serialize(SentryEvent entry) private sealed class RecordingHandler : HttpMessageHandler { + internal int DisposeCount; internal ConcurrentQueue Bodies { get; } = new ConcurrentQueue(); + protected override void Dispose(bool disposing) + { + Interlocked.Increment(ref DisposeCount); + base.Dispose(disposing); + } protected override async Task SendAsync(HttpRequestMessage request, CancellationToken token) { using Stream content = await request.Content.ReadAsStreamAsync(token); @@ -225,6 +277,12 @@ private sealed class BlockingHandler : HttpMessageHandler internal TaskCompletionSource Started { get; } = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); internal TaskCompletionSource Canceled { get; } = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); internal int StartCount; + internal int DisposeCount; + protected override void Dispose(bool disposing) + { + Interlocked.Increment(ref DisposeCount); + base.Dispose(disposing); + } protected override async Task SendAsync(HttpRequestMessage request, CancellationToken token) { Interlocked.Increment(ref StartCount); @@ -234,4 +292,22 @@ protected override async Task SendAsync(HttpRequestMessage return new HttpResponseMessage(HttpStatusCode.OK); } } + + private sealed class RateLimitedHandler : HttpMessageHandler + { + private readonly bool plain429; + internal int SendCount; + internal RateLimitedHandler(bool plain429) { this.plain429 = plain429; } + protected override Task SendAsync(HttpRequestMessage request, CancellationToken token) + { + Interlocked.Increment(ref SendCount); + HttpResponseMessage response = new HttpResponseMessage(plain429 ? HttpStatusCode.TooManyRequests : HttpStatusCode.OK) + { + Content = new StringContent("{}") + }; + response.Headers.TryAddWithoutValidation(plain429 ? "Retry-After" : "X-Sentry-Rate-Limits", + plain429 ? "60" : "60:error:organization:quota"); + return Task.FromResult(response); + } + } } From 3b89895de2913b556a36fa79fc1419f64e946a4c Mon Sep 17 00:00:00 2001 From: Microck Date: Sat, 10 Oct 2026 17:22:16 +0000 Subject: [PATCH 05/10] feat: default automatic error reports on with a visible opt-out notice --- CHANGELOG.md | 2 +- Source/Module/AkronModule.cs | 1 + Source/Module/AkronModuleSettings.cs | 4 ++-- Source/Module/akron-module-menu.cs | 6 +++--- Source/Module/akron-module-telemetry.cs | 13 +++++++++++++ Source/Overlay/akron-overlay-copy.cs | 2 +- Source/Overlay/akron-overlay-entries.cs | 4 ++-- docs/feature-guide/interface.mdx | 2 +- docs/reference/error-reporting.mdx | 8 +++++--- tests/akron-tests.csproj | 2 ++ tests/module-settings-tests.cs | 14 ++++++++++++-- tests/packages.lock.json | 6 ++++++ 12 files changed, 49 insertions(+), 15 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index c5c80dfb..fb9e545c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,7 +8,7 @@ This project uses version tags that match the mod version in `everest.yaml`, whi ### Added -- Add optional Automatic Error Reports for selected Akron startup, overlay, settings and StartPos failures. Reporting is off by default, requires a configured Sentry destination, and sends only bounded Akron stack details and the mod version. Local logging and Send diagnostics keep their existing behavior. +- Add Automatic Error Reports for selected Akron startup, overlay, settings and StartPos failures. Reporting is on by default when a Sentry destination is configured, with a launch notice and an opt-out in Interface and mod options. Existing saved Off choices are preserved. Reports send only bounded Akron stack details and the mod version. Local logging and Send diagnostics keep their existing behavior. ## Akron Beta 84 diff --git a/Source/Module/AkronModule.cs b/Source/Module/AkronModule.cs index c53fe10c..56f9a781 100644 --- a/Source/Module/AkronModule.cs +++ b/Source/Module/AkronModule.cs @@ -804,6 +804,7 @@ private static AreaModeStats TryGetAreaModeStats(AreaKey area) { private static void EngineOnUpdate(On.Monocle.Engine.orig_Update orig, Engine self, GameTime gameTime) { AkronDiagnosticsMenu.CloseIfSceneChanged(Engine.Scene); + ShowErrorReportingNotice(Engine.Scene); // First thing in the hook, so the recorded interval spans a whole engine // update including everything Akron itself adds to the frame. AkronPerformanceTelemetry.RecordUpdateFrame(); diff --git a/Source/Module/AkronModuleSettings.cs b/Source/Module/AkronModuleSettings.cs index 50c9854c..a4d13e2e 100644 --- a/Source/Module/AkronModuleSettings.cs +++ b/Source/Module/AkronModuleSettings.cs @@ -138,8 +138,8 @@ public void ApplySubmissionMode(bool enabled) { public int OverlayScale { get; set; } = 100; public int OverlayBlur { get; set; } public bool Logging { get; set; } = true; - // Machine-local consent: intentionally absent from AkronSetupState and .akr setup packs. - public bool ErrorReportingEnabled { get; set; } = false; + // Machine-local preference: intentionally absent from AkronSetupState and .akr setup packs. + public bool ErrorReportingEnabled { get; set; } = true; public AkronLoggingLevel LoggingLevel { get; set; } = AkronLoggingLevel.Diagnostic; public bool LoggingMirrorWarningsToEverest { get; set; } = true; public int LoggingMaxFileSizeMb { get; set; } = 5; diff --git a/Source/Module/akron-module-menu.cs b/Source/Module/akron-module-menu.cs index bdc16d14..9d6280f7 100644 --- a/Source/Module/akron-module-menu.cs +++ b/Source/Module/akron-module-menu.cs @@ -25,10 +25,10 @@ public override void CreateModMenuSection(TextMenu menu, bool inGame, EventInsta menu.Add(new TextMenu.SubHeader("Support")); AddWrappedModMenuSubHeaders(menu, AkronTelemetry.IsConfigured - ? "Automatic error reports send Akron stack frames and its version to Sentry at " + AkronTelemetry.Destination + ". No logs, exception messages, names, save contents or full paths are included." + ? "Automatic error reports are on by default. Turn off below. Reports send Akron stack frames and its version to Sentry at " + AkronTelemetry.Destination + ". No logs, exception messages, names, save contents or full paths are included." : "Automatic error reports are not configured in this build."); - menu.Add(new TextMenu.OnOff("Automatic Error Reports", Settings.ErrorReportingEnabled) { - Disabled = !AkronTelemetry.IsConfigured && !Settings.ErrorReportingEnabled + menu.Add(new TextMenu.OnOff("Automatic Error Reports", AkronTelemetry.IsConfigured && Settings.ErrorReportingEnabled) { + Disabled = !AkronTelemetry.IsConfigured }.Change(SetErrorReportingEnabled)); menu.Add(new TextMenu.Button("Send diagnostics").Pressed(() => AkronDiagnosticsMenu.Open(menu))); } diff --git a/Source/Module/akron-module-telemetry.cs b/Source/Module/akron-module-telemetry.cs index 54ed91d8..3bdfc743 100644 --- a/Source/Module/akron-module-telemetry.cs +++ b/Source/Module/akron-module-telemetry.cs @@ -1,10 +1,13 @@ using System; +using Celeste; using Monocle; namespace Celeste.Mod.Akron; public partial class AkronModule { + private static bool errorReportingNoticeShown; + private static void ConfigureErrorReporting() { try @@ -18,6 +21,16 @@ private static void ConfigureErrorReporting() } } + private static void ShowErrorReportingNotice(Scene scene) + { + // Wait for a player-facing scene with fonts loaded, not the startup loader. + if (errorReportingNoticeShown || !AkronTelemetry.IsEnabled || scene is not (Overworld or Level)) return; + errorReportingNoticeShown = true; + scene.Add(new AkronToast( + "Automatic Error Reports are on. Akron stack details and version go to Sentry.\nTurn off: Akron > Interface > Automatic Error Reports.", + forceVisible: true, durationSeconds: 8f)); + } + internal static void SetErrorReportingEnabled(bool enabled) { AkronModuleSettings settings = TryGetSettings(); diff --git a/Source/Overlay/akron-overlay-copy.cs b/Source/Overlay/akron-overlay-copy.cs index f1d41b63..cf91f543 100644 --- a/Source/Overlay/akron-overlay-copy.cs +++ b/Source/Overlay/akron-overlay-copy.cs @@ -333,7 +333,7 @@ public sealed partial class AkronOverlay { ["Opacity"] = "Set Akron overlay background opacity.", ["Streamer Mode"] = "Show filenames instead of full local filesystem paths.", ["Logging"] = "Write local Akron diagnostic logs for debugging.", - ["Automatic Error Reports"] = "Send selected Akron errors to Sentry. Off by default; reports contain Akron stack frames and the mod version, without logs, exception messages, names, save contents or full paths.", + ["Automatic Error Reports"] = "Send selected Akron errors to Sentry. On by default when configured; turn off here. Reports contain Akron stack frames and the mod version, without logs, exception messages, names, save contents or full paths.", ["Room Capture"] = "Capture overlapping tiles across the current room.", ["Map Capture"] = "Capture each room in the current map.", ["Search Autofocus"] = "Focus Akron search automatically when the overlay opens.", diff --git a/Source/Overlay/akron-overlay-entries.cs b/Source/Overlay/akron-overlay-entries.cs index f0dbf1d2..5708de66 100644 --- a/Source/Overlay/akron-overlay-entries.cs +++ b/Source/Overlay/akron-overlay-entries.cs @@ -559,11 +559,11 @@ private static OverlayEntry Toggle(string label, AkronFeatureKind? featureKind, private static OverlayEntry ErrorReportingToggle() { return new OverlayEntry( "Automatic Error Reports", - () => AkronTelemetry.IsConfigured || AkronModule.Settings.ErrorReportingEnabled, + () => AkronTelemetry.IsConfigured, () => AkronTelemetry.IsConfigured ? (AkronModule.Settings.ErrorReportingEnabled ? "On" : "Off") : "Not configured", () => AkronModule.SetErrorReportingEnabled(!AkronModule.Settings.ErrorReportingEnabled), BuildSearchTerms("Automatic Error Reports", new[] { "sentry", "errors", "support", "privacy", "consent" }), - true, OverlayEntryControl.Toggle, null, active: () => AkronModule.Settings.ErrorReportingEnabled); + true, OverlayEntryControl.Toggle, null, active: () => AkronTelemetry.IsConfigured && AkronModule.Settings.ErrorReportingEnabled); } private static OverlayEntry LoggingToggle() { diff --git a/docs/feature-guide/interface.mdx b/docs/feature-guide/interface.mdx index eab4762d..df91ebb7 100644 --- a/docs/feature-guide/interface.mdx +++ b/docs/feature-guide/interface.mdx @@ -18,7 +18,7 @@ The Interface tab controls the Akron overlay itself and the public `.akr` pack w | Block Gameplay Input | Toggle | Keep gameplay input from reaching Madeline while the overlay is open. On by default. | None | | Streamer Mode | Toggle | Replace full local filesystem paths with filenames. | None | | Logging | Toggle | Write local Akron diagnostic logs to `Saves/AkronLogs`. | Goldberry/Hardlist clear | -| Automatic Error Reports | Toggle | Opt in to selected Akron error reports through Sentry. Off by default; unavailable without a configured destination. See [error reporting](/reference/error-reporting) for the exact data sent. | None | +| Automatic Error Reports | Toggle | Send selected Akron error reports through Sentry. On by default when configured; turn off here. Unavailable without a configured destination. See [error reporting](/reference/error-reporting) for the exact data sent. | None | | Send diagnostics | Action | Describe a problem, review consent, and send bounded logs, mod versions and system specs to Akron's private Discord support channel. | None | | Search Autofocus | Toggle | Focus Akron search automatically when the overlay opens. | None | | Search | Text input | Filter visible Akron rows by label or search alias. | None | diff --git a/docs/reference/error-reporting.mdx b/docs/reference/error-reporting.mdx index d745d5ec..5be99ac3 100644 --- a/docs/reference/error-reporting.mdx +++ b/docs/reference/error-reporting.mdx @@ -5,9 +5,11 @@ description: Choose whether Akron sends selected errors to Sentry and configure ## Player choice -**Automatic Error Reports** is off by default. Enable it in the Interface tab or Akron's mod options under Support. Mod options show the configured destination. The control is unavailable when no valid Sentry destination is configured. +**Automatic Error Reports** is on by default when a valid Sentry destination is configured. Turn it off in the Interface tab or Akron's mod options under Support. Mod options show the configured destination. The control is unavailable when no valid destination is configured, and no reports are sent. -The preference is stored only in your local mod settings as `ErrorReportingEnabled`. Importing a `.akr` pack cannot enable it or replace your choice. Turning it off immediately stops new sends and cancels pending requests; a report already sent cannot be recalled. +When reporting is active, a notice appears once per game launch on the first main-menu or gameplay screen. It explains what is sent and where to turn reporting off. Handled errors keep their existing player-facing behavior; automatic reporting does not add an error popup. + +The preference is stored only in your local mod settings as `ErrorReportingEnabled`. An existing saved Off choice remains Off. Importing a `.akr` pack cannot enable it or replace your choice. Turning it off immediately stops new sends and cancels pending requests; a report already sent cannot be recalled. Automatic reports contain the failure phase, exception type, Akron version, Akron source filenames and method/line details, and identifiers used to resolve that build's debug symbols. They exclude exception messages and data, local directory paths, usernames, machine names, map/save content, other mods' stack frames, HTTP bodies and URLs, breadcrumbs, and log attachments. The receiving server necessarily sees the network connection's IP address; the SDK does not add it as a user field. @@ -21,7 +23,7 @@ The SDK is pinned to Sentry .NET `6.12.0`. An absent DSN keeps reporting inert, Akron owns the SDK's queue and HTTP client through its public worker/transport extension points. Opt-out and unload cancel pending work and dispose those resources; the standalone SDK client does not create a retained background worker. Sentry rate-limit headers and plain HTTP 429 responses are honored. -For a local build, set the public ingest DSN through the MSBuild property `AkronSentryDsn`. For a local run, `AKRON_SENTRY_DSN` overrides the compiled value; setting that environment variable to an empty string disables the destination. Only HTTPS DSNs with a public key and numeric project ID are accepted. A configured destination still requires the player's explicit opt-in. +For a local build, set the public ingest DSN through the MSBuild property `AkronSentryDsn`. For a local run, `AKRON_SENTRY_DSN` overrides the compiled value; setting that environment variable to an empty string disables the destination. Only HTTPS DSNs with a public key and numeric project ID are accepted. Reporting follows the player's local setting, which defaults to On. For official releases, configure these values in GitHub's `release-build` environment: diff --git a/tests/akron-tests.csproj b/tests/akron-tests.csproj index b1941da7..ba347d7b 100644 --- a/tests/akron-tests.csproj +++ b/tests/akron-tests.csproj @@ -23,6 +23,8 @@ + + all runtime; build; native; contentfiles; analyzers; buildtransitive diff --git a/tests/module-settings-tests.cs b/tests/module-settings-tests.cs index d5a94c47..a0842991 100644 --- a/tests/module-settings-tests.cs +++ b/tests/module-settings-tests.cs @@ -466,9 +466,19 @@ public void DeathParticleCustomShapeNormalizesToEightByEightMask() } [Fact] - public void AutomaticErrorReportingDefaultsOff() + public void AutomaticErrorReportingDefaultsOn() { - Assert.False(new AkronModuleSettings().ErrorReportingEnabled); + Assert.True(new AkronModuleSettings().ErrorReportingEnabled); + } + + [Theory] + [InlineData("Logging: true\n", true)] + [InlineData("ErrorReportingEnabled: false\n", false)] + [InlineData("ErrorReportingEnabled: true\n", true)] + public void LoadedSettingsPreserveTheReportingPreference(string yaml, bool enabled) + { + AkronModuleSettings settings = global::Celeste.Mod.YamlHelper.Deserializer.Deserialize(yaml); + Assert.Equal(enabled, settings.ErrorReportingEnabled); } [Theory] diff --git a/tests/packages.lock.json b/tests/packages.lock.json index 4faf1bfb..7a8dd5be 100644 --- a/tests/packages.lock.json +++ b/tests/packages.lock.json @@ -42,6 +42,12 @@ "resolved": "2.8.2", "contentHash": "vm1tbfXhFmjFMUmS4M0J0ASXz3/U5XvXBa6DOQUL3fEz4Vt6YPhv+ESCarx6M6D+9kJkJYZKCNvJMas1+nVfmQ==" }, + "YamlDotNet": { + "type": "Direct", + "requested": "[16.0.0, )", + "resolved": "16.0.0", + "contentHash": "kZ4jR5ltFhnjaUqK9x81zXRIUTH4PTXTTEmJDNQdkDLQhcv+2Nl19r0dCSvPW1mstOYBfXTnjdieRbUO6gHMDw==" + }, "Microsoft.CodeCoverage": { "type": "Transitive", "resolved": "17.12.0", From aae7a28b83eeb11ccfe4b1bc06a722e76dba7376 Mon Sep 17 00:00:00 2001 From: Microck Date: Sat, 10 Oct 2026 17:34:22 +0000 Subject: [PATCH 06/10] test: parse persisted reporting preferences without stripped game helpers --- tests/akron-tests.csproj | 2 +- tests/module-settings-tests.cs | 3 ++- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/tests/akron-tests.csproj b/tests/akron-tests.csproj index ba347d7b..d1083752 100644 --- a/tests/akron-tests.csproj +++ b/tests/akron-tests.csproj @@ -23,7 +23,7 @@ - + all diff --git a/tests/module-settings-tests.cs b/tests/module-settings-tests.cs index a0842991..58b6a2bf 100644 --- a/tests/module-settings-tests.cs +++ b/tests/module-settings-tests.cs @@ -477,7 +477,8 @@ public void AutomaticErrorReportingDefaultsOn() [InlineData("ErrorReportingEnabled: true\n", true)] public void LoadedSettingsPreserveTheReportingPreference(string yaml, bool enabled) { - AkronModuleSettings settings = global::Celeste.Mod.YamlHelper.Deserializer.Deserialize(yaml); + AkronModuleSettings settings = new YamlDotNet.Serialization.DeserializerBuilder().Build() + .Deserialize(yaml); Assert.Equal(enabled, settings.ErrorReportingEnabled); } From 2146dd865848804bf18536c05285ef71d6c4db3b Mon Sep 17 00:00:00 2001 From: Microck Date: Sat, 10 Oct 2026 17:43:05 +0000 Subject: [PATCH 07/10] docs: record the verified Everest runtime symbol limitation --- docs/reference/error-reporting.mdx | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/docs/reference/error-reporting.mdx b/docs/reference/error-reporting.mdx index 5be99ac3..a72803fd 100644 --- a/docs/reference/error-reporting.mdx +++ b/docs/reference/error-reporting.mdx @@ -11,7 +11,7 @@ When reporting is active, a notice appears once per game launch on the first mai The preference is stored only in your local mod settings as `ErrorReportingEnabled`. An existing saved Off choice remains Off. Importing a `.akr` pack cannot enable it or replace your choice. Turning it off immediately stops new sends and cancels pending requests; a report already sent cannot be recalled. -Automatic reports contain the failure phase, exception type, Akron version, Akron source filenames and method/line details, and identifiers used to resolve that build's debug symbols. They exclude exception messages and data, local directory paths, usernames, machine names, map/save content, other mods' stack frames, HTTP bodies and URLs, breadcrumbs, and log attachments. The receiving server necessarily sees the network connection's IP address; the SDK does not add it as a user field. +Automatic reports contain the failure phase, exception type, Akron version and Akron method names. Source filenames, line details and debug identifiers are included only when available to the runtime. Normal packaged Everest runs currently provide method names without source lines: Everest relinks the assembly, and the player package does not include the input PDB. They exclude exception messages and data, local directory paths, usernames, machine names, map/save content, other mods' stack frames, HTTP bodies and URLs, breadcrumbs, and log attachments. The receiving server necessarily sees the network connection's IP address; the SDK does not add it as a user field. Reporting covers selected failures already caught by Akron: startup helpers, ImGui initialization/rendering, settings saves, deferred actions, StartPos persistence and completion, and unexpected persistent restore failures. Expected cancellation and explicit reconstruction refusals are not reported. Repeated failures from the same phase, exception type and Akron method are reported once per reporting session, with at most 32 reports per session. The client does not install global exception or native crash handlers, record gameplay frames, trace requests, or track sessions. @@ -36,6 +36,8 @@ For official releases, configure these values in GitHub's `release-build` enviro The release workflow uploads `Akron.dll` and its portable PDB privately to Sentry, includes embedded source information, and associates release `akron@` with the exact Git commit. The player ZIP continues to exclude PDB files and the Sentry CLI. Symbol upload is skipped without a DSN; a configured release fails if its symbol-upload credentials are incomplete. +These uploads identify the original build, not Everest's rewritten runtime assembly. They do not currently enable source-line symbolication for normal packaged Everest runs. That limitation remains open; do not treat a successful upload as proof that runtime symbols match. + ## Verification Run the game-independent suite with a fake HTTP handler; it does not send events to Sentry or need Celeste reference assemblies: @@ -47,4 +49,4 @@ dotnet test tests/telemetry/akron-telemetry-tests.csproj --configuration Release The suite checks disabled reporting, payload redaction through the actual SDK, stable event identity, debug-image index remapping, renderer deduplication, session limits, consent revocation, bounded shutdown, resource disposal across repeated reporting sessions, and server rate limits. The full mod build and settings tests still require the project's verified Celeste reference archive. -Before release, a maintainer should test the visible controls in Celeste/Everest, confirm a controlled handled error in a dedicated Sentry test project, verify its source line against the release symbols, turn reporting off and verify no further requests, and check both normal quit and mod unload. Do not manufacture a gameplay crash to test reporting. +Before release, a maintainer should test the visible controls in Celeste/Everest, confirm a controlled handled error in a dedicated Sentry test project, turn reporting off and verify no further requests, and check both normal quit and mod unload. Source-line reporting additionally requires resolving and verifying the relinked-symbol limitation above. Do not manufacture a gameplay crash to test reporting. From 166ec036055ab4ce0ef6842bf918ebccc7277bba Mon Sep 17 00:00:00 2001 From: Microck Date: Sat, 10 Oct 2026 17:50:29 +0000 Subject: [PATCH 08/10] fix: remove the automatic error reporting launch notice --- CHANGELOG.md | 2 +- Source/Module/AkronModule.cs | 1 - Source/Module/akron-module-telemetry.cs | 13 ------------- docs/reference/error-reporting.mdx | 2 +- 4 files changed, 2 insertions(+), 16 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index fb9e545c..b4324d75 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,7 +8,7 @@ This project uses version tags that match the mod version in `everest.yaml`, whi ### Added -- Add Automatic Error Reports for selected Akron startup, overlay, settings and StartPos failures. Reporting is on by default when a Sentry destination is configured, with a launch notice and an opt-out in Interface and mod options. Existing saved Off choices are preserved. Reports send only bounded Akron stack details and the mod version. Local logging and Send diagnostics keep their existing behavior. +- Add Automatic Error Reports for selected Akron startup, overlay, settings and StartPos failures. Reporting is on by default when a Sentry destination is configured, with an opt-out in Interface and mod options. Existing saved Off choices are preserved. Reports send only bounded Akron stack details and the mod version. Local logging and Send diagnostics keep their existing behavior. ## Akron Beta 84 diff --git a/Source/Module/AkronModule.cs b/Source/Module/AkronModule.cs index 56f9a781..c53fe10c 100644 --- a/Source/Module/AkronModule.cs +++ b/Source/Module/AkronModule.cs @@ -804,7 +804,6 @@ private static AreaModeStats TryGetAreaModeStats(AreaKey area) { private static void EngineOnUpdate(On.Monocle.Engine.orig_Update orig, Engine self, GameTime gameTime) { AkronDiagnosticsMenu.CloseIfSceneChanged(Engine.Scene); - ShowErrorReportingNotice(Engine.Scene); // First thing in the hook, so the recorded interval spans a whole engine // update including everything Akron itself adds to the frame. AkronPerformanceTelemetry.RecordUpdateFrame(); diff --git a/Source/Module/akron-module-telemetry.cs b/Source/Module/akron-module-telemetry.cs index 3bdfc743..54ed91d8 100644 --- a/Source/Module/akron-module-telemetry.cs +++ b/Source/Module/akron-module-telemetry.cs @@ -1,13 +1,10 @@ using System; -using Celeste; using Monocle; namespace Celeste.Mod.Akron; public partial class AkronModule { - private static bool errorReportingNoticeShown; - private static void ConfigureErrorReporting() { try @@ -21,16 +18,6 @@ private static void ConfigureErrorReporting() } } - private static void ShowErrorReportingNotice(Scene scene) - { - // Wait for a player-facing scene with fonts loaded, not the startup loader. - if (errorReportingNoticeShown || !AkronTelemetry.IsEnabled || scene is not (Overworld or Level)) return; - errorReportingNoticeShown = true; - scene.Add(new AkronToast( - "Automatic Error Reports are on. Akron stack details and version go to Sentry.\nTurn off: Akron > Interface > Automatic Error Reports.", - forceVisible: true, durationSeconds: 8f)); - } - internal static void SetErrorReportingEnabled(bool enabled) { AkronModuleSettings settings = TryGetSettings(); diff --git a/docs/reference/error-reporting.mdx b/docs/reference/error-reporting.mdx index a72803fd..7ff359fa 100644 --- a/docs/reference/error-reporting.mdx +++ b/docs/reference/error-reporting.mdx @@ -7,7 +7,7 @@ description: Choose whether Akron sends selected errors to Sentry and configure **Automatic Error Reports** is on by default when a valid Sentry destination is configured. Turn it off in the Interface tab or Akron's mod options under Support. Mod options show the configured destination. The control is unavailable when no valid destination is configured, and no reports are sent. -When reporting is active, a notice appears once per game launch on the first main-menu or gameplay screen. It explains what is sent and where to turn reporting off. Handled errors keep their existing player-facing behavior; automatic reporting does not add an error popup. +Reporting does not show a launch notice. Handled errors keep their existing player-facing behavior; automatic reporting does not add an error popup. The preference is stored only in your local mod settings as `ErrorReportingEnabled`. An existing saved Off choice remains Off. Importing a `.akr` pack cannot enable it or replace your choice. Turning it off immediately stops new sends and cancels pending requests; a report already sent cannot be recalled. From 10adc81fc8744da21e068fcbac98fb3cca48843b Mon Sep 17 00:00:00 2001 From: Microck Date: Sat, 10 Oct 2026 18:09:46 +0000 Subject: [PATCH 09/10] fix: preserve source lines through Everest relinking --- .github/workflows/ci.yml | 5 +++-- .github/workflows/release.yml | 5 +++-- Source/Akron.csproj | 2 ++ docs/reference/error-reporting.mdx | 10 ++++++---- 4 files changed, 14 insertions(+), 8 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index e9153b13..3f474575 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -79,8 +79,9 @@ jobs: - name: Verify packaged mod run: | test -f Akron.zip - if unzip -Z1 Akron.zip | grep -E '\.pdb$'; then - echo "::error::Player package must not contain PDB files." + unzip -Z1 Akron.zip | grep -Fx bin/Akron.pdb >/dev/null + if unzip -Z1 Akron.zip | grep -E '\.pdb$' | grep -Fvx bin/Akron.pdb; then + echo "::error::Only Akron's portable PDB belongs in the player package." exit 1 fi unzip -Z1 Akron.zip | grep -Fx LICENSE >/dev/null diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 40d5cba5..2b3930a1 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -144,8 +144,9 @@ jobs: run: | set -euo pipefail test -f Akron.zip - if unzip -Z1 Akron.zip | grep -E '\.pdb$'; then - echo "::error::Player package must not contain PDB files." + unzip -Z1 Akron.zip | grep -Fx bin/Akron.pdb >/dev/null + if unzip -Z1 Akron.zip | grep -E '\.pdb$' | grep -Fvx bin/Akron.pdb; then + echo "::error::Only Akron's portable PDB belongs in the player package." exit 1 fi unzip -Z1 Akron.zip | grep -Fx LICENSE >/dev/null diff --git a/Source/Akron.csproj b/Source/Akron.csproj index 5157b64e..9e0ba26a 100644 --- a/Source/Akron.csproj +++ b/Source/Akron.csproj @@ -57,6 +57,8 @@ + + LICENSE diff --git a/docs/reference/error-reporting.mdx b/docs/reference/error-reporting.mdx index 7ff359fa..eaa7ae2c 100644 --- a/docs/reference/error-reporting.mdx +++ b/docs/reference/error-reporting.mdx @@ -11,7 +11,7 @@ Reporting does not show a launch notice. Handled errors keep their existing play The preference is stored only in your local mod settings as `ErrorReportingEnabled`. An existing saved Off choice remains Off. Importing a `.akr` pack cannot enable it or replace your choice. Turning it off immediately stops new sends and cancels pending requests; a report already sent cannot be recalled. -Automatic reports contain the failure phase, exception type, Akron version and Akron method names. Source filenames, line details and debug identifiers are included only when available to the runtime. Normal packaged Everest runs currently provide method names without source lines: Everest relinks the assembly, and the player package does not include the input PDB. They exclude exception messages and data, local directory paths, usernames, machine names, map/save content, other mods' stack frames, HTTP bodies and URLs, breadcrumbs, and log attachments. The receiving server necessarily sees the network connection's IP address; the SDK does not add it as a user field. +Automatic reports contain the failure phase, exception type, Akron version and Akron method names. Source filenames, line details and debug identifiers are included only when available to the runtime. The player package includes Akron's portable PDB so Everest can preserve source filenames and line numbers while relinking the assembly. They exclude exception messages and data, local directory paths, usernames, machine names, map/save content, other mods' stack frames, HTTP bodies and URLs, breadcrumbs, and log attachments. The receiving server necessarily sees the network connection's IP address; the SDK does not add it as a user field. Reporting covers selected failures already caught by Akron: startup helpers, ImGui initialization/rendering, settings saves, deferred actions, StartPos persistence and completion, and unexpected persistent restore failures. Expected cancellation and explicit reconstruction refusals are not reported. Repeated failures from the same phase, exception type and Akron method are reported once per reporting session, with at most 32 reports per session. The client does not install global exception or native crash handlers, record gameplay frames, trace requests, or track sessions. @@ -25,6 +25,8 @@ Akron owns the SDK's queue and HTTP client through its public worker/transport e For a local build, set the public ingest DSN through the MSBuild property `AkronSentryDsn`. For a local run, `AKRON_SENTRY_DSN` overrides the compiled value; setting that environment variable to an empty string disables the destination. Only HTTPS DSNs with a public key and numeric project ID are accepted. Reporting follows the player's local setting, which defaults to On. +The maintained Sentry organization is `syntaxis-h1` and the mod project is `akron`. That project discards client IP addresses and derived geolocation, enables default sensitive-data scrubbing, and restricts debug-file downloads to owners. + For official releases, configure these values in GitHub's `release-build` environment: | Name | Kind | Purpose | @@ -34,9 +36,9 @@ For official releases, configure these values in GitHub's `release-build` enviro | `SENTRY_PROJECT` | Variable | Sentry project slug for the mod. | | `SENTRY_AUTH_TOKEN` | Secret | CI-only token authorized to upload debug files and manage releases. | -The release workflow uploads `Akron.dll` and its portable PDB privately to Sentry, includes embedded source information, and associates release `akron@` with the exact Git commit. The player ZIP continues to exclude PDB files and the Sentry CLI. Symbol upload is skipped without a DSN; a configured release fails if its symbol-upload credentials are incomplete. +The release workflow uploads `Akron.dll` and its portable PDB privately to Sentry, includes embedded source information, and associates release `akron@` with the exact Git commit. The player ZIP includes only Akron's portable PDB, which contains code locations and embedded public source; other dependency PDBs and the Sentry CLI remain excluded. Symbol upload is skipped without a DSN; a configured release fails if its symbol-upload credentials are incomplete. -These uploads identify the original build, not Everest's rewritten runtime assembly. They do not currently enable source-line symbolication for normal packaged Everest runs. That limitation remains open; do not treat a successful upload as proof that runtime symbols match. +Everest rewrites the runtime assembly and its debug identifier. The packaged input PDB lets Everest retain source lines in the rewritten output, which Akron reads at capture time. Private uploads still describe the original build; they do not match Everest's rewritten debug identifier. An isolated packaged-game run verified filenames and line numbers in a handled settings-save report with the PDB present. ## Verification @@ -49,4 +51,4 @@ dotnet test tests/telemetry/akron-telemetry-tests.csproj --configuration Release The suite checks disabled reporting, payload redaction through the actual SDK, stable event identity, debug-image index remapping, renderer deduplication, session limits, consent revocation, bounded shutdown, resource disposal across repeated reporting sessions, and server rate limits. The full mod build and settings tests still require the project's verified Celeste reference archive. -Before release, a maintainer should test the visible controls in Celeste/Everest, confirm a controlled handled error in a dedicated Sentry test project, turn reporting off and verify no further requests, and check both normal quit and mod unload. Source-line reporting additionally requires resolving and verifying the relinked-symbol limitation above. Do not manufacture a gameplay crash to test reporting. +Before release, a maintainer should test the visible controls in Celeste/Everest, confirm a controlled handled error in a dedicated Sentry test project, turn reporting off and verify no further requests, and check both normal quit and mod unload. Verify source filenames and line numbers in the captured event after Everest relinks the packaged build. Do not manufacture a gameplay crash to test reporting. From 82f0f12beb0fefefea2fb0855221615106867a09 Mon Sep 17 00:00:00 2001 From: Microck Date: Sat, 10 Oct 2026 18:43:54 +0000 Subject: [PATCH 10/10] docs: clarify automatic report privacy exclusions --- docs/reference/error-reporting.mdx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/reference/error-reporting.mdx b/docs/reference/error-reporting.mdx index eaa7ae2c..0d5e5629 100644 --- a/docs/reference/error-reporting.mdx +++ b/docs/reference/error-reporting.mdx @@ -11,7 +11,7 @@ Reporting does not show a launch notice. Handled errors keep their existing play The preference is stored only in your local mod settings as `ErrorReportingEnabled`. An existing saved Off choice remains Off. Importing a `.akr` pack cannot enable it or replace your choice. Turning it off immediately stops new sends and cancels pending requests; a report already sent cannot be recalled. -Automatic reports contain the failure phase, exception type, Akron version and Akron method names. Source filenames, line details and debug identifiers are included only when available to the runtime. The player package includes Akron's portable PDB so Everest can preserve source filenames and line numbers while relinking the assembly. They exclude exception messages and data, local directory paths, usernames, machine names, map/save content, other mods' stack frames, HTTP bodies and URLs, breadcrumbs, and log attachments. The receiving server necessarily sees the network connection's IP address; the SDK does not add it as a user field. +Automatic reports contain the failure phase, exception type, Akron version and Akron method names. Source filenames, line details and debug identifiers are included only when available to the runtime. The player package includes Akron's portable PDB so Everest can preserve source filenames and line numbers while relinking the assembly. Reports exclude exception messages and data, local directory paths, usernames, machine names, map/save content, other mods' stack frames, HTTP bodies and URLs, breadcrumbs, and log attachments. The receiving server necessarily sees the network connection's IP address; the SDK does not add it as a user field. Reporting covers selected failures already caught by Akron: startup helpers, ImGui initialization/rendering, settings saves, deferred actions, StartPos persistence and completion, and unexpected persistent restore failures. Expected cancellation and explicit reconstruction refusals are not reported. Repeated failures from the same phase, exception type and Akron method are reported once per reporting session, with at most 32 reports per session. The client does not install global exception or native crash handlers, record gameplay frames, trace requests, or track sessions.