@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 +85 -2
- package/dist/index.d.ts +87 -0
- package/dist/index.js +268 -0
- package/package.json +35 -5
package/README.md
CHANGED
|
@@ -1,3 +1,86 @@
|
|
|
1
|
-
#
|
|
1
|
+
# @viktorbarzin/log
|
|
2
2
|
|
|
3
|
-
|
|
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.*`.
|
package/dist/index.d.ts
ADDED
|
@@ -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
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
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
|
+
}
|