Observed since 20 Oct, 19:13 UTC
No superseded metadata states collected. Unchanged observations do not create new revisions.
Low-overhead structured event logs for agents and developers
2g is a structured event logger for command-line tools. It writes JSONL logs
that can be replayed, tailed, or exported to trace formats.
In short, 2g,
The goal of 2g is to replace ad-hoc debug logging with structured events
that are always recorded, without instrumented code paths paying for it. A disabled logging
call is a single property check, and an enabled call serializes straight into a batched
file stream — there is no interchange format, no transport negotiation, and no
per-event allocation beyond the payload the caller already builds.
This has been built with the explicit purpose in mind to:
As such, sessions write newline-framed JSONL through a single batched fd writer, rotate across fixed-size segments, and broadcast to live subscribers over a local socket. Child processes and workers inherit the session over an IPC socket published through the environment, so the whole process tree agrees on one destination.
The package ships two entrypoints so consumers don't load the producer graph and vice versa:
2g — the producer surface instrumented processes import2g/api — the consumption surface for tooling that reads sessionsInstall logging once when a CLI command starts, then create loggers in any package:
import { installEventLogger, events } from '2g';
installEventLogger({
command: 'expo start -p web',
metadata: { version: '1.0.0' },
});
const log = events('metro');
log('ready', { port: 8081 });
const end = log.span();
end('bundle', { platform: 'ios', cached: false });
Tap into the session from another terminal:
2g tap "expo start" --filter metro:* --tail
installEventLogger resolves its destination in precedence order:
LOG_EVENTS environment target — an fd number or a file pathsession: falseImporting 2g automatically attaches to an inherited parent logger. If this installs
the logger, a later installEventLogger call cannot override its destination or options.
Calling installEventLogger() with no arguments activates logging only when LOG_EVENTS
or a parent process is present — the right idiom for child processes, workers, and
libraries. Passing session: false keeps this env-only activation while still providing
options. There is no uninstall: logging is installed once per process and torn down on
exit.
When LOG_EVENTS targets stdout, console is transparently redirected to stderr so
event output stays machine-readable.
Sessions live under event-log-<uid> in the system temporary directory. Each
session directory holds rotating JSONL segments (0.jsonl is current), a meta.json
describing the process, and local sockets for live subscribers and child IPC. With the
defaults of 3 segments × 512 KiB, a session retains roughly the last 12k events.
Stale sessions are cleaned opportunistically: exited sessions are removed after 7 days, and only the newest 100 exited sessions are kept. An unwritable temporary directory disables session capture without affecting the host process.
events.debug(category) creates a logger for chatty, debug-level events. Debug events
carry _l: 1 on the wire. Session output drops them at the emit site — keeping the
rotation budget for normal history — unless LOG_DEBUG is set or installEventLogger
is passed debug: true; explicit file/fd targets record them by default. An explicit
debug option takes precedence over these defaults when installing. tap and export
skip debug events unless --debug (CLI) or debug: true (API) is passed.
In session mode, LOG_DEBUG also prints matching events, including forwarded child
events, to stderr in a readable format. Explicit file/fd targets do not add this mirror,
and debug: true alone only enables recording. The LOG_DEBUG filter selects what is
printed, not which debug events are recorded:
LOG_DEBUG=metro:* expo start
LOG_DEBUG=* expo export
Declare the application metadata you expect, then supply it at installation or as values become known:
import { installEventLogger, updateEventLoggerMetadata } from '2g';
declare module '2g' {
interface MetadataRegistry {
port: number;
ready: boolean;
devServerUrl: string | null;
}
}
installEventLogger({ metadata: { version: '1.0.0', ready: false } });
updateEventLoggerMetadata({
port: 8081,
devServerUrl: 'http://localhost:8081',
});
updateEventLoggerMetadata({ ready: true });
Both calls accept partial, JSON-compatible metadata. Updates merge shallowly:
supplied keys replace previous values, including nested objects and arrays.
Top-level undefined values are ignored, leaving existing values unchanged.
Initial values emit root:init; later patches emit root:update. Sessions persist
their own metadata in meta.json, visible through ps and list(). Child updates
do not change the parent's metadata. Top-level metadata keys starting with _
are omitted when saving.
Metadata serialization omits circular references and BigInt values while retaining
valid fields (omitted array elements become null).
Use metadata.version for the application version. The session's meta.json
stores format: 'v0-jsonl' at the top level, separate from application metadata.
log.path(absolutePath) logs paths relative to the working directory, and
log.error(error) serializes an error to { name, message, code, stack, cause }. Both
return Serialized<T> wrappers that only do their work when an event is written,
so disabled loggers skip path.relative calls and error.stack materialization entirely.
Payloads accept Serialized<T> wherever their declared types expect T,
so EventRegistry declarations keep using the wire shapes.
Extend EventRegistry to type event payloads; keys are category:kind and payloads
merge across packages through declaration merging:
declare module '2g' {
interface EventRegistry {
'metro:ready': { port: number };
'metro:bundle': { platform?: string; cached?: boolean };
}
}
Unknown event names are uncallable, reserved wire fields (_e, _t, _d, _l, _w)
are rejected in payload types, and payloads with no required keys become optional
arguments. 2g typegen merges the registry declarations of a whole project into
one schema.
Categories and event names must be nonempty ASCII identifiers using only letters,
digits, _, -, ., and :. TypeScript checks literals; typegen checks registered
names. Dynamic strings, JavaScript callers, and type assertions must follow the
same convention: logging does not validate or escape names at runtime. Put dynamic
text in payloads.
Use the CLI to find sessions, replay logs, or export traces:
2g ps --json
2g tap "expo start" --filter metro:* --tail
2g export "expo start" --format chrome-trace -o trace.json
2g export "expo start" --format opentelemetry -o otel.json
2g typegen --project tsconfig.json --format dts
2g clean --json
Selectors match a session by PID, command, session directory, or working directory.
--filter patterns are event-name prefixes matched on whole segments — metro:bundling
matches metro:bundling and metro:bundling:started, but not metro:bundling2 — and a
* matches anything, even across segments. tap and export replay retained history
first. --tail appends live events; an event written in the instant --tail attaches
may appear twice at the seam.
Open Chrome traces in ui.perfetto.dev or via Chrome DevTools →
Performance → "Load profile" (renders as a plain flame chart); chrome://tracing is
deprecated. For a near-live view of a running command, tail the export:
2g export "expo start" --tail -o trace.json
Capture a subprocess's events in integration tests by handing it a pipe as its
LOG_EVENTS target:
import { spawn } from 'node:child_process';
import { captureEvents } from '2g/api';
const capture = captureEvents({ filter: 'metro:*' });
const child = spawn(
'expo',
['export'],
capture.spawnOptions({ env: process.env })
);
const events = await capture.attach(child).collect();
spawnOptions appends a pipe to stdio and points LOG_EVENTS at it; attach
consumes the pipe and parses events, and can also be iterated with for await for live
consumption. Iteration ends when the pipe closes, so every event the child wrote is
received.
[!IMPORTANT] The child's events are flushed on natural exit. Child code that calls
process.exit()shouldawait flushEventLogger()first or trailing events may be lost.
2gThe producer entry that instrumented processes import.
events(category: string) => EventLoggerReturns a typed logger for category. The logger is callable directly and carries
helpers:
log(event, data?): writes a category:event line with the payloadlog.span(): starts a span; returns an end(event, data?) function that writes a single
event carrying the duration as _d (name the event :done/:failed at the end)log.path(target): returns a Serialized<string> of the path relative to the working
directory. A target far outside that directory, and a target that is already relative,
stay unchangedlog.error(error): returns a Serialized<SerializedError> of
{ name, message, code, stack, cause }, with cause chains serialized recursivelylog.category: the category stringSpan kinds ending in :started, :done, or :failed are naming conventions; trace
exporters strip the suffix from displayed names.
events.debug(category: string) => EventLoggerIdentical to events(category), but events are debug-level (_l: 1) and subject to
debug gating.
installEventLogger(target?: string | number | InstallEventLoggerOptions) => voidInstalls logging for the process following the activation precedence above. All options are optional:
| Option | Default | Description |
|---|---|---|
command | argv | Command line recorded in session metadata |
metadata | {} | Initial application metadata, including optional version, recorded in the session and init event |
maxSegments | 3 | Rotated JSONL segments kept per session |
maxSegmentSize | 512 KiB | Segment size that triggers rotation |
session | true | Allow the session fall-through; false keeps env-only activation |
debug | varies | Record debug events; defaults to true on explicit targets and to LOG_DEBUG presence on session and IPC output |
flushEventLogger() => Promise<void>Drains buffered events to the active destination. Resolves immediately when logging is inactive.
getEventLoggerInfo() => EventLoggerInfo | nullReturns the active destination — { destination, isUserVisibleOutput, debug } plus a
file, fd, or sessionDir — or null when logging is inactive.
isUserVisibleOutput is true when events write to stdout or stderr.
interface Serialized<T>interface Serialized<T> {
toJSON(): T;
}
A deferred value that serializes to T when an event is written. Payload types accept
Serialized<T> wherever T is declared, so user code can defer its own expensive
values the same way.
2g/apiThe consumption entry for tooling that reads sessions.
list(options?: ListSessionsOptions) => Promise<ListedSession[]>selector?: matches by PID, command, session directory, or working directoryReturns known sessions, newest first. Each ListedSession carries pid, command,
cwd, startedAt, alive, and sessionDir.
resolveSession(selector?: string) => Promise<ListedSession>Resolves a selector to exactly one session, preferring the only alive match. Throws when nothing matches or the selector is ambiguous.
tap(sessionDir: string, options?: TapOptions) => AsyncIterable<ParsedEvent>Replays a session's retained history and optionally follows live events.
| Option | Default | Description |
|---|---|---|
since | — | Replay from a duration ('5m'), Unix time, ISO date, or Date |
follow | false | Follow live events after replaying history |
filter | — | Event-name prefixes (whole segments); * wildcards, comma-separated |
spans | false | Only yield events carrying a span duration |
debug | false | Include debug-level events |
signal | — | AbortSignal that stops following |
timeout | — | Stop following after an absolute duration in milliseconds |
idleTimeout | — | Stop following after this many milliseconds without a new event |
captureEvents(options?: CaptureOptions) => EventCapturefilter? and debug?: as in TapOptionsReturns an EventCapture for receiving a subprocess's events over an inherited pipe:
capture.spawnOptions(options?): returns spawn options with a 'pipe' slot appended
to stdio and LOG_EVENTS pointing at it, preserving the caller's env and stdiocapture.attach(child): consumes the child's pipe; the capture is an
AsyncIterable<ParsedEvent> that ends when the pipe closescapture.collect(): iterates to the end and returns all eventsinterface ParsedEventThe wire format of a parsed JSONL event; all other properties are payload fields.
interface ParsedEvent {
_e: string; // "category:kind"
_t: number; // wall-clock timestamp in milliseconds
_d?: number; // span duration in milliseconds
_l?: number; // level; 1 marks debug events
_w?: string; // originating worker or child process
}
Download counts and popularity will appear here as the collector receives them.
This describes our collected data; it is not a security assessment.
Observed since 20 Oct, 19:13 UTC
No superseded metadata states collected. Unchanged observations do not create new revisions.
package:0f75815c7b5081cde99d95f8c429e614a1a068176938e4228fb5ef1527581b6d