devlogs-node 2.2.7
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/dist/build-info.d.ts +72 -0
- package/dist/circuit-breaker.d.ts +36 -0
- package/dist/client.d.ts +19 -0
- package/dist/config.d.ts +27 -0
- package/dist/context.d.ts +37 -0
- package/dist/devlogs.cjs.js +765 -0
- package/dist/devlogs.cjs.js.map +1 -0
- package/dist/devlogs.esm.js +752 -0
- package/dist/devlogs.esm.js.map +1 -0
- package/dist/formatter.d.ts +10 -0
- package/dist/index.d.ts +34 -0
- package/dist/interceptor.d.ts +19 -0
- package/dist/types.d.ts +109 -0
- package/dist/url-parser.d.ts +12 -0
- package/package.json +36 -0
|
@@ -0,0 +1,752 @@
|
|
|
1
|
+
import { existsSync, readFileSync } from 'node:fs';
|
|
2
|
+
import { resolve, dirname } from 'node:path';
|
|
3
|
+
import http from 'node:http';
|
|
4
|
+
import https from 'node:https';
|
|
5
|
+
import { AsyncLocalStorage } from 'node:async_hooks';
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Parse a devlogs URL and auto-detect mode.
|
|
9
|
+
*
|
|
10
|
+
* Detection logic:
|
|
11
|
+
* - user:pass@ in URL → OpenSearch direct mode
|
|
12
|
+
* - token@ (no password) or ?token= → Collector mode
|
|
13
|
+
* - No credentials → Collector mode
|
|
14
|
+
*
|
|
15
|
+
* Supports schemes: http://, https://, opensearch://, opensearchs://
|
|
16
|
+
*/
|
|
17
|
+
function parseDevlogsUrl(url, index) {
|
|
18
|
+
// Normalize opensearch(s):// to http(s)://
|
|
19
|
+
let normalizedUrl = url;
|
|
20
|
+
let forceOpenSearch = false;
|
|
21
|
+
if (url.startsWith('opensearchs://')) {
|
|
22
|
+
normalizedUrl = 'https://' + url.slice('opensearchs://'.length);
|
|
23
|
+
forceOpenSearch = true;
|
|
24
|
+
}
|
|
25
|
+
else if (url.startsWith('opensearch://')) {
|
|
26
|
+
normalizedUrl = 'http://' + url.slice('opensearch://'.length);
|
|
27
|
+
forceOpenSearch = true;
|
|
28
|
+
}
|
|
29
|
+
const parsed = new URL(normalizedUrl);
|
|
30
|
+
const scheme = parsed.protocol.replace(':', '');
|
|
31
|
+
const host = parsed.hostname || 'localhost';
|
|
32
|
+
const port = parsed.port
|
|
33
|
+
? parseInt(parsed.port, 10)
|
|
34
|
+
: scheme === 'https'
|
|
35
|
+
? 443
|
|
36
|
+
: 9200;
|
|
37
|
+
// Extract index from path if present (e.g., /devlogs-myapp)
|
|
38
|
+
const pathIndex = parsed.pathname && parsed.pathname !== '/'
|
|
39
|
+
? parsed.pathname.slice(1).split('/')[0]
|
|
40
|
+
: undefined;
|
|
41
|
+
const resolvedIndex = index || pathIndex || 'devlogs-0001';
|
|
42
|
+
// Determine mode:
|
|
43
|
+
// 1. opensearch(s):// scheme → always OpenSearch
|
|
44
|
+
// 2. Both user AND password → OpenSearch
|
|
45
|
+
// 3. User only (no password) → Collector with token
|
|
46
|
+
// 4. No credentials → Collector
|
|
47
|
+
if (forceOpenSearch || (parsed.username && parsed.password)) {
|
|
48
|
+
return {
|
|
49
|
+
mode: 'opensearch',
|
|
50
|
+
scheme,
|
|
51
|
+
host,
|
|
52
|
+
port,
|
|
53
|
+
user: decodeURIComponent(parsed.username || 'admin'),
|
|
54
|
+
password: decodeURIComponent(parsed.password || 'admin'),
|
|
55
|
+
index: resolvedIndex,
|
|
56
|
+
};
|
|
57
|
+
}
|
|
58
|
+
// Collector mode: username-only is a token
|
|
59
|
+
const token = parsed.username ? decodeURIComponent(parsed.username) : null;
|
|
60
|
+
return {
|
|
61
|
+
mode: 'collector',
|
|
62
|
+
scheme,
|
|
63
|
+
host,
|
|
64
|
+
port,
|
|
65
|
+
token,
|
|
66
|
+
index: resolvedIndex,
|
|
67
|
+
};
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
let envLoaded = false;
|
|
71
|
+
/**
|
|
72
|
+
* Minimal .env file parser. No external dependencies.
|
|
73
|
+
* Handles KEY=VALUE, KEY="VALUE", KEY='VALUE', and # comments.
|
|
74
|
+
*/
|
|
75
|
+
function parseDotenv(content) {
|
|
76
|
+
const result = {};
|
|
77
|
+
for (const line of content.split('\n')) {
|
|
78
|
+
const trimmed = line.trim();
|
|
79
|
+
if (!trimmed || trimmed.startsWith('#'))
|
|
80
|
+
continue;
|
|
81
|
+
const eqIndex = trimmed.indexOf('=');
|
|
82
|
+
if (eqIndex === -1)
|
|
83
|
+
continue;
|
|
84
|
+
const key = trimmed.slice(0, eqIndex).trim();
|
|
85
|
+
let value = trimmed.slice(eqIndex + 1).trim();
|
|
86
|
+
// Strip surrounding quotes
|
|
87
|
+
if ((value.startsWith('"') && value.endsWith('"')) ||
|
|
88
|
+
(value.startsWith("'") && value.endsWith("'"))) {
|
|
89
|
+
value = value.slice(1, -1);
|
|
90
|
+
}
|
|
91
|
+
result[key] = value;
|
|
92
|
+
}
|
|
93
|
+
return result;
|
|
94
|
+
}
|
|
95
|
+
/**
|
|
96
|
+
* Search upward from cwd for a file by name.
|
|
97
|
+
*/
|
|
98
|
+
function findFileUpward(filename, startDir) {
|
|
99
|
+
let dir = process.cwd();
|
|
100
|
+
const root = resolve('/');
|
|
101
|
+
while (true) {
|
|
102
|
+
const candidate = resolve(dir, filename);
|
|
103
|
+
if (existsSync(candidate)) {
|
|
104
|
+
return candidate;
|
|
105
|
+
}
|
|
106
|
+
const parent = dirname(dir);
|
|
107
|
+
if (parent === dir || dir === root) {
|
|
108
|
+
return null;
|
|
109
|
+
}
|
|
110
|
+
dir = parent;
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
/**
|
|
114
|
+
* Load environment variables from .env.devlogs (preferred) or .env file.
|
|
115
|
+
* Only sets variables that are not already defined in process.env.
|
|
116
|
+
* Called once automatically on first config load.
|
|
117
|
+
*/
|
|
118
|
+
function loadDotenv() {
|
|
119
|
+
if (envLoaded)
|
|
120
|
+
return;
|
|
121
|
+
envLoaded = true;
|
|
122
|
+
const dotenvPath = process.env.DOTENV_PATH;
|
|
123
|
+
let filePath = null;
|
|
124
|
+
if (dotenvPath) {
|
|
125
|
+
filePath = dotenvPath;
|
|
126
|
+
}
|
|
127
|
+
else {
|
|
128
|
+
// Prefer .env.devlogs over .env
|
|
129
|
+
filePath = findFileUpward('.env.devlogs');
|
|
130
|
+
if (!filePath) {
|
|
131
|
+
filePath = findFileUpward('.env');
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
if (!filePath || !existsSync(filePath))
|
|
135
|
+
return;
|
|
136
|
+
try {
|
|
137
|
+
const content = readFileSync(filePath, 'utf-8');
|
|
138
|
+
const vars = parseDotenv(content);
|
|
139
|
+
for (const [key, value] of Object.entries(vars)) {
|
|
140
|
+
// Don't override existing env vars
|
|
141
|
+
if (process.env[key] === undefined) {
|
|
142
|
+
process.env[key] = value;
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
catch {
|
|
147
|
+
// Silently ignore read errors
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
/**
|
|
151
|
+
* Load configuration from DEVLOGS_* environment variables.
|
|
152
|
+
* Automatically loads .env.devlogs on first call.
|
|
153
|
+
*/
|
|
154
|
+
function loadEnvConfig() {
|
|
155
|
+
loadDotenv();
|
|
156
|
+
return {
|
|
157
|
+
url: process.env.DEVLOGS_URL || undefined,
|
|
158
|
+
index: process.env.DEVLOGS_INDEX || undefined,
|
|
159
|
+
application: process.env.DEVLOGS_APPLICATION || undefined,
|
|
160
|
+
component: process.env.DEVLOGS_COMPONENT || undefined,
|
|
161
|
+
area: process.env.DEVLOGS_AREA || undefined,
|
|
162
|
+
environment: process.env.DEVLOGS_ENVIRONMENT || undefined,
|
|
163
|
+
version: process.env.DEVLOGS_VERSION || undefined,
|
|
164
|
+
};
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
/**
|
|
168
|
+
* Time-based circuit breaker with auto-recovery.
|
|
169
|
+
*
|
|
170
|
+
* Port of the Go SDK circuit breaker pattern:
|
|
171
|
+
* - Opens on failure, pauses indexing for `duration` (default 60s)
|
|
172
|
+
* - Auto-resets after duration expires (time-based)
|
|
173
|
+
* - Closes immediately on explicit success (RecordSuccess)
|
|
174
|
+
* - Throttles error output to once per `errorInterval` (default 10s)
|
|
175
|
+
*/
|
|
176
|
+
class CircuitBreaker {
|
|
177
|
+
/**
|
|
178
|
+
* @param duration - How long to keep circuit open in ms (default: 60000)
|
|
179
|
+
* @param errorInterval - Min interval between error prints in ms (default: 10000)
|
|
180
|
+
* @param stderr - Output function for error messages (default: process.stderr.write)
|
|
181
|
+
*/
|
|
182
|
+
constructor(duration = 60000, errorInterval = 10000, stderr) {
|
|
183
|
+
this.isOpen = false;
|
|
184
|
+
this.openUntil = 0;
|
|
185
|
+
this.lastErrorPrinted = 0;
|
|
186
|
+
this.duration = duration;
|
|
187
|
+
this.errorInterval = errorInterval;
|
|
188
|
+
this.stderr = stderr ?? ((msg) => process.stderr.write(msg + '\n'));
|
|
189
|
+
}
|
|
190
|
+
/**
|
|
191
|
+
* Check if the circuit breaker is open (should skip indexing).
|
|
192
|
+
* Auto-resets if duration has expired.
|
|
193
|
+
*/
|
|
194
|
+
shouldSkip() {
|
|
195
|
+
if (!this.isOpen)
|
|
196
|
+
return false;
|
|
197
|
+
if (Date.now() >= this.openUntil) {
|
|
198
|
+
this.isOpen = false;
|
|
199
|
+
return false;
|
|
200
|
+
}
|
|
201
|
+
return true;
|
|
202
|
+
}
|
|
203
|
+
/**
|
|
204
|
+
* Record a failure - opens the circuit breaker.
|
|
205
|
+
*/
|
|
206
|
+
recordFailure(err) {
|
|
207
|
+
const now = Date.now();
|
|
208
|
+
this.isOpen = true;
|
|
209
|
+
this.openUntil = now + this.duration;
|
|
210
|
+
// Throttle error printing
|
|
211
|
+
if (now - this.lastErrorPrinted > this.errorInterval) {
|
|
212
|
+
this.lastErrorPrinted = now;
|
|
213
|
+
const secs = Math.round(this.duration / 1000);
|
|
214
|
+
this.stderr(`[devlogs] Failed to index log, pausing indexing for ${secs}s: ${err}`);
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
/**
|
|
218
|
+
* Record a success - closes the circuit breaker.
|
|
219
|
+
*/
|
|
220
|
+
recordSuccess() {
|
|
221
|
+
if (this.isOpen) {
|
|
222
|
+
this.isOpen = false;
|
|
223
|
+
this.stderr('[devlogs] Connection restored, resuming indexing');
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
/**
|
|
229
|
+
* Dual-mode HTTP client for sending log documents.
|
|
230
|
+
*
|
|
231
|
+
* - OpenSearch mode: POST to /{index}/_doc with Basic auth
|
|
232
|
+
* - Collector mode: POST to / with optional Bearer token
|
|
233
|
+
* - Fire-and-forget: does not await response
|
|
234
|
+
* - Circuit breaker: stops sending on failures, auto-recovers
|
|
235
|
+
*/
|
|
236
|
+
class DevlogsClient {
|
|
237
|
+
constructor(config, cb) {
|
|
238
|
+
this.config = config;
|
|
239
|
+
this.cb = cb ?? new CircuitBreaker();
|
|
240
|
+
}
|
|
241
|
+
/**
|
|
242
|
+
* Send a log document. Fire-and-forget - does not block.
|
|
243
|
+
*/
|
|
244
|
+
send(doc) {
|
|
245
|
+
if (this.cb.shouldSkip())
|
|
246
|
+
return;
|
|
247
|
+
const body = JSON.stringify(doc);
|
|
248
|
+
const transport = this.config.scheme === 'https' ? https : http;
|
|
249
|
+
const headers = {
|
|
250
|
+
'Content-Type': 'application/json',
|
|
251
|
+
'Content-Length': Buffer.byteLength(body).toString(),
|
|
252
|
+
};
|
|
253
|
+
let path;
|
|
254
|
+
if (this.config.mode === 'opensearch') {
|
|
255
|
+
path = `/${this.config.index}/_doc`;
|
|
256
|
+
const credentials = `${this.config.user}:${this.config.password}`;
|
|
257
|
+
headers['Authorization'] = `Basic ${Buffer.from(credentials).toString('base64')}`;
|
|
258
|
+
}
|
|
259
|
+
else {
|
|
260
|
+
path = '/';
|
|
261
|
+
if (this.config.token) {
|
|
262
|
+
headers['Authorization'] = `Bearer ${this.config.token}`;
|
|
263
|
+
}
|
|
264
|
+
}
|
|
265
|
+
const req = transport.request({
|
|
266
|
+
hostname: this.config.host,
|
|
267
|
+
port: this.config.port,
|
|
268
|
+
path,
|
|
269
|
+
method: 'POST',
|
|
270
|
+
headers,
|
|
271
|
+
}, (res) => {
|
|
272
|
+
// Consume response to free socket
|
|
273
|
+
res.resume();
|
|
274
|
+
const status = res.statusCode ?? 0;
|
|
275
|
+
if (status >= 200 && status < 300) {
|
|
276
|
+
this.cb.recordSuccess();
|
|
277
|
+
}
|
|
278
|
+
else {
|
|
279
|
+
this.cb.recordFailure(new Error(`HTTP ${status} from ${this.config.mode}`));
|
|
280
|
+
}
|
|
281
|
+
});
|
|
282
|
+
req.on('error', (err) => {
|
|
283
|
+
this.cb.recordFailure(err);
|
|
284
|
+
});
|
|
285
|
+
req.end(body);
|
|
286
|
+
}
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
/**
|
|
290
|
+
* AsyncLocalStorage instance for per-request context isolation.
|
|
291
|
+
* Each async context (e.g., HTTP request handler) gets its own LogContext.
|
|
292
|
+
*/
|
|
293
|
+
const storage = new AsyncLocalStorage();
|
|
294
|
+
/**
|
|
295
|
+
* Default context used when no async context is active.
|
|
296
|
+
*/
|
|
297
|
+
let defaultContext = {
|
|
298
|
+
application: 'unknown',
|
|
299
|
+
component: 'node',
|
|
300
|
+
area: null,
|
|
301
|
+
operationId: null,
|
|
302
|
+
environment: null,
|
|
303
|
+
version: null,
|
|
304
|
+
fields: {},
|
|
305
|
+
};
|
|
306
|
+
/**
|
|
307
|
+
* Set the default context values (called during init).
|
|
308
|
+
*/
|
|
309
|
+
function setDefaultContext(ctx) {
|
|
310
|
+
defaultContext = { ...defaultContext, ...ctx };
|
|
311
|
+
}
|
|
312
|
+
/**
|
|
313
|
+
* Get the current logging context.
|
|
314
|
+
* Returns the AsyncLocalStorage context if inside withContext/withOperation,
|
|
315
|
+
* otherwise returns the default (module-level) context.
|
|
316
|
+
*/
|
|
317
|
+
function getContext() {
|
|
318
|
+
return storage.getStore() ?? { ...defaultContext };
|
|
319
|
+
}
|
|
320
|
+
/**
|
|
321
|
+
* Set the application area in the current context.
|
|
322
|
+
*/
|
|
323
|
+
function setArea(area) {
|
|
324
|
+
const store = storage.getStore();
|
|
325
|
+
if (store) {
|
|
326
|
+
store.area = area;
|
|
327
|
+
}
|
|
328
|
+
else {
|
|
329
|
+
defaultContext.area = area;
|
|
330
|
+
}
|
|
331
|
+
}
|
|
332
|
+
/**
|
|
333
|
+
* Set the operation ID in the current context.
|
|
334
|
+
*/
|
|
335
|
+
function setOperationId(operationId) {
|
|
336
|
+
const store = storage.getStore();
|
|
337
|
+
if (store) {
|
|
338
|
+
store.operationId = operationId;
|
|
339
|
+
}
|
|
340
|
+
else {
|
|
341
|
+
defaultContext.operationId = operationId;
|
|
342
|
+
}
|
|
343
|
+
}
|
|
344
|
+
/**
|
|
345
|
+
* Set custom fields in the current context.
|
|
346
|
+
*/
|
|
347
|
+
function setFields(fields) {
|
|
348
|
+
const store = storage.getStore();
|
|
349
|
+
if (store) {
|
|
350
|
+
store.fields = { ...store.fields, ...fields };
|
|
351
|
+
}
|
|
352
|
+
else {
|
|
353
|
+
defaultContext.fields = { ...defaultContext.fields, ...fields };
|
|
354
|
+
}
|
|
355
|
+
}
|
|
356
|
+
/**
|
|
357
|
+
* Run a function with a temporary operation ID in an isolated async context.
|
|
358
|
+
* The operation ID is automatically scoped to the callback and its async children.
|
|
359
|
+
*/
|
|
360
|
+
function withOperation(operationId, fn) {
|
|
361
|
+
const parent = getContext();
|
|
362
|
+
const childCtx = {
|
|
363
|
+
...parent,
|
|
364
|
+
operationId,
|
|
365
|
+
fields: { ...parent.fields },
|
|
366
|
+
};
|
|
367
|
+
return storage.run(childCtx, fn);
|
|
368
|
+
}
|
|
369
|
+
/**
|
|
370
|
+
* Run a function with a fully isolated async context.
|
|
371
|
+
* Changes made inside the callback don't affect the parent context.
|
|
372
|
+
*/
|
|
373
|
+
function withContext(overrides, fn) {
|
|
374
|
+
const parent = getContext();
|
|
375
|
+
const childCtx = {
|
|
376
|
+
...parent,
|
|
377
|
+
...overrides,
|
|
378
|
+
fields: { ...parent.fields, ...overrides.fields },
|
|
379
|
+
};
|
|
380
|
+
return storage.run(childCtx, fn);
|
|
381
|
+
}
|
|
382
|
+
/**
|
|
383
|
+
* Reset the default context (for testing/destroy).
|
|
384
|
+
*/
|
|
385
|
+
function resetContext() {
|
|
386
|
+
defaultContext = {
|
|
387
|
+
application: 'unknown',
|
|
388
|
+
component: 'node',
|
|
389
|
+
area: null,
|
|
390
|
+
operationId: null,
|
|
391
|
+
environment: null,
|
|
392
|
+
version: null,
|
|
393
|
+
fields: {},
|
|
394
|
+
};
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
/**
|
|
398
|
+
* Normalize console method name to standard log level.
|
|
399
|
+
*/
|
|
400
|
+
function normalizeLevel(method) {
|
|
401
|
+
if (method === 'warn')
|
|
402
|
+
return 'warning';
|
|
403
|
+
if (method === 'log')
|
|
404
|
+
return 'info';
|
|
405
|
+
return method;
|
|
406
|
+
}
|
|
407
|
+
/**
|
|
408
|
+
* Format console arguments into a single message string.
|
|
409
|
+
*/
|
|
410
|
+
function formatMessage(args) {
|
|
411
|
+
return args
|
|
412
|
+
.map((arg) => {
|
|
413
|
+
if (typeof arg === 'string')
|
|
414
|
+
return arg;
|
|
415
|
+
if (arg instanceof Error)
|
|
416
|
+
return `${arg.name}: ${arg.message}`;
|
|
417
|
+
try {
|
|
418
|
+
return JSON.stringify(arg);
|
|
419
|
+
}
|
|
420
|
+
catch {
|
|
421
|
+
return String(arg);
|
|
422
|
+
}
|
|
423
|
+
})
|
|
424
|
+
.join(' ');
|
|
425
|
+
}
|
|
426
|
+
/**
|
|
427
|
+
* Extract source location from an Error stack trace.
|
|
428
|
+
* Walks the stack to find the first frame outside of devlogs internals.
|
|
429
|
+
*/
|
|
430
|
+
function extractSourceLocation() {
|
|
431
|
+
const base = {
|
|
432
|
+
logger: 'node',
|
|
433
|
+
pathname: null,
|
|
434
|
+
lineno: null,
|
|
435
|
+
funcName: null,
|
|
436
|
+
};
|
|
437
|
+
const err = new Error();
|
|
438
|
+
const stack = err.stack;
|
|
439
|
+
if (!stack)
|
|
440
|
+
return base;
|
|
441
|
+
const lines = stack.split('\n');
|
|
442
|
+
// Skip frames inside devlogs (interceptor, formatter, index)
|
|
443
|
+
for (let i = 1; i < lines.length; i++) {
|
|
444
|
+
const line = lines[i].trim();
|
|
445
|
+
// Skip internal devlogs package frames
|
|
446
|
+
if (line.includes('devlogs.cjs') ||
|
|
447
|
+
line.includes('devlogs.esm') ||
|
|
448
|
+
line.includes('node_modules/devlogs-node') ||
|
|
449
|
+
line.includes('/node/dist/') ||
|
|
450
|
+
line.includes('/node/src/interceptor.') ||
|
|
451
|
+
line.includes('/node/src/formatter.') ||
|
|
452
|
+
line.includes('/node/src/index.')) {
|
|
453
|
+
continue;
|
|
454
|
+
}
|
|
455
|
+
// Parse V8 stack frame: "at funcName (file:line:col)" or "at file:line:col"
|
|
456
|
+
const withFunc = line.match(/at\s+(.+?)\s+\((.+?):(\d+):\d+\)/);
|
|
457
|
+
if (withFunc) {
|
|
458
|
+
return {
|
|
459
|
+
logger: 'node',
|
|
460
|
+
pathname: withFunc[2],
|
|
461
|
+
lineno: parseInt(withFunc[3], 10),
|
|
462
|
+
funcName: withFunc[1],
|
|
463
|
+
};
|
|
464
|
+
}
|
|
465
|
+
const noFunc = line.match(/at\s+(.+?):(\d+):\d+/);
|
|
466
|
+
if (noFunc) {
|
|
467
|
+
return {
|
|
468
|
+
logger: 'node',
|
|
469
|
+
pathname: noFunc[1],
|
|
470
|
+
lineno: parseInt(noFunc[2], 10),
|
|
471
|
+
funcName: null,
|
|
472
|
+
};
|
|
473
|
+
}
|
|
474
|
+
}
|
|
475
|
+
return base;
|
|
476
|
+
}
|
|
477
|
+
/**
|
|
478
|
+
* Extract fields from console arguments if an object is provided.
|
|
479
|
+
*/
|
|
480
|
+
function extractFields(args, contextFields) {
|
|
481
|
+
const fields = { ...contextFields };
|
|
482
|
+
// If last argument is a plain object, merge it as fields
|
|
483
|
+
const lastArg = args[args.length - 1];
|
|
484
|
+
if (lastArg &&
|
|
485
|
+
typeof lastArg === 'object' &&
|
|
486
|
+
!Array.isArray(lastArg) &&
|
|
487
|
+
!(lastArg instanceof Error)) {
|
|
488
|
+
Object.assign(fields, lastArg);
|
|
489
|
+
}
|
|
490
|
+
return fields;
|
|
491
|
+
}
|
|
492
|
+
/**
|
|
493
|
+
* Format a log entry into the devlogs v2.0 document schema.
|
|
494
|
+
*/
|
|
495
|
+
function formatLogDocument(method, args, context, source) {
|
|
496
|
+
const fields = extractFields(args, context.fields);
|
|
497
|
+
const resolvedSource = source ?? extractSourceLocation();
|
|
498
|
+
const doc = {
|
|
499
|
+
doc_type: 'log_entry',
|
|
500
|
+
application: context.application,
|
|
501
|
+
component: context.component,
|
|
502
|
+
timestamp: new Date().toISOString(),
|
|
503
|
+
message: formatMessage(args),
|
|
504
|
+
level: normalizeLevel(method),
|
|
505
|
+
area: context.area,
|
|
506
|
+
operation_id: context.operationId,
|
|
507
|
+
source: resolvedSource,
|
|
508
|
+
process: {
|
|
509
|
+
id: process.pid,
|
|
510
|
+
thread: null,
|
|
511
|
+
},
|
|
512
|
+
};
|
|
513
|
+
if (context.environment) {
|
|
514
|
+
doc.environment = context.environment;
|
|
515
|
+
}
|
|
516
|
+
if (context.version) {
|
|
517
|
+
doc.version = context.version;
|
|
518
|
+
}
|
|
519
|
+
if (Object.keys(fields).length > 0) {
|
|
520
|
+
doc.fields = fields;
|
|
521
|
+
}
|
|
522
|
+
return doc;
|
|
523
|
+
}
|
|
524
|
+
|
|
525
|
+
/**
|
|
526
|
+
* Store original console methods before interception.
|
|
527
|
+
* Used for:
|
|
528
|
+
* 1. Calling the original console so terminal output still works
|
|
529
|
+
* 2. Avoiding infinite loops from internal logging
|
|
530
|
+
*/
|
|
531
|
+
const originalConsole = {
|
|
532
|
+
log: console.log.bind(console),
|
|
533
|
+
warn: console.warn.bind(console),
|
|
534
|
+
error: console.error.bind(console),
|
|
535
|
+
debug: console.debug.bind(console),
|
|
536
|
+
info: console.info.bind(console),
|
|
537
|
+
};
|
|
538
|
+
const METHODS = ['log', 'warn', 'error', 'debug', 'info'];
|
|
539
|
+
/**
|
|
540
|
+
* Intercept console methods to forward logs to OpenSearch/collector.
|
|
541
|
+
* Original console methods are still called so terminal output works normally.
|
|
542
|
+
* Reads context from AsyncLocalStorage on each call for per-request isolation.
|
|
543
|
+
*/
|
|
544
|
+
function interceptConsole(client) {
|
|
545
|
+
METHODS.forEach((method) => {
|
|
546
|
+
console[method] = (...args) => {
|
|
547
|
+
// Capture source location before calling original (preserves stack)
|
|
548
|
+
const source = extractSourceLocation();
|
|
549
|
+
// Always call the original console method
|
|
550
|
+
originalConsole[method](...args);
|
|
551
|
+
// Read context from AsyncLocalStorage (per-request)
|
|
552
|
+
const context = getContext();
|
|
553
|
+
// Format and send
|
|
554
|
+
const doc = formatLogDocument(method, args, context, source);
|
|
555
|
+
client.send(doc);
|
|
556
|
+
};
|
|
557
|
+
});
|
|
558
|
+
}
|
|
559
|
+
/**
|
|
560
|
+
* Restore original console methods.
|
|
561
|
+
*/
|
|
562
|
+
function restoreConsole() {
|
|
563
|
+
METHODS.forEach((method) => {
|
|
564
|
+
console[method] = originalConsole[method];
|
|
565
|
+
});
|
|
566
|
+
}
|
|
567
|
+
|
|
568
|
+
/**
|
|
569
|
+
* Build info helper for devlogs node client.
|
|
570
|
+
*
|
|
571
|
+
* Provides a stable build identifier that applications can use to tag
|
|
572
|
+
* every log entry without requiring git at runtime.
|
|
573
|
+
*/
|
|
574
|
+
/**
|
|
575
|
+
* Format a Date as compact ISO-like UTC timestamp: YYYYMMDDTHHMMSSZ.
|
|
576
|
+
*/
|
|
577
|
+
function formatTimestamp(date) {
|
|
578
|
+
const pad = (n) => n.toString().padStart(2, '0');
|
|
579
|
+
return (date.getUTCFullYear().toString() +
|
|
580
|
+
pad(date.getUTCMonth() + 1) +
|
|
581
|
+
pad(date.getUTCDate()) +
|
|
582
|
+
'T' +
|
|
583
|
+
pad(date.getUTCHours()) +
|
|
584
|
+
pad(date.getUTCMinutes()) +
|
|
585
|
+
pad(date.getUTCSeconds()) +
|
|
586
|
+
'Z');
|
|
587
|
+
}
|
|
588
|
+
/**
|
|
589
|
+
* Get environment variable value.
|
|
590
|
+
*/
|
|
591
|
+
function getEnv(name, env) {
|
|
592
|
+
if (env)
|
|
593
|
+
return env[name];
|
|
594
|
+
return process.env[name];
|
|
595
|
+
}
|
|
596
|
+
/**
|
|
597
|
+
* Resolve build information from data, environment, or generate it.
|
|
598
|
+
*
|
|
599
|
+
* Priority order:
|
|
600
|
+
* 1. Environment variable BUILD_ID (highest precedence)
|
|
601
|
+
* 2. Provided build info data (from file)
|
|
602
|
+
* 3. Environment variables for branch/timestamp
|
|
603
|
+
* 4. Generated values
|
|
604
|
+
*/
|
|
605
|
+
function resolveBuildInfo(options = {}) {
|
|
606
|
+
const envPrefix = options.envPrefix ?? 'DEVLOGS_';
|
|
607
|
+
const nowFn = options.nowFn ?? (() => new Date());
|
|
608
|
+
const env = options.env;
|
|
609
|
+
const data = options.data;
|
|
610
|
+
const envBuildId = `${envPrefix}BUILD_ID`;
|
|
611
|
+
const envBranch = `${envPrefix}BRANCH`;
|
|
612
|
+
const envTimestamp = `${envPrefix}BUILD_TIMESTAMP_UTC`;
|
|
613
|
+
// Check for direct BUILD_ID env override (highest precedence)
|
|
614
|
+
const directBuildId = getEnv(envBuildId, env);
|
|
615
|
+
if (directBuildId) {
|
|
616
|
+
const branch = getEnv(envBranch, env) ?? null;
|
|
617
|
+
const timestamp = getEnv(envTimestamp, env) ?? formatTimestamp(nowFn());
|
|
618
|
+
return {
|
|
619
|
+
buildId: directBuildId,
|
|
620
|
+
branch,
|
|
621
|
+
timestampUtc: timestamp,
|
|
622
|
+
source: 'env',
|
|
623
|
+
path: null,
|
|
624
|
+
};
|
|
625
|
+
}
|
|
626
|
+
// Check provided data (from file loaded at build time)
|
|
627
|
+
if (data && typeof data === 'object' && data.build_id) {
|
|
628
|
+
const branch = getEnv(envBranch, env) ?? data.branch ?? null;
|
|
629
|
+
const timestamp = getEnv(envTimestamp, env) ?? data.timestamp_utc ?? formatTimestamp(nowFn());
|
|
630
|
+
return {
|
|
631
|
+
buildId: data.build_id,
|
|
632
|
+
branch,
|
|
633
|
+
timestampUtc: timestamp,
|
|
634
|
+
source: 'file',
|
|
635
|
+
path: null,
|
|
636
|
+
};
|
|
637
|
+
}
|
|
638
|
+
// Check if env provides branch and/or timestamp
|
|
639
|
+
const envBranchValue = getEnv(envBranch, env);
|
|
640
|
+
const envTimestampValue = getEnv(envTimestamp, env);
|
|
641
|
+
const branch = envBranchValue ?? null;
|
|
642
|
+
const timestamp = envTimestampValue ?? formatTimestamp(nowFn());
|
|
643
|
+
const branchForId = branch ?? 'unknown';
|
|
644
|
+
const buildId = `${branchForId}-${timestamp}`;
|
|
645
|
+
const source = envBranchValue || envTimestampValue ? 'env' : 'generated';
|
|
646
|
+
return {
|
|
647
|
+
buildId,
|
|
648
|
+
branch,
|
|
649
|
+
timestampUtc: timestamp,
|
|
650
|
+
source,
|
|
651
|
+
path: null,
|
|
652
|
+
};
|
|
653
|
+
}
|
|
654
|
+
/**
|
|
655
|
+
* Convenience function that returns only the build_id string.
|
|
656
|
+
*/
|
|
657
|
+
function resolveBuildId(options = {}) {
|
|
658
|
+
return resolveBuildInfo(options).buildId;
|
|
659
|
+
}
|
|
660
|
+
/**
|
|
661
|
+
* Create build info data object for writing to .build.json during build.
|
|
662
|
+
*/
|
|
663
|
+
function createBuildInfoData(options = {}) {
|
|
664
|
+
const nowFn = options.nowFn ?? (() => new Date());
|
|
665
|
+
const branch = options.branch ?? null;
|
|
666
|
+
const timestamp = formatTimestamp(nowFn());
|
|
667
|
+
const branchForId = branch ?? 'unknown';
|
|
668
|
+
const buildId = `${branchForId}-${timestamp}`;
|
|
669
|
+
return {
|
|
670
|
+
build_id: buildId,
|
|
671
|
+
branch: branch ?? undefined,
|
|
672
|
+
timestamp_utc: timestamp,
|
|
673
|
+
};
|
|
674
|
+
}
|
|
675
|
+
|
|
676
|
+
let initialized = false;
|
|
677
|
+
let client = null;
|
|
678
|
+
/**
|
|
679
|
+
* Initialize the devlogs node client.
|
|
680
|
+
*
|
|
681
|
+
* Auto-loads configuration from DEVLOGS_* environment variables and .env.devlogs
|
|
682
|
+
* files if options are not explicitly provided.
|
|
683
|
+
*
|
|
684
|
+
* @example
|
|
685
|
+
* ```js
|
|
686
|
+
* // Auto-load from environment
|
|
687
|
+
* devlogs.init();
|
|
688
|
+
*
|
|
689
|
+
* // Or provide explicit options
|
|
690
|
+
* devlogs.init({
|
|
691
|
+
* url: 'http://admin:admin@localhost:9200',
|
|
692
|
+
* application: 'my-api',
|
|
693
|
+
* component: 'server',
|
|
694
|
+
* });
|
|
695
|
+
* ```
|
|
696
|
+
*/
|
|
697
|
+
function init(options) {
|
|
698
|
+
if (initialized) {
|
|
699
|
+
originalConsole.warn('[devlogs] Already initialized');
|
|
700
|
+
return;
|
|
701
|
+
}
|
|
702
|
+
// Merge explicit options with env config
|
|
703
|
+
const envConfig = loadEnvConfig();
|
|
704
|
+
const url = options?.url ?? envConfig.url;
|
|
705
|
+
const application = options?.application ?? envConfig.application;
|
|
706
|
+
const component = options?.component ?? envConfig.component;
|
|
707
|
+
if (!url) {
|
|
708
|
+
originalConsole.warn('[devlogs] No URL configured. Set DEVLOGS_URL or pass url option.');
|
|
709
|
+
return;
|
|
710
|
+
}
|
|
711
|
+
if (!application) {
|
|
712
|
+
originalConsole.warn('[devlogs] No application configured. Set DEVLOGS_APPLICATION or pass application option.');
|
|
713
|
+
return;
|
|
714
|
+
}
|
|
715
|
+
if (!component) {
|
|
716
|
+
originalConsole.warn('[devlogs] No component configured. Set DEVLOGS_COMPONENT or pass component option.');
|
|
717
|
+
return;
|
|
718
|
+
}
|
|
719
|
+
const config = parseDevlogsUrl(url, options?.index ?? envConfig.index);
|
|
720
|
+
client = new DevlogsClient(config);
|
|
721
|
+
setDefaultContext({
|
|
722
|
+
application,
|
|
723
|
+
component,
|
|
724
|
+
area: options?.area ?? envConfig.area ?? null,
|
|
725
|
+
operationId: options?.operationId ?? null,
|
|
726
|
+
environment: options?.environment ?? envConfig.environment ?? null,
|
|
727
|
+
version: options?.version ?? envConfig.version ?? null,
|
|
728
|
+
fields: {},
|
|
729
|
+
});
|
|
730
|
+
interceptConsole(client);
|
|
731
|
+
initialized = true;
|
|
732
|
+
}
|
|
733
|
+
/**
|
|
734
|
+
* Disable devlogs and restore original console methods.
|
|
735
|
+
*/
|
|
736
|
+
function destroy() {
|
|
737
|
+
if (!initialized)
|
|
738
|
+
return;
|
|
739
|
+
restoreConsole();
|
|
740
|
+
resetContext();
|
|
741
|
+
client = null;
|
|
742
|
+
initialized = false;
|
|
743
|
+
}
|
|
744
|
+
/**
|
|
745
|
+
* Check if devlogs is currently initialized.
|
|
746
|
+
*/
|
|
747
|
+
function isInitialized() {
|
|
748
|
+
return initialized;
|
|
749
|
+
}
|
|
750
|
+
|
|
751
|
+
export { createBuildInfoData, destroy, formatTimestamp, init, isInitialized, resolveBuildId, resolveBuildInfo, setArea, setFields, setOperationId, withContext, withOperation };
|
|
752
|
+
//# sourceMappingURL=devlogs.esm.js.map
|