As I’ve already said countless times, I’m working on my first game for Steam, which you can check out online at reprobate.site.
Since it’s a paid game, I need to provide proper bug support. With that in mind, and based on both professional and personal experience, I decided to use Sentry.
At first, integrating C++ and Lua should have been straightforward, but I ran into some issues when using the Conan package manager.
Initially, the package wasn’t being included in the compiler’s include flags, which led me to open an issue both on Conan Center and on Sentry Native.
After spending a whole day on this, I eventually found out that the fix was actually pretty simple. Now Carimbo has native support for Sentry, both on the web (WebAssembly) and natively (Android, iOS, Linux, Windows, and macOS).
Here’s how I managed to get it working.
CMake & Conan
This was certainly my biggest problem. For some reason, even when following Conan’s documentation, I couldn’t get it to include the header path for Sentry Native. In the end, my solution looked like this:
find_package(sentry CONFIG QUIET)
...
if(sentry_FOUND)
target_link_libraries(${PROJECT_NAME} PRIVATE sentry-native::sentry-native)
target_compile_definitions(${PROJECT_NAME} PRIVATE HAVE_SENTRY=1)
endif()
Web & Native at same time
The native part was pretty easy, but for the web part I had an insight while walking, because I realized I could inject JavaScript using the Emscripten API.
enginefactory& enginefactory::with_sentry(const std::string& dsn) noexcept {
if (dsn.empty()) {
return *this;
}
#ifdef EMSCRIPTEN
const auto script = std::format(
R"javascript(
(function(){{
const __dsn="{}";
if (window.Sentry && window.__sentry_inited__) return;
const script = document.createElement('script');
script.src = 'https://cdn.jsdelivr.net/npm/@sentry/browser@latest/build/bundle.min.js';
script.crossOrigin = 'anonymous';
script.defer = true;
script.onload = function(){{
if (!window.Sentry) return;
window.Sentry.init({{ dsn: __dsn }});
window.__sentry_inited__ = true;
}};
document.head.appendChild(script);
}})();
)javascript",
dsn
);
emscripten_run_script(script.c_str());
#endif
#if defined(HAVE_SENTRY) && !defined(SANDBOX)
auto *options = sentry_options_new();
sentry_options_set_dsn(options, dsn.c_str());
// sentry_options_set_debug(options, 1);
// sentry_options_set_logger_level(options, SENTRY_LEVEL_DEBUG);
sentry_init(options);
#endif
return *this;
}
Better Lua Stack Traces
Since the game assets are stored in a compressed file, PhysicsFS provides an API to handle them transparently. It’s great for distributing the game — you only need the cartridge.zip and the executable — and it works even better on the web. The engine must provide a searcher so that Lua can find the game’s other Lua scripts. For this, I use a custom searcher: it first looks for the scripts inside the game package, and if they’re not found, it falls back to the interpreter’s default search to load them from the standard library.
sol::object searcher(sol::this_state state, const std::string& module) {
sol::state_view lua{state};
const auto filename = std::format("scripts/{}.lua", module);
const auto buffer = storage::io::read(filename);
std::string_view script{reinterpret_cast<const char *>(buffer.data()), buffer.size()};
const auto loader = lua.load(script, std::format("@{}", filename));
if (!loader.valid()) [[unlikely]] {
sol::error err = loader;
throw std::runtime_error(err.what());
}
return sol::make_object(lua, loader.get<sol::protected_function>());
}
lua["searcher"] = &searcher;
const auto inject = std::format(R"lua(
local list = package.searchers or package.loaders
table.insert(list, searcher)
)lua");
lua.script(inject);
To improve stack traces, the secret lies in the second parameter of lua.load. You can pass a string starting with @ followed by the file name.
This alone gives you a much richer stack trace.
Error handling
In Carimbo, I have a terminate hook that catches any exception and atexit hooks to always handle cleanup.
[[noreturn]] void fail() {
if (const auto ptr = std::current_exception()) {
const char* error = nullptr;
try {
std::rethrow_exception(ptr);
} catch (const std::bad_exception&) {
} catch (const std::exception& e) {
error = e.what();
} catch (...) {
error = "Unhandled unknown exception";
}
if (error) {
#ifdef HAVE_SENTRY
const auto exc = sentry_value_new_exception("exception", error);
const auto ev = sentry_value_new_event();
sentry_event_add_exception(ev, exc);
sentry_capture_event(ev);
#endif
#ifdef HAVE_STACKTRACE
boost::stacktrace::stacktrace st;
std::println(stderr, "Stack trace:\n{}\n", boost::stacktrace::to_string(st));
#endif
std::println(stderr, "{}", error);
SDL_ShowSimpleMessageBox(SDL_MESSAGEBOX_ERROR, "Ink Spill Disaster", error, nullptr);
#ifdef DEBUG
#ifdef _MSC_VER
__debugbreak();
#else
raise(SIGTRAP);
#endif
#endif
}
}
std::exit(EXIT_FAILURE);
}
application::application(int argc, char **argv) {
UNUSED(argc);
UNUSED(argv);
std::set_terminate(fail);
constexpr const auto fn = [](int) {
std::exit(EXIT_SUCCESS);
};
std::signal(SIGINT, fn);
std::signal(SIGTERM, fn);
#ifdef HAVE_SENTRY
std::atexit([] { sentry_close(); });
#endif
std::atexit([] { PHYSFS_deinit(); });
std::atexit([] { SDL_Quit(); });
SDL_Init(SDL_INIT_GAMEPAD | SDL_INIT_VIDEO);
PHYSFS_init(argv[0]);
#ifdef HAVE_STEAM
SteamAPI_InitSafe();
#endif
}
int32_t application::run() {
static_assert(std::endian::native == std::endian::little);
const auto* p = std::getenv("CARTRIDGE");
storage::filesystem::mount(p ? p : "cartridge.zip", "/");
auto se = scriptengine();
se.run();
return 0;
}
Conclusion
This way, I can provide Sentry support in a practically universal and abstract manner for the engine’s user.
You can find more details about the engine and its implementation in the official repository: github.com/willtobyte/carimbo.