Skip to content

Folders and files

NameName
Last commit message
Last commit date

Latest commit

 

History

81 Commits
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

English | 中文


Log Uploader

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.

Dependencies

Setup

  1. Copy LocalConfig.targets.example to LocalConfig.targets.
  2. Fill in STS2Path (path to your Slay the Spire 2 installation) and GodotExe (path to the Godot v4.5.1 executable).
  3. Sts2DataDir is derived from STS2Path automatically 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>

CI/CD

This project uses GitHub Actions for continuous integration and automated publishing.

Build Workflow

Triggered on every push or pull request to the main branch:

  1. Restoredotnet restore downloads NuGet dependencies
  2. Builddotnet build compiles the project (uses Book.Sts2.RefLib as fallback when game assemblies are unavailable)
  3. Generates the mod assembly and validates compilation

Release Workflow

Triggered when a tag matching v* (e.g., v0.3.0) is pushed:

  1. Build & Export — Compiles the DLL and exports the .pck file via Godot headless
  2. Steam Workshop — Uploads the mod content via steamcmd, then updates multi-language descriptions (English, Simplified Chinese, Japanese) via Steam Web API
  3. NuGet — Publishes the NuGet package GensoFrontier.Sts2.LogUploader using trusted publishing (OIDC)
  4. GitHub Release — Creates a release with the mod package and NuGet artifact

Required Secrets

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

Build

dotnet build -t:ExportPck

This compiles the DLL and exports the PCK file to the game's mods directory.

Registering Uploaders via Interop

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.

1. Define an Interop Class

BaseLib

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) { }
}

RitsuLib

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());

2. Register Uploaders

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");
    }
}

modNamespace is used by SimpleMatcher to match exception stack traces. If an exception's StackTrace contains this namespace, the exception is routed to the corresponding uploader.

3. Delegate-based Matching

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).

4. Custom Upload

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> — return true to 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).

5. JSON-based Registration

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.

GameState Context

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.

Supported Platforms

Datadog

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.).

BugSnag

Reports exceptions via the BugSnag .NET SDK. Requires a BugSnag API Key.

Sentry

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.

Rollbar

Reports exceptions via the Rollbar Create Item API. Automatically parses stack frames and attaches game state context. Requires a Rollbar Access Token.

Grafana Loki

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.

Seq

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.


English | 中文

About

A mod of slay the spire 2, which can upload logs when exception occurred.

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages