diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 20687e84..3f474575 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -15,6 +15,25 @@ 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: 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 + build-and-test: name: Build and test runs-on: ubuntu-24.04 @@ -60,12 +79,18 @@ 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 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..2b3930a1 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -109,21 +109,53 @@ 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 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 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..b4324d75 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 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 ### 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..9e0ba26a 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 @@ + + @@ -51,6 +57,8 @@ + + LICENSE 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 new file mode 100644 index 00000000..8326034b --- /dev/null +++ b/Source/Core/akron-telemetry.cs @@ -0,0 +1,316 @@ +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 AkronTelemetryWorker worker; + private readonly string release; + private Task shutdown = Task.CompletedTask; + 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 + }; + 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. + try { client = new SentryClient(options); } + catch + { + worker.Dispose(); + throw; + } + } + + 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) + { + shutdown = Task.Run(DisposeClient); + return; + } + DisposeClient(); + } + + private void DisposeClient() + { + try { client.Dispose(); } + catch (Exception) { } + 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) + { + 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..a4d13e2e 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 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 14b3601c..9d6280f7 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 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", 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 new file mode 100644 index 00000000..54ed91d8 --- /dev/null +++ b/Source/Module/akron-module-telemetry.cs @@ -0,0 +1,36 @@ +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..cf91f543 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. 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 d130b1bf..5708de66 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, + () => 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: () => AkronTelemetry.IsConfigured && 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..91168094 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": "7IRSoMWSxzjD204mFrQs0+kU7/ZAQRcqNdiFB8tN9R8CxgwZvP/rrD2nuWmQnFleQwA3yZYcpR8m3EtGfP5x5w==" + }, "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..df91ebb7 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 | 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 new file mode 100644 index 00000000..0d5e5629 --- /dev/null +++ b/docs/reference/error-reporting.mdx @@ -0,0 +1,54 @@ +--- +title: Error reporting +description: Choose whether Akron sends selected errors to Sentry and configure reporting for maintained builds. +--- + +## Player choice + +**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. + +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. + +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. + +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. + +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. 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 | +|---|---|---| +| `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 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. + +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 + +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, 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. 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. 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..d1083752 100644 --- a/tests/akron-tests.csproj +++ b/tests/akron-tests.csproj @@ -15,6 +15,7 @@ + @@ -22,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 3ce8f505..58b6a2bf 100644 --- a/tests/module-settings-tests.cs +++ b/tests/module-settings-tests.cs @@ -465,6 +465,35 @@ public void DeathParticleCustomShapeNormalizesToEightByEightMask() Assert.All(normalized, character => Assert.Contains(character, new[] { '0', '1' })); } + [Fact] + public void AutomaticErrorReportingDefaultsOn() + { + 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 = new YamlDotNet.Serialization.DeserializerBuilder().Build() + .Deserialize(yaml); + Assert.Equal(enabled, settings.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/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", diff --git a/tests/telemetry/akron-telemetry-tests.csproj b/tests/telemetry/akron-telemetry-tests.csproj new file mode 100644 index 00000000..69b0640a --- /dev/null +++ b/tests/telemetry/akron-telemetry-tests.csproj @@ -0,0 +1,17 @@ + + + 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..ed4ca481 --- /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": "7IRSoMWSxzjD204mFrQs0+kU7/ZAQRcqNdiFB8tN9R8CxgwZvP/rrD2nuWmQnFleQwA3yZYcpR8m3EtGfP5x5w==" + }, + "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..315c7101 --- /dev/null +++ b/tests/telemetry/telemetry-tests.cs @@ -0,0 +1,313 @@ +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(); + 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] + 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)); + 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() + { + 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 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); + 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; + 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); + Started.TrySetResult(true); + try { await Task.Delay(Timeout.Infinite, token); } + catch (OperationCanceledException) { Canceled.TrySetResult(true); throw; } + 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); + } + } +}