Practical Logger Module Walkthrough
This walkthrough builds a small structured logger package that can be imported
from Silk and exposed to C through a generated header. It starts with the normal
Silk API in src/lib.slk, then adds src/c_api.slk for the narrow ABI wrapper.
After the logger_static target emits build/acme_logger.h and
build/libacme_logger.a, a C caller can drive the package with borrowed
SilkString values:
#include <silk/silk.h>
#include "acme_logger.h"
static SilkString silk_str(const char *ptr) {
SilkString s = { .ptr = (char *)ptr, .len = 0 };
while (ptr[s.len] != '\0') s.len++;
return s;
}
int main(void) {
return logger_write_c(20, silk_str("ffi"), silk_str("hello from C"));
}
The manifest package name is logger. Source modules declare the symbol
namespace with package acme::logger;. Downstream code imports the package's
default module through the dependency key:
import logger from "logger";
Direct imports such as import acme::logger::logger_write_c; are shown only in
the ABI section because they bind ABI/export paths directly.
Package Layout#
Create this directory:
logger/
silk.toml
README.md
LICENSE
src/
lib.slk
c_api.slk
defs/
api.slk
examples/
main.slk
tests/
logger_test.slk
src/lib.slk is the normal Silk API. src/c_api.slk is the C/ABI edge.
defs/api.slk mirrors the public surface for binary consumers.
Manifest#
silk.toml makes the package root portable:
[package]
name = "logger"
version = "0.1.0"
description = "Structured logging package for Silk applications"
license = "MIT"
repository = "https://github.com/acme/silk-logger"
readme = "README.md"
definitions = ["defs/api.slk"]
[sources]
include = [
"src/**/*.slk",
"defs/**/*.slk",
"examples/**/*.slk",
"tests/**/*.slk"
]
[dist]
include = [
"silk.toml",
"README.md",
"LICENSE",
"src/**",
"defs/**",
"examples/**"
]
[[target]]
name = "logger_static"
kind = "static"
entry = "src/c_api.slk"
output = "build/libacme_logger.a"
c_header = "build/acme_logger.h"
[[target]]
name = "logger_demo"
kind = "executable"
entry = "examples/main.slk"
output = "build/logger-demo"
[[target]]
name = "logger_wasi_demo"
kind = "executable"
entry = "examples/main.slk"
target = "wasm32-wasi"
output = "build/logger-demo.wasm"
The manifest package name is the identifier logger. The source package
namespace is acme::logger. Target names select build recipes. The static
target enters through src/c_api.slk because that artifact is meant for ABI
consumers; ordinary Silk consumers import the default module with "logger".
Implementation Module#
src/lib.slk defines the reusable logging API:
package acme::logger;
import { println } from "std/io";
export enum Level {
Trace,
Debug,
Info,
Warn,
Error,
}
export error LogError {
message: string
}
export struct Field {
key: string,
value: string,
}
export struct Entry {
level: Level,
target: string?,
message: string,
request_id: string?,
fields: Field[],
}
export type WriteFn = fn (entry: &Entry) -> LogError?;
export interface Sink {
fn write(entry: &Entry) -> LogError?;
}
export struct ConsoleSink {}
impl ConsoleSink as Sink {
fn write (self: &ConsoleSink, entry: &Entry) -> LogError? {
return console_writer(entry);
}
}
export struct Config {
min_level: Level,
target: string,
include_target: bool,
writer: WriteFn,
}
export struct Logger {
config: Config,
}
impl Logger {
public fn enabled (self: &Logger, level: Level) -> bool {
return level_value(level) >= level_value(self.config.min_level);
}
public fn with_target (self: &Logger, target: string) -> Logger {
return Logger{
config: Config{
min_level: self.config.min_level,
target: target,
include_target: self.config.include_target,
writer: self.config.writer,
},
};
}
public fn with_writer (self: &Logger, writer: WriteFn) -> Logger {
return Logger{
config: Config{
min_level: self.config.min_level,
target: self.config.target,
include_target: self.config.include_target,
writer: writer,
},
};
}
public fn write (
self: &Logger,
level: Level,
message: string,
request_id: string?,
fields: Field[]
) -> LogError? {
if self.enabled(level) == false {
return None;
}
let entry = Entry{
level: level,
target: visible_target(&self.config),
message: message,
request_id: request_id,
fields: fields,
};
let writer = self.config.writer;
return writer(&entry);
}
}
export fn default_config () -> Config {
return Config{
min_level: Level::Info,
target: default_target(),
include_target: true,
writer: console_writer,
};
}
export fn init (config: Config) -> Logger {
return Logger{
config: config,
};
}
export fn enabled (logger: &Logger, level: Level) -> bool {
return logger.enabled(level);
}
export fn with_target (logger: &Logger, target: string) -> Logger {
return logger.with_target(target);
}
export fn with_writer (logger: &Logger, writer: WriteFn) -> Logger {
return logger.with_writer(writer);
}
export fn log (
logger: &Logger,
level: Level,
message: string,
request_id: string?,
fields: Field[]
) -> LogError? {
return logger.write(level, message, request_id, fields);
}
export fn debug (logger: &Logger, message: string, request_id: string?, fields: Field[]) -> LogError? {
return log(logger, Level::Debug, message, request_id, fields);
}
export fn info (logger: &Logger, message: string, request_id: string?, fields: Field[]) -> LogError? {
return log(logger, Level::Info, message, request_id, fields);
}
export fn warn (logger: &Logger, message: string, request_id: string?, fields: Field[]) -> LogError? {
return log(logger, Level::Warn, message, request_id, fields);
}
export fn error (logger: &Logger, message: string, request_id: string?, fields: Field[]) -> LogError? {
return log(logger, Level::Error, message, request_id, fields);
}
export fn console_writer (entry: &Entry) -> LogError? {
let level: string = level_name(entry.level);
match (entry.target) {
Some(target) => match (entry.request_id) {
Some(id) => println("{s} target={s} req_id={s} {s}", level, target, id, entry.message),
None => println("{s} target={s} {s}", level, target, entry.message),
},
None => match (entry.request_id) {
Some(id) => println("{s} req_id={s} {s}", level, id, entry.message),
None => println("{s} {s}", level, entry.message),
},
};
for field in entry.fields {
println(" {s}={s}", field.key, field.value);
}
return None;
}
export fn parse_level (raw: string) -> Result(Level, string) {
if raw == "trace" { return Ok(Level::Trace); }
if raw == "debug" { return Ok(Level::Debug); }
if raw == "info" { return Ok(Level::Info); }
if raw == "warn" { return Ok(Level::Warn); }
if raw == "error" { return Ok(Level::Error); }
return Err("unknown log level");
}
export fn level_name (level: Level) -> string {
return match (level) {
Level::Trace => "trace",
Level::Debug => "debug",
Level::Info => "info",
Level::Warn => "warn",
Level::Error => "error",
};
}
fn level_value (level: Level) -> int {
return match (level) {
Level::Trace => 5,
Level::Debug => 10,
Level::Info => 20,
Level::Warn => 30,
Level::Error => 40,
};
}
fn visible_target (config: &Config) -> string? {
if config.include_target {
return Some(config.target);
}
return None;
}
fn default_target () -> string {
if attr(target="wasm32-wasi") { return "wasi"; }
if attr(os="linux") { return "linux"; }
if attr(os="macos") { return "macos"; }
if attr(os="windows") { return "windows"; }
return "app";
}
This is still compact, but it is no longer a print helper. It has:
- filtering (
Logger.enabled), - target/module naming (
Config.target,Logger.with_target), - optional request/correlation IDs (
request_id: string?), - multiple structured fields per call (
fields: Field[]), - a configurable writer hook (
WriteFn) for file, JSON, syslog, or test sinks, - a source-level sink contract (
interface Sink) for concrete sink types, - a concrete console writer,
- recoverable parse errors (
Result(Level, string)), - recoverable write errors (
LogError?), - and target-aware defaults through
attr(...).
Downstream Use#
examples/main.slk imports the package's default module namespace and handles parse/write
outcomes explicitly:
import logger from "logger";
fn main () -> int {
let min_level = match (logger::parse_level("info")) {
Ok(level) => level,
Err(_) => logger::Level::Info,
};
let log = logger::init(logger::Config{
min_level: min_level,
target: "checkout",
include_target: true,
writer: logger::console_writer,
});
let fields: logger::Field[] = [
logger::Field{ key: "total_usd", value: "42.00" },
logger::Field{ key: "payment", value: "card" },
];
let err = logger::info(
&log,
"accepted order",
Some("req-7f1a"),
fields
);
if err != None {
return 1;
}
return 0;
}
Selected imports are useful when a module owns the logging setup:
import { Config, Field, Level, console_writer, init, warn } from "logger";
fn main () -> int {
let log = init(Config{
min_level: Level::Warn,
target: "scheduler",
include_target: true,
writer: console_writer,
});
let fields: Field[] = [Field{ key: "attempt", value: "2" }];
warn(&log, "retrying job", None, fields);
return 0;
}
Replacing the Writer#
A real application usually keeps logger setup in one module and injects a
writer. The public logger API stores a WriteFn, so callers can swap console,
file, JSON, syslog, or test writers without changing call sites.
import logger from "logger";
import { println } from "std/io";
fn audit_writer (entry: &logger::Entry) -> logger::LogError? {
println("audit=true");
return logger::console_writer(entry);
}
fn main () -> int {
let base = logger::init(logger::default_config());
let audit = logger::with_writer(&base, audit_writer);
let fields: logger::Field[] = [logger::Field{ key: "subject", value: "order" }];
let err = logger::warn(&audit, "manual review required", Some("req-88"), fields);
if err != None { return 1; }
return 0;
}
interface Sink is still valuable for concrete sink types that want conformance
checking inside one build. It is a source-level contract, not a stable
open-world plugin ABI. Across FFI or separately built libraries, expose explicit
functions such as logger_write_c instead.
Definition Module#
defs/api.slk mirrors the public type-checking surface:
module acme::logger;
export enum Level {
Trace,
Debug,
Info,
Warn,
Error,
}
export error LogError {
message: string
}
export struct Field {
key: string,
value: string,
}
export struct Entry {
level: Level,
target: string?,
message: string,
request_id: string?,
fields: Field[],
}
export type WriteFn = fn (entry: &Entry) -> LogError?;
export interface Sink {
fn write(entry: &Entry) -> LogError?;
}
export struct ConsoleSink {}
export struct Config {
min_level: Level,
target: string,
include_target: bool,
writer: WriteFn,
}
export struct Logger {
config: Config,
}
export fn default_config () -> Config;
export fn init (config: Config) -> Logger;
export fn enabled (logger: &Logger, level: Level) -> bool;
export fn with_target (logger: &Logger, target: string) -> Logger;
export fn with_writer (logger: &Logger, writer: WriteFn) -> Logger;
export fn log (logger: &Logger, level: Level, message: string, request_id: string?, fields: Field[]) -> LogError?;
export fn debug (logger: &Logger, message: string, request_id: string?, fields: Field[]) -> LogError?;
export fn info (logger: &Logger, message: string, request_id: string?, fields: Field[]) -> LogError?;
export fn warn (logger: &Logger, message: string, request_id: string?, fields: Field[]) -> LogError?;
export fn error (logger: &Logger, message: string, request_id: string?, fields: Field[]) -> LogError?;
export fn console_writer (entry: &Entry) -> LogError?;
export fn parse_level (raw: string) -> Result(Level, string);
export fn level_name (level: Level) -> string;
export fn logger_write_c (level: int, target: string, message: string) -> int;
Definition modules use module ...; because they describe an importable
surface without implementation bodies. Binary packages can ship this file plus
lib/<target>/... artifacts.
Tests#
tests/logger_test.slk exercises parsing and filtering:
import logger from "logger";
import { expect, expect_equal } from "std/test";
test "parse level" {
match (logger::parse_level("warn")) {
Ok(level) => expect_equal(logger::Level::Warn, level),
Err(_) => expect(false, Some("warn parses")),
};
}
test "filter debug when minimum is info" {
let log = logger::init(logger::Config{
min_level: logger::Level::Info,
target: "test",
include_target: false,
writer: logger::console_writer,
});
expect(logger::enabled(&log, logger::Level::Info), Some("info is enabled"));
expect(logger::enabled(&log, logger::Level::Debug) == false, Some("debug is filtered"));
}
Run the package tests:
silk test --package .
Loading Rules#
The imports above resolve through the active module set:
std/ioandstd/testload stdlib modules from the configured std root.Resultis a std-prelude global fromstd::runtime::globals, so it does not need an import in user-space modules.loggerresolves through the dependency key or package root namedlogger; by convention it loads the package'ssrc/lib.slkmodule../file.slkresolves relative to the importing file.
Quoted import specifiers use / path separators. Do not put :: inside the
string; :: belongs to direct package/symbol imports and qualified names.
For local development, downstream apps should use a path dependency:
[dependencies]
logger = { path = "../logger", version = "^0.1.0" }
Source still imports the package through the dependency module specifier:
import logger from "logger";
The dependency key (logger) is manifest-local. Source files inside the package
declare the corresponding symbol namespace with package acme::logger;.
Build Targets#
Build the demo executable:
silk build --package . --package-target logger_demo
Build the static library and C header:
silk build --package . --package-target logger_static
Build the WASI demo:
silk build --package . --package-target logger_wasi_demo
Target selection changes code guarded with attr(os="...") and
attr(target="..."). The quoted dependency module specifier stays logger.
ABI and FFI Edge#
Most user code should avoid direct package imports. ABI code is the exception because it intentionally names linker-visible exports.
src/c_api.slk exposes a C-friendly wrapper:
package acme::logger;
export fn logger_write_c (level: int, target: string, message: string) -> int {
let ctx = init(Config{
min_level: Level::Info,
target: target,
include_target: true,
writer: console_writer,
});
let err = log(&ctx, level_from_int(level), message, None, []);
if err != None { return 1; }
return 0;
}
fn level_from_int (level: int) -> Level {
if level <= 5 { return Level::Trace; }
if level <= 10 { return Level::Debug; }
if level <= 20 { return Level::Info; }
if level <= 30 { return Level::Warn; }
return Level::Error;
}
The generated header uses the ABI shapes from silk/silk.h. A minimal C caller
can pass borrowed string views into the exported wrapper:
#include <silk/silk.h>
#include "acme_logger.h"
static SilkString silk_str(const char *ptr) {
SilkString s = { .ptr = (char *)ptr, .len = 0 };
while (ptr[s.len] != '\0') s.len++;
return s;
}
int main(void) {
return logger_write_c(
20,
silk_str("ffi"),
silk_str("hello from C"));
}
An ABI-facing Silk consumer can import the exact exported symbol:
import acme::logger::logger_write_c;
fn main () -> int {
return logger_write_c(20, "ffi", "hello from a direct ABI symbol");
}
That import binds the logger_write_c ABI/export path directly. It is useful
for definition modules, FFI wrappers, and low-level integration tests. Ordinary
application code should use:
import logger from "logger";
Publishing on GitHub#
GitHub can distribute the same package root that local builds use:
git tag v0.1.0
git archive --format=tar --prefix=logger-0.1.0/ v0.1.0 | gzip > logger-0.1.0.tar.gz
Consumers can unpack it and use a path dependency:
[dependencies]
logger = { path = "vendor/logger", version = "^0.1.0" }
For package-search-path consumption, place the root under a directory layout that matches the manifest package name:
vendor/logger/silk.toml
Then:
export SILK_PACKAGE_PATH="$PWD/vendor"
silk build --package .
Publishing on npm#
npm can act as a transport for a Silk package root. Silk reads files from disk; it does not fetch npm packages itself.
Minimal package.json:
{
"name": "@acme/silk-logger",
"version": "0.1.0",
"description": "Structured logging package for Silk applications",
"files": [
"silk.toml",
"README.md",
"LICENSE",
"src",
"defs",
"examples"
]
}
After npm install @acme/silk-logger, consumers can depend on the installed
directory directly:
[dependencies]
logger = { path = "node_modules/@acme/silk-logger", version = "^0.1.0" }
If you want SILK_PACKAGE_PATH lookup instead, materialize the package under a
search root that mirrors the package name logger:
packages/logger -> ../../node_modules/@acme/silk-logger
Then set:
export SILK_PACKAGE_PATH="$PWD/packages"
Best Practices#
- Keep the public import style user-facing:
import logger from "logger";. - Use direct
import package::ns::symbol;imports only in ABI/FFI code. - Keep a narrow public API in
defs/api.slk. - Treat
Configas the stable setup surface; add new behavior there before changing logger call signatures. - Keep sink-specific code behind
WriteFnfunctions or concrete types that satisfyinterface Sink; add file, JSON, or syslog sinks as separate modules. - Use
LogError?for write paths where failure is uncommon but must be visible. - Use
Resultfor parsing or configuration loading where failure carries a message. - Keep target-specific defaults behind
attr(os="...")orattr(target="..."). - Publish the same
silk.tomlpackage root through GitHub, npm, vendored directories, or system packages.
Next#
Source repository · Edit this page · View Markdown