@astralarium/repo-telemetry 0.0.0-stage → 0.1.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/LICENSE +21 -0
- package/README.md +23 -2
- package/dist/src/json.d.ts +11 -0
- package/dist/src/json.js +35 -0
- package/dist/src/logger.d.ts +97 -0
- package/dist/src/logger.js +282 -0
- package/dist/src/template.d.ts +2 -0
- package/dist/src/template.js +22 -0
- package/package.json +41 -3
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Astralarium
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
CHANGED
|
@@ -1,3 +1,24 @@
|
|
|
1
|
-
#
|
|
1
|
+
# `@astralarium/repo-telemetry`
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
OpenTelemetry compatible structured logging.
|
|
4
|
+
Supports minimal ICU syntax.
|
|
5
|
+
Strongly typed.
|
|
6
|
+
|
|
7
|
+
```ts
|
|
8
|
+
import { log } from "@astralarium/repo-telemetry";
|
|
9
|
+
|
|
10
|
+
log.info("user.joined", "{user} joined", { user });
|
|
11
|
+
log.info("cart.items", "{n, plural, one {# item} other {# items}}", { n });
|
|
12
|
+
log.info("user.left", "{why, select, kick {kicked} other {left}}", { why });
|
|
13
|
+
log.error("job.failed", "job failed", { exception });
|
|
14
|
+
```
|
|
15
|
+
|
|
16
|
+
`pnpm lint --fix` updates type annotations.
|
|
17
|
+
|
|
18
|
+
Error logging exceptions also outputs a stack trace,
|
|
19
|
+
unless thrown as `ExpectedError` (useful for CLIs).
|
|
20
|
+
|
|
21
|
+
## Environment Variables
|
|
22
|
+
|
|
23
|
+
- `LOG_FORMAT=json`: output JSON lines.
|
|
24
|
+
- `LOG_STACKS=1`: always output stack trace.
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
/** Arbitrary values as JSON or text, never throwing. */
|
|
2
|
+
/** JSON replacer: bigints as decimal strings. */
|
|
3
|
+
export declare const jsonReplacer: (_key: string, value: unknown) => unknown;
|
|
4
|
+
/** `value` as JSON, bigints as strings; `[unserializable]` when cyclic or
|
|
5
|
+
* otherwise rejected. */
|
|
6
|
+
export declare function safeJson(value: unknown): string;
|
|
7
|
+
/** `value`, or `[unserializable]` when JSON rejects it (cycles). */
|
|
8
|
+
export declare function serializable(value: unknown): unknown;
|
|
9
|
+
/** `String(value)`, or `[unprintable]` where that throws (null-prototype
|
|
10
|
+
* objects). */
|
|
11
|
+
export declare function safeString(value: unknown): string;
|
package/dist/src/json.js
ADDED
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
/** Arbitrary values as JSON or text, never throwing. */
|
|
2
|
+
/** JSON replacer: bigints as decimal strings. */
|
|
3
|
+
export const jsonReplacer = (_key, value) => typeof value === "bigint" ? value.toString() : value;
|
|
4
|
+
/** Stand-in for a value JSON rejects. */
|
|
5
|
+
const UNSERIALIZABLE = "[unserializable]";
|
|
6
|
+
/** `value` as JSON, bigints as strings; `[unserializable]` when cyclic or
|
|
7
|
+
* otherwise rejected. */
|
|
8
|
+
export function safeJson(value) {
|
|
9
|
+
try {
|
|
10
|
+
return JSON.stringify(value, jsonReplacer) ?? String(value);
|
|
11
|
+
}
|
|
12
|
+
catch {
|
|
13
|
+
return UNSERIALIZABLE;
|
|
14
|
+
}
|
|
15
|
+
}
|
|
16
|
+
/** `value`, or `[unserializable]` when JSON rejects it (cycles). */
|
|
17
|
+
export function serializable(value) {
|
|
18
|
+
try {
|
|
19
|
+
JSON.stringify(value, jsonReplacer);
|
|
20
|
+
return value;
|
|
21
|
+
}
|
|
22
|
+
catch {
|
|
23
|
+
return UNSERIALIZABLE;
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
/** `String(value)`, or `[unprintable]` where that throws (null-prototype
|
|
27
|
+
* objects). */
|
|
28
|
+
export function safeString(value) {
|
|
29
|
+
try {
|
|
30
|
+
return String(value);
|
|
31
|
+
}
|
|
32
|
+
catch {
|
|
33
|
+
return "[unprintable]";
|
|
34
|
+
}
|
|
35
|
+
}
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Structured logging facade. Records follow the OpenTelemetry log data
|
|
3
|
+
* model (event names, severity numbers, exception.* attributes) without
|
|
4
|
+
* depending on the OTel SDK, so any sink stays OTLP-compatible.
|
|
5
|
+
* Templates are an ICU MessageFormat subset (`template.ts`).
|
|
6
|
+
*
|
|
7
|
+
* `log` picks its sink per runtime (`runtimeSink`).
|
|
8
|
+
*/
|
|
9
|
+
/** OTel severity numbers per level. */
|
|
10
|
+
declare const SEVERITY: {
|
|
11
|
+
readonly debug: 5;
|
|
12
|
+
readonly info: 9;
|
|
13
|
+
readonly warn: 13;
|
|
14
|
+
readonly error: 17;
|
|
15
|
+
};
|
|
16
|
+
export type LogLevel = keyof typeof SEVERITY;
|
|
17
|
+
export type LogAttributes = Record<string, unknown>;
|
|
18
|
+
export interface LogRecord {
|
|
19
|
+
level: LogLevel;
|
|
20
|
+
severityNumber: number;
|
|
21
|
+
/** OTel event name: lowercase, dot-separated, globally unique, e.g.
|
|
22
|
+
* "match.seed_failed". */
|
|
23
|
+
event: string;
|
|
24
|
+
/** ICU template whose arguments name attributes. */
|
|
25
|
+
template: string;
|
|
26
|
+
/** `template` filled in from `attributes`. */
|
|
27
|
+
message: string;
|
|
28
|
+
/** Caller attributes as given, `exception` lifted out. */
|
|
29
|
+
attributes: LogAttributes;
|
|
30
|
+
/** The `exception` attribute: the caught value. */
|
|
31
|
+
exception?: unknown;
|
|
32
|
+
}
|
|
33
|
+
export type LogSink = (record: LogRecord) => void;
|
|
34
|
+
/** Emits `event` with `template` filled in from `attributes`. `V` is the
|
|
35
|
+
* template's argument contract, written by `telemetry/template` lint;
|
|
36
|
+
* attributes are required while it names any. The `exception` attribute
|
|
37
|
+
* takes a caught value. */
|
|
38
|
+
export type LogMethod = <V extends LogAttributes = Record<never, never>>(event: string, template: string, ...attributes: [keyof V] extends [never] ? [attributes?: LogAttributes] : [attributes: V & LogAttributes]) => void;
|
|
39
|
+
export interface Logger {
|
|
40
|
+
debug: LogMethod;
|
|
41
|
+
info: LogMethod;
|
|
42
|
+
warn: LogMethod;
|
|
43
|
+
error: LogMethod;
|
|
44
|
+
}
|
|
45
|
+
/** A failure its message fully explains: input, environment, config or
|
|
46
|
+
* state the user fixes. Terminal output shows its message and causes,
|
|
47
|
+
* stack-free. */
|
|
48
|
+
export declare class ExpectedError extends Error {
|
|
49
|
+
name: string;
|
|
50
|
+
}
|
|
51
|
+
/** Emits one JSON object per record to `console`, at its level; on
|
|
52
|
+
* Workers, Workers Logs indexes and timestamps it. */
|
|
53
|
+
export declare const consoleSink: LogSink;
|
|
54
|
+
/** `consoleSink` with the caught error as a second argument: DevTools
|
|
55
|
+
* expands it with source-mapped frames and causes. */
|
|
56
|
+
export declare const browserSink: LogSink;
|
|
57
|
+
/** Writes one JSON line per record to `console.error`, led by a
|
|
58
|
+
* `timestamp`: stderr in Node. Bigints become strings; an attribute JSON
|
|
59
|
+
* rejects becomes `[unserializable]`. */
|
|
60
|
+
export declare const jsonLinesSink: LogSink;
|
|
61
|
+
/** How `textSink` renders errors. */
|
|
62
|
+
export interface TextOptions {
|
|
63
|
+
/** Every error's stack trace, error attributes included; otherwise only
|
|
64
|
+
* an error record's unexpected exception shows one. */
|
|
65
|
+
stacks?: boolean;
|
|
66
|
+
}
|
|
67
|
+
/**
|
|
68
|
+
* Human-readable sink: one line per record,
|
|
69
|
+
* `[level: ]message[: exception message] key=value…` (pairs: attributes
|
|
70
|
+
* the template leaves unread; event and undefined values omitted).
|
|
71
|
+
* Indented below: the exception's stack on error records unless expected,
|
|
72
|
+
* else its causes; with `stacks`, every error's stack.
|
|
73
|
+
*/
|
|
74
|
+
export declare function textSink(write: (line: string) => void, { stacks }?: TextOptions): LogSink;
|
|
75
|
+
/** Logger writing to `sink`. */
|
|
76
|
+
export declare function createLogger(sink: LogSink): Logger;
|
|
77
|
+
/** Runtime globals `runtimeSink` reads, each absent somewhere. */
|
|
78
|
+
export interface RuntimeGlobals {
|
|
79
|
+
navigator?: {
|
|
80
|
+
userAgent?: string;
|
|
81
|
+
};
|
|
82
|
+
document?: unknown;
|
|
83
|
+
process?: {
|
|
84
|
+
versions?: {
|
|
85
|
+
node?: string;
|
|
86
|
+
};
|
|
87
|
+
env?: Record<string, string | undefined>;
|
|
88
|
+
};
|
|
89
|
+
}
|
|
90
|
+
/** The sink for `runtime`: Workers before Node (`nodejs_compat` reports a
|
|
91
|
+
* Node version), browsers (jsdom too) before Node. Node: `textSink` onto
|
|
92
|
+
* stderr, for CLIs; `LOG_FORMAT=json` writes JSON lines; `LOG_STACKS=1`
|
|
93
|
+
* prints every stack. */
|
|
94
|
+
export declare function runtimeSink(runtime?: RuntimeGlobals): LogSink;
|
|
95
|
+
/** Logger onto the runtime's sink. */
|
|
96
|
+
export declare const log: Logger;
|
|
97
|
+
export {};
|
|
@@ -0,0 +1,282 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Structured logging facade. Records follow the OpenTelemetry log data
|
|
3
|
+
* model (event names, severity numbers, exception.* attributes) without
|
|
4
|
+
* depending on the OTel SDK, so any sink stays OTLP-compatible.
|
|
5
|
+
* Templates are an ICU MessageFormat subset (`template.ts`).
|
|
6
|
+
*
|
|
7
|
+
* `log` picks its sink per runtime (`runtimeSink`).
|
|
8
|
+
*/
|
|
9
|
+
import { templateArguments } from "@astralarium/icu-template";
|
|
10
|
+
import { jsonReplacer, safeJson, safeString, serializable } from "./json.js";
|
|
11
|
+
import { formatTemplate } from "./template.js";
|
|
12
|
+
/** OTel severity numbers per level. */
|
|
13
|
+
const SEVERITY = {
|
|
14
|
+
debug: 5,
|
|
15
|
+
info: 9,
|
|
16
|
+
warn: 13,
|
|
17
|
+
error: 17,
|
|
18
|
+
};
|
|
19
|
+
/** Errors `error` consists of under their labels: a suppressed pair or
|
|
20
|
+
* aggregate members. */
|
|
21
|
+
function constituents(error) {
|
|
22
|
+
if (typeof SuppressedError !== "undefined" &&
|
|
23
|
+
error instanceof SuppressedError)
|
|
24
|
+
return [
|
|
25
|
+
["Caused by", error.error],
|
|
26
|
+
["Suppressed", error.suppressed],
|
|
27
|
+
];
|
|
28
|
+
if (error instanceof AggregateError)
|
|
29
|
+
return error.errors.map((inner) => ["Caused by", inner]);
|
|
30
|
+
return [];
|
|
31
|
+
}
|
|
32
|
+
/** Errors nested in `error` under their labels: its cause, then its
|
|
33
|
+
* constituents. */
|
|
34
|
+
function nestedErrors(error) {
|
|
35
|
+
return [
|
|
36
|
+
...(error.cause === undefined
|
|
37
|
+
? []
|
|
38
|
+
: [["Caused by", error.cause]]),
|
|
39
|
+
...constituents(error),
|
|
40
|
+
];
|
|
41
|
+
}
|
|
42
|
+
/** A failure its message fully explains: input, environment, config or
|
|
43
|
+
* state the user fixes. Terminal output shows its message and causes,
|
|
44
|
+
* stack-free. */
|
|
45
|
+
export class ExpectedError extends Error {
|
|
46
|
+
name = "ExpectedError";
|
|
47
|
+
}
|
|
48
|
+
/** `error` is an `ExpectedError`, or a `SuppressedError` or
|
|
49
|
+
* `AggregateError` whose constituents are all expected. */
|
|
50
|
+
function isExpected(error, ancestors = new Set()) {
|
|
51
|
+
if (error instanceof ExpectedError)
|
|
52
|
+
return true;
|
|
53
|
+
if (!(error instanceof Error) || ancestors.has(error))
|
|
54
|
+
return false;
|
|
55
|
+
const parts = constituents(error);
|
|
56
|
+
ancestors.add(error);
|
|
57
|
+
const expected = parts.length > 0 &&
|
|
58
|
+
parts.every(([, inner]) => isExpected(inner, ancestors));
|
|
59
|
+
ancestors.delete(error);
|
|
60
|
+
return expected;
|
|
61
|
+
}
|
|
62
|
+
/** `error`'s message; `String(error)` for other thrown values. */
|
|
63
|
+
function errorMessage(error) {
|
|
64
|
+
return error instanceof Error ? error.message : safeString(error);
|
|
65
|
+
}
|
|
66
|
+
/** Lines naming `error`'s nested errors, each under its label and
|
|
67
|
+
* indented by depth; an error nested in itself shows as circular. */
|
|
68
|
+
function causeLines(error, indent = " ", ancestors = new Set()) {
|
|
69
|
+
if (!(error instanceof Error))
|
|
70
|
+
return [];
|
|
71
|
+
ancestors.add(error);
|
|
72
|
+
const lines = nestedErrors(error).flatMap(([label, inner]) => inner instanceof Error && ancestors.has(inner)
|
|
73
|
+
? [`${indent}${label}: [circular ${inner.name}]`]
|
|
74
|
+
: [
|
|
75
|
+
`${indent}${label}: ${errorMessage(inner)}`,
|
|
76
|
+
...causeLines(inner, `${indent} `, ancestors),
|
|
77
|
+
]);
|
|
78
|
+
ancestors.delete(error);
|
|
79
|
+
return lines;
|
|
80
|
+
}
|
|
81
|
+
/** `error`'s stack, then each nested error's after its label; an error
|
|
82
|
+
* nested in itself shows as circular. */
|
|
83
|
+
function stackTrace(error, ancestors = new Set()) {
|
|
84
|
+
if (!(error instanceof Error))
|
|
85
|
+
return safeString(error);
|
|
86
|
+
if (ancestors.has(error))
|
|
87
|
+
return `[circular ${error.name}]`;
|
|
88
|
+
ancestors.add(error);
|
|
89
|
+
const trace = [
|
|
90
|
+
error.stack ?? `${error.name}: ${error.message}`,
|
|
91
|
+
...nestedErrors(error).map(([label, inner]) => `${label}: ${stackTrace(inner, ancestors)}`),
|
|
92
|
+
].join("\n");
|
|
93
|
+
ancestors.delete(error);
|
|
94
|
+
return trace;
|
|
95
|
+
}
|
|
96
|
+
/** OTel `exception.*` attributes for a caught value; the stacktrace
|
|
97
|
+
* carries nested errors' stacks after `Caused by:` / `Suppressed:`. */
|
|
98
|
+
function exceptionAttributes(error) {
|
|
99
|
+
if (error instanceof Error)
|
|
100
|
+
return {
|
|
101
|
+
"exception.type": error.name,
|
|
102
|
+
"exception.message": error.message,
|
|
103
|
+
"exception.stacktrace": stackTrace(error),
|
|
104
|
+
};
|
|
105
|
+
return { "exception.message": safeString(error) };
|
|
106
|
+
}
|
|
107
|
+
/** `value` with an `Error` made JSON-ready: `{ type, message,
|
|
108
|
+
* stacktrace }`. */
|
|
109
|
+
function serializeError(value) {
|
|
110
|
+
if (!(value instanceof Error))
|
|
111
|
+
return value;
|
|
112
|
+
return {
|
|
113
|
+
type: value.name,
|
|
114
|
+
message: value.message,
|
|
115
|
+
stacktrace: stackTrace(value),
|
|
116
|
+
};
|
|
117
|
+
}
|
|
118
|
+
/** `record`'s attributes in OTel form: the exception expanded to
|
|
119
|
+
* `exception.*` (caller attributes win), other errors serialized. */
|
|
120
|
+
function otelAttributes(record) {
|
|
121
|
+
return {
|
|
122
|
+
...("exception" in record && exceptionAttributes(record.exception)),
|
|
123
|
+
...Object.fromEntries(Object.entries(record.attributes).map(([k, v]) => [k, serializeError(v)])),
|
|
124
|
+
};
|
|
125
|
+
}
|
|
126
|
+
const CONSOLE_METHOD = {
|
|
127
|
+
debug: "debug",
|
|
128
|
+
info: "log",
|
|
129
|
+
warn: "warn",
|
|
130
|
+
error: "error",
|
|
131
|
+
};
|
|
132
|
+
/** A record as JSON: `event.name`, `message`, `level`, `severityNumber`,
|
|
133
|
+
* and `attributes` nested. */
|
|
134
|
+
const jsonRecord = (record) => ({
|
|
135
|
+
"event.name": record.event,
|
|
136
|
+
message: record.message,
|
|
137
|
+
level: record.level,
|
|
138
|
+
severityNumber: record.severityNumber,
|
|
139
|
+
attributes: otelAttributes(record),
|
|
140
|
+
});
|
|
141
|
+
/** Emits one JSON object per record to `console`, at its level; on
|
|
142
|
+
* Workers, Workers Logs indexes and timestamps it. */
|
|
143
|
+
export const consoleSink = (record) => {
|
|
144
|
+
console[CONSOLE_METHOD[record.level]](jsonRecord(record));
|
|
145
|
+
};
|
|
146
|
+
/** `consoleSink` with the caught error as a second argument: DevTools
|
|
147
|
+
* expands it with source-mapped frames and causes. */
|
|
148
|
+
export const browserSink = (record) => {
|
|
149
|
+
const write = console[CONSOLE_METHOD[record.level]].bind(console);
|
|
150
|
+
if (record.exception === undefined)
|
|
151
|
+
write(jsonRecord(record));
|
|
152
|
+
else
|
|
153
|
+
write(jsonRecord(record), record.exception);
|
|
154
|
+
};
|
|
155
|
+
/** Writes one JSON line per record to `console.error`, led by a
|
|
156
|
+
* `timestamp`: stderr in Node. Bigints become strings; an attribute JSON
|
|
157
|
+
* rejects becomes `[unserializable]`. */
|
|
158
|
+
export const jsonLinesSink = (record) => {
|
|
159
|
+
const json = jsonRecord(record);
|
|
160
|
+
json.attributes = Object.fromEntries(Object.entries(json.attributes).map(([k, v]) => [k, serializable(v)]));
|
|
161
|
+
console.error(JSON.stringify({ timestamp: new Date().toISOString(), ...json }, jsonReplacer));
|
|
162
|
+
};
|
|
163
|
+
/** Line prefix per level; info lines carry none. */
|
|
164
|
+
const TEXT_PREFIX = {
|
|
165
|
+
debug: "debug: ",
|
|
166
|
+
info: "",
|
|
167
|
+
warn: "warning: ",
|
|
168
|
+
error: "error: ",
|
|
169
|
+
};
|
|
170
|
+
/** Attribute value as text: `?` when null, an error's message, bare when
|
|
171
|
+
* unambiguous, JSON otherwise. */
|
|
172
|
+
function textValue(value) {
|
|
173
|
+
if (value === null)
|
|
174
|
+
return "?";
|
|
175
|
+
if (value instanceof Error)
|
|
176
|
+
return textValue(value.message);
|
|
177
|
+
if (typeof value === "bigint")
|
|
178
|
+
return value.toString();
|
|
179
|
+
if (value instanceof Date)
|
|
180
|
+
return value.toISOString();
|
|
181
|
+
if (typeof value === "string")
|
|
182
|
+
return value === "" || /[\s"=]/.test(value) ? JSON.stringify(value) : value;
|
|
183
|
+
return safeJson(value);
|
|
184
|
+
}
|
|
185
|
+
/** Argument names `template` reads; none when malformed, which
|
|
186
|
+
* `formatTemplate` renders as text. */
|
|
187
|
+
function shownArguments(template) {
|
|
188
|
+
try {
|
|
189
|
+
return templateArguments(template);
|
|
190
|
+
}
|
|
191
|
+
catch {
|
|
192
|
+
return new Map();
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
/**
|
|
196
|
+
* Human-readable sink: one line per record,
|
|
197
|
+
* `[level: ]message[: exception message] key=value…` (pairs: attributes
|
|
198
|
+
* the template leaves unread; event and undefined values omitted).
|
|
199
|
+
* Indented below: the exception's stack on error records unless expected,
|
|
200
|
+
* else its causes; with `stacks`, every error's stack.
|
|
201
|
+
*/
|
|
202
|
+
export function textSink(write, { stacks = false } = {}) {
|
|
203
|
+
return (record) => {
|
|
204
|
+
const { level, template, message, attributes } = record;
|
|
205
|
+
const caught = "exception" in record;
|
|
206
|
+
const shown = shownArguments(template);
|
|
207
|
+
const pairs = Object.entries(attributes)
|
|
208
|
+
.filter(([k, v]) => v !== undefined && !shown.has(k))
|
|
209
|
+
.map(([k, v]) => ` ${k}=${textValue(v)}`);
|
|
210
|
+
const indent = (text) => text.replace(/^/gm, " ");
|
|
211
|
+
const exceptionStack = caught &&
|
|
212
|
+
(stacks || (level === "error" && !isExpected(record.exception)));
|
|
213
|
+
const below = [
|
|
214
|
+
...(exceptionStack
|
|
215
|
+
? [indent(stackTrace(record.exception))]
|
|
216
|
+
: caught
|
|
217
|
+
? causeLines(record.exception)
|
|
218
|
+
: []),
|
|
219
|
+
...(stacks
|
|
220
|
+
? Object.entries(attributes)
|
|
221
|
+
.filter(([, v]) => v instanceof Error)
|
|
222
|
+
.map(([k, v]) => indent(`${k}: ${stackTrace(v)}`))
|
|
223
|
+
: []),
|
|
224
|
+
];
|
|
225
|
+
write([
|
|
226
|
+
`${TEXT_PREFIX[level]}${message}${caught ? `: ${errorMessage(record.exception)}` : ""}${pairs.join("")}`,
|
|
227
|
+
...below,
|
|
228
|
+
].join("\n"));
|
|
229
|
+
};
|
|
230
|
+
}
|
|
231
|
+
/** Logger writing to `sink`. */
|
|
232
|
+
export function createLogger(sink) {
|
|
233
|
+
const method = (level) => ((event, template, given = {}) => {
|
|
234
|
+
// Logging runs in callers' error paths; it never throws into them.
|
|
235
|
+
try {
|
|
236
|
+
const { exception, ...attributes } = given;
|
|
237
|
+
sink({
|
|
238
|
+
level,
|
|
239
|
+
severityNumber: SEVERITY[level],
|
|
240
|
+
event,
|
|
241
|
+
template,
|
|
242
|
+
message: formatTemplate(template, attributes),
|
|
243
|
+
attributes,
|
|
244
|
+
...("exception" in given && { exception }),
|
|
245
|
+
});
|
|
246
|
+
}
|
|
247
|
+
catch (failure) {
|
|
248
|
+
try {
|
|
249
|
+
console.error("log dropped", event, failure);
|
|
250
|
+
}
|
|
251
|
+
catch {
|
|
252
|
+
// Console itself failed; nothing left to report to.
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
});
|
|
256
|
+
return {
|
|
257
|
+
debug: method("debug"),
|
|
258
|
+
info: method("info"),
|
|
259
|
+
warn: method("warn"),
|
|
260
|
+
error: method("error"),
|
|
261
|
+
};
|
|
262
|
+
}
|
|
263
|
+
/** The sink for `runtime`: Workers before Node (`nodejs_compat` reports a
|
|
264
|
+
* Node version), browsers (jsdom too) before Node. Node: `textSink` onto
|
|
265
|
+
* stderr, for CLIs; `LOG_FORMAT=json` writes JSON lines; `LOG_STACKS=1`
|
|
266
|
+
* prints every stack. */
|
|
267
|
+
export function runtimeSink(runtime = globalThis) {
|
|
268
|
+
if (runtime.navigator?.userAgent === "Cloudflare-Workers")
|
|
269
|
+
return consoleSink;
|
|
270
|
+
if (runtime.document !== undefined)
|
|
271
|
+
return browserSink;
|
|
272
|
+
const node = runtime.process;
|
|
273
|
+
if (node?.versions?.node)
|
|
274
|
+
return node.env?.LOG_FORMAT === "json"
|
|
275
|
+
? jsonLinesSink
|
|
276
|
+
: textSink((line) => {
|
|
277
|
+
console.error(line);
|
|
278
|
+
}, { stacks: node.env?.LOG_STACKS === "1" });
|
|
279
|
+
return consoleSink;
|
|
280
|
+
}
|
|
281
|
+
/** Logger onto the runtime's sink. */
|
|
282
|
+
export const log = createLogger(runtimeSink());
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
/** Log templates: `@astralarium/icu-template` over log values. */
|
|
2
|
+
import { renderText } from "@astralarium/icu-template";
|
|
3
|
+
import { safeJson, safeString } from "./json.js";
|
|
4
|
+
/** Argument value as message text: `?` when null or undefined, lists
|
|
5
|
+
* comma-joined. */
|
|
6
|
+
function messageValue(value) {
|
|
7
|
+
if (value === null || value === undefined)
|
|
8
|
+
return "?";
|
|
9
|
+
if (Array.isArray(value))
|
|
10
|
+
return value.map(messageValue).join(", ");
|
|
11
|
+
if (value instanceof Date)
|
|
12
|
+
return value.toISOString();
|
|
13
|
+
if (value instanceof Error)
|
|
14
|
+
return value.message;
|
|
15
|
+
if (typeof value === "object")
|
|
16
|
+
return safeJson(value);
|
|
17
|
+
return safeString(value);
|
|
18
|
+
}
|
|
19
|
+
/** `template` filled in from `values`. */
|
|
20
|
+
export function formatTemplate(template, values) {
|
|
21
|
+
return renderText(template, values, messageValue);
|
|
22
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,44 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@astralarium/repo-telemetry",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"
|
|
5
|
-
"
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"license": "MIT",
|
|
5
|
+
"homepage": "https://github.com/astralarium/repo/tree/main/packages/repo-telemetry#readme",
|
|
6
|
+
"repository": {
|
|
7
|
+
"type": "git",
|
|
8
|
+
"url": "git+https://github.com/astralarium/repo.git",
|
|
9
|
+
"directory": "packages/repo-telemetry"
|
|
10
|
+
},
|
|
11
|
+
"type": "module",
|
|
12
|
+
"files": [
|
|
13
|
+
"dist"
|
|
14
|
+
],
|
|
15
|
+
"exports": {
|
|
16
|
+
".": "./dist/src/logger.js",
|
|
17
|
+
"./template": "./dist/src/template.js"
|
|
18
|
+
},
|
|
19
|
+
"publishConfig": {
|
|
20
|
+
"access": "public"
|
|
21
|
+
},
|
|
22
|
+
"dependencies": {
|
|
23
|
+
"@astralarium/icu-template": "^0.1.0"
|
|
24
|
+
},
|
|
25
|
+
"devDependencies": {
|
|
26
|
+
"@types/node": "^24.2.0",
|
|
27
|
+
"@typescript-eslint/rule-tester": "^8.71.0",
|
|
28
|
+
"eslint": "^10.2.1",
|
|
29
|
+
"intl-messageformat": "^12.1.2",
|
|
30
|
+
"typescript": "^6.0.3",
|
|
31
|
+
"vitest": "^5.0.3",
|
|
32
|
+
"@astralarium/repo-config-typescript": "^0.1.0",
|
|
33
|
+
"@astralarium/repo-config-eslint": "^0.1.0",
|
|
34
|
+
"@astralarium/repo-package-files": "^0.1.0"
|
|
35
|
+
},
|
|
36
|
+
"engines": {
|
|
37
|
+
"node": ">=24.2"
|
|
38
|
+
},
|
|
39
|
+
"scripts": {
|
|
40
|
+
"check-types": "tsc --noEmit && tsc -p tsconfig.build.json --noEmit",
|
|
41
|
+
"lint": "eslint",
|
|
42
|
+
"test": "vitest"
|
|
43
|
+
}
|
|
6
44
|
}
|