Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
29 changes: 27 additions & 2 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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)

Expand Down
38 changes: 35 additions & 3 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Comment thread
Microck marked this conversation as resolved.
"$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"
Expand Down
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 2 additions & 0 deletions Source/Actions/akron-startpos-persistence.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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
Expand Down
8 changes: 8 additions & 0 deletions Source/Akron.csproj
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,8 @@
<AllowUnsafeBlocks>true</AllowUnsafeBlocks>
<CopyLocalLockFileAssemblies>true</CopyLocalLockFileAssemblies>
<RestorePackagesWithLockFile>true</RestorePackagesWithLockFile>
<DebugType>portable</DebugType>
<EmbedAllSources>true</EmbedAllSources>
<CelestePrefix Condition="'$(CelestePrefix)' == '' And Exists('..\..\..\Celeste.dll')">..\..\..</CelestePrefix>
<CelestePrefix Condition="'$(CelestePrefix)' == ''">..\lib-stripped</CelestePrefix>
<ZippedFiles>..\everest.yaml;..\bin\**\*.*</ZippedFiles>
Expand All @@ -19,6 +21,8 @@
</ItemDefinitionGroup>

<ItemGroup>
<!-- Runtime must be included explicitly: the item defaults exclude it. Keep SDK build/native hooks out of this shared game process. -->
<PackageReference Include="Sentry" Version="6.12.0" PrivateAssets="all" IncludeAssets="compile;runtime" ExcludeAssets="" />
<PackageReference Include="ImGui.NET" Version="1.91.6.1" PrivateAssets="all" ExcludeAssets="" />
<PackageReference Include="Newtonsoft.Json" Version="13.0.3" PrivateAssets="all" ExcludeAssets="" />
<PackageReference Include="MonoMod.RuntimeDetour" Version="25.3.1" PrivateAssets="all" ExcludeAssets="runtime" />
Expand All @@ -34,6 +38,8 @@
</ItemGroup>

<ItemGroup>
<!-- A DSN is a public ingest address, never an auth token. No value means reporting stays unavailable. -->
<AssemblyMetadata Include="AkronSentryDsn" Value="$(AkronSentryDsn)" />
<EmbeddedResource Include="Resources\poppins-imgui-18.png" />
<EmbeddedResource Include="Resources\poppins-imgui-18.csv" />
<EmbeddedResource Include="Resources\poppins.ttf" />
Expand All @@ -51,6 +57,8 @@
<Target Name="Package" AfterTargets="Build" DependsOnTargets="CopyFiles">
<ItemGroup>
<FilesToPackage Include="$(ZippedFiles)" Exclude="..\bin\**\*.pdb" />
<!-- Everest needs Akron's input symbols while relinking to preserve runtime source lines. -->
<FilesToPackage Include="..\bin\Akron.pdb" />
<FilesToPackage Include="..\LICENSE">
<PackagePath>LICENSE</PackagePath>
</FilesToPackage>
Expand Down
106 changes: 106 additions & 0 deletions Source/Core/akron-telemetry-worker.cs
Original file line number Diff line number Diff line change
@@ -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(); }
}
}
Loading
Loading