A complete guide to parsing and transforming log streams using JavaScript modules—from first script to Go integration.
log-parse is a JavaScript-based log processor that lets you parse, filter, and transform log lines into structured events without inventing a new DSL. It uses the goja JavaScript runtime embedded in Go, which means you get a familiar JavaScript syntax with the performance and deployment simplicity of a single binary.
The core idea: write small JavaScript modules that describe how to parse your logs, and let log-parse handle the streaming, error isolation, and output normalization. You can run a single module for simple cases, or fan out to many modules that each produce a different "view" of the same log stream (errors, metrics, security events).
This guide goes deep on the JavaScript module API, the fan-out pipeline, and Go integration. If you just want to run a quick example:
# From the devctl repo root
cat examples/log-parse/sample-json-lines.txt | go run ./cmd/log-parse --module examples/log-parse/parser-json.js
You'll see NDJSON output with structured events. The rest of this guide explains how to write your own modules, use the helper API, and integrate log-parse into larger systems.
Every log stream has structure—timestamps, levels, trace IDs, error codes—but it's often buried in text. log-parse extracts that structure by running your JavaScript parsing logic against each line and emitting normalized JSON events.
┌─────────────────────────────────────────────────────────────────┐
│ log-parse pipeline │
├─────────────────────────────────────────────────────────────────┤
│ │
│ raw log lines JavaScript modules NDJSON out │
│ ──────────────> ┌──────────────┐ ──────────────> │
│ │ parse │ │
│ INFO: startup │ filter │ {"level":"INFO",│
│ ERROR: db down │ transform │ "message":...} │
│ ... └──────────────┘ │
│ │
└─────────────────────────────────────────────────────────────────┘
Key properties:
Let's parse JSON logs. Create a file my-parser.js:
register({
name: "my-json-parser",
parse(line, ctx) {
const obj = log.parseJSON(line);
if (!obj) return null; // skip non-JSON lines
return {
timestamp: obj.ts,
level: obj.level || "INFO",
message: obj.msg || line,
service: obj.service,
trace_id: obj.trace_id,
};
},
});
Run it:
echo '{"ts":"2026-01-06T12:00:00Z","level":"INFO","msg":"startup","service":"api"}' | \
go run ./cmd/log-parse --module my-parser.js
Output:
{"timestamp":"2026-01-06T12:00:00Z","level":"INFO","message":"startup","fields":{"_module":"my-json-parser","_tag":"my-json-parser","service":"api"},"tags":["my-json-parser"],"source":"stdin","raw":"{\"ts\":\"2026-01-06T12:00:00Z\",\"level\":\"INFO\",\"msg\":\"startup\",\"service\":\"api\"}","lineNumber":1}
The module parsed the JSON, extracted fields, and log-parse normalized everything into a consistent event schema.
register({ ... })Every log-parse module calls register() exactly once with a configuration object. This object defines the module's name, optional tag, and hook functions.
register({
name: "my-module", // required: unique module name
tag: "errors", // optional: derived stream tag (defaults to name)
// Required hook: parse each line
parse(line, ctx) {
// return object, string, array, or null
},
// Optional hooks
filter(event, ctx) { return true; }, // return false to drop
transform(event, ctx) { return event; }, // return modified event
init(ctx) {}, // called once at startup
shutdown(ctx) {}, // called once at shutdown
onError(err, payload, ctx) {}, // called when hooks throw
});
For each input line, log-parse executes:
line ──> parse() ──> filter() ──> transform() ──> emit
│ │ │
└──────────┴──────────────┘
(if any returns null/false, line is dropped)
nameThe name field is required and must be unique within a run. It's used for:
_module field in emitted eventstagThe tag field sets the derived stream identifier. If omitted, it defaults to name. Use explicit tags when you want multiple modules to contribute to the same logical stream:
// Both modules emit to the "security" stream
register({ name: "auth-failures", tag: "security", ... });
register({ name: "access-denials", tag: "security", ... });
Every hook receives a ctx object with:
{
hook: "parse", // current hook name
source: "stdin", // input source label
lineNumber: 42, // 1-indexed line number
now: Date, // snapshot of current time
state: {}, // mutable per-module state (persists across lines)
}
The state object is your module's scratch space. Use it for counters, buffers, or any state that spans multiple lines:
register({
name: "line-counter",
parse(line, ctx) {
ctx.state.count = (ctx.state.count || 0) + 1;
return { message: line, line_count: ctx.state.count };
},
});
parse(line, ctx)The parse hook is required. It receives the raw line (without trailing newline) and returns:
| Return value | Behavior |
|---|---|
null or undefined | Drop line (no event emitted) |
string | Shorthand for { message: string } |
object | Treated as an event (normalized by log-parse) |
array | Each element is treated as a separate event |
Example: returning multiple events
parse(line, ctx) {
const obj = log.parseJSON(line);
if (!obj) return null;
// Emit both raw event and a derived metric
return [
{ level: obj.level, message: obj.msg },
{ level: "INFO", message: "metric", fields: { duration_ms: obj.duration } },
];
}
filter(event, ctx)The filter hook receives the parsed event and returns a boolean:
true: keep the eventfalse: drop the eventfilter(event, ctx) {
// Only keep errors and warnings
return event.level === "ERROR" || event.level === "WARN";
}
transform(event, ctx)The transform hook receives a parsed (and filtered) event and returns a modified event:
| Return value | Behavior |
|---|---|
null or undefined | Drop event |
object | Use as the new event |
array | Each element becomes a separate event |
transform(event, ctx) {
// Redact sensitive fields
if (event.fields && event.fields.password) {
event.fields.password = "[REDACTED]";
}
return event;
}
init(ctx) and shutdown(ctx)These lifecycle hooks run once per module:
init: called after the module is loaded, before processing any linesshutdown: called after all lines are processedUse them for setup (initializing buffers) and cleanup (flushing state):
register({
name: "buffered",
init(ctx) {
ctx.state.buffer = log.createMultilineBuffer({
pattern: /^ERROR/,
match: "after",
});
},
parse(line, ctx) {
return ctx.state.buffer.add(line);
},
shutdown(ctx) {
const remaining = ctx.state.buffer.flush();
if (remaining) console.warn("unflushed:", remaining);
},
});
onError(err, payload, ctx)When a hook throws an exception, log-parse calls onError (if defined) with:
err: the error objectpayload: the value passed to the failing hook (line or event)ctx: the context at the time of errorUse this for logging, metrics, or graceful degradation:
onError(err, payload, ctx) {
console.error(`[${ctx.hook}] error: ${err.message}`);
}
log-parse normalizes every returned event into a consistent schema before emitting:
{
"timestamp": "2026-01-06T12:00:00Z",
"level": "INFO",
"message": "something happened",
"fields": { "service": "api", "trace_id": "abc123" },
"tags": ["my-module"],
"source": "stdin",
"raw": "original log line",
"lineNumber": 42
}
When your hook returns an object, log-parse applies these rules:
| Field | Default | Notes |
|---|---|---|
timestamp | omitted | Pass a Date object or ISO string |
level | "INFO" | Uppercase recommended |
message | raw line | Falls back to raw input if missing |
fields | {} | Merged from returned fields + extra keys |
tags | [] | Empty strings are filtered out |
source | from context | Usually the input filename or "stdin" |
raw | original line | Always preserved |
lineNumber | from context | 1-indexed |
Extra keys become fields: Any key you return that isn't in the reserved set (timestamp, level, message, fields, tags) is moved into fields:
// This:
return { level: "ERROR", message: "boom", trace_id: "abc" };
// Becomes:
{ "level": "ERROR", "message": "boom", "fields": { "trace_id": "abc" }, ... }
Pass timestamps as:
toISOString()log.parseTimestamp() results: best-effort parsing (see helpers section)return {
timestamp: log.parseTimestamp(obj.ts), // robust parsing
message: obj.msg,
};
log.*log-parse provides a global log object with parsing helpers. These are pure functions (no I/O) designed for common log parsing tasks.
log.parseJSON(line)Parse a JSON string. Returns the parsed object or null on failure.
const obj = log.parseJSON(line);
if (!obj) return null;
log.parseLogfmt(line)Parse logfmt-style key=value pairs. Supports quoted values with escapes.
// Input: level=INFO msg="hello world" trace_id=abc
const obj = log.parseLogfmt(line);
// Result: { level: "INFO", msg: "hello world", trace_id: "abc" }
log.parseKeyValue(line, delimiter?, separator?)Generic key-value parsing with configurable delimiters.
// Default: space-delimited, "=" separator
log.parseKeyValue("a=1 b=2") // { a: "1", b: "2" }
// Custom: comma-delimited, ":" separator
log.parseKeyValue("a:1,b:2", ",", ":") // { a: "1", b: "2" }
log.capture(line, regex)Return an array of capture groups (without the full match).
const m = log.capture(line, /^(\w+)\s+\[([^\]]+)\]\s+(.*)$/);
if (m) {
return { level: m[0], service: m[1], message: m[2] };
}
log.namedCapture(line, regex)Return an object from named capture groups. Note: goja's RegExp engine doesn't support (?<name>...) syntax, so this is limited.
log.extract(line, regex, group?)Extract a single group (default: group 1).
const traceId = log.extract(line, /trace_id=(\w+)/);
log.getPath(obj, path) / log.field(obj, path)Dot-notation path access. Returns null if any segment is missing.
const userId = log.getPath(obj, "user.profile.id");
log.hasPath(obj, path)Returns true if the path exists and is not null.
log.addTag(event, tag)Add a tag to an event's tags array (idempotent).
log.addTag(event, "security");
log.addTag(event, "auth_failed");
log.removeTag(event, tag)Remove a tag from an event's tags array.
log.hasTag(event, tag)Check if an event has a specific tag.
log.toNumber(value)Convert to number safely. Returns null if not a finite number.
const duration = log.toNumber(obj.duration_ms);
if (duration == null) return null;
log.parseTimestamp(value, formats?)Parse timestamps with best-effort heuristics. Supports:
return { timestamp: log.parseTimestamp(obj.ts) };
// With explicit formats (Go time.Parse layouts)
log.parseTimestamp(obj.ts, ["2006-01-02 15:04:05", "Jan 2 2006"])
log.createMultilineBuffer(config)Create a buffer for accumulating multiline log entries (like stack traces).
const buffer = log.createMultilineBuffer({
pattern: /^\s+at /, // continuation pattern
negate: true, // true = pattern marks START of new record
match: "after", // only "after" is supported
maxLines: 200, // max lines per record
timeout: "5s", // flush after timeout (best-effort)
});
// In parse():
const complete = buffer.add(line);
if (complete) {
return { message: complete.split("\n")[0], fields: { stack: complete } };
}
return null;
The buffer returns null while accumulating and returns the complete record when a new record starts.
log-parse can run multiple modules simultaneously, each producing its own tagged event stream. This is the "fan-out" model: one input stream, many derived output streams.
Instead of building one giant module that handles everything, you write small, focused modules:
┌─────────────┐
┌──>│ errors.js │──> tag: errors
│ └─────────────┘
input lines ──────────────┼──>┌─────────────┐
│ │ metrics.js │──> tag: metrics
│ └─────────────┘
└──>┌─────────────┐
│ security.js │──> tag: security
└─────────────┘
Each module:
Use --module (repeatable) or --modules-dir:
# Explicit module list
log-parse --module errors.js --module metrics.js --input app.log
# Load all *.js from a directory (lexicographic order)
log-parse --modules-dir ./parsers/ --input app.log
Files in --modules-dir are loaded in lexicographic order. Use numeric prefixes for explicit ordering:
parsers/
01-errors.js
02-metrics.js
03-security.js
For each event emitted from a module, log-parse automatically:
tag to event.tags (if not already present)event.fields._tag to the tagevent.fields._module to the module nameThis ensures downstream consumers can always group/filter by tag.
01-errors.js:
register({
name: "errors",
tag: "errors",
parse(line, ctx) {
const obj = log.parseJSON(line);
if (!obj) return null;
const level = String(obj.level || "").toUpperCase();
if (level !== "ERROR" && level !== "FATAL") return null;
return {
level,
message: obj.msg || obj.message || line,
fields: { service: obj.service, trace_id: obj.trace_id },
};
},
});
02-metrics.js:
register({
name: "metrics",
tag: "metrics",
parse(line, ctx) {
const obj = log.parseJSON(line);
if (!obj) return null;
const durationMs = log.toNumber(obj.duration_ms);
if (durationMs == null) return null;
return {
level: "INFO",
message: "request_duration_ms",
fields: { service: obj.service, route: obj.route, duration_ms: durationMs },
};
},
});
03-security.js:
register({
name: "security",
tag: "security",
parse(line, ctx) {
const obj = log.parseJSON(line);
if (!obj) return null;
const msg = String(obj.msg || obj.message || "");
if (!msg.toLowerCase().includes("authentication failed")) return null;
const ev = {
level: "WARN",
message: msg,
fields: { service: obj.service, user: obj.user, ip: obj.ip },
tags: [],
};
log.addTag(ev, "auth_failed");
return ev;
},
});
Run:
cat app.log | log-parse --modules-dir ./parsers/ --print-pipeline --stats
Output includes the pipeline summary and per-module statistics.
log-parse [flags] [command]
| Flag | Description | Default |
|---|---|---|
--module <path> | Path to a JS module file (repeatable) | required |
--modules-dir <dir> | Directory to load all *.js files from (repeatable) | - |
--input <path> | Input file path | stdin |
--source <label> | Source label in events | filename or "stdin" |
--format <fmt> | Output format: ndjson or pretty | ndjson |
--js-timeout <dur> | Per-hook timeout (e.g. 50ms, 200ms) | 0 (no timeout) |
--print-pipeline | Print loaded modules and hooks (stderr) | false |
--stats | Print per-module stats on exit (stderr) | false |
--errors <path> | Write error records to file (stderr or - for stderr) | - |
log-parse validateValidate modules without processing input. Checks:
register() calllog-parse validate --modules-dir ./parsers/
# Parse stdin with a single module
cat app.log | log-parse --module parser.js
# Parse file with multiple modules
log-parse --modules-dir ./parsers/ --input app.log
# Pretty-print output
log-parse --module parser.js --input app.log --format pretty
# Show pipeline and stats
log-parse --modules-dir ./parsers/ --input app.log --print-pipeline --stats
# Set timeout to prevent infinite loops
log-parse --module parser.js --js-timeout 100ms --input app.log
# Write errors to file
log-parse --modules-dir ./parsers/ --input app.log --errors errors.ndjson
# Validate modules
log-parse validate --modules-dir ./parsers/
Errors in one module don't affect other modules. If a hook throws:
--errors)onError is called (if defined)With --errors, log-parse writes structured error records:
{
"module": "my-parser",
"tag": "my-parser",
"hook": "parse",
"source": "stdin",
"lineNumber": 42,
"timeout": false,
"message": "TypeError: Cannot read property 'foo' of undefined",
"rawLine": "the original line"
}
Use --js-timeout to prevent infinite loops from blocking the pipeline:
log-parse --module parser.js --js-timeout 50ms --input app.log
If a hook exceeds the timeout, it's interrupted and treated as an error.
console.log() and console.error() in your modules (output goes to stdout/stderr)--print-pipeline to verify which modules and hooks are loaded--stats to see drop rates and error counts--format pretty for readable output during developmentThe pkg/logjs package provides a Go API for embedding log-parse in your own applications.
import "github.com/go-go-golems/devctl/pkg/logjs"
// Event is the normalized output event
type Event struct {
Timestamp *string `json:"timestamp,omitempty"`
Level string `json:"level"`
Message string `json:"message"`
Fields map[string]any `json:"fields"`
Tags []string `json:"tags"`
Source string `json:"source"`
Raw string `json:"raw"`
LineNumber int64 `json:"lineNumber"`
}
// ErrorRecord captures hook failures
type ErrorRecord struct {
Module string `json:"module"`
Tag string `json:"tag"`
Hook string `json:"hook"`
Source string `json:"source"`
LineNumber int64 `json:"lineNumber"`
Timeout bool `json:"timeout"`
Message string `json:"message"`
RawLine *string `json:"rawLine,omitempty"`
}
// Options configures module loading
type Options struct {
HookTimeout string // e.g. "50ms"
}
import (
"context"
"github.com/go-go-golems/devctl/pkg/logjs"
)
func main() {
ctx := context.Background()
// Load a module from file
module, err := logjs.LoadFromFile(ctx, "parser.js", logjs.Options{
HookTimeout: "100ms",
})
if err != nil {
log.Fatal(err)
}
defer module.Close(ctx)
// Process lines
events, errors, err := module.ProcessLine(ctx, logLine, "source", lineNumber)
if err != nil {
log.Fatal(err)
}
for _, ev := range events {
// Handle event
fmt.Printf("%s: %s\n", ev.Level, ev.Message)
}
for _, errRec := range errors {
// Handle error record
fmt.Fprintf(os.Stderr, "error in %s: %s\n", errRec.Hook, errRec.Message)
}
}
func main() {
ctx := context.Background()
// Load multiple modules
scriptPaths := []string{"errors.js", "metrics.js", "security.js"}
fanout, err := logjs.LoadFanoutFromFiles(ctx, scriptPaths, logjs.Options{
HookTimeout: "100ms",
})
if err != nil {
log.Fatal(err)
}
defer fanout.Close(ctx)
// Process lines through all modules
events, errors, err := fanout.ProcessLine(ctx, logLine, "source", lineNumber)
// events contains tagged results from all modules
}
// Get module info
info := module.Info()
fmt.Printf("Name: %s, Tag: %s\n", info.Name, info.Tag)
fmt.Printf("Has filter: %v, Has transform: %v\n", info.HasFilter, info.HasTransform)
// Get stats after processing
stats := module.Stats()
fmt.Printf("Lines: %d, Emitted: %d, Dropped: %d\n",
stats.LinesProcessed, stats.EventsEmitted, stats.LinesDropped)
fmt.Printf("Hook errors: %d, Timeouts: %d\n",
stats.HookErrors, stats.HookTimeouts)
For real-time log processing (like tail -f):
func streamLogs(ctx context.Context, reader io.Reader, module *logjs.Module, sink func(*logjs.Event)) error {
scanner := bufio.NewScanner(reader)
var lineNumber int64
for scanner.Scan() {
lineNumber++
line := scanner.Text()
events, _, err := module.ProcessLine(ctx, line, "stream", lineNumber)
if err != nil {
return err
}
for _, ev := range events {
sink(ev)
}
}
return scanner.Err()
}
JSON with nested fields:
register({
name: "nested-json",
parse(line, ctx) {
const obj = log.parseJSON(line);
if (!obj) return null;
return {
timestamp: log.getPath(obj, "metadata.timestamp"),
level: log.getPath(obj, "metadata.level") || "INFO",
message: log.getPath(obj, "payload.message"),
trace_id: log.getPath(obj, "context.trace_id"),
fields: obj.payload,
};
},
});
Regex for custom formats:
register({
name: "custom-format",
parse(line, ctx) {
// Format: [2026-01-06 12:00:00] [INFO] [service] message
const m = log.capture(line, /^\[([^\]]+)\]\s+\[(\w+)\]\s+\[(\w+)\]\s+(.*)$/);
if (!m) return null;
return {
timestamp: log.parseTimestamp(m[0]),
level: m[1],
service: m[2],
message: m[3],
};
},
});
register({
name: "java-exceptions",
init(ctx) {
ctx.state.buffer = log.createMultilineBuffer({
pattern: /^\s+at |^\s+\.\.\. \d+ more|^Caused by:/,
negate: true, // pattern marks continuation, not start
match: "after",
maxLines: 100,
});
},
parse(line, ctx) {
const complete = ctx.state.buffer.add(line);
if (!complete) return null;
const lines = complete.split("\n");
const firstLine = lines[0];
// Extract exception class and message
const m = log.capture(firstLine, /^(\w+(?:\.\w+)*Exception):\s*(.*)$/);
return {
level: "ERROR",
message: m ? m[1] + ": " + m[0] : firstLine,
fields: {
exception_class: m ? m[0] : null,
stack_trace: complete,
stack_depth: lines.length,
},
};
},
});
register({
name: "latency-buckets",
tag: "metrics",
parse(line, ctx) {
const obj = log.parseJSON(line);
if (!obj) return null;
const latencyMs = log.toNumber(obj.latency_ms);
if (latencyMs == null) return null;
let bucket = "fast";
if (latencyMs > 1000) bucket = "slow";
else if (latencyMs > 100) bucket = "medium";
return {
message: "request_latency",
fields: {
latency_ms: latencyMs,
bucket: bucket,
endpoint: obj.endpoint,
},
};
},
});
register({
name: "suspicious-activity",
tag: "security",
init(ctx) {
ctx.state.failedLogins = {}; // Track per-IP failures
},
parse(line, ctx) {
const obj = log.parseJSON(line);
if (!obj) return null;
const msg = String(obj.message || "").toLowerCase();
const ip = obj.client_ip;
if (msg.includes("login failed") && ip) {
const count = (ctx.state.failedLogins[ip] || 0) + 1;
ctx.state.failedLogins[ip] = count;
if (count >= 5) {
const ev = {
level: "WARN",
message: "Potential brute force attack",
fields: { ip: ip, failed_attempts: count },
};
log.addTag(ev, "brute_force");
return ev;
}
}
return null;
},
});
"script did not call register()"
Your module file doesn't call register({ ... }). Every module must call it exactly once.
"register({ name: string, ... }): name is required"
The register() call is missing the name field. Add name: "my-module".
"register({ parse: function, ... }): parse is required"
The register() call is missing the parse function. Add a parse(line, ctx) { ... } function.
"duplicate module name"
Two modules have the same name. Module names must be unique within a run.
Infinite loop / timeout
Your JavaScript has an infinite loop or very slow logic. Use --js-timeout to protect against this:
log-parse --module parser.js --js-timeout 50ms --input app.log
Non-JSON lines cause errors
Your parser might be throwing on non-JSON input. Check for null:
parse(line, ctx) {
const obj = log.parseJSON(line);
if (!obj) return null; // gracefully skip non-JSON
// ...
}
Events missing expected fields
Check the normalization rules. Extra keys are moved to fields:
// Your return:
return { level: "INFO", message: "hi", my_field: 123 };
// After normalization:
{ "level": "INFO", "message": "hi", "fields": { "my_field": 123 }, ... }
For more details on specific topics:
examples/log-parse/ in the devctl repottmp/2026/01/06/MO-007-LOG-PARSER--*/design-doc/pkg/logjs/*_test.go for edge cases and behavior examples