Logcie is a lightweight, single-header logging library for C with a modular design that supports multiple output sinks, customizable formatting, and flexible filtering.
- Multiple log levels
- ANSI color support
- Fully customizable output format
- Filters support
- Support for multiple sinks (stdout, file, etc.)
- c11/c99 compatible (with -pedantic file)
- Quick Start
- Installation
- Migrating From v2
- Building Examples
- Basic Usage
- Log Levels
- Architecture Overview
- Configuration Macros
- Recursive Logging
- Sinks and Output Configuration
- Module-Based Logging
- Memory Management Notes
- Format Tokens
- Filters
- Limitations
- Testing
- Usage in libraries
- License
#define LOGCIE_IMPLEMENTATION
#include "logcie.h"
int main() {
LOGCIE_INFO("Application started");
LOGCIE_DEBUG("Processing value: %d", 42);
LOGCIE_WARN("This is a warning message");
LOGCIE_ERROR("An error occurred: %s", "file not found");
return 0;
}Copy logcie.h into your project and include it.
// In one file (main.c, libs.c, etc)
#define LOGCIE_IMPLEMENTATION
#include "logcie.h"
// In any other file where you want to use logcie
#include "logcie.h"Logcie_Writer gained a flush field, in the middle:
/* v2 */ typedef struct { Logcie_WriterFn *write; void *data; } Logcie_Writer;
/* v3 */ typedef struct { Logcie_WriterFn *write; Logcie_WriterFlushFn *flush; void *data; } Logcie_Writer;If you initialize it positionally, your target now lands in the flush slot.
Add the flush argument, or NULL:
/* v2 */ .writer = {my_writer, target}
/* v3 */ .writer = {my_writer, NULL, target}Designated initializers (.write, .data) need no change. logcie_file_flush
is the built-in flush to pair with logcie_file_writer.
Nothing else moved, and nothing has to be flushed for logging to keep working.
But two behaviours are new and worth knowing about: a log at
LOGCIE_AUTOFLUSH_LEVEL or above (LOGCIE_LEVEL_ERROR by default) now flushes
the sink it was written to, and logcie_flush() exists for the rest. See Flush.
Define any of these before you include logcie.h (or before
#define LOGCIE_IMPLEMENTATION) to customise Logcie.
| Macro | Description | Default |
|---|---|---|
LOGCIE_MODULE |
Module name attached to classic macros (LOGCIE_INFO, …). (see Module-Based Logging) |
"Logcie" |
LOGCIE_DEFAULT_SINK_FORMAT |
Format string for the automatic stdout sink. | "$c$L$r … $f:$x$r: $m" |
LOGCIE_THREAD_SAFE |
Enables a mutex around all sink operations and log calls (requires pthreads). | (not defined) |
LOGCIE_ALLOW_RECURSIVE_LOGGING |
Allows logging calls inside formatters/writers/filters (dangerous!). (see Recursive Logging) | (not defined) |
LOGCIE_DEF |
Linkage qualifier for public functions (e.g. static). |
extern |
LOGCIE_PEDANTIC |
Forces the strict C99 macro fallback (LOGCIE_*_VA) even on GCC/Clang. |
(not defined) |
LOGCIE_COLOR_* |
ANSI escape codes for each level color. You can override them or use logcie_set_colors(). |
(see source) |
LOGCIE_MODULE_SEPARATOR |
Character separating levels of a module name. (see Module-Based Logging) | '.' |
LOGCIE_MAX_SINKS |
How many sinks can be registered at once. logcie_add_sink returns 0 once full. |
16 |
LOGCIE_AUTOFLUSH_LEVEL |
Level at and above which a log flushes the sink it was written to. (see Flush) | LOGCIE_LEVEL_ERROR |
LOGCIE_AUTOFLUSH_DISABLE |
Define it to switch autoflush off entirely. logcie_flush() still works. |
(not defined) |
LOGCIE_MAX_LINE |
Stack buffer a line is formatted into. Lines that fit cost no allocation. | 1024 |
LOGCIE_MALLOC / LOGCIE_FREE |
Allocator used only for lines longer than LOGCIE_MAX_LINE. Define both or neither. |
malloc / free |
LOGCIE_NO_MALLOC |
Never allocate. Lines longer than LOGCIE_MAX_LINE are truncated instead. |
(not defined) |
LOGCIE_DEBUG_CHECKS |
Enable internal consistency assertions. | (not defined) |
Note: The compiler‑pedantic fallback (
LOGCIE_VA_LOGS) is automatically defined when variadic macros are not available - you don’t need to touch it.
Logcie is header-only, so there is nothing to build to use it. The build system compiles the examples and runs the test suite.
cc -o build build.c # once
./build # compile every example into ./out/
./build --help| Flag | Meaning |
|---|---|
-d, --debug |
-ggdb -fsanitize=address -Og -DLOGCIE_DEBUG_CHECKS instead of -O3 |
-s, --silent |
only warnings and errors |
-r, --dry-run |
print the commands, run nothing |
-o, --outdir |
output directory (default ./out/) |
-c, --c-compiler |
C compiler (default clang) |
-x, --cpp-compiler |
C++ compiler (default clang++) |
Each directory under examples/ is one program, and they are meant to be read
in order. Each adds one thing to the one before it.
An example needing extra compiler flags puts them in a build.flags file next
to its sources; 09_threads uses that for -lpthread.
Logcie provides macros for all log levels that automatically capture the file name and line number:
LOGCIE_TRACE("Detailed tracing information");
LOGCIE_DEBUG("Debug value: %d", some_value);
LOGCIE_VERBOSE("Additional verbose details");
LOGCIE_INFO("Informational message");
LOGCIE_WARN("Warning: %s", warning_message);
LOGCIE_ERROR("Error code: %d", error_code);
LOGCIE_FATAL("Fatal error, shutting down");All macros support printf-style formatting. The message string supports the same format specifiers as printf().
Logcie defines seven log levels in increasing order of severity:
| Level | Description | Typical Use |
|---|---|---|
| TRACE | Most detailed information | Function entry/exit, variable values |
| DEBUG | Debugging information | State changes, intermediate results |
| VERBOSE | Verbose operational details | Configuration loading, minor events |
| INFO | General information | Startup messages, major events |
| WARN | Warning conditions | Recoverable errors, deprecated usage |
| ERROR | Error conditions | Operation failures, unexpected states |
| FATAL | Fatal conditions | Unrecoverable errors, immediate shutdown |
Logcie is built around three core components:
Turns a log into bytes and hands them to the Writer. It owns the
serialization, so logcie_token_formatter is one choice, not the only possible
one — a JSON or binary formatter would be the same interface with different
output. Its user_data is whatever that formatter needs: for the built-in one
that is a format token string.
size_t my_formatter(Logcie_Writer *writer, void *user_data, Logcie_Log log, va_list *args);Use logcie_render_message(buf, cap, &log, args) to render log.msg and its
arguments — every formatter needs it, and it handles the va_list copying.
Puts one finished line somewhere: a FILE *, a socket, a ring buffer, a UART.
size_t my_writer(void *user_data, const Logcie_Log *log, const char *bytes, size_t len);Three things worth knowing:
- One call is one complete line, terminating newline included. A writer is never handed a fragment, so a sink that treats each call as one record — syslog, a network endpoint — is safe.
- The log comes along so a transport can use metadata as a value instead of
parsing it back out of the text.
syslog(3)wants a priority, Android wants a priority and a tag, a network sink may want the module as a routing key. log->msgis the format string from the call site, not the text. The rendered line isbytes. Usebytes; uselogfor metadata.
bytes is not NUL terminated, so always use len.
// route by severity without needing two sinks
size_t console_writer(void *user_data, const Logcie_Log *log, const char *bytes, size_t len) {
(void)user_data;
FILE *out = log->level >= LOGCIE_LEVEL_WARN ? stderr : stdout;
return fwrite(bytes, 1, len, out);
}A writer with NULL user data discards everything. logcie_file_writer does
exactly that, which is the cheapest way to mute a sink without removing it —
and the first thing to check when a sink is unexpectedly silent, since Logcie
does not substitute stdout for you.
Pushes whatever the writer has buffered out to its destination.
void my_flush(void *user_data);It lives on the writer rather than beside it, and it is handed the same
user_data as write. A flush is only meaningful against the destination that
was written to, so giving it its own pointer would only make it possible to
aim the two at different things.
Logcie_Writer w = {my_writer, my_flush, target};NULL means there is nothing to flush, and such a sink is skipped rather than
treated as a failure. logcie_file_flush is the built-in one, and a NULL
target does nothing — fflush(NULL) would flush every open stream in the
process, which is not one sink's business.
Two things reach it:
logcie_flush()walks every registered sink and flushes it. Call it before exiting, and before removing a sink. Logcie does not flush on removal: the sink is yours, and so is closing whatever it writes to.- Autoflush. A log at
LOGCIE_AUTOFLUSH_LEVELor above flushes the sink it was just written to. It defaults toLOGCIE_LEVEL_ERROR, because those are the lines a crash would otherwise take with it. Lower it toLOGCIE_LEVEL_TRACEto flush everything, or defineLOGCIE_AUTOFLUSH_DISABLEto switch it off and flush by hand.
Decides whether a log should be emitted.
A combination of these three components is called a Sink
Recursive logging from formatters, writers or filters is not supported!
By default, Logcie suppresses recursive log attempts to avoid infinite recursion
and deadlocks. Recursive calls return 0 and produce no output.
If you want to avoid the small overhead of the recursion check, or if you intentionally rely on recursive logging and you know what you are doing you can disable the recursion guard:
#define LOGCIE_ALLOW_RECURSIVE_LOGGINGDisabling the recursion guard may cause infinite recursion, deadlocks, or stack overflows.
A sink defines where log messages are written and how they are formatted. You can add additional sinks for files, network sockets, or custom destinations.
Logcie provides a default stdout sink automatically, so you can start logging immediately.
This is how default sinks looks like:
static Logcie_Sink default_stdout_sink = {
.formatter = {logcie_token_formatter, "$c$L$r " LOGCIE_COLOR_GRAY "$f:$x$r: $m"},
.writer = {logcie_file_writer, logcie_file_flush, stdout},
.filter = {NULL, NULL},
};You can configure it to your liking with:
Logcie_Sink *default_sink = logcie_get_default_sink();
// For example: add filter
default_sink->filter = logcie_filter_level_min(LOGCIE_LEVEL_ERROR);Or you can remove defualt sink all together with:
// Remove default sink by pointer
logcie_remove_sink(logcie_get_default_sink());
// Or remove by its index. Scinse default sink is there from start
// it will have index 0
logcie_remove_sink_by_index(0);
// Or just empty whole thing
logcie_remove_all_sinks();// Create a file sink for error logs.
// NOTE: fopen() is not a constant expression, so the writer target is filled
// in at run time rather than in the initializer.
static Logcie_Sink error_sink = {
// nice format: date, time, level, module, message
.formatter = {logcie_token_formatter, "$d $t [$L] $f:$x - $m"},
.writer = {logcie_file_writer, logcie_file_flush, NULL},
.filter = logcie_filter_level_min(LOGCIE_LEVEL_ERROR)
};
int main(void) {
error_sink.writer.data = fopen("errors.log", "a");
logcie_add_sink(&error_sink);
// ... log ...
logcie_flush();
fclose(error_sink.writer.data);
}The filter field takes a Logcie_Filter, which the logcie_filter_* macros
build for you. Writing {logcie_filter_level_min_fn, LOGCIE_LEVEL_ERROR} by
hand puts an int where a const void * belongs.
The default sink stays registered when you add your own, so there is nothing to restore. To drop it, remove it like any other sink:
logcie_remove_sink(logcie_get_default_sink());A module is a string label that identifies the origin of a log message,
such as "network", "core", or "database". It can be displayed with the $M token
and used in filters.
Logcie supports three ways to set the module, from simplest to most explicit:
#define LOGCIE_MODULE "core"
#include "logcie.h"All classic macros (LOGCIE_INFO, LOGCIE_ERROR, …) in that file will be tagged with "core".
LOGCIE_LOG_MOD("network", INFO, "Connected");
// Use LOGCIE_LOG_MOD_VA when variadic macros are unavailable.See the Usage in libraries section for a ready‑to‑use snippet.
The module name appears in logs when using the $M format token:
LOGCIE_LOG_MOD("network", INFO, "Connection established to %s", "gnu.org");
// With format "$d $t [$L] ($M) $m" produces:
// 2026-05-26 12:00:00 [INFO] (network) Connection established to gnu.orgModules can also be used in filters to selectively allow or block logs from specific parts of your application.
Module names are hierarchical: "net", "net.http" and "net.http.tls" form a tree that logcie_filter_module_prefix matches on. Redefine this before including logcie.h if '.' clashes with your naming.
Since logcie_add_sink() stores the pointer to your sink structure (not a copy), you must ensure:
- Stack-allocated sinks: Must not go out of scope while registered
- Heap-allocated sinks: Must be freed only after removal
- Modification: You can modify sink properties after adding (changes take effect immediately)
Format strings use $ tokens to insert log metadata. The default formatter supports the following tokens:
| Token | Description | Example Output |
|---|---|---|
$m |
Log message with printf formatting | "Connection established" |
$f |
Source file name | "main.c" |
$x |
Line number | "42" |
$M |
Module name | "network" |
$l |
Log level (lowercase) | "info" |
$L |
Log level (uppercase) | "INFO" |
$c |
ANSI color code for log level | \x1b[36;20m |
$r |
ANSI reset color code | \x1b[0m |
$d |
Date (YYYY-MM-DD) | "2025-12-24" |
$t |
Time (HH:MM:SS) | "14:30:15" |
$N |
Nanoseconds | "970431843" |
$z |
Timezone offset | "+3" |
$<n |
Pads prevous token out to n columns (example: $<5) |
" " |
$$ |
Literal dollar sign | "$" |
// Simple format with color
"$c$L$r: $m"
// Detailed format with timestamp and location
"$d $t [$L] $f:$x - $m"
// Module-based format
"[$M] $c$L$r $t - $m"Logcie_Log carries nanos alongside time. Which clock is used depends on
what <time.h> already exposes, checked at compile time:
| Build | Clock | nanos |
|---|---|---|
| C11 or later | timespec_get |
real |
_POSIX_C_SOURCE >= 199309L |
clock_gettime |
real |
anything else, e.g. -std=c99 -pedantic |
time() |
always 0 |
Logcie never defines a feature-test macro itself — those only take effect
before the first libc header, and a single-header library cannot know what you
included above it. To get sub-second time on C99, define _POSIX_C_SOURCE
yourself before any include.
The timestamp is captured at the call site, not when the log is rendered.
Filters allow you to control which logs are emitted to a specific Sink. Each Sink can have its own filter, enabling fine-grained routing of logs.
A filter is a structure that consist of pointer to filtering function and a pointer to custom data that filter might want to use.
A filtering function is simply a function that receives a Logcie_Log and returns:
- 1 (true) - to allow the log
- 0 (false) - to suppress the log
If a Sink has no filter all logs are allowed.
Here is a list of built-in filters:
-
logcie_filter_level_min(level) Allows logs with level >= specified level
-
logcie_filter_level_max(level) Allows logs with level <= specified level
-
logcie_filter_module_eq("module") Allows logs only from specific module (see below for learning about modules)
-
logcie_filter_module_prefix_eq("module") Allows logs only from specific module root (see below for learning about modules)
-
logcie_filter_message_contains("text") Allows logs whose messages contains the given substring
Combining filters:
- logcie_filter_and(a, b) - Allows logs only if BOTH filters pass
- logcie_filter_or(a, b) - Allows logs only if EITHER filters pass
- logcie_filter_not(a) - Inverts the result of a filter
Example:
// Sink that takes logs with level more than VERBOSE and not from "network" module
Logcie_Sink sink = {
//...
.filter = logcie_filter_and(
logcie_filter_level_min(LOGCIE_VERBOSE),
logcie_filter_not(
logcie_filter_module_eq("network")
)
)
};
uint8_t custom_filter_fn(void *data, Logcie_Log *log) {
(void) data; // ignored
// Do not allow logs from even lines
return log->location.line % 2 == 0;
}
Logcie_Sink another_sink = {
// ...
.filter = (Logcie_Filter) {
.filter = custom_filter_fn,
.data = NULL,
}
}
// Or if you do not need any custom data and you want to
// deal with creating custom structs you can do this:
Logcie_Sink another_sink = {
// ...
.filter = {
.filter = custom_filter_fn,
.data = NULL,
}
}- Filters are evaluated per sink, independently.
- Be careful when using temporary data in filters (they rely on compound literals and must remain valid during logging).
- Thread safety is opt‑in - Define
LOGCIE_THREAD_SAFEbefore the implementation to serialise all operations with a mutex. Without it, concurrent calls may interleave or crash. - No built-in log rotation - File management must be handled by the application (or just use
logrotate) - Custom formatters require
va_listhandling - Advanced usage requires understanding of variadic arguments
Future versions may address these limitations based on user feedback and requirements.
The suite lives in tests/ and is written in the .tspec format, run by strum.
./build tests # whole suite
./build tests tests/filters # one directory
./build tests -t ./path/to/runner # a different .tspec runner./build tests looks for strum on PATH and fails with a clear message if it is not there. --tspec-runner overrides it - the format is not tied to any one implementation.
Each directory covers one area:
| Directory | Covers |
|---|---|
levels |
every level renders its own name |
format_tokens |
$m $L $l $M $f $x $c $r $$ and padding |
filters |
all built-in filters and the and/or/not combinators |
sinks |
add, remove, count, and default-sink replacement |
modules |
per-file, per-call and default module names |
api |
sink lookup, colors, removal by index |
printf_args |
printf specifiers reaching the message |
timestamps |
$d $t $z compared against the clock |
recursion |
the recursion guard suppresses instead of looping |
cplusplus |
the extern "C" and lambda-filter paths |
matrix_gcc, matrix_clang |
c99/c11/c17 / pedantic / thread-safe / allow-recursive, compiled and run |
compile_errors |
things that must fail: format mismatches, missing or duplicated LOGCIE_IMPLEMENTATION |
Tests assert exact stdout rather than checking inside the fixture, so a failure prints expected and actual bytes with escapes.
Adding a case to an existing area is usually one :test block; the fixtures take their format string or filter name as argv[1].
You can add simple snippet to make your library support logcie
// Logcie integration
#ifndef YOURLIB_LOG
#ifdef LOGCIE
#ifdef LOGCIE_VA_LOGS
#define YOURLIB_LOG(level, msg, ...) LOGCIE_LOG_MOD_VA("YOURLIB", level, msg, __VA_ARGS__)
#else
#define YOURLIB_LOG(level, ...) LOGCIE_LOG_MOD("YOURLIB", level, __VA_ARGS__)
#endif
#else
#define YOURLIB_LOG(level, ...) (void*)0
#endif
#endifIf you need to have fallback logging this can be used instead of (void *)0:
#define YOURLIB_LOG(level, ...) \
do { \
fprintf(stderr, #level ": "__VA_ARGS__); \
fprintf(stderr, "\n"); \
} while (0)Just change YOURLLIB to something more fitting :)
Logcie is released under the MIT License. See LICENSE file for more info
For questions or contributions, contact: Nikita (Strongleong) Chulkov nikita_chul@mail.ru