A log uploading mod for Slay the Spire 2. Other mods can register log matchers and uploaders via DLL reference or Interop, allowing exceptions caught at runtime to be matched and uploaded to configured platforms.
- BaseLib
- BugSnag.dll 4.1.1.0
- Copy
LocalConfig.targets.exampletoLocalConfig.targets. - Fill in
STS2Path(path to your Slay the Spire 2 installation) andGodotExe(path to the Godot v4.5.1 executable). Sts2DataDiris derived fromSTS2Pathautomatically and does not need modification.
<STS2Path>Your path to Slay the Spire 2 folder</STS2Path>
<GodotExe>Your path to Godot v4.5.1 executable</GodotExe>This project uses GitHub Actions for continuous integration and automated publishing.
Triggered on every push or pull request to the main branch:
- Restore —
dotnet restoredownloads NuGet dependencies - Build —
dotnet buildcompiles the project (usesBook.Sts2.RefLibas fallback when game assemblies are unavailable) - Generates the mod assembly and validates compilation
Triggered when a tag matching v* (e.g., v0.3.0) is pushed:
- Build & Export — Compiles the DLL and exports the
.pckfile via Godot headless - Steam Workshop — Uploads the mod content via
steamcmd, then updates multi-language descriptions (English, Simplified Chinese, Japanese) via Steam Web API - NuGet — Publishes the NuGet package
GensoFrontier.Sts2.LogUploaderusing trusted publishing (OIDC) - GitHub Release — Creates a release with the mod package and NuGet artifact
| Secret | Purpose |
|---|---|
STEAM_USERNAME |
Steam account for workshop upload |
STEAM_CONFIG_VDF |
Steam login session (generated via steamcmd +login) |
STEAM_WEB_API_KEY |
Steam Web API key for description updates |
NUGET_USER |
NuGet.org username for trusted publishing |
dotnet build -t:ExportPckThis compiles the DLL and exports the PCK file to the game's mods directory.
This mod exposes RegisterHelper for registering matchers and uploaders. Your mod can call it through BaseLib or RitsuLib's Interop mechanism without referencing LogUploader.dll in your .csproj.
Registration should be done in a postfix patch of ModelDb.Init, when all mods have finished loading.
using BaseLib.Utils.ModInterop;
[ModInterop("LogUploader", "LogUploader.Scripts.Util.RegisterHelper")]
public static class LogUploaderInterop
{
public static void RegisterBugSnagUploader(string modNamespace, string modId, string modVersion, string apiKey) { }
public static void RegisterRollbarUploader(string modNamespace, string modId, string modVersion, string accessToken, string environment, string? userIdentifier) { }
public static void RegisterSentryUploader(string modNamespace, string modId, string modVersion, string dsn) { }
public static void RegisterGrafanaLokiUploader(string modNamespace, string modId, string modVersion, string endpoint, string username, string apiToken) { }
public static void RegisterSeqUploader(string modNamespace, string modId, string modVersion, string serverUrl, string apiKey) { }
public static void RegisterDatadogUploader(string modNamespace, string modId, string modVersion, string apiKey, string? site) { }
}using STS2RitsuLib.Interop;
[ModInterop("LogUploader", "LogUploader.Scripts.Util.RegisterHelper")]
public static class LogUploaderInterop
{
public static void RegisterBugSnagUploader(string modNamespace, string modId, string modVersion, string apiKey) { }
public static void RegisterRollbarUploader(string modNamespace, string modId, string modVersion, string accessToken, string environment, string? userIdentifier) { }
public static void RegisterSentryUploader(string modNamespace, string modId, string modVersion, string dsn) { }
public static void RegisterGrafanaLokiUploader(string modNamespace, string modId, string modVersion, string endpoint, string username, string apiToken) { }
public static void RegisterSeqUploader(string modNamespace, string modId, string modVersion, string serverUrl, string apiKey) { }
public static void RegisterDatadogUploader(string modNamespace, string modId, string modVersion, string apiKey, string? site) { }
}When using RitsuLib, register your assembly during mod initialization:
ModTypeDiscoveryHub.RegisterModAssembly(Entry.ModId, Assembly.GetExecutingAssembly());
Call in a postfix patch of ModelDb.Init:
using HarmonyLib;
using MegaCrit.Sts2.Core.Models;
[HarmonyPatch(typeof(ModelDb), nameof(ModelDb.Init))]
public static class ModelDbInitPatch
{
[HarmonyPostfix]
private static void RegisterUploaders()
{
var logUploaderLoaded = ModManager.GetLoadedMods()
.Any(m => string.Equals(m.manifest?.id, "LogUploader"));
if (!logUploaderLoaded) return;
// Parameter guide:
// modNamespace - namespace used to match exception stack traces; exceptions whose
// StackTrace contains this namespace will be routed to this uploader
// modId - unique identifier used for the per-mod toggle in the settings UI
// modVersion - version attached to each uploaded report
// BugSnag
LogUploaderInterop.RegisterBugSnagUploader(
"YourMod.Namespace", "YourModId", "1.0.0", "your-bugsnag-api-key");
// Sentry
LogUploaderInterop.RegisterSentryUploader(
"YourMod.Namespace", "YourModId", "1.0.0", "your-sentry-dsn");
// Rollbar
LogUploaderInterop.RegisterRollbarUploader(
"YourMod.Namespace", "YourModId", "1.0.0",
"your-rollbar-access-token", "production", null);
// Grafana Loki
LogUploaderInterop.RegisterGrafanaLokiUploader(
"YourMod.Namespace", "YourModId", "1.0.0",
"https://loki.example.com/loki/api/v1/push", "username", "your-api-token");
// Seq (self-hosted)
LogUploaderInterop.RegisterSeqUploader(
"YourMod.Namespace", "YourModId", "1.0.0",
"http://localhost:5341", "your-seq-api-key");
// Datadog
LogUploaderInterop.RegisterDatadogUploader(
"YourMod.Namespace", "YourModId", "1.0.0",
"your-datadog-api-key", "ap1.datadoghq.com");
}
}
modNamespaceis used bySimpleMatcherto match exception stack traces. If an exception'sStackTracecontains this namespace, the exception is routed to the corresponding uploader.
Every platform also provides an overload accepting a Func<Exception, Dictionary<string, object>, bool> delegate for custom matching logic:
public static void RegisterBugSnagUploader(
Func<Exception, Dictionary<string, object>, bool> matcher,
string modId, string modVersion, string apiKey) { }
public static void RegisterSentryUploader(
Func<Exception, Dictionary<string, object>, bool> matcher,
string modId, string modVersion, string dsn, string modNamespace) { }
public static void RegisterRollbarUploader(
Func<Exception, Dictionary<string, object>, bool> matcher,
string modId, string modVersion, string accessToken,
string environment = "production", string? userIdentifier = null) { }
public static void RegisterGrafanaLokiUploader(
Func<Exception, Dictionary<string, object>, bool> matcher,
string modId, string modVersion, string endpoint, string username, string apiToken) { }
public static void RegisterSeqUploader(
Func<Exception, Dictionary<string, object>, bool> matcher,
string modId, string modVersion, string serverUrl, string apiKey) { }
public static void RegisterDatadogUploader(
Func<Exception, Dictionary<string, object>, bool> matcher,
string modId, string modVersion, string apiKey, string? site) { }Pass custom logic; return true if the exception belongs to your mod:
LogUploaderInterop.RegisterBugSnagUploader(
(ex, gameState) => ex is MyModException || ex.StackTrace?.Contains("MyMod") == true,
"MyModId", "1.0.0", "my-api-key");The gameState dictionary provides game context and can be used for more precise filtering (e.g., only upload in specific room types).
If none of the built-in platform uploaders fit your needs, use RegisterCustomUploader to define the full upload pipeline. It accepts two delegates:
- matcher
Func<Exception, Dictionary<string, object>, bool>— returntrueto have your uploader handle the exception - upload
Func<Exception, Dictionary<string, object>, Task<(int, string?)>>— upload logic; the return value is described below
public static void RegisterCustomUploader(
Func<Exception, Dictionary<string, object>, bool> matcher,
string modId,
Func<Exception, Dictionary<string, object>, Task<(int, string?)>> upload) { }The int returned by upload corresponds to the UploadStatus enum:
| Value | Name | Meaning |
|---|---|---|
0 |
Delivered |
Server confirmed receipt — success |
1 |
Accepted |
SDK accepted the report, but delivery is not verified |
2 |
Rejected |
Server rejected the report (bad params, auth failure) — do not retry |
3 |
RetryableFailure |
Transient failure (network error, timeout) — may be retried |
string? is an optional reason description (may be null).
Instead of calling individual registration methods, you can configure all uploaders from a single JSON string using RegisterUploaderByJson:
// Namespace-based matcher
RegisterHelper.RegisterUploaderByJson("YourMod.Namespace", "YourModId", "1.0.0", json);
// Delegate-based matcher
RegisterHelper.RegisterUploaderByJson(
(ex, _) => ex is YourModException,
"YourModId", "1.0.0", json);The JSON format describes which platforms to enable and their credentials. Only configured platforms will be registered; unconfigured ones are skipped.
{
"bugSnag": {
"apiKey": "your-bugsnag-api-key"
},
"sentry": {
"dsn": "your-sentry-dsn"
},
"rollbar": {
"accessToken": "your-rollbar-access-token",
"environment": "production",
"userIdentifier": null
},
"grafanaLoki": {
"endpoint": "https://loki.example.com/loki/api/v1/push",
"username": "your-loki-username",
"apiToken": "your-loki-api-token"
},
"seq": {
"serverUrl": "http://localhost:5341",
"apiKey": "your-seq-api-key"
},
"datadog": {
"apiKey": "your-datadog-api-key",
"site": "ap1.datadoghq.com"
}
}Example usage in a ModelDb.Init patch:
using Godot;
using HarmonyLib;
using MegaCrit.Sts2.Core.Models;
using LogUploader.Scripts.Util;
[HarmonyPatch(typeof(ModelDb), nameof(ModelDb.Init))]
public static class ModelDbInitPatch
{
[HarmonyPostfix]
private static void RegisterUploaders()
{
if (!ModManager.GetLoadedMods().Any(m => string.Equals(m.manifest?.id, "LogUploader")))
return;
const string path = "res://your-mod/uploader-config.json";
if (!ResourceLoader.Exists(path)) return;
using var file = FileAccess.Open(path, FileAccess.ModeFlags.Read);
var json = file.GetAsText();
RegisterHelper.RegisterUploaderByJson("YourMod.Namespace", "YourModId", "1.0.0", json);
}
}All sections are optional. Add them to your JSON file as needed.
When an exception is captured, a snapshot of the current game state is included as Dictionary<string, object>:
| Key | Type | Description |
|---|---|---|
loc.language |
string |
Current game language |
game.scene |
string |
Current scene (MainMenu / CombatRoom / MapRoom / EventRoom / RestSiteRoom / MerchantRoom / TreasureRoom / Run / LogoAnimation) |
game.in_run |
string |
Whether a run is in progress ("true" / "false") |
game.seed |
string |
Current run seed |
game.ascension |
int |
Ascension level |
game.act |
int |
Current act number |
game.act_name |
string |
Current act ID |
game.floor |
int |
Total floors cleared |
game.mode |
string |
Game mode |
game.room_type |
string |
Current room type name |
game.event |
string |
Current event ID (EventRoom only) |
game.characters |
string |
Comma-separated character IDs |
game.player_count |
int |
Number of players |
combat.encounter |
string |
Current encounter ID |
combat.round |
int |
Current round number |
combat.enemy_count |
int |
Number of enemies |
combat.enemies |
string |
Comma-separated enemy IDs |
combat.player_hp |
string |
Comma-separated currentHp/maxHp per player |
Some fields are only present during a run or combat. If an error occurs during state collection, a collect.exception field is added.
Sends exceptions to Datadog Logs via the HTTP API v2. Requires a Datadog API Key. The site parameter configures the Datadog site (defaults to datadoghq.com; use ap1.datadoghq.com for Asia Pacific, datadoghq.eu for Europe, etc.).
Reports exceptions via the BugSnag .NET SDK. Requires a BugSnag API Key.
Sends exceptions using the Sentry Envelope protocol directly, without the Sentry .NET SDK. Includes built-in stack frame filtering (IInAppFrameDetector) to distinguish mod/game code from external calls. Requires a Sentry DSN.
Reports exceptions via the Rollbar Create Item API. Automatically parses stack frames and attaches game state context. Requires a Rollbar Access Token.
Pushes exceptions to Loki using the Push API with structured labels (mod version, exception type, etc.). Supports Basic authentication. Requires a Loki endpoint, username, and API token.
A self-hosted structured log server by Datalust. Sends exceptions via the raw events API. Lightweight and easy to set up with Docker: docker run -d --name seq -e ACCEPT_EULA=Y -p 5341:80 datalust/seq. Requires a Seq server URL and API key.