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.
First-release Linux support is:
x86_64-linux-gnux86_64-linux-muslaarch64-linux-gnuaarch64-linux-muslarmhf-linux-gnuarmhf-linux-musl
All six are wired for:
- configure/build via CMake presets
- tests
- runtime package generation
- dev package generation
For ARM targets, the test presets run under qemu.
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;
base[0] = pslog_str("subsystem", "worker");
child = log->with(log, base, 1u);
child = child->with_level_field(child);
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
cc -I../build/host/generated/include -I../include \
-o example example.c ../build/host/libpslog.a
./exampleBuild the same example in single-header mode:
cmake --preset host
cmake --build --preset package-single-header
cd examples
cc -DPSLOG_EXAMPLE_SINGLE_HEADER=1 \
-I../build/host/generated/include \
-o example example.c
./exampleThe shipped single-header artifacts are written to dist/pslog-<version>.h
and dist/pslog-<version>.h.gz. 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.
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 releaseRun make help for the full target list.
Standard debug build:
cmake --preset debug
cmake --build --preset debug
ctest --preset debugAddress-sanitized run:
cmake --preset asan
cmake --build --preset asan
ctest --preset asanFuzzing:
cmake --preset fuzz
cmake --build --preset fuzz
./build/fuzz/pslog_fuzz -runs=1000One-command sweep for the full shipped Linux matrix:
./scripts/run_linux_release_matrix.shThat script runs, for every shipped Linux target:
cmake --preset ...cmake --build --preset ...ctest --preset ...- runtime package generation
- dev package generation
- final
libpslog-<version>-CHECKSUMSgeneration over every shipped file indist/
Toolchain expectations:
linux-gnucross presets expect distro cross compilers such asaarch64-linux-gnu-gccandarm-linux-gnueabihf-gcc.x86_64-linux-muslexpects hostmusl-gcc.aarch64-linux-muslandarmhf-linux-muslexpect real musl cross compilers such asaarch64-linux-musl-gccandarm-linux-musleabihf-gcc.- musl ARM qemu runs expect the musl loader symlink in the target sysroot to resolve within the prefix, for example
ld-musl-aarch64.so.1 -> libc.so.
Single-target examples:
cmake --preset aarch64-linux-gnu-release
cmake --build --preset aarch64-linux-gnu-release
ctest --preset aarch64-linux-gnu-release
cmake --build --preset package-runtime-aarch64-linux-gnu
cmake --build --preset package-dev-aarch64-linux-gnuArtifacts are written to dist/.
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:
./build/host/pslog_bench 200000 all
./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.

