@iskra-bun/core 0.1.1 → 0.3.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/CHANGELOG.md +74 -0
- package/README.md +3 -2
- package/dist/index.d.ts +690 -108
- package/dist/index.js +526 -62
- package/dist/index.js.map +1 -1
- package/package.json +7 -2
- package/src/app.ts +219 -21
- package/src/config/loader.ts +9 -2
- package/src/config/schema.ts +78 -23
- package/src/env.ts +31 -0
- package/src/errors.ts +32 -3
- package/src/index.ts +2 -0
- package/src/logger/index.ts +190 -28
- package/src/otel.ts +221 -25
- package/src/traced.ts +59 -0
- package/src/types.ts +61 -8
package/dist/index.js
CHANGED
|
@@ -3,39 +3,146 @@ import mitt from "mitt";
|
|
|
3
3
|
|
|
4
4
|
// src/logger/index.ts
|
|
5
5
|
import pino from "pino";
|
|
6
|
+
import pretty from "pino-pretty";
|
|
7
|
+
|
|
8
|
+
// src/env.ts
|
|
9
|
+
var NODE_ENV = "NODE_ENV";
|
|
10
|
+
function nodeEnv() {
|
|
11
|
+
return process.env[NODE_ENV] || void 0;
|
|
12
|
+
}
|
|
13
|
+
function isDevelopmentEnv(env = nodeEnv()) {
|
|
14
|
+
return env === "development" || env === "test";
|
|
15
|
+
}
|
|
16
|
+
function isProductionEnv(env = nodeEnv()) {
|
|
17
|
+
return !isDevelopmentEnv(env);
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
// src/logger/index.ts
|
|
21
|
+
var REDACTED_KEYS = [
|
|
22
|
+
"password",
|
|
23
|
+
"pass",
|
|
24
|
+
"passwd",
|
|
25
|
+
"apiKey",
|
|
26
|
+
"apiSecret",
|
|
27
|
+
"token",
|
|
28
|
+
"authToken",
|
|
29
|
+
"accessToken",
|
|
30
|
+
"refreshToken",
|
|
31
|
+
"idToken",
|
|
32
|
+
"secret",
|
|
33
|
+
"clientSecret",
|
|
34
|
+
"secretKey",
|
|
35
|
+
"privateKey",
|
|
36
|
+
"authorization",
|
|
37
|
+
"proxyAuthorization",
|
|
38
|
+
"cookie",
|
|
39
|
+
"setCookie",
|
|
40
|
+
"sessionId"
|
|
41
|
+
];
|
|
42
|
+
var REDACTED_SUFFIXES = ["password", "passwd", "secret", "token", "apikey", "secretkey", "privatekey", "accesskey"];
|
|
43
|
+
var normalizeKey = (key) => key.toLowerCase().replace(/[-_]/g, "");
|
|
44
|
+
var SENSITIVE = new Set(REDACTED_KEYS.map(normalizeKey));
|
|
45
|
+
var isSensitive = (key) => {
|
|
46
|
+
const normalized = normalizeKey(key);
|
|
47
|
+
return SENSITIVE.has(normalized) || REDACTED_SUFFIXES.some((suffix) => normalized.endsWith(suffix));
|
|
48
|
+
};
|
|
49
|
+
var CENSOR = "[REDACTED]";
|
|
50
|
+
var MAX_DEPTH = 8;
|
|
51
|
+
var IN_PROGRESS = /* @__PURE__ */ Symbol("in progress");
|
|
52
|
+
var SERIALIZED_ERROR_PROTO = Object.getPrototypeOf(pino.stdSerializers.err(new Error()));
|
|
53
|
+
var isPlainObject = (value) => {
|
|
54
|
+
const proto = Object.getPrototypeOf(value);
|
|
55
|
+
return proto === Object.prototype || proto === null || proto === SERIALIZED_ERROR_PROTO;
|
|
56
|
+
};
|
|
57
|
+
var SECRET_PARAM = /pass|secret|token|key|sig|auth/i;
|
|
58
|
+
function maskCredentials(text) {
|
|
59
|
+
return text.replace(/\b([a-z][\w+.-]{0,30}:\/\/[^\s:@/]{0,256}):[^\s@/]{1,256}@/gi, "$1:[REDACTED]@").replace(
|
|
60
|
+
/([?&])([\w.-]{1,100})=([^&\s'"#]+)/g,
|
|
61
|
+
(match, sep, name) => SECRET_PARAM.test(name) ? `${sep}${name}=[REDACTED]` : match
|
|
62
|
+
);
|
|
63
|
+
}
|
|
64
|
+
function scrubError(err, depth, done) {
|
|
65
|
+
const serialized = { ...pino.stdSerializers.err(err) };
|
|
66
|
+
if (typeof serialized.message === "string") serialized.message = maskCredentials(serialized.message);
|
|
67
|
+
if (typeof serialized.stack === "string") serialized.stack = maskCredentials(serialized.stack);
|
|
68
|
+
return scrubEntries(serialized, depth, done, true);
|
|
69
|
+
}
|
|
70
|
+
function scrub(value, depth, done, deep = false) {
|
|
71
|
+
if (value === null || typeof value !== "object" || depth > MAX_DEPTH) return value;
|
|
72
|
+
if (done.has(value)) {
|
|
73
|
+
const result2 = done.get(value);
|
|
74
|
+
return result2 === IN_PROGRESS ? "[Circular]" : result2;
|
|
75
|
+
}
|
|
76
|
+
const isError = value instanceof Error;
|
|
77
|
+
const toJSON = value.toJSON;
|
|
78
|
+
const walk = isError || Array.isArray(value) || isPlainObject(value) || typeof toJSON === "function" || deep;
|
|
79
|
+
if (!walk) return value;
|
|
80
|
+
done.set(value, IN_PROGRESS);
|
|
81
|
+
let result;
|
|
82
|
+
if (isError) {
|
|
83
|
+
result = scrubError(value, depth, done);
|
|
84
|
+
} else if (Array.isArray(value)) {
|
|
85
|
+
let copy;
|
|
86
|
+
value.forEach((item, i) => {
|
|
87
|
+
const scrubbed = scrub(item, depth + 1, done, deep);
|
|
88
|
+
if (scrubbed !== item) (copy ??= value.slice())[i] = scrubbed;
|
|
89
|
+
});
|
|
90
|
+
result = copy ?? value;
|
|
91
|
+
} else if (isPlainObject(value)) {
|
|
92
|
+
result = scrubEntries(value, depth, done, deep);
|
|
93
|
+
} else if (typeof toJSON === "function") {
|
|
94
|
+
result = scrub(toJSON.call(value), depth, done, deep);
|
|
95
|
+
} else {
|
|
96
|
+
result = scrubEntries({ ...value }, depth, done, deep);
|
|
97
|
+
}
|
|
98
|
+
done.set(value, result);
|
|
99
|
+
return result;
|
|
100
|
+
}
|
|
101
|
+
function scrubEntries(value, depth, done, deep = false) {
|
|
102
|
+
let copy;
|
|
103
|
+
for (const [key, item] of Object.entries(value)) {
|
|
104
|
+
const scrubbed = isSensitive(key) ? CENSOR : scrub(item, depth + 1, done, deep);
|
|
105
|
+
if (scrubbed !== item) (copy ??= { ...value })[key] = scrubbed;
|
|
106
|
+
}
|
|
107
|
+
return copy ?? (Object.getPrototypeOf(value) === SERIALIZED_ERROR_PROTO ? { ...value } : value);
|
|
108
|
+
}
|
|
6
109
|
var createLogger = (name, level = "info") => {
|
|
7
|
-
const isDev =
|
|
8
|
-
|
|
110
|
+
const isDev = isDevelopmentEnv();
|
|
111
|
+
const options = {
|
|
9
112
|
name,
|
|
10
113
|
level,
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
"*.apiSecret",
|
|
20
|
-
"token",
|
|
21
|
-
"*.token",
|
|
22
|
-
"*.authToken",
|
|
23
|
-
"secret",
|
|
24
|
-
"*.secret",
|
|
25
|
-
"config.env",
|
|
26
|
-
"*.data"
|
|
27
|
-
],
|
|
28
|
-
censor: "[REDACTED]"
|
|
114
|
+
formatters: {
|
|
115
|
+
log: (object) => scrub(object, 0, /* @__PURE__ */ new WeakMap())
|
|
116
|
+
},
|
|
117
|
+
// The formatter above already serialized errors (scrubbed): pino's own
|
|
118
|
+
// err serializer would take that plain object for an error again and
|
|
119
|
+
// rewrite its `type` as "Object".
|
|
120
|
+
serializers: {
|
|
121
|
+
err: (value) => value instanceof Error ? scrub(value, 0, /* @__PURE__ */ new WeakMap()) : value
|
|
29
122
|
},
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
123
|
+
hooks: {
|
|
124
|
+
// Messages are strings the formatter never sees: mask connection
|
|
125
|
+
// string passwords there too, including the message pino takes
|
|
126
|
+
// from an error logged on its own (`logger.error(err)`).
|
|
127
|
+
logMethod(args, method) {
|
|
128
|
+
const masked = args.map((arg) => typeof arg === "string" ? maskCredentials(arg) : arg);
|
|
129
|
+
if (masked[0] instanceof Error && typeof masked[1] !== "string") {
|
|
130
|
+
masked.splice(1, 0, maskCredentials(masked[0].message));
|
|
35
131
|
}
|
|
132
|
+
return method.apply(this, masked);
|
|
36
133
|
}
|
|
134
|
+
},
|
|
135
|
+
redact: {
|
|
136
|
+
paths: ["config.env", "*.data"],
|
|
137
|
+
censor: CENSOR
|
|
37
138
|
}
|
|
38
|
-
}
|
|
139
|
+
};
|
|
140
|
+
const logger = isDev ? pino(options, pretty({ colorize: true })) : pino(options);
|
|
141
|
+
const child = logger.child;
|
|
142
|
+
logger.child = function(bindings, childOptions) {
|
|
143
|
+
return child.call(this, scrub(bindings, 0, /* @__PURE__ */ new WeakMap()), childOptions);
|
|
144
|
+
};
|
|
145
|
+
return logger;
|
|
39
146
|
};
|
|
40
147
|
|
|
41
148
|
// src/config/loader.ts
|
|
@@ -43,12 +150,17 @@ import { loadConfig } from "c12";
|
|
|
43
150
|
|
|
44
151
|
// src/config/schema.ts
|
|
45
152
|
import { z } from "zod";
|
|
153
|
+
var RestartBackoffSchema = z.object({
|
|
154
|
+
initialMs: z.number().positive().optional(),
|
|
155
|
+
maxMs: z.number().positive().optional(),
|
|
156
|
+
factor: z.number().positive().optional()
|
|
157
|
+
});
|
|
46
158
|
var AppConfigSchema = z.object({
|
|
47
159
|
name: z.string().default("IskraApp"),
|
|
48
160
|
debug: z.boolean().default(false),
|
|
49
161
|
logger: z.object({
|
|
50
162
|
level: z.string().default("info")
|
|
51
|
-
}).default({}),
|
|
163
|
+
}).passthrough().default({}),
|
|
52
164
|
otel: z.object({
|
|
53
165
|
enabled: z.boolean().default(true),
|
|
54
166
|
endpoint: z.string().default("http://localhost:4318"),
|
|
@@ -57,16 +169,40 @@ var AppConfigSchema = z.object({
|
|
|
57
169
|
environment: z.string().optional(),
|
|
58
170
|
metricIntervalMs: z.number().default(6e4),
|
|
59
171
|
resourceAttributes: z.record(z.string()).optional(),
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
172
|
+
// passthrough: the instrumentations' own options (hooks, redactedQueryParams) were stripped.
|
|
173
|
+
instrumentations: z.record(z.object({ enabled: z.boolean().optional() }).passthrough()).optional()
|
|
174
|
+
}).passthrough().optional(),
|
|
175
|
+
shutdownSignals: z.union([z.array(z.string()), z.literal(false)]).optional(),
|
|
176
|
+
shutdownTimeoutMs: z.number().positive().optional(),
|
|
177
|
+
processes: z.record(
|
|
178
|
+
z.object({
|
|
179
|
+
command: z.string(),
|
|
180
|
+
args: z.array(z.string()).optional(),
|
|
181
|
+
mode: z.enum(["daemon", "oneshot", "stdio"]).default("daemon"),
|
|
182
|
+
restartOnCrash: z.boolean().default(false),
|
|
183
|
+
maxRestarts: z.number().int().nonnegative().optional(),
|
|
184
|
+
restartCooldown: z.number().nonnegative().optional(),
|
|
185
|
+
restartBackoff: RestartBackoffSchema.optional(),
|
|
186
|
+
env: z.record(z.string()).optional(),
|
|
187
|
+
inheritEnv: z.union([z.boolean(), z.array(z.string())]).optional(),
|
|
188
|
+
maxPendingStdinBytes: z.number().int().positive().optional()
|
|
189
|
+
}).passthrough()
|
|
190
|
+
).optional(),
|
|
191
|
+
socket: z.object({
|
|
192
|
+
enabled: z.boolean(),
|
|
193
|
+
port: z.number().optional(),
|
|
194
|
+
adapter: z.enum(["bun", "socket.io"]).optional()
|
|
195
|
+
}).passthrough().optional(),
|
|
196
|
+
kv: z.object({
|
|
197
|
+
driver: z.enum(["memory", "redis"]),
|
|
198
|
+
connection: z.any().optional()
|
|
199
|
+
}).passthrough().optional(),
|
|
200
|
+
db: z.object({
|
|
201
|
+
driver: z.enum(["postgres", "mysql", "sqlite", "libsql"]),
|
|
202
|
+
url: z.string(),
|
|
203
|
+
authToken: z.string().optional()
|
|
204
|
+
}).passthrough().optional()
|
|
205
|
+
}).passthrough();
|
|
70
206
|
|
|
71
207
|
// src/config/loader.ts
|
|
72
208
|
async function loadAppConfig(cwd = process.cwd()) {
|
|
@@ -74,7 +210,14 @@ async function loadAppConfig(cwd = process.cwd()) {
|
|
|
74
210
|
name: "app",
|
|
75
211
|
configFile: "app.config",
|
|
76
212
|
cwd,
|
|
77
|
-
dotenv: true
|
|
213
|
+
dotenv: true,
|
|
214
|
+
// c12 would also merge a `.apprc` file from the working directory (a
|
|
215
|
+
// plain key=value file that could add `processes` to spawn or move
|
|
216
|
+
// `otel.endpoint`), and download and run `extends` layers from
|
|
217
|
+
// github:/gitlab:/https:// sources on every start. Local `extends`
|
|
218
|
+
// paths still work.
|
|
219
|
+
rcFile: false,
|
|
220
|
+
giget: false
|
|
78
221
|
});
|
|
79
222
|
const validConfig = AppConfigSchema.parse(config || {});
|
|
80
223
|
return validConfig;
|
|
@@ -104,8 +247,14 @@ var ErrorCodes = {
|
|
|
104
247
|
UNAUTHORIZED: "UNAUTHORIZED",
|
|
105
248
|
FORBIDDEN: "FORBIDDEN",
|
|
106
249
|
NOT_FOUND: "NOT_FOUND",
|
|
250
|
+
METHOD_NOT_ALLOWED: "METHOD_NOT_ALLOWED",
|
|
107
251
|
CONFLICT: "CONFLICT",
|
|
252
|
+
PAYLOAD_TOO_LARGE: "PAYLOAD_TOO_LARGE",
|
|
253
|
+
UNSUPPORTED_MEDIA_TYPE: "UNSUPPORTED_MEDIA_TYPE",
|
|
108
254
|
VALIDATION_ERROR: "VALIDATION_ERROR",
|
|
255
|
+
RATE_LIMITED: "RATE_LIMITED",
|
|
256
|
+
SERVICE_UNAVAILABLE: "SERVICE_UNAVAILABLE",
|
|
257
|
+
TIMEOUT: "TIMEOUT",
|
|
109
258
|
// Data
|
|
110
259
|
DATABASE_ERROR: "DATABASE_ERROR",
|
|
111
260
|
CONNECTION_ERROR: "CONNECTION_ERROR",
|
|
@@ -150,7 +299,11 @@ var ConfigError = class extends IskraError {
|
|
|
150
299
|
};
|
|
151
300
|
var DriverError = class extends IskraError {
|
|
152
301
|
constructor(message, options) {
|
|
153
|
-
super(message, {
|
|
302
|
+
super(message, {
|
|
303
|
+
code: options?.code ?? ErrorCodes.DRIVER_INIT_FAILED,
|
|
304
|
+
cause: options?.cause,
|
|
305
|
+
context: options?.context
|
|
306
|
+
});
|
|
154
307
|
this.name = "DriverError";
|
|
155
308
|
}
|
|
156
309
|
};
|
|
@@ -183,6 +336,114 @@ var OTEL_MODULES = {
|
|
|
183
336
|
function importOptional(specifier) {
|
|
184
337
|
return import(specifier);
|
|
185
338
|
}
|
|
339
|
+
var DEFAULT_ENDPOINT = "http://localhost:4318";
|
|
340
|
+
var HTTP_INSTRUMENTATION = "@opentelemetry/instrumentation-http";
|
|
341
|
+
var SECRET_QUERY_PARAMS = [
|
|
342
|
+
"token",
|
|
343
|
+
"access_token",
|
|
344
|
+
"refresh_token",
|
|
345
|
+
"id_token",
|
|
346
|
+
"auth_token",
|
|
347
|
+
"session_token",
|
|
348
|
+
"api_key",
|
|
349
|
+
"key",
|
|
350
|
+
"secret",
|
|
351
|
+
"client_secret",
|
|
352
|
+
"password",
|
|
353
|
+
"passwd",
|
|
354
|
+
"code",
|
|
355
|
+
"state",
|
|
356
|
+
"ticket",
|
|
357
|
+
"otp",
|
|
358
|
+
"jwt",
|
|
359
|
+
"signature",
|
|
360
|
+
"sig",
|
|
361
|
+
"X-Amz-Signature",
|
|
362
|
+
"X-Amz-Credential",
|
|
363
|
+
"X-Amz-Security-Token",
|
|
364
|
+
"AWSAccessKeyId",
|
|
365
|
+
"X-Goog-Signature",
|
|
366
|
+
"X-Goog-Credential"
|
|
367
|
+
];
|
|
368
|
+
var normalizeParam = (name) => name.toLowerCase().replace(/[-_]/g, "");
|
|
369
|
+
function decodeParam(name) {
|
|
370
|
+
try {
|
|
371
|
+
return decodeURIComponent(name.replace(/\+/g, " "));
|
|
372
|
+
} catch {
|
|
373
|
+
return name;
|
|
374
|
+
}
|
|
375
|
+
}
|
|
376
|
+
function redactUrl(url, params = SECRET_QUERY_PARAMS) {
|
|
377
|
+
const start = url.indexOf("?");
|
|
378
|
+
if (start === -1 || params.length === 0) return url;
|
|
379
|
+
const names = new Set(params.map(normalizeParam));
|
|
380
|
+
const hash = url.indexOf("#", start);
|
|
381
|
+
const end = hash === -1 ? url.length : hash;
|
|
382
|
+
let redacted = false;
|
|
383
|
+
const query = url.slice(start + 1, end).split("&").map((pair) => {
|
|
384
|
+
const eq = pair.indexOf("=");
|
|
385
|
+
if (eq === -1 || !names.has(normalizeParam(decodeParam(pair.slice(0, eq))))) return pair;
|
|
386
|
+
redacted = true;
|
|
387
|
+
return `${pair.slice(0, eq)}=REDACTED`;
|
|
388
|
+
}).join("&");
|
|
389
|
+
return redacted ? url.slice(0, start + 1) + query + url.slice(end) : url;
|
|
390
|
+
}
|
|
391
|
+
var URL_ATTRIBUTES = ["url.full", "url.query", "http.url", "http.target"];
|
|
392
|
+
function redactSpanUrls(span, params) {
|
|
393
|
+
const { attributes, setAttribute } = span ?? {};
|
|
394
|
+
if (!attributes || typeof setAttribute !== "function") return;
|
|
395
|
+
for (const key of URL_ATTRIBUTES) {
|
|
396
|
+
const value = attributes[key];
|
|
397
|
+
if (typeof value !== "string") continue;
|
|
398
|
+
const redacted = key === "url.query" ? redactUrl(`?${value}`, params).slice(1) : redactUrl(value, params);
|
|
399
|
+
if (redacted !== value) setAttribute.call(span, key, redacted);
|
|
400
|
+
}
|
|
401
|
+
}
|
|
402
|
+
function autoInstrumentationOptions(config) {
|
|
403
|
+
const http = { ...config.instrumentations?.[HTTP_INSTRUMENTATION] };
|
|
404
|
+
const list = (value) => Array.isArray(value) ? value : void 0;
|
|
405
|
+
const client = list(http.redactedQueryParams) ?? SECRET_QUERY_PARAMS;
|
|
406
|
+
const server = list(http.redactedQueryParamsServer) ?? SECRET_QUERY_PARAMS;
|
|
407
|
+
const appHook = http.requestHook;
|
|
408
|
+
return {
|
|
409
|
+
"@opentelemetry/instrumentation-fs": { enabled: false },
|
|
410
|
+
...config.instrumentations,
|
|
411
|
+
[HTTP_INSTRUMENTATION]: {
|
|
412
|
+
...http,
|
|
413
|
+
redactedQueryParams: client,
|
|
414
|
+
redactedQueryParamsServer: server,
|
|
415
|
+
requestHook: (span, request) => {
|
|
416
|
+
const outgoing = typeof request?.setHeader === "function";
|
|
417
|
+
redactSpanUrls(span, outgoing ? client : server);
|
|
418
|
+
if (typeof appHook === "function") appHook(span, request);
|
|
419
|
+
}
|
|
420
|
+
}
|
|
421
|
+
};
|
|
422
|
+
}
|
|
423
|
+
function isPrivateHost(hostname) {
|
|
424
|
+
const host = hostname.replace(/^\[|\]$/g, "").toLowerCase();
|
|
425
|
+
if (host.includes(":")) return host === "::1" || /^f[cd]/.test(host);
|
|
426
|
+
return !host.includes(".") || /\.(localhost|local|internal)$/.test(host) || /^(127|10)\./.test(host) || /^192\.168\./.test(host) || /^172\.(1[6-9]|2\d|3[01])\./.test(host);
|
|
427
|
+
}
|
|
428
|
+
function describeOtelEndpoint(config) {
|
|
429
|
+
try {
|
|
430
|
+
const url = new URL(config.endpoint || DEFAULT_ENDPOINT);
|
|
431
|
+
return { origin: url.origin, plaintext: url.protocol === "http:" && !isPrivateHost(url.hostname) };
|
|
432
|
+
} catch {
|
|
433
|
+
return { origin: "(invalid URL)", plaintext: false };
|
|
434
|
+
}
|
|
435
|
+
}
|
|
436
|
+
function createResource(resources, attributes) {
|
|
437
|
+
if (typeof resources?.resourceFromAttributes === "function") {
|
|
438
|
+
return resources.resourceFromAttributes(attributes);
|
|
439
|
+
}
|
|
440
|
+
if (typeof resources?.Resource === "function") {
|
|
441
|
+
return new resources.Resource(attributes);
|
|
442
|
+
}
|
|
443
|
+
throw new Error(
|
|
444
|
+
"[iskra/otel] Unsupported @opentelemetry/resources: it exports neither resourceFromAttributes() nor Resource"
|
|
445
|
+
);
|
|
446
|
+
}
|
|
186
447
|
async function initOtel(config, appName) {
|
|
187
448
|
if (sdkInstance) return;
|
|
188
449
|
try {
|
|
@@ -192,7 +453,7 @@ async function initOtel(config, appName) {
|
|
|
192
453
|
{ OTLPTraceExporter },
|
|
193
454
|
{ OTLPMetricExporter },
|
|
194
455
|
{ PeriodicExportingMetricReader },
|
|
195
|
-
|
|
456
|
+
resources,
|
|
196
457
|
semconv
|
|
197
458
|
] = await Promise.all([
|
|
198
459
|
importOptional(OTEL_MODULES.sdkNode),
|
|
@@ -204,17 +465,14 @@ async function initOtel(config, appName) {
|
|
|
204
465
|
importOptional(OTEL_MODULES.semconv)
|
|
205
466
|
]);
|
|
206
467
|
const serviceName = config.serviceName || appName;
|
|
207
|
-
const endpoint = config.endpoint ||
|
|
208
|
-
const resource =
|
|
209
|
-
[semconv.ATTR_SERVICE_NAME]: serviceName,
|
|
210
|
-
[semconv.ATTR_SERVICE_VERSION]: config.serviceVersion || "0.1.0",
|
|
211
|
-
"deployment.environment": config.environment ||
|
|
468
|
+
const endpoint = config.endpoint || DEFAULT_ENDPOINT;
|
|
469
|
+
const resource = createResource(resources, {
|
|
470
|
+
[semconv.ATTR_SERVICE_NAME ?? "service.name"]: serviceName,
|
|
471
|
+
[semconv.ATTR_SERVICE_VERSION ?? "service.version"]: config.serviceVersion || "0.1.0",
|
|
472
|
+
"deployment.environment": config.environment || nodeEnv() || "development",
|
|
212
473
|
...config.resourceAttributes
|
|
213
474
|
});
|
|
214
|
-
const instrumentationOverrides =
|
|
215
|
-
"@opentelemetry/instrumentation-fs": { enabled: false },
|
|
216
|
-
...config.instrumentations
|
|
217
|
-
};
|
|
475
|
+
const instrumentationOverrides = autoInstrumentationOptions(config);
|
|
218
476
|
const sdk = new NodeSDK({
|
|
219
477
|
resource,
|
|
220
478
|
traceExporter: new OTLPTraceExporter({ url: `${endpoint}/v1/traces` }),
|
|
@@ -227,7 +485,8 @@ async function initOtel(config, appName) {
|
|
|
227
485
|
sdk.start();
|
|
228
486
|
sdkInstance = sdk;
|
|
229
487
|
} catch (err) {
|
|
230
|
-
|
|
488
|
+
const e = err;
|
|
489
|
+
if (e?.code === "ERR_MODULE_NOT_FOUND" || e?.code === "MODULE_NOT_FOUND" || e?.message?.includes("Cannot find")) {
|
|
231
490
|
throw new Error(
|
|
232
491
|
`[iskra/otel] OpenTelemetry packages are not installed. Install them with: bun add @opentelemetry/sdk-node @opentelemetry/auto-instrumentations-node @opentelemetry/exporter-trace-otlp-http @opentelemetry/exporter-metrics-otlp-http @opentelemetry/sdk-metrics @opentelemetry/resources @opentelemetry/semantic-conventions`
|
|
233
492
|
);
|
|
@@ -243,12 +502,29 @@ async function shutdownOtel() {
|
|
|
243
502
|
}
|
|
244
503
|
|
|
245
504
|
// src/app.ts
|
|
505
|
+
var AppContext = class extends Map {
|
|
506
|
+
get(key) {
|
|
507
|
+
return super.get(key);
|
|
508
|
+
}
|
|
509
|
+
set(key, value) {
|
|
510
|
+
return super.set(key, value);
|
|
511
|
+
}
|
|
512
|
+
};
|
|
513
|
+
var DEFAULT_SHUTDOWN_SIGNALS = ["SIGTERM", "SIGINT"];
|
|
514
|
+
var DEFAULT_SHUTDOWN_TIMEOUT_MS = 1e4;
|
|
246
515
|
var App = class {
|
|
247
516
|
config;
|
|
248
517
|
logger;
|
|
249
518
|
events;
|
|
250
519
|
drivers = [];
|
|
251
|
-
|
|
520
|
+
/** Drivers whose start() succeeded, in start order; null until start() runs. */
|
|
521
|
+
startedDrivers = null;
|
|
522
|
+
pendingInstalls = [];
|
|
523
|
+
/** The shutdown in progress, shared by concurrent stop() calls. */
|
|
524
|
+
stopping = null;
|
|
525
|
+
signalHandler;
|
|
526
|
+
signals = [];
|
|
527
|
+
context = new AppContext();
|
|
252
528
|
constructor(config) {
|
|
253
529
|
this.events = mitt();
|
|
254
530
|
this.config = config || { name: "Bootstrapping", logger: { level: "info" } };
|
|
@@ -261,13 +537,32 @@ var App = class {
|
|
|
261
537
|
}
|
|
262
538
|
if (this.config.otel && this.config.otel.enabled !== false) {
|
|
263
539
|
await initOtel(this.config.otel, this.config.name);
|
|
264
|
-
|
|
540
|
+
const { origin, plaintext } = describeOtelEndpoint(this.config.otel);
|
|
541
|
+
this.logger.info({ endpoint: origin }, "OpenTelemetry initialized");
|
|
542
|
+
if (plaintext) {
|
|
543
|
+
this.logger.warn(
|
|
544
|
+
{ endpoint: origin },
|
|
545
|
+
"OpenTelemetry endpoint is plain http:// on a remote host: spans (URLs, captured headers) travel unencrypted; use https://"
|
|
546
|
+
);
|
|
547
|
+
}
|
|
265
548
|
}
|
|
266
549
|
this.logger.info("Initializing App...");
|
|
550
|
+
await Promise.all(this.pendingInstalls);
|
|
551
|
+
this.pendingInstalls = [];
|
|
552
|
+
const initialized = [];
|
|
267
553
|
for (const driver of this.drivers) {
|
|
268
|
-
if (driver.init)
|
|
269
|
-
|
|
554
|
+
if (!driver.init) continue;
|
|
555
|
+
this.logger.debug(`Initializing driver: ${driver.name}`);
|
|
556
|
+
initialized.push(driver);
|
|
557
|
+
try {
|
|
270
558
|
await driver.init(this);
|
|
559
|
+
} catch (err) {
|
|
560
|
+
this.logger.error(
|
|
561
|
+
{ err, driver: driver.name },
|
|
562
|
+
"Driver failed to initialize; stopping the drivers already initialized"
|
|
563
|
+
);
|
|
564
|
+
await this.rollback(initialized.reverse());
|
|
565
|
+
throw err;
|
|
271
566
|
}
|
|
272
567
|
}
|
|
273
568
|
}
|
|
@@ -276,7 +571,13 @@ var App = class {
|
|
|
276
571
|
return this;
|
|
277
572
|
}
|
|
278
573
|
use(plugin) {
|
|
279
|
-
plugin.install(this);
|
|
574
|
+
const result = plugin.install(this);
|
|
575
|
+
if (result && typeof result.then === "function") {
|
|
576
|
+
const install = Promise.resolve(result);
|
|
577
|
+
install.catch(() => {
|
|
578
|
+
});
|
|
579
|
+
this.pendingInstalls.push(install);
|
|
580
|
+
}
|
|
280
581
|
return this;
|
|
281
582
|
}
|
|
282
583
|
on(event, handler) {
|
|
@@ -298,39 +599,202 @@ var App = class {
|
|
|
298
599
|
emit(event, payload) {
|
|
299
600
|
this.events.emit(event, payload);
|
|
300
601
|
}
|
|
602
|
+
/**
|
|
603
|
+
* Starts drivers one at a time, in registration order, so a driver can rely
|
|
604
|
+
* on the ones registered before it (e.g. the DB before the web server). If
|
|
605
|
+
* one fails, every driver is stopped in reverse order (all were initialized
|
|
606
|
+
* and may hold connections, including the failing one), OpenTelemetry is
|
|
607
|
+
* shut down and the error is rethrown, instead of leaving ports bound and
|
|
608
|
+
* children running.
|
|
609
|
+
*/
|
|
301
610
|
async start() {
|
|
302
611
|
await this.init();
|
|
612
|
+
if (!nodeEnv()) {
|
|
613
|
+
this.logger.warn(
|
|
614
|
+
"NODE_ENV is not set: production defaults apply (Secure cookies, no sample secrets, JSON logs). Set NODE_ENV=development for local development."
|
|
615
|
+
);
|
|
616
|
+
}
|
|
303
617
|
this.logger.info("Starting drivers...");
|
|
304
|
-
|
|
618
|
+
const started = [];
|
|
619
|
+
for (const driver of this.drivers) {
|
|
620
|
+
try {
|
|
621
|
+
if (driver.start) await driver.start();
|
|
622
|
+
started.push(driver);
|
|
623
|
+
} catch (err) {
|
|
624
|
+
this.logger.error({ err, driver: driver.name }, "Driver failed to start; stopping every driver");
|
|
625
|
+
await this.rollback([...this.drivers].reverse());
|
|
626
|
+
throw err;
|
|
627
|
+
}
|
|
628
|
+
}
|
|
629
|
+
this.startedDrivers = started;
|
|
630
|
+
this.installSignalHandlers();
|
|
305
631
|
this.logger.info("App started successfully \u{1F680}");
|
|
306
632
|
}
|
|
307
|
-
|
|
633
|
+
/**
|
|
634
|
+
* Stops drivers in reverse start order (the web server before the DB it
|
|
635
|
+
* uses), continuing past failures, and always flushes OpenTelemetry.
|
|
636
|
+
*
|
|
637
|
+
* A call made while a stop is in progress (e.g. an app's own signal
|
|
638
|
+
* handler next to the App's) gets that same stop: it resolves only once
|
|
639
|
+
* every driver has stopped, instead of right away with nothing left to do.
|
|
640
|
+
*/
|
|
641
|
+
stop() {
|
|
642
|
+
this.stopping ??= this.stopOnce().finally(() => {
|
|
643
|
+
this.stopping = null;
|
|
644
|
+
});
|
|
645
|
+
return this.stopping;
|
|
646
|
+
}
|
|
647
|
+
async stopOnce() {
|
|
308
648
|
this.logger.info("Stopping app...");
|
|
309
|
-
const
|
|
310
|
-
|
|
649
|
+
const toStop = [...this.startedDrivers ?? this.drivers].reverse();
|
|
650
|
+
this.startedDrivers = [];
|
|
651
|
+
let failures;
|
|
652
|
+
try {
|
|
653
|
+
failures = await this.stopDrivers(toStop);
|
|
654
|
+
} finally {
|
|
655
|
+
await shutdownOtel();
|
|
656
|
+
this.removeSignalHandlers();
|
|
657
|
+
}
|
|
311
658
|
if (failures.length > 0) {
|
|
312
659
|
this.logger.error({ failures }, "Some drivers failed to stop");
|
|
313
660
|
throw new LifecycleError("Some drivers failed to stop", {
|
|
314
661
|
failures,
|
|
315
|
-
context: { driverCount:
|
|
662
|
+
context: { driverCount: toStop.length, failedCount: failures.length }
|
|
316
663
|
});
|
|
317
664
|
}
|
|
318
|
-
await shutdownOtel();
|
|
319
665
|
this.logger.info("App stopped.");
|
|
320
666
|
}
|
|
667
|
+
/**
|
|
668
|
+
* Stops `drivers` after a failed init() or start() and shuts down
|
|
669
|
+
* OpenTelemetry. Failures to stop are logged, not thrown: the caller
|
|
670
|
+
* rethrows the original error. A later stop() stops nothing more.
|
|
671
|
+
*/
|
|
672
|
+
async rollback(drivers) {
|
|
673
|
+
this.startedDrivers = [];
|
|
674
|
+
await this.stopDrivers(drivers);
|
|
675
|
+
try {
|
|
676
|
+
await shutdownOtel();
|
|
677
|
+
} catch (err) {
|
|
678
|
+
this.logger.error({ err }, "OpenTelemetry failed to shut down");
|
|
679
|
+
}
|
|
680
|
+
}
|
|
681
|
+
async stopDrivers(drivers) {
|
|
682
|
+
const failures = [];
|
|
683
|
+
for (const driver of drivers) {
|
|
684
|
+
try {
|
|
685
|
+
if (driver.stop) await driver.stop();
|
|
686
|
+
} catch (reason) {
|
|
687
|
+
this.logger.error({ err: reason, driver: driver.name }, "Driver failed to stop");
|
|
688
|
+
failures.push({ status: "rejected", reason });
|
|
689
|
+
}
|
|
690
|
+
}
|
|
691
|
+
return failures;
|
|
692
|
+
}
|
|
693
|
+
/**
|
|
694
|
+
* Stops the app gracefully on SIGTERM/SIGINT (configurable with
|
|
695
|
+
* `shutdownSignals`, off by default under NODE_ENV=test). Once a listener
|
|
696
|
+
* exists the runtime no longer exits on its own, so the handler exits with
|
|
697
|
+
* 0 after a clean stop, or 1 on failure / after `shutdownTimeoutMs`. A
|
|
698
|
+
* second signal forces exit.
|
|
699
|
+
*/
|
|
700
|
+
installSignalHandlers() {
|
|
701
|
+
const configured = this.config.shutdownSignals;
|
|
702
|
+
const signals = configured === false ? [] : configured ?? (nodeEnv() === "test" ? [] : DEFAULT_SHUTDOWN_SIGNALS);
|
|
703
|
+
if (signals.length === 0 || this.signalHandler) return;
|
|
704
|
+
const timeoutMs = this.config.shutdownTimeoutMs ?? DEFAULT_SHUTDOWN_TIMEOUT_MS;
|
|
705
|
+
let shuttingDown = false;
|
|
706
|
+
this.signalHandler = (signal) => {
|
|
707
|
+
if (shuttingDown) {
|
|
708
|
+
this.logger.warn({ signal }, "Second shutdown signal; exiting immediately");
|
|
709
|
+
process.exit(1);
|
|
710
|
+
}
|
|
711
|
+
shuttingDown = true;
|
|
712
|
+
this.logger.info({ signal }, "Shutdown signal received; stopping app");
|
|
713
|
+
const timer = setTimeout(() => {
|
|
714
|
+
this.logger.error({ timeoutMs }, "Graceful shutdown timed out; exiting");
|
|
715
|
+
process.exit(1);
|
|
716
|
+
}, timeoutMs);
|
|
717
|
+
this.stop().then(
|
|
718
|
+
() => {
|
|
719
|
+
clearTimeout(timer);
|
|
720
|
+
process.exit(0);
|
|
721
|
+
},
|
|
722
|
+
(err) => {
|
|
723
|
+
clearTimeout(timer);
|
|
724
|
+
this.logger.error({ err }, "Graceful shutdown failed");
|
|
725
|
+
process.exit(1);
|
|
726
|
+
}
|
|
727
|
+
);
|
|
728
|
+
};
|
|
729
|
+
this.signals = signals;
|
|
730
|
+
for (const signal of this.signals) process.on(signal, this.signalHandler);
|
|
731
|
+
}
|
|
732
|
+
removeSignalHandlers() {
|
|
733
|
+
if (!this.signalHandler) return;
|
|
734
|
+
for (const signal of this.signals) process.off(signal, this.signalHandler);
|
|
735
|
+
this.signalHandler = void 0;
|
|
736
|
+
this.signals = [];
|
|
737
|
+
}
|
|
321
738
|
};
|
|
739
|
+
|
|
740
|
+
// src/traced.ts
|
|
741
|
+
import { SpanStatusCode, trace } from "@opentelemetry/api";
|
|
742
|
+
function fail(span, error) {
|
|
743
|
+
span.recordException(error instanceof Error ? error : new Error(String(error)));
|
|
744
|
+
span.setStatus({ code: SpanStatusCode.ERROR, message: error instanceof Error ? error.name : "Error" });
|
|
745
|
+
}
|
|
746
|
+
function traced(name, fn, options = {}) {
|
|
747
|
+
return function(...args) {
|
|
748
|
+
const tracer = options.tracer ?? trace.getTracer("iskra");
|
|
749
|
+
return tracer.startActiveSpan(name, { attributes: options.attributes }, (span) => {
|
|
750
|
+
let result;
|
|
751
|
+
try {
|
|
752
|
+
result = fn.apply(this, args);
|
|
753
|
+
} catch (error) {
|
|
754
|
+
fail(span, error);
|
|
755
|
+
span.end();
|
|
756
|
+
throw error;
|
|
757
|
+
}
|
|
758
|
+
if (result instanceof Promise) {
|
|
759
|
+
return result.then(
|
|
760
|
+
(value) => {
|
|
761
|
+
span.end();
|
|
762
|
+
return value;
|
|
763
|
+
},
|
|
764
|
+
(error) => {
|
|
765
|
+
fail(span, error);
|
|
766
|
+
span.end();
|
|
767
|
+
throw error;
|
|
768
|
+
}
|
|
769
|
+
);
|
|
770
|
+
}
|
|
771
|
+
span.end();
|
|
772
|
+
return result;
|
|
773
|
+
});
|
|
774
|
+
};
|
|
775
|
+
}
|
|
322
776
|
export {
|
|
323
777
|
App,
|
|
324
778
|
AppConfigSchema,
|
|
779
|
+
AppContext,
|
|
325
780
|
ConfigError,
|
|
326
781
|
DriverError,
|
|
327
782
|
ErrorCodes,
|
|
328
783
|
IskraError,
|
|
329
784
|
LifecycleError,
|
|
330
785
|
PluginError,
|
|
786
|
+
SECRET_QUERY_PARAMS,
|
|
787
|
+
autoInstrumentationOptions,
|
|
331
788
|
createLogger,
|
|
789
|
+
createResource,
|
|
790
|
+
describeOtelEndpoint,
|
|
332
791
|
initOtel,
|
|
792
|
+
isDevelopmentEnv,
|
|
793
|
+
isProductionEnv,
|
|
333
794
|
loadAppConfig,
|
|
334
|
-
|
|
795
|
+
nodeEnv,
|
|
796
|
+
redactUrl,
|
|
797
|
+
shutdownOtel,
|
|
798
|
+
traced
|
|
335
799
|
};
|
|
336
800
|
//# sourceMappingURL=index.js.map
|