@viktorbarzin/log 0.0.0-stage → 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -1,3 +1,86 @@
1
- # Temporary Holding Version
1
+ # @viktorbarzin/log
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ A JSON-lines logger for the platform's log standard (app-kit ADR-0002): one
4
+ object per line on stdout, with field names from the OpenTelemetry log data
5
+ model and semantic conventions. No dependencies; works on Bun and Node.
6
+
7
+ <!-- typecheck-preamble
8
+ declare const err: unknown;
9
+ declare const request: Request;
10
+ declare const handle: (request: Request) => Promise<Response>;
11
+ -->
12
+ ```ts
13
+ import {
14
+ getLogger,
15
+ installJsonConsole,
16
+ setLogContext,
17
+ withLogContext,
18
+ } from "@viktorbarzin/log";
19
+
20
+ const log = getLogger("myapp.sync");
21
+ log.info("sync served", { lists: 3, duration_ms: 12 });
22
+ log.exception("sync failed", err, { path: "/api/sync" });
23
+
24
+ // Per request: every line inside carries enduser.id, trace_id and span_id.
25
+ await withLogContext({ traceId: request.headers.get("x-trace-id") ?? undefined }, async () => {
26
+ setLogContext({ enduserId: "the person's OIDC sub" });
27
+ return handle(request);
28
+ });
29
+
30
+ installJsonConsole(); // console.* from the framework becomes JSON lines too (logger "console")
31
+ ```
32
+
33
+ ```json
34
+ {"timestamp":"2026-10-16T04:05:06.789000000Z","severity_text":"INFO","severity_number":9,"body":"sync served","logger.name":"myapp.sync","service.name":"myapp","service.version":"1a2b3c4d","deployment.environment.name":"production","enduser.id":"…","lists":3,"duration_ms":12}
35
+ ```
36
+
37
+ ## Fields
38
+
39
+ | Field | Meaning |
40
+ |---|---|
41
+ | `timestamp` | RFC 3339 in UTC with nine fractional digits and `Z`. JavaScript's clock has millisecond precision, so the last six digits are zero unless `formatRecord` is given nanoseconds |
42
+ | `severity_text`, `severity_number` | `DEBUG` 5, `INFO` 9, `WARN` 13, `ERROR` 17, `FATAL` 21 |
43
+ | `body` | the message |
44
+ | `logger.name` | the name given to `getLogger` |
45
+ | `service.name`, `service.version`, `deployment.environment.name` | from `setLogService`, else `OTEL_SERVICE_NAME`, `SERVICE_VERSION`, `DEPLOYMENT_ENVIRONMENT` |
46
+ | `trace_id`, `span_id`, `enduser.id` | from the current `withLogContext` scope |
47
+ | extras | the call's extras (snake_case keys by convention), after the scope's `attributes` |
48
+ | `exception.type`, `exception.message`, `exception.stacktrace` | for an error, last |
49
+
50
+ Fields with no value are left out rather than written as `null`. Extras
51
+ never overwrite the standard fields.
52
+
53
+ `logger.name` is our key. OpenTelemetry's data model records the logger
54
+ name as the instrumentation scope name, which has no attribute key of its
55
+ own in a flat JSON line, and the semantic conventions define no logger
56
+ attribute. `logger.name` keeps the dotted OpenTelemetry style and says
57
+ what the value is. The libraries for other languages should use the same
58
+ key, so that one Loki query covers every app.
59
+
60
+ ## API
61
+
62
+ | Export | |
63
+ |---|---|
64
+ | `getLogger(name)` | `debug`, `info`, `warn` (and `warning`), `error`, `fatal`, `exception(msg, err, extra?)`, `record(level, msg, extra?, err?)` |
65
+ | `withLogContext(context, fn)` | run `fn` with `enduserId`, `traceId`, `spanId` and `attributes` on every line written inside it, async work included; scopes nest |
66
+ | `setLogContext(context)` | add fields to the current scope (for example once the session is known); false outside a scope |
67
+ | `getLogContext()` | the current scope's fields |
68
+ | `setLogService(service)` | set `name`, `version`, `environment` explicitly; returns a restore function |
69
+ | `serviceFromEnv(env?)` | the service fields from the environment |
70
+ | `parseLogLevel(raw)` | `WARNING` → `WARN`; unknown or unset → `INFO` |
71
+ | `setLogLevel(level)` | the threshold; starts from `LOG_LEVEL` (`WARNING` is accepted for `WARN`) |
72
+ | `setLogSink(sink)` | send lines elsewhere (tests); returns a restore function |
73
+ | `installJsonConsole(target?)` | route `console.*` through the logger; an `Error` argument goes under `exception.*`; returns a restore function |
74
+ | `formatRecord`, `formatTimestamp`, `SEVERITY_NUMBER` | the formatting, exported for tests |
75
+
76
+ The sink, threshold, service fields and context store are one per process
77
+ (kept on `globalThis`), so a server bundled twice still logs through one
78
+ sink and one context. The environment variables read are `LOG_LEVEL`,
79
+ `OTEL_SERVICE_NAME`, `SERVICE_VERSION` and `DEPLOYMENT_ENVIRONMENT`.
80
+
81
+ ## Upgrading from 0.x
82
+
83
+ 1.0 renames every field (ADR-0002) and writes to stdout instead of stderr.
84
+ Code that calls the logger needs no change. Loki queries, dashboards and
85
+ tests that read `ts`, `level`, `logger`, `msg` or `exc` move to
86
+ `timestamp`, `severity_text`, `logger.name`, `body` and `exception.*`.
@@ -0,0 +1,87 @@
1
+ export type LogLevel = "DEBUG" | "INFO" | "WARN" | "ERROR" | "FATAL";
2
+ /** What `setLogLevel` and `record` accept: a level, or `WARNING` for WARN. */
3
+ export type LogLevelName = LogLevel | "WARNING";
4
+ export type LogExtra = Record<string, unknown>;
5
+ export type LogSink = (line: string) => void;
6
+ /** OpenTelemetry severity numbers: the first number of each range. */
7
+ export declare const SEVERITY_NUMBER: Readonly<Record<LogLevel, number>>;
8
+ /** The resource fields on every line. Unknown fields are left out. */
9
+ export interface LogService {
10
+ /** `service.name`, the app. */
11
+ name?: string | undefined;
12
+ /** `service.version`, the release. */
13
+ version?: string | undefined;
14
+ /** `deployment.environment.name`, e.g. `production` or `dev`. */
15
+ environment?: string | undefined;
16
+ }
17
+ /** Per-request fields, set with `withLogContext` and `setLogContext`. */
18
+ export interface LogContext {
19
+ /** `enduser.id`: the person's stable id (for example the OIDC `sub`). */
20
+ enduserId?: string | undefined;
21
+ /** `trace_id`, hex. */
22
+ traceId?: string | undefined;
23
+ /** `span_id`, hex. */
24
+ spanId?: string | undefined;
25
+ /** More snake_case attributes for every line in the scope. */
26
+ attributes?: LogExtra | undefined;
27
+ }
28
+ /** `WARNING` means WARN; anything unknown or unset is INFO. */
29
+ export declare function parseLogLevel(raw: string | undefined): LogLevel;
30
+ /** The service fields from OTEL_SERVICE_NAME, SERVICE_VERSION and DEPLOYMENT_ENVIRONMENT. */
31
+ export declare function serviceFromEnv(env?: Record<string, string | undefined>): LogService;
32
+ /**
33
+ * RFC 3339 in UTC with nine fractional digits: `2026-10-16T04:05:06.789000000Z`.
34
+ * Takes a Date (millisecond precision, so the last six digits are zero) or
35
+ * nanoseconds since the epoch as a bigint (every digit kept).
36
+ */
37
+ export declare function formatTimestamp(time: Date | bigint): string;
38
+ /** Send lines somewhere other than stdout (tests). Returns a restore function. */
39
+ export declare function setLogSink(sink: LogSink): () => void;
40
+ export declare function setLogLevel(level: LogLevelName): void;
41
+ /**
42
+ * Set the service fields explicitly; given fields replace the ones from the
43
+ * environment. Returns a restore function.
44
+ */
45
+ export declare function setLogService(service: LogService): () => void;
46
+ /**
47
+ * Run `fn` with `context` added to every log line written inside it,
48
+ * including in async work it starts. Nested scopes inherit the outer fields
49
+ * and may override them.
50
+ */
51
+ export declare function withLogContext<T>(context: LogContext, fn: () => T): T;
52
+ /**
53
+ * Add fields to the current `withLogContext` scope, for example `enduserId`
54
+ * once the session is known. The whole scope sees them, including lines
55
+ * written after this call by code that started earlier. Outside a scope it
56
+ * changes nothing and returns false.
57
+ */
58
+ export declare function setLogContext(context: LogContext): boolean;
59
+ /** The current scope's fields, or undefined outside `withLogContext`. */
60
+ export declare function getLogContext(): LogContext | undefined;
61
+ /** The service and context for one record; `formatRecord` reads the process's when omitted. */
62
+ export interface RecordScope {
63
+ service?: LogService | undefined;
64
+ context?: LogContext | undefined;
65
+ }
66
+ export declare function formatRecord(level: LogLevelName, logger: string, body: string, extra?: LogExtra, err?: unknown, now?: Date | bigint, scope?: RecordScope): string;
67
+ export interface Logger {
68
+ debug(msg: string, extra?: LogExtra): void;
69
+ info(msg: string, extra?: LogExtra): void;
70
+ warn(msg: string, extra?: LogExtra): void;
71
+ /** The same as `warn`. */
72
+ warning(msg: string, extra?: LogExtra): void;
73
+ error(msg: string, extra?: LogExtra): void;
74
+ fatal(msg: string, extra?: LogExtra): void;
75
+ /** ERROR with the error under `exception.*`, like Python's logger.exception. */
76
+ exception(msg: string, err: unknown, extra?: LogExtra): void;
77
+ /** Any level, with an optional error under `exception.*`. */
78
+ record(level: LogLevelName, msg: string, extra?: LogExtra, err?: unknown): void;
79
+ }
80
+ export declare function getLogger(name: string): Logger;
81
+ /**
82
+ * Route console.* through the JSON-lines logger (logger "console"), so the
83
+ * framework's own output (Nitro's error handler, srvx's banner, uncaught
84
+ * errors) is JSON lines too. An Error argument goes under `exception.*`, as
85
+ * one line. Returns a function that puts the old methods back.
86
+ */
87
+ export declare function installJsonConsole(target?: Console): () => void;
package/dist/index.js ADDED
@@ -0,0 +1,268 @@
1
+ /**
2
+ * JSON-lines logging with OpenTelemetry log data model field names (app-kit
3
+ * ADR-0002): one JSON object per line on stdout with `timestamp`,
4
+ * `severity_text`, `severity_number`, `body`, `logger.name`, the service
5
+ * fields, the request context (`trace_id`, `span_id`, `enduser.id`), then
6
+ * the extras passed at the call site, then `exception.*` for an error.
7
+ * Extras never overwrite the standard keys.
8
+ *
9
+ * The sink, threshold, service fields and the context store are one per
10
+ * process, kept on globalThis: a server bundled twice (Nitro's entry plus
11
+ * Start's SSR graph) still has one of each. The initial values come from
12
+ * LOG_LEVEL, OTEL_SERVICE_NAME, SERVICE_VERSION and DEPLOYMENT_ENVIRONMENT.
13
+ */
14
+ import { AsyncLocalStorage } from "node:async_hooks";
15
+ import { format } from "node:util";
16
+ /** OpenTelemetry severity numbers: the first number of each range. */
17
+ export const SEVERITY_NUMBER = {
18
+ DEBUG: 5,
19
+ INFO: 9,
20
+ WARN: 13,
21
+ ERROR: 17,
22
+ FATAL: 21,
23
+ };
24
+ function toLevel(level) {
25
+ return level === "WARNING" ? "WARN" : level;
26
+ }
27
+ /** `WARNING` means WARN; anything unknown or unset is INFO. */
28
+ export function parseLogLevel(raw) {
29
+ const upper = (raw ?? "").trim().toUpperCase();
30
+ if (upper === "WARNING")
31
+ return "WARN";
32
+ return upper in SEVERITY_NUMBER ? upper : "INFO";
33
+ }
34
+ function nonBlank(value) {
35
+ const trimmed = value?.trim();
36
+ return trimmed ? trimmed : undefined;
37
+ }
38
+ /** The service fields from OTEL_SERVICE_NAME, SERVICE_VERSION and DEPLOYMENT_ENVIRONMENT. */
39
+ export function serviceFromEnv(env = process.env) {
40
+ const service = {};
41
+ const name = nonBlank(env.OTEL_SERVICE_NAME);
42
+ const version = nonBlank(env.SERVICE_VERSION);
43
+ const environment = nonBlank(env.DEPLOYMENT_ENVIRONMENT);
44
+ if (name !== undefined)
45
+ service.name = name;
46
+ if (version !== undefined)
47
+ service.version = version;
48
+ if (environment !== undefined)
49
+ service.environment = environment;
50
+ return service;
51
+ }
52
+ function state() {
53
+ const store = globalThis;
54
+ const slot = Symbol.for("@viktorbarzin:log");
55
+ let found = store[slot];
56
+ if (found === undefined) {
57
+ found = {
58
+ sink: (line) => {
59
+ process.stdout.write(`${line}\n`);
60
+ },
61
+ threshold: SEVERITY_NUMBER[parseLogLevel(process.env.LOG_LEVEL)],
62
+ service: serviceFromEnv(),
63
+ context: new AsyncLocalStorage(),
64
+ };
65
+ store[slot] = found;
66
+ }
67
+ return found;
68
+ }
69
+ const NANOS_PER_MS = 1000000n;
70
+ const NANOS_PER_SECOND = 1000000000n;
71
+ /**
72
+ * RFC 3339 in UTC with nine fractional digits: `2026-10-16T04:05:06.789000000Z`.
73
+ * Takes a Date (millisecond precision, so the last six digits are zero) or
74
+ * nanoseconds since the epoch as a bigint (every digit kept).
75
+ */
76
+ export function formatTimestamp(time) {
77
+ const nanos = typeof time === "bigint" ? time : BigInt(time.getTime()) * NANOS_PER_MS;
78
+ const seconds = nanos / NANOS_PER_SECOND;
79
+ const fraction = (nanos % NANOS_PER_SECOND).toString().padStart(9, "0");
80
+ const whole = new Date(Number(seconds) * 1000).toISOString().slice(0, 19);
81
+ return `${whole}.${fraction}Z`;
82
+ }
83
+ /** Send lines somewhere other than stdout (tests). Returns a restore function. */
84
+ export function setLogSink(sink) {
85
+ const current = state();
86
+ const previous = current.sink;
87
+ current.sink = sink;
88
+ return () => {
89
+ current.sink = previous;
90
+ };
91
+ }
92
+ export function setLogLevel(level) {
93
+ state().threshold = SEVERITY_NUMBER[toLevel(level)];
94
+ }
95
+ /**
96
+ * Set the service fields explicitly; given fields replace the ones from the
97
+ * environment. Returns a restore function.
98
+ */
99
+ export function setLogService(service) {
100
+ const current = state();
101
+ const previous = current.service;
102
+ const next = { ...previous };
103
+ for (const key of ["name", "version", "environment"]) {
104
+ const value = nonBlank(service[key]);
105
+ if (value !== undefined)
106
+ next[key] = value;
107
+ }
108
+ current.service = next;
109
+ return () => {
110
+ current.service = previous;
111
+ };
112
+ }
113
+ function mergeContext(parent, child) {
114
+ const merged = { ...parent };
115
+ if (child.enduserId !== undefined)
116
+ merged.enduserId = child.enduserId;
117
+ if (child.traceId !== undefined)
118
+ merged.traceId = child.traceId;
119
+ if (child.spanId !== undefined)
120
+ merged.spanId = child.spanId;
121
+ if (child.attributes !== undefined)
122
+ merged.attributes = { ...parent?.attributes, ...child.attributes };
123
+ return merged;
124
+ }
125
+ /**
126
+ * Run `fn` with `context` added to every log line written inside it,
127
+ * including in async work it starts. Nested scopes inherit the outer fields
128
+ * and may override them.
129
+ */
130
+ export function withLogContext(context, fn) {
131
+ const store = state().context;
132
+ return store.run(mergeContext(store.getStore(), context), fn);
133
+ }
134
+ /**
135
+ * Add fields to the current `withLogContext` scope, for example `enduserId`
136
+ * once the session is known. The whole scope sees them, including lines
137
+ * written after this call by code that started earlier. Outside a scope it
138
+ * changes nothing and returns false.
139
+ */
140
+ export function setLogContext(context) {
141
+ const current = state().context.getStore();
142
+ if (current === undefined)
143
+ return false;
144
+ Object.assign(current, mergeContext(current, context));
145
+ return true;
146
+ }
147
+ /** The current scope's fields, or undefined outside `withLogContext`. */
148
+ export function getLogContext() {
149
+ const current = state().context.getStore();
150
+ return current === undefined ? undefined : { ...current };
151
+ }
152
+ function jsonReplacer(_key, value) {
153
+ // Stringify what JSON cannot carry instead of throwing.
154
+ if (typeof value === "bigint")
155
+ return value.toString();
156
+ if (value instanceof Error)
157
+ return String(value);
158
+ return value;
159
+ }
160
+ function exceptionFields(err) {
161
+ if (!(err instanceof Error))
162
+ return { "exception.message": String(err) };
163
+ const ctor = err.constructor?.name;
164
+ const type = ctor && ctor !== "Error" ? ctor : err.name;
165
+ return {
166
+ "exception.type": type,
167
+ "exception.message": err.message,
168
+ "exception.stacktrace": err.stack ?? `${type}: ${err.message}`,
169
+ };
170
+ }
171
+ const RESERVED = new Set([
172
+ "timestamp",
173
+ "severity_text",
174
+ "severity_number",
175
+ "body",
176
+ "logger.name",
177
+ "service.name",
178
+ "service.version",
179
+ "deployment.environment.name",
180
+ "trace_id",
181
+ "span_id",
182
+ "enduser.id",
183
+ "exception.type",
184
+ "exception.message",
185
+ "exception.stacktrace",
186
+ ]);
187
+ export function formatRecord(level, logger, body, extra, err, now = new Date(), scope) {
188
+ const severity = toLevel(level);
189
+ const service = scope?.service ?? state().service;
190
+ const context = scope ? scope.context : state().context.getStore();
191
+ const payload = {
192
+ timestamp: formatTimestamp(now),
193
+ severity_text: severity,
194
+ severity_number: SEVERITY_NUMBER[severity],
195
+ body,
196
+ "logger.name": logger,
197
+ };
198
+ if (service.name !== undefined)
199
+ payload["service.name"] = service.name;
200
+ if (service.version !== undefined)
201
+ payload["service.version"] = service.version;
202
+ if (service.environment !== undefined)
203
+ payload["deployment.environment.name"] = service.environment;
204
+ if (context?.traceId !== undefined)
205
+ payload.trace_id = context.traceId;
206
+ if (context?.spanId !== undefined)
207
+ payload.span_id = context.spanId;
208
+ if (context?.enduserId !== undefined)
209
+ payload["enduser.id"] = context.enduserId;
210
+ const attributes = { ...context?.attributes, ...extra };
211
+ for (const [key, value] of Object.entries(attributes)) {
212
+ if (!RESERVED.has(key))
213
+ payload[key] = value;
214
+ }
215
+ if (err !== undefined)
216
+ Object.assign(payload, exceptionFields(err));
217
+ return JSON.stringify(payload, jsonReplacer);
218
+ }
219
+ export function getLogger(name) {
220
+ const emit = (level, msg, extra, err) => {
221
+ const current = state();
222
+ if (SEVERITY_NUMBER[toLevel(level)] < current.threshold)
223
+ return;
224
+ current.sink(formatRecord(level, name, msg, extra, err));
225
+ };
226
+ return {
227
+ debug: (msg, extra) => emit("DEBUG", msg, extra),
228
+ info: (msg, extra) => emit("INFO", msg, extra),
229
+ warn: (msg, extra) => emit("WARN", msg, extra),
230
+ warning: (msg, extra) => emit("WARN", msg, extra),
231
+ error: (msg, extra) => emit("ERROR", msg, extra),
232
+ fatal: (msg, extra) => emit("FATAL", msg, extra),
233
+ exception: (msg, err, extra) => emit("ERROR", msg, extra, err),
234
+ record: emit,
235
+ };
236
+ }
237
+ const CONSOLE_LEVELS = {
238
+ error: "ERROR",
239
+ warn: "WARN",
240
+ log: "INFO",
241
+ info: "INFO",
242
+ debug: "DEBUG",
243
+ trace: "DEBUG",
244
+ };
245
+ /**
246
+ * Route console.* through the JSON-lines logger (logger "console"), so the
247
+ * framework's own output (Nitro's error handler, srvx's banner, uncaught
248
+ * errors) is JSON lines too. An Error argument goes under `exception.*`, as
249
+ * one line. Returns a function that puts the old methods back.
250
+ */
251
+ export function installJsonConsole(target = console) {
252
+ const consoleLog = getLogger("console");
253
+ const methods = target;
254
+ const saved = new Map();
255
+ for (const [method, level] of Object.entries(CONSOLE_LEVELS)) {
256
+ saved.set(method, methods[method]);
257
+ methods[method] = (...args) => {
258
+ const err = args.find((arg) => arg instanceof Error);
259
+ const rest = args.filter((arg) => arg !== err);
260
+ const msg = rest.length > 0 ? format(...rest) : err ? String(err) : "";
261
+ consoleLog.record(level, msg, undefined, err);
262
+ };
263
+ }
264
+ return () => {
265
+ for (const [method, fn] of saved)
266
+ methods[method] = fn;
267
+ };
268
+ }
package/package.json CHANGED
@@ -1,6 +1,36 @@
1
1
  {
2
- "name": "@viktorbarzin/log",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
2
+ "name": "@viktorbarzin/log",
3
+ "version": "1.0.0",
4
+ "description": "JSON-lines logger with OpenTelemetry log field names: timestamp, severity, body, service, trace and enduser context, exception fields.",
5
+ "license": "MIT",
6
+ "type": "module",
7
+ "main": "./dist/index.js",
8
+ "types": "./dist/index.d.ts",
9
+ "exports": {
10
+ ".": {
11
+ "types": "./dist/index.d.ts",
12
+ "default": "./dist/index.js"
13
+ }
14
+ },
15
+ "files": [
16
+ "dist"
17
+ ],
18
+ "sideEffects": false,
19
+ "engines": {
20
+ "bun": ">=1.4.3"
21
+ },
22
+ "repository": {
23
+ "type": "git",
24
+ "url": "git+https://github.com/ViktorBarzin/app-kit.git",
25
+ "directory": "packages/log"
26
+ },
27
+ "homepage": "https://github.com/ViktorBarzin/app-kit/tree/master/packages/log#readme",
28
+ "publishConfig": {
29
+ "access": "public",
30
+ "provenance": true
31
+ },
32
+ "scripts": {
33
+ "build": "tsc -b",
34
+ "test": "bun test"
35
+ }
36
+ }