https://github.com/forge18/lua-zero-log
Zero-allocation deferred-formatting ring logger for LuaJIT (FFI).
https://github.com/forge18/lua-zero-log
ffi gamedev logger logging lua luajit ring-buffer zero-allocation
Last synced: about 7 hours ago
JSON representation
Zero-allocation deferred-formatting ring logger for LuaJIT (FFI).
- Host: GitHub
- URL: https://github.com/forge18/lua-zero-log
- Owner: forge18
- License: mit
- Created: 2026-06-20T15:59:38.000Z (19 days ago)
- Default Branch: main
- Last Pushed: 2026-06-20T16:03:48.000Z (19 days ago)
- Last Synced: 2026-06-20T18:05:11.058Z (19 days ago)
- Topics: ffi, gamedev, logger, logging, lua, luajit, ring-buffer, zero-allocation
- Language: Lua
- Homepage:
- Size: 11.7 KB
- Stars: 0
- Watchers: 0
- Forks: 0
- Open Issues: 0
-
Metadata Files:
- Readme: README.md
- License: LICENSE
Awesome Lists containing this project
README
# lua-zero-log
A **zero-allocation, deferred-formatting ring logger for LuaJIT**.
The hot path never touches the GC. A log call writes a level, a pre-registered message id, and a few
numeric args into a preallocated `ffi` ring buffer; the expensive part — `string.format` — is
deferred until the ring is drained (on flush, on crash, periodically). This is the "binary / deferred
logging" technique (NanoLog, spdlog's backtrace) done in pure LuaJIT FFI: no native build, no
cross-platform binary matrix.
Requires **LuaJIT** (uses `ffi`). Not thread-safe — use one logger per thread.
## Install
**[Lux](https://github.com/nvim-neorocks/lux) (recommended)** — add it as a git dependency:
```sh
lx add github:forge18/lua-zero-log
```
or pin it yourself in `lux.toml`:
```toml
[dependencies.lua-zero-log]
git = "github:forge18/lua-zero-log"
version = "" # omit to track the latest
```
Then `local zerolog = require("zerolog")`. (A project with git dependencies can't be published to
luarocks.org — that's a luarocks limitation, not this library's.)
**Vendored (single file)** — there are no dependencies, so you can just copy `src/zerolog.lua` into
your project (e.g. `lib/zerolog.lua`) and `require` it.
**LÖVE** — LÖVE already runs LuaJIT, so put `zerolog.lua` anywhere on `package.path` and require it.
## Two tiers
```lua
local zerolog = require("zerolog")
local L = zerolog.LEVELS
local log = zerolog.new({
capacity = 4096, -- ring size in records; oldest are overwritten
clock = function() return game.minutes end, -- timestamp source (your sim clock)
sink = function(level, ts, message, seq) -- where flush() sends entries
io.write(zerolog.line(level, ts, message), "\n")
end,
level = L.DEBUG, -- minimum level to record
})
-- Cold / ergonomic: formats eagerly, takes any args. The ~99% path (lifecycle, errors).
log:info("loaded zone %s in %d ms", zoneName, ms)
log:error("save failed: %s", err)
-- Hot / zero-alloc: register once, then call with exactly that many *numeric* args. No string work,
-- no varargs, no garbage. Use inside per-frame / per-entity loops.
local scored = log:def(L.TRACE, "ai scored %.2f for unit %d") -- arity inferred = 2
for _, u in ipairs(units) do
scored(u.score, u.id)
end
-- Later — drain the buffer.
log:flush() -- to the sink, then clears
log:drain(function(level, ts, message, seq) ... end) -- non-destructive
```
## API
| Call | Description |
|------|-------------|
| `zerolog.new(opts)` | Create a logger. `opts`: `capacity`, `clock`, `sink`, `level`, `name`. |
| `log:def(level, fmt [, argc])` | Register a deferred message; returns a fixed-arity, alloc-free emitter. Arity is inferred from `fmt` unless given. |
| `log:log(level, fmt, ...)` | Eager, any-args log. |
| `log:trace/debug/info/warn/error/fatal(fmt, ...)` | Level shorthands for `:log`. |
| `log:drain(visit)` | Visit live entries oldest→newest as `visit(level, ts, message, seq)`. Non-destructive. Returns count. |
| `log:flush()` | Drain to the sink, then `reset`. Returns count (0 with no sink). |
| `log:reset()` | Forget all buffered entries. |
| `log:count()` | Entries currently held (saturates at `capacity`). |
| `log:setSink(fn)` · `log:setLevel(lvl)` | Mutators. |
| `zerolog.LEVELS` · `zerolog.LEVEL_NAMES` | `TRACE…FATAL` = 1…6, and the reverse map. |
| `zerolog.line(level, ts, message)` | Convenience one-line formatter for sinks. |
## Notes & limits
| Limit | Detail |
|-------|--------|
| Deferred args are numeric | Stored as doubles — integers exact to 2^53. For strings, use the cold path or bake them into the format string. |
| The hot emitter is strict | Wrong arity or a non-number argument raises. |
| ≤ 8 args per deferred message | The cap is `zerolog.MAXARGS`. |
| Inject a JIT-friendly clock for hot loops | A plain Lua function returning a number. The `os.clock` default is convenience-only and can abort the trace. |
| The library owns only the ring + formatting | A clock, a file sink, and a crash-time drain are the caller's job. |
## Test
```sh
luajit spec/run.lua # or: lx test
```