commit 1fa21a9c74e49f28904c9d2cbabacf1d84df9c40 Author: ge Date: Sat Jan 3 16:38:28 2026 +0300 init diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 0000000..01072ca --- /dev/null +++ b/.editorconfig @@ -0,0 +1,8 @@ +[*] +charset = utf-8 +end_of_line = lf +insert_final_newline = true +trim_trailing_whitespace = true + +[*.v] +indent_style = tab diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..9a98968 --- /dev/null +++ b/.gitattributes @@ -0,0 +1,8 @@ +* text=auto eol=lf +*.bat eol=crlf + +*.v linguist-language=V +*.vv linguist-language=V +*.vsh linguist-language=V +v.mod linguist-language=V +.vdocignore linguist-language=ignore diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..02e493c --- /dev/null +++ b/.gitignore @@ -0,0 +1,24 @@ +# Binaries for programs and plugins +main +structlog +*.exe +*.exe~ +*.so +*.dylib +*.dll + +# Ignore binary output folders +bin/ + +# Ignore common editor/system specific metadata +.DS_Store +.idea/ +.vscode/ +*.iml + +# ENV +.env + +# vweb and database +*.db +*.js diff --git a/.vdocignore b/.vdocignore new file mode 100644 index 0000000..d838da9 --- /dev/null +++ b/.vdocignore @@ -0,0 +1 @@ +examples/ diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..188de5f --- /dev/null +++ b/LICENSE @@ -0,0 +1,18 @@ +MIT License + +Copyright (c) 2026 gechandesu + +Permission is hereby granted, free of charge, to any person obtaining a copy of this software and +associated documentation files (the "Software"), to deal in the Software without restriction, including +without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the +following conditions: + +The above copyright notice and this permission notice shall be included in all copies or substantial +portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT +LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO +EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER +IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE +USE OR OTHER DEALINGS IN THE SOFTWARE. diff --git a/README.md b/README.md new file mode 100644 index 0000000..d435068 --- /dev/null +++ b/README.md @@ -0,0 +1,41 @@ +# Structured Logs + +The `structlog` module develops the idea of [vlogger](https://github.com/CG-SS/vlogger) +by constructing a record using a chain of method calls. + +## Concept + +When initialized, the logger starts a thread with a record handler. The logger +has a number of methods, each of which creates a record with the corresponding +logging level, e.g. `info()`. + +By chaining method calls, the module user can create a record with any structure. +The final `.send()` call sends the record to the handler for writing. + +The record handler completely defines how to prepare the `Record` object for +writing, how and whereto the writing will occur. The handler must implement the +`RecordHandler` interface. Two ready-made handlers for recording are provided: +`TextHandler` (the default) and `JSONHandler` for JSON formatted logs. + +## Usage + +```v +import structlog + +fn main() { + log := structlog.new() + defer { + log.close() + } + + log.info().message('Hello, World!').send() +} +``` + +Output: + +``` +2026-01-03T09:33:35.366Z [INFO ] message: 'Hello, World!' +``` + +See also [examples](examples/) dir for more usage examples. diff --git a/examples/basic.v b/examples/basic.v new file mode 100644 index 0000000..12113c9 --- /dev/null +++ b/examples/basic.v @@ -0,0 +1,27 @@ +import rand +import structlog + +fn main() { + // Initialize logger with default configuratuion. + log := structlog.new() + defer { + // Since processing and writing the log is done in a separate thread, + // we need to wait for it to complete before exiting the program. + log.close() + } + + // Write some logs. + // + // Note the call chain. First, the info() call creates a empty `structlog.Record` + // object with `info` log level. The next message() call adds a message field with + // the specified text to the record. The final send() call sends the record to the + // record handler (TextHandler by default) which writes log to stardard output. + log.info().message('Hello, World!').send() + + // You can set your own named fields. + log.info().field('random_string', rand.string(5)).send() + log.info().field('answer', 42).field('computed_by', 'Deep Thought').send() + + // Errors can be passed to logger as is. + log.error().message('this line contains error').error(error('oops')).send() +} diff --git a/examples/json_log.v b/examples/json_log.v new file mode 100644 index 0000000..5ed8072 --- /dev/null +++ b/examples/json_log.v @@ -0,0 +1,21 @@ +import os +import rand +import structlog + +fn main() { + // Initialize logger with JSONHandler. + log := structlog.new( + level: .trace + handler: structlog.JSONHandler{ + writer: os.stdout() + } + ) + defer { + log.close() + } + + log.info().message('Hello, World!').send() + log.info().field('random_string', rand.string(5)).send() + log.info().field('answer', 42).field('computed_by', 'Deep Thought').send() + log.error().message('this line contains error').error(error('oops')).send() +} diff --git a/examples/logging_levels.v b/examples/logging_levels.v new file mode 100644 index 0000000..1d52a5f --- /dev/null +++ b/examples/logging_levels.v @@ -0,0 +1,16 @@ +import structlog + +fn main() { + // Initialize logger with non-default logging level. + log := structlog.new(level: .trace) // try to change logging level + defer { + log.close() + } + + log.trace().message('hello trace').send() + log.debug().message('hello debug').send() + log.info().message('hello info').send() + log.warn().message('hello warn').send() + log.error().message('hello error').send() + log.fatal().message('hello fatal').send() // on fatal program exits immediately with exit code 1 +} diff --git a/examples/unix_timestamp.v b/examples/unix_timestamp.v new file mode 100644 index 0000000..1060b08 --- /dev/null +++ b/examples/unix_timestamp.v @@ -0,0 +1,17 @@ +import os +import structlog + +fn main() { + // Initialize logger with edited timestamp. + log := structlog.new( + timestamp_format: .unix + handler: structlog.JSONHandler{ + writer: os.stdout() + } + ) + defer { + log.close() + } + + log.info().message('Hello, World!').send() +} diff --git a/structlog.v b/structlog.v new file mode 100644 index 0000000..9edd341 --- /dev/null +++ b/structlog.v @@ -0,0 +1,435 @@ +module structlog + +import io +import os +import strings +import time +import term +import x.json2 as json + +pub interface RecordHandler { +mut: + // handle method must prepare the Record for writing and write it. + handle(rec Record) ! +} + +pub enum Level { + none // disables all logs. + fatal // disables error, warn, info, debug and trace. + error // disables warn, info, debug and trace. + warn // disables info, debug and trace. + info // disables debug and trace. + debug // disables trace. + trace +} + +pub type Value = i8 + | i16 + | i32 + | i64 + | int + | isize + | u8 + | u16 + | u32 + | u64 + | usize + | f32 + | f64 + | string + | bool + | []Value + | map[string]Value + +// str returns a string representation of Value. +pub fn (v Value) str() string { + return match v { + i8 { v.str() } + i16 { v.str() } + i32 { v.str() } + i64 { v.str() } + int { v.str() } + isize { v.str() } + u8 { v.str() } + u16 { v.str() } + u32 { v.str() } + u64 { v.str() } + usize { v.str() } + f32 { v.str() } + f64 { v.str() } + string { v.str() } + bool { v.str() } + []Value { v.str() } + map[string]Value { v.str() } + } +} + +// Field represents a named field of log record. +pub struct Field { +pub: + name string + value Value +} + +// as_map converts array of fields into map. +pub fn (f []Field) as_map() map[string]Value { + mut mapping := map[string]Value{} + for field in f { + mapping[field.name] = field.value + } + return mapping +} + +@[noinit] +pub struct Record { + channel chan Record +pub: + level Level + fields []Field +} + +// append adds new fields to a record and returns the modified record. +pub fn (r Record) append(field ...Field) Record { + if field.len == 0 { + return r + } + mut fields_orig := unsafe { r.fields } + fields_orig << field + return Record{ + channel: r.channel + level: r.level + fields: &fields_orig + } +} + +// prepend adds new fields to the beginning of the record and returns the modified record. +pub fn (r Record) prepend(field ...Field) Record { + if field.len == 0 { + return r + } + mut new_fields := unsafe { field } + new_fields << r.fields + return Record{ + channel: r.channel + level: r.level + fields: new_fields + } +} + +// field adds new field with given name and value to a record and returns the modified record. +pub fn (r Record) field(name string, value Value) Record { + return r.append(Field{ name: name, value: value }) +} + +// field adds new message field to a record and returns the modified record. +// This is a shothand for `field('message', 'message text')`. +pub fn (r Record) message(s string) Record { + return r.field('message', s) +} + +// error adds an error as new field to a record and returns the modified record. +// The IError .msg() and .code() methods output will be logged. +pub fn (r Record) error(err IError) Record { + return r.append(Field{ + name: 'error' + value: { + 'msg': Value(err.msg()) + 'code': Value(err.code()) + } + }) +} + +// send sends a record to the record handler for the futher processing and writing. +pub fn (r Record) send() { + r.channel <- r +} + +pub enum TimestampFormat { + default + rfc3339 + rfc3339_micro + rfc3339_nano + ss + ss_micro + ss_milli + ss_nano + unix + unix_micro + unix_milli + unix_nano + custom +} + +@[params] +pub struct LogConfig { +pub: + // level holds a logging level for the logger. + // This value cannot be changed after logger initialization. + level Level = .info + + add_level bool = true // if true add `level` field to all log records. + add_timestamp bool = true // if true add `timestamp` field to all log records. + + // timestamp_format sets the format of datettime for logs. + // TimestampFormat values ​​map 1-to-1 to the date formats provided by `time.Time`. + // If .custom format is selected the timestamp_custom field must be set. + timestamp_format TimestampFormat = .rfc3339 + + // timestamp_custom sets the custom datetime string format if timestapm_format is + // set to .custom. See docs for Time.format_custom() fn from stadnard `time` module. + timestamp_custom string + + // If timestamp_local is true the local time will be used instead of UTC. + timestamp_local bool + + // handler holds a log record handler object which is used to process logs. + handler RecordHandler = TextHandler{ + writer: os.stdout() + } +} + +fn timestamp(format TimestampFormat, custom string, local bool) Value { + mut t := time.utc() + if local { + t = t.local() + } + return match format { + .default { t.format() } + .rfc3339 { t.format_rfc3339() } + .rfc3339_micro { t.format_rfc3339_micro() } + .rfc3339_nano { t.format_rfc3339_nano() } + .ss { t.format_ss() } + .ss_micro { t.format_ss_micro() } + .ss_milli { t.format_ss_milli() } + .ss_nano { t.format_ss_nano() } + .unix { t.unix() } + .unix_micro { t.unix_micro() } + .unix_milli { t.unix_milli() } + .unix_nano { t.unix_nano() } + .custom { t.custom_format(custom) } + } +} + +// new creates new logger with given config. See LogConfig for defaults. +// This function starts a separate thread for processing and writing logs. +// The calling code MUST wait for this thread to complete to ensure all logs +// are written correctly. To do this, close the logger as shown in the examples. +// Example: +// ```v ignore +// log := structlog.new() +// defer { +// log.close() +// } +// ``` +pub fn new(config LogConfig) StructuredLog { + ch := chan Record{cap: 4096} + + mut logger := StructuredLog{ + LogConfig: config + channel: ch + } + + handler_thread := go fn [mut logger] () { + loop: for { + mut rec := <-logger.channel or { break } + + if int(rec.level) > int(logger.level) { + continue loop + } + + mut extra_fields := []Field{} + + if logger.add_timestamp { + extra_fields << Field{ + name: 'timestamp' + value: timestamp(logger.timestamp_format, logger.timestamp_custom, + logger.timestamp_local) + } + } + + if logger.add_level { + extra_fields << Field{ + name: 'level' + value: rec.level.str() + } + } + + rec = rec.prepend(...extra_fields) + + mut handler := logger.handler + handler.handle(rec) or { eprintln('error when handling log record!') } + + if rec.level == .fatal { + exit(1) + } + } + }() + + logger.handler_thread = handler_thread + + return logger +} + +@[heap; noinit] +pub struct StructuredLog { + LogConfig +mut: + channel chan Record + handler_thread thread +} + +fn (s StructuredLog) record(level Level) Record { + return Record{ + channel: s.channel + level: level + } +} + +// trace creates new log record with trace level. +pub fn (s StructuredLog) trace() Record { + return s.record(.trace) +} + +// debug creates new log record with debug level. +pub fn (s StructuredLog) debug() Record { + return s.record(.debug) +} + +// info creates new log record with info level. +pub fn (s StructuredLog) info() Record { + return s.record(.info) +} + +// warn creates new log record wth warning level. +pub fn (s StructuredLog) warn() Record { + return s.record(.warn) +} + +// error creates new log record with error level. +pub fn (s StructuredLog) error() Record { + return s.record(.error) +} + +// fatal creates new log record with fatal level. +// Note: After calling `send()` on record with fatal level the program will +// immediately exit with exit code 1. +pub fn (s StructuredLog) fatal() Record { + return s.record(.fatal) +} + +// close closes the internal communication channell (which is used for transfer +// log messages) and waits for record handler thread. It MUST be called for +// normal log processing. +pub fn (s StructuredLog) close() { + s.channel.close() + s.handler_thread.wait() +} + +// DefaultHandler is a default empty implementation of RecordHandler interface. +// Its only purpose for existence is to be embedded in a concrete implementation +// of the interface for common struct fields. +pub struct DefaultHandler { +pub mut: + writer io.Writer +} + +// handle is the default implementation of handle method of RecordHandler. It does nothing. +pub fn (mut h DefaultHandler) handle(rec Record) ! {} + +pub struct JSONHandler { + DefaultHandler +} + +// handle converts the log record into json string and writes it into underlying writer. +pub fn (mut h JSONHandler) handle(rec Record) ! { + str := json.encode[map[string]Value](rec.fields.as_map()) + '\n' + h.writer.write(str.bytes())! +} + +pub struct TextHandler { + DefaultHandler +pub: + // If true use colors in log messages. Otherwise disable colors at all. + // Turning on/off color here does not affect any colors that may be contained + // within the log itself i.e. in-string ANSI escape sequences are not processed. + color bool = true +} + +// handle builds a log string from given record and writes it into underlying writer. +pub fn (mut h TextHandler) handle(rec Record) ! { + mut buf := strings.new_builder(512) + for i, field in rec.fields { + match field.name { + 'timestamp' { + if field.value is string { + buf.write_string(field.value) + } else { + buf.write_string((field.value as i64).str()) + } + } + 'level' { + mut lvl := '' + if h.color { + lvl = match rec.level { + .trace { term.magenta('TRACE') } + .debug { term.cyan('DEBUG') } + .info { term.white('INFO ') } + .warn { term.yellow('WARN ') } + .error { term.red('ERROR') } + .fatal { term.bg_red('FATAL') } + .none { '' } + } + } else { + lvl = match rec.level { + .trace { 'TRACE' } + .debug { 'DEBUG' } + .info { 'INFO ' } + .warn { 'WARN ' } + .error { 'ERROR' } + .fatal { 'FATAL' } + .none { '' } + } + } + buf.write_byte(`[`) + buf.write_string(lvl) + buf.write_byte(`]`) + } + else { + if field.value is map[string]Value { + mut j := 0 + for k, v in field.value { + j++ + buf.write_string('${field.name}.${k}') + buf.write_byte(`:`) + buf.write_byte(` `) + buf.write_string(quote(v.str())) + if j != field.value.len { + buf.write_byte(` `) + } + } + } else { + buf.write_string(field.name) + buf.write_byte(`:`) + buf.write_byte(` `) + buf.write_string(quote(field.value.str())) + } + } + } + if i != rec.fields.len { + buf.write_byte(` `) + } + } + buf.write_byte(`\n`) + h.writer.write(buf)! +} + +@[inline] +fn quote(input string) string { + if !input.contains(' ') { + return input + } + if input.contains("'") { + return '"' + input + '"' + } + return "'" + input + "'" +} diff --git a/v.mod b/v.mod new file mode 100644 index 0000000..d8959c9 --- /dev/null +++ b/v.mod @@ -0,0 +1,7 @@ +Module { + name: 'structlog' + description: 'Structured logs' + version: '0.1.0' + license: 'MIT' + dependencies: [] +}