libpslog is a high-performance, zero-dependency structured logger for C.
libpslog is the C port of the Go pkt.systems/pslog logger and aims to preserve the same overall product shape: console and JSON output, strong structured logging support, careful control over allocations, aggressive benchmarking, and output semantics that are close to the Go implementation. The Go variant is already one of the fastest loggers in that ecosystem; libpslog brings the same performance-first design to C while exposing a C-shaped API.
- Console and JSON loggers from the same API surface.
- Colorized and non-colorized output selected from config, with adaptive tty-aware color by default.
- Structured fields through typed
pslog_field[]arrays. - Structured
kvfmtlogging throughtracef/debugf/infof/warnf/errorf, whereinfofmeansmessage + kvfmt, notprintf-formatting the message itself. - Derived loggers through
with(),withf(),with_level(), andwith_level_field(). - Environment-driven construction through
pslog_new_from_env(). - Explicit trusted-string fast paths for JSON emission.
- Thread-safe shared logging through the same logger tree.
- Zero-allocation hot-path emission for normal log lines, with chunked output for oversized lines instead of truncation.
- Unit tests, fuzzing, pure C benchmarks, and Go-vs-C comparison benchmarks.
- Optional Lua binding release artifacts shipped as
lua-pslog.
First-release binary support is:
x86_64-linux-gnux86_64-linux-muslaarch64-linux-gnuaarch64-linux-muslarmhf-linux-gnuarmhf-linux-muslarm64-apple-darwin
All targets are wired for:
- configure/build via CMake presets
- tests where the target can run locally or under qemu
- runtime package generation
- dev package generation
For Linux ARM targets, the test presets run under qemu. These are mandatory
release routes: make release-matrix and make release fail if qemu-aarch64
or qemu-arm is unavailable. The Darwin target is a build-and-package target
when the local osxcross toolchain is available.
Public symbols use the pslog_ prefix. Preferred usage is through instance methods on pslog_logger.
Core structured path:
#include "pslog.h"
pslog_config config;
pslog_logger *log;
pslog_field fields[2];
pslog_default_config(&config);
config.mode = PSLOG_MODE_JSON;
config.color = PSLOG_COLOR_NEVER;
config.output = pslog_output_from_fp(stdout, 0);
log = pslog_new(&config);
fields[0] = pslog_str("service", "api");
fields[1] = pslog_i64("attempt", 3L);
log->info(log, "request handled", fields, 2u);
log->destroy(log);Derived logger path:
pslog_field base[1];
pslog_logger *child;
pslog_logger *next;
base[0] = pslog_str("subsystem", "worker");
child = log->with(log, base, 1u);
next = child->with_level_field(child);
child->destroy(child);
child = next;
child->warn(child, "retrying", NULL, 0u);
child->destroy(child);Derived logger from kvfmt:
child = log->withf(log, "service=%s subsystem=%s", "api", "worker");
child->info(child, "request handled", NULL, 0u);
child->destroy(child);kvfmt path:
log->infof(log, "request handled", "user=%s code=%d ok=%b ms=%f",
"alice", 200, 1, 12.34);Direct built-in palette selection:
pslog_default_config(&config);
config.mode = PSLOG_MODE_CONSOLE;
config.color = PSLOG_COLOR_ALWAYS;
config.palette = &pslog_builtin_palette_nord;Additional typed fields include:
pslog_boolpslog_bytes_fieldpslog_duration_fieldpslog_f64pslog_i64pslog_nullpslog_ptrpslog_time_fieldpslog_trusted_strpslog_u64
Relevant behavioral notes:
PSLOG_LEVEL_NOLEVELemits---in console mode and"nolevel"in JSON mode.log->fatal(...)andlog->fatalf(...)log and then exit with failure status.log->panic(...)andlog->panicf(...)log and then abort.- The free-function
pslog_fatal(...)/pslog_fatalf(...)wrappers also terminate. - The free-function
pslog_panic(...)/pslog_panicf(...)wrappers also abort. with()returns a derived logger and does not mutate the receiver.close()closes only owned outputs.pslog_new_from_env()overlaysLOG_*settings on top of a seed config.
The public header in include/pslog.h contains the full API contract and doc comments.
The main example is examples/example.c. It demonstrates:
- console and JSON loggers
log()and level-specific methodswith(),with_level(), andwith_level_field()withf()for statickvfmt-derived fields- typed fields
infof/kvfmtpslog_new_from_env()- palette iteration
- adaptive color behavior
Build it in normal library mode:
cmake --preset host
cmake --build --preset host
cd examples
"$(sed -n 's/^CMAKE_C_COMPILER:[^=]*=//p' ../build/host/CMakeCache.txt)" -I../build/host/generated/include -I../include \
-o example example.c ../build/host/libpslog.a -pthread
./exampleFor an extracted binary SDK, point pkg-config at that SDK and use the compiler selected for the matching target rather than an ambient host compiler:
sdk=/path/to/libpslog-<version>-<target>
export PKG_CONFIG_PATH="$sdk/lib/pkgconfig${PKG_CONFIG_PATH:+:$PKG_CONFIG_PATH}"
"$CC" $(pkg-config --cflags pslog) -o example example.c $(pkg-config --libs pslog)set(CMAKE_PREFIX_PATH "/path/to/libpslog-<version>-<target>")
find_package(pslog CONFIG REQUIRED)
target_link_libraries(example PRIVATE pslog::pslog)The default CMake target links the shared library. Use pslog::pslog_static
when a static link is preferred.
Build the same example in single-header mode:
cmake --preset host
cmake --build ../build/host --target package-single-header
cd examples
"$(sed -n 's/^CMAKE_C_COMPILER:[^=]*=//p' ../build/host/CMakeCache.txt)" -DPSLOG_EXAMPLE_SINGLE_HEADER=1 \
-I../build/host/generated/include \
-o example example.c -pthread
./exampleThe release single-header artifact is written to dist/pslog-<version>.h.gz.
The uncompressed generated header remains in the build tree at
build/host/generated/include/pslog_single_header.h for local compile smoke
tests. The generated header contains the public API, the PSLOG_IMPLEMENTATION
section, the embedded license text, and the single-header usage notes at the top
of the file.
libpslog ships an optional Lua binding as the lua-pslog rock package. The
Lua module itself is required as pslog:
local pslog = require("pslog")
local log = pslog.new_json("/tmp/app.log", {
no_color = true,
disable_timestamp = true,
}):with("service", "checkout")
log:info("ready", "port", 8080, "ok", true)The Lua binding keeps the same logger product shape as C and Go, but the API is written for Lua instead of mirroring the C surface literally:
- ordinary Lua scalars are inferred automatically
- structured logging through key/value pairs is the primary call shape
- a single-table field form exists as convenience
- explicit wrappers exist for semantic types Lua does not natively represent
such as bytes, time, duration, errno, trusted strings, and
u64 - constructors support stdout/stderr, file handles, file paths, and callback sinks
Examples:
log:info("typed fields",
"payload", pslog.bytes(string.char(0, 1, 2, 255)),
"started_at", pslog.time { sec = 1711737600, nsec = 0, offset = 0 },
"elapsed", pslog.duration_ms(125),
"errno", pslog.errno(2)
)Local Lua development helpers:
make lua-rock
make lua-testmake lua-rock first installs the Bootlin-built public C SDK into the
repo-local build/lua-sdk/ prefix. The Lua module, C interop checks, and Go
Lua benchmark bridge all consume that installed SDK rather than source-tree
headers or libraries.
Run the Lua examples from the repository root:
make lua-rock
eval "$(make lua-env)"
lua lua/examples/example.lua
lua lua/examples/basic.lua
lua lua/examples/from_env.lua
lua lua/examples/callback.luaExample entry points live under lua/examples/:
The full Lua API reference lives in lua/README.md.
make release also emits:
dist/lua-pslog-<version>.tar.gzdist/lua-pslog-<version>-1.rockspecdist/lua-pslog-<version>-1.src.rock
If you want a simple frontend instead of typing the CMake commands directly, use the repository Makefile:
make build
make test
make test-all
make fuzz
make benchmarks-c
make benchmarks-gobencher
make benchmarks-all
make lua-test
make prerelease
make prerelease-hardening
make releaseRun make help for the full target list.
Standard debug build:
cmake --preset debug
cmake --build --preset debug
ctest --preset debugNative Valgrind check:
make valgrindFuzzing:
make fuzzOne-command incremental rehearsal for the full shipped matrix:
make release-matrixmake prerelease runs the shared deterministic proof graph without cleaning
generated state first. make release cleans first and then runs that exact
same proof graph; it is the final local release gate.
make prerelease-hardening adds the explicit long AFL++ fuzz tier to that
proof graph. make release writes a tab-separated phase timing report to
build/release-timings.tsv; its matrix entries distinguish configure, build,
QEMU-backed test, packaging, source smoke, Lua artifacts, checksums, and
privacy verification. It also separates ordinary C tests, Valgrind, AFL++
smoke, cross tests, Go tests, and the performance gate.
Inspect the latest completed report with:
column -t -s $'\t' build/release-timings.tsvThat script runs, for every shipped Linux target:
cmake --preset ...cmake --build --preset ...ctest --preset ...- runtime package generation
- dev package generation
lua-pslog-<version>-1.rockspecgenerationlua-pslog-<version>-1.src.rockgeneration- final
libpslog-<version>-CHECKSUMSgeneration over every shipped file indist/
When the osxcross arm64 Darwin compiler is available, the script also builds
and packages arm64-apple-darwin.
Toolchain expectations:
- Release, cross, Valgrind, and fuzz presets provision checksum-pinned Bootlin GCC collections through
scripts/cpkt-toolchains.sh; host-nativedebugandhostpresets use the local development compiler selected by CMake. - Bootlin-backed native x86_64 Valgrind and AFL++ execution launch through the selected Bootlin sysroot loader, so they do not depend on a matching host glibc or musl installation. Native memory checking still uses host Valgrind against a focused Bootlin-built facade test; native x86_64 fuzzing uses the cached AFL++ GCC-plugin wrapper from
scripts/cpkt-aflpp.sh, which delegates to the same Bootlin collection. Host benchmark and Go/Lua comparison gates usescripts/run_host_binary.sh, which runs native host binaries directly and sysroot-backed host binaries through the configured loader. clang-formatandclangdare host development tools only.make clangdchecks the native public C consumer withbuild/debug/compile_commands.json, including its public-header surface. Public declarations use Doxygen comments so hover documentation remains useful in clangd. clangd is not a compiler, target-ABI verifier, package check, or release dependency; cross builds, packages, and releases do not invoke it.- The shared toolchain cache is
${CPKT_TOOLCHAIN_CACHE:-${XDG_CACHE_HOME:-$HOME/.cache}/c.pkt.systems/toolchains}. Its Bootlin and AFL++ provisioners serialize each collection with a boundedCPKT_TOOLCHAIN_LOCK_TIMEOUT(600 seconds by default), and the AFL++ cache identity includes the selected Bootlin collection and sysroot. External dependency archives use${CPKT_DEPENDENCY_CACHE:-${XDG_CACHE_HOME:-$HOME/.cache}/c.pkt.systems/deps}. Both survivemake clean; disposable extracted dependency source state is kept under the active CMake build tree. - Cross test execution requires
qemu-aarch64andqemu-arm; each uses the matching Bootlin sysroot. arm64-apple-darwinexpects osxcross underOSXCROSS_ROOTor$HOME/.local/cross/osxcross.
Single-target examples:
cmake --preset aarch64-linux-gnu-release
cmake --build --preset aarch64-linux-gnu-release
ctest --preset aarch64-linux-gnu-release
cmake --build build/aarch64-linux-gnu-release --target package-archiveEach target archive in dist/ now contains the public headers, the shared
library, and the static library for that target variant.
After the full matrix runs, dist/ also contains libpslog-<version>-CHECKSUMS
with sha256sum output for every shipped release file except the checksum file
itself. The listed filenames are bare archive names, not dist/... paths.
There are two benchmark layers:
Useful commands:
make benchmarks-c
./bench/run_rebaseline.shThe benchmark suite covers:
- fixed synthetic workloads
- production-shaped workloads
- prebuilt field-array paths
- per-call field rebuild paths
with()-based production shapeskvfmtconvenience paths- optional benchmark-only
libloggercomparison on JSON - optional benchmark-only
Quillcomparison on JSON
The external comparisons are intentionally scoped:
libloggeris kept only as a simple JSON baseline. It emits JSON, but it is still not a full peer for modern container-style structured JSONL workloads: nowith()-style persistent fields, no native boolean field type, and a narrower JSON surface thanlibpslog. It is also far slower on the production-shaped benchmark, so this is not just a feature gap.Quillis kept only as an opt-in negative comparison. ItsJsonSinkis not first-class structured JSON for modern container-style JSONL logging: it does not preserve typed structured fields, it stringifies named arguments internally, and it lacks persistent structured-field attachment. It is also far slower on the production-shaped benchmark once forced into this workload class. If you are evaluating JSON loggers for Kubernetes, serverless, or other container-first workloads, do not treat Quill'sJsonSinkas the same kind of JSON logging product aslibpslog.
See bench/README.md and gobencher/README.md for naming and interpretation details.
pslog_new_from_env() reads settings like:
LOG_MODELOG_LEVELLOG_DISABLE_TIMESTAMPLOG_VERBOSE_FIELDSLOG_NO_COLORLOG_FORCE_COLORLOG_PALETTELOG_OUTPUTLOG_OUTPUT_FILE_MODELOG_TIME_FORMATLOG_UTC
Example:
pslog_config seed;
pslog_logger *log;
pslog_default_config(&seed);
seed.output = pslog_output_from_fp(stdout, 0);
log = pslog_new_from_env("LOG_", &seed);
log->infof(log, "hello", "key=%s", "value");
log->destroy(log);- Shared logging through the same logger tree is thread-safe.
infof/debugf/...do structuredkvfmt; they are notprintfmessage formatters.- Normal emission stays allocation-free on the hot path for ordinary line sizes.
- Long lines are streamed in chunks instead of truncated.
- Trusted strings are explicit. Untrusted strings are escaped normally.
- The runtime
.soand static.apackages both ship the public header.

