observe-node 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 ADDED
File without changes
package/package.json ADDED
@@ -0,0 +1,20 @@
1
+ {
2
+ "name": "observe-node",
3
+ "version": "1.0.0",
4
+ "description": "Structured logs + Prometheus metrics SDK for Node.js (Loki/Grafana ready)",
5
+ "main": "src/index.js",
6
+ "type": "commonjs",
7
+ "files": ["src"],
8
+ "keywords": ["observability","logging","metrics","prometheus","loki","grafana"],
9
+ "license": "MIT",
10
+ "scripts": {
11
+ "dev": "nodemon example.js"
12
+ },
13
+ "dependencies": {
14
+ "axios": "^1.13.5",
15
+ "prom-client": "^15.1.3"
16
+ },
17
+ "devDependencies": {
18
+ "nodemon": "^3.1.14"
19
+ }
20
+ }
package/src/config.js ADDED
@@ -0,0 +1,34 @@
1
+ // src/config.js
2
+ const path = require("path");
3
+ const os = require("os");
4
+ const { createEventEmitter } = require("./schema/event");
5
+
6
+ function defaultLogDir() {
7
+ // windows
8
+ if (process.platform === "win32") {
9
+ return path.join(os.homedir(), ".observe");
10
+ }
11
+ // linux/mac
12
+ return "/var/log/observe";
13
+ }
14
+
15
+ function loadConfig(userConfig) {
16
+ const config = {
17
+ app: userConfig.app || "unknown-app",
18
+ env: userConfig.env || process.env.NODE_ENV || "dev",
19
+
20
+ // ✅ output modes: "stdout" | "file" | "both"
21
+ output: userConfig.output || "file",
22
+
23
+ logDir: userConfig.logDir || process.env.OBSERVE_LOG_DIR || defaultLogDir(),
24
+ logFileName: userConfig.logFileName || `${userConfig.app || "unknown-app"}.log`,
25
+
26
+ metrics: userConfig.metrics !== false,
27
+ console: userConfig.console !== false,
28
+ };
29
+
30
+ config.emitEvent = createEventEmitter(config);
31
+ return config;
32
+ }
33
+
34
+ module.exports = { loadConfig };
@@ -0,0 +1,64 @@
1
+ // src/http/express.js
2
+ const { runWithContext } = require("../utils/context");
3
+ const { newId } = require("../utils/ids");
4
+
5
+ function getRoutePath(req) {
6
+ // Prefer express route pattern if available (best for metrics + grouping)
7
+ if (req.route && req.route.path) {
8
+ const base = req.baseUrl || "";
9
+ return `${base}${req.route.path}`;
10
+ }
11
+
12
+ // Fallback: originalUrl without querystring
13
+ const raw = req.originalUrl || req.url || "";
14
+ return raw.split("?")[0] || raw;
15
+ }
16
+
17
+ function expressMiddleware(observe) {
18
+ const config = observe.__config;
19
+
20
+ return function observeExpress(req, res, next) {
21
+ const requestId =
22
+ req.headers["x-request-id"] ||
23
+ req.headers["x-correlation-id"] ||
24
+ newId();
25
+
26
+ // expose id to downstream services
27
+ res.setHeader("x-request-id", String(requestId));
28
+
29
+ const start = process.hrtime.bigint();
30
+
31
+ runWithContext({ request_id: String(requestId) }, () => {
32
+ res.on("finish", () => {
33
+ const end = process.hrtime.bigint();
34
+ const durationMs = Number(end - start) / 1_000_000;
35
+
36
+ const status = res.statusCode;
37
+ const level = status >= 500 ? "error" : status >= 400 ? "warn" : "info";
38
+
39
+ const path = getRoutePath(req);
40
+
41
+ config.emitEvent({
42
+ event_name: "http.request",
43
+ level,
44
+ message: "HTTP request completed",
45
+ http: {
46
+ method: req.method,
47
+ path,
48
+ status,
49
+ duration_ms: Math.round(durationMs),
50
+ },
51
+ });
52
+
53
+ // metrics hook (if enabled)
54
+ if (typeof config._observeHttpMetric === "function") {
55
+ config._observeHttpMetric(req.method, path, status, durationMs);
56
+ }
57
+ });
58
+
59
+ next();
60
+ });
61
+ };
62
+ }
63
+
64
+ module.exports = { expressMiddleware };
@@ -0,0 +1,30 @@
1
+ // src/http/expressError.js
2
+ function createExpressErrorMiddleware(observe, opts = {}) {
3
+ const {
4
+ includeStack = true,
5
+ } = opts;
6
+
7
+ // eslint-disable-next-line no-unused-vars
8
+ return function observeExpressError(err, req, res, next) {
9
+ const status = err.status || err.statusCode || 500;
10
+
11
+ // your apps may set these:
12
+ const code = err.code; // e.g. "AUTH-401" / "WH-INV-001"
13
+
14
+ observe.emit({
15
+ event_name: "error",
16
+ level: "error",
17
+ message: err.message || "Unhandled error",
18
+ error: {
19
+ name: err.name || "Error",
20
+ code,
21
+ status,
22
+ stack: includeStack ? err.stack : undefined,
23
+ },
24
+ });
25
+
26
+ next(err);
27
+ };
28
+ }
29
+
30
+ module.exports = { createExpressErrorMiddleware };
@@ -0,0 +1,15 @@
1
+ // src/http/metricsExpress.js
2
+ const { metricsHandler } = require("../metrics/handler");
3
+
4
+ function metricsExpress(observe) {
5
+ return async function (req, res) {
6
+ try {
7
+ await metricsHandler(observe, req, res);
8
+ } catch (e) {
9
+ res.statusCode = 500;
10
+ res.end("metrics error");
11
+ }
12
+ };
13
+ }
14
+
15
+ module.exports = { metricsExpress };
package/src/index.js ADDED
@@ -0,0 +1,8 @@
1
+ // src/index.js
2
+ const start = require("./start");
3
+ const { expressMiddleware } = require("./http/express");
4
+
5
+ module.exports = {
6
+ start,
7
+ expressMiddleware,
8
+ };
@@ -0,0 +1,75 @@
1
+ // src/logger/console.js
2
+ const util = require("util");
3
+
4
+ function safeStringify(args) {
5
+ try {
6
+ // util.format handles strings + objects like console does
7
+ return util.format(...args);
8
+ } catch {
9
+ try {
10
+ return args.map(a => (typeof a === "string" ? a : JSON.stringify(a))).join(" ");
11
+ } catch {
12
+ return "[unstringifiable]";
13
+ }
14
+ }
15
+ }
16
+
17
+ function initConsolePatch(config) {
18
+ if (config.console === false) return;
19
+
20
+ const original = {
21
+ log: console.log,
22
+ info: console.info,
23
+ warn: console.warn,
24
+ error: console.error,
25
+ debug: console.debug,
26
+ };
27
+
28
+ // prevent recursion when our writer uses console.log(JSON)
29
+ let inPatch = false;
30
+
31
+ function wrap(level, origFn) {
32
+ return function (...args) {
33
+ // always keep original console behavior
34
+ origFn.apply(console, args);
35
+
36
+ // avoid recursion
37
+ if (inPatch) return;
38
+ inPatch = true;
39
+
40
+ try {
41
+ const msg = safeStringify(args);
42
+
43
+ config.emitEvent({
44
+ event_name: "log.console",
45
+ level: level === "error" ? "error" : level === "warn" ? "warn" : "info",
46
+ message: msg,
47
+ console: {
48
+ level,
49
+ },
50
+ });
51
+ } catch {
52
+ // never crash app
53
+ } finally {
54
+ inPatch = false;
55
+ }
56
+ };
57
+ }
58
+
59
+ console.log = wrap("log", original.log);
60
+ console.info = wrap("info", original.info);
61
+ console.warn = wrap("warn", original.warn);
62
+ console.error = wrap("error", original.error);
63
+ console.debug = wrap("debug", original.debug);
64
+
65
+ // allow restore
66
+ config._restoreConsole = () => {
67
+ console.log = original.log;
68
+ console.info = original.info;
69
+ console.warn = original.warn;
70
+ console.error = original.error;
71
+ console.debug = original.debug;
72
+ };
73
+ }
74
+
75
+ module.exports = { initConsolePatch };
@@ -0,0 +1,27 @@
1
+ // src/logger/crash.js
2
+ function initCrashHandlers(config) {
3
+ process.on("uncaughtException", (err) => {
4
+ config.emitEvent({
5
+ event_name: "process.crash",
6
+ level: "fatal",
7
+ error: {
8
+ type: err.name,
9
+ message: err.message,
10
+ stack: err.stack?.replace(/\n/g, " | ")
11
+ }
12
+ });
13
+ process.exit(1);
14
+ });
15
+
16
+ process.on("unhandledRejection", (reason) => {
17
+ config.emitEvent({
18
+ event_name: "process.rejection",
19
+ level: "error",
20
+ error: {
21
+ message: String(reason)
22
+ }
23
+ });
24
+ });
25
+ }
26
+
27
+ module.exports = { initCrashHandlers };
@@ -0,0 +1,43 @@
1
+ // src/logger/patchConsole.js
2
+ function safeToString(v) {
3
+ try {
4
+ if (typeof v === "string") return v;
5
+ return JSON.stringify(v);
6
+ } catch {
7
+ return String(v);
8
+ }
9
+ }
10
+
11
+ function patchConsole(observe, opts = {}) {
12
+ const { enabled = true } = opts;
13
+ if (!enabled) return () => {};
14
+
15
+ const original = {
16
+ log: console.log,
17
+ warn: console.warn,
18
+ error: console.error,
19
+ };
20
+
21
+ console.log = (...args) => {
22
+ observe.emit({ event_name: "log", level: "info", message: args.map(safeToString).join(" "), log: { logger: "console", args } });
23
+ original.log(...args);
24
+ };
25
+
26
+ console.warn = (...args) => {
27
+ observe.emit({ event_name: "log", level: "warn", message: args.map(safeToString).join(" "), log: { logger: "console", args } });
28
+ original.warn(...args);
29
+ };
30
+
31
+ console.error = (...args) => {
32
+ observe.emit({ event_name: "log", level: "error", message: args.map(safeToString).join(" "), log: { logger: "console", args } });
33
+ original.error(...args);
34
+ };
35
+
36
+ return function restore() {
37
+ console.log = original.log;
38
+ console.warn = original.warn;
39
+ console.error = original.error;
40
+ };
41
+ }
42
+
43
+ module.exports = { patchConsole };
@@ -0,0 +1,72 @@
1
+ // src/logger/writer.js
2
+ const fs = require("fs");
3
+ const path = require("path");
4
+ const os = require("os");
5
+
6
+ let stream = null;
7
+
8
+ function ensureDir(dir) {
9
+ try {
10
+ fs.mkdirSync(dir, { recursive: true });
11
+ return true;
12
+ } catch {
13
+ return false;
14
+ }
15
+ }
16
+
17
+ function openStream(filePath) {
18
+ return fs.createWriteStream(filePath, { flags: "a" });
19
+ }
20
+
21
+ function buildFilePath(config) {
22
+ const primaryDir = config.logDir;
23
+ const primaryPath = path.join(primaryDir, config.logFileName);
24
+
25
+ // try primary
26
+ if (ensureDir(primaryDir)) {
27
+ try {
28
+ const s = openStream(primaryPath);
29
+ return { filePath: primaryPath, stream: s };
30
+ } catch {}
31
+ }
32
+
33
+ // fallback to home dir
34
+ const fallbackDir = path.join(os.homedir(), ".observe");
35
+ const fallbackPath = path.join(fallbackDir, config.logFileName);
36
+ ensureDir(fallbackDir);
37
+ const s2 = openStream(fallbackPath);
38
+ return { filePath: fallbackPath, stream: s2 };
39
+ }
40
+
41
+ function initWriter(config) {
42
+ const { filePath, stream: s } = buildFilePath(config);
43
+ stream = s;
44
+ config._logFilePath = filePath;
45
+
46
+ config.write = (event) => {
47
+ const line = JSON.stringify(event) + "\n";
48
+
49
+ // stdout
50
+ if (config.output === "stdout" || config.output === "both") {
51
+ try {
52
+ process.stdout.write(line);
53
+ } catch {}
54
+ }
55
+
56
+ // file
57
+ if (config.output === "file" || config.output === "both") {
58
+ try {
59
+ stream.write(line);
60
+ } catch {}
61
+ }
62
+ };
63
+
64
+ config._closeWriter = () => {
65
+ try {
66
+ stream?.end();
67
+ } catch {}
68
+ stream = null;
69
+ };
70
+ }
71
+
72
+ module.exports = { initWriter };
@@ -0,0 +1,16 @@
1
+ // src/metrics/handler.js
2
+ async function metricsHandler(observe, req, res) {
3
+ const config = observe.__config;
4
+ if (!config.metricsRegistry) {
5
+ res.statusCode = 404;
6
+ res.end("metrics disabled");
7
+ return;
8
+ }
9
+
10
+ res.setHeader("Content-Type", config.metricsRegistry.register.contentType);
11
+ const body = await config.metricsRegistry.register.metrics();
12
+ res.statusCode = 200;
13
+ res.end(body);
14
+ }
15
+
16
+ module.exports = { metricsHandler };
@@ -0,0 +1,41 @@
1
+ // src/metrics/http.js
2
+ function initHttpMetrics(config) {
3
+ if (!config.metricsRegistry) return;
4
+
5
+ const client = config.metricsRegistry;
6
+
7
+ const httpReqCount = new client.Counter({
8
+ name: "observe_http_requests_total",
9
+ help: "Total HTTP requests",
10
+ labelNames: ["app", "env", "method", "path", "status"]
11
+ });
12
+
13
+ const httpReqDuration = new client.Histogram({
14
+ name: "observe_http_request_duration_ms",
15
+ help: "HTTP request duration in ms",
16
+ labelNames: ["app", "env", "method", "path", "status"],
17
+ buckets: [5, 10, 25, 50, 100, 250, 500, 1000, 2500, 5000, 10000]
18
+ });
19
+
20
+ // Keep path label sane (avoid high-cardinality)
21
+ function normalizePath(path) {
22
+ // basic cleanup: remove query string
23
+ const p = String(path || "").split("?")[0];
24
+ // optional: collapse numeric ids (simple rule)
25
+ return p.replace(/\/\d+(\b|\/)/g, "/:id$1");
26
+ }
27
+
28
+ config._observeHttpMetric = (method, path, status, durationMs) => {
29
+ const labels = {
30
+ app: config.app,
31
+ env: config.env,
32
+ method: String(method || "GET"),
33
+ path: normalizePath(path),
34
+ status: String(status || 0)
35
+ };
36
+ httpReqCount.inc(labels, 1);
37
+ httpReqDuration.observe(labels, durationMs);
38
+ };
39
+ }
40
+
41
+ module.exports = { initHttpMetrics };
@@ -0,0 +1,12 @@
1
+ // src/metrics/registry.js
2
+ const client = require("prom-client");
3
+
4
+ function initMetrics(config) {
5
+ if (!config.metrics) return;
6
+
7
+ client.collectDefaultMetrics();
8
+
9
+ config.metricsRegistry = client;
10
+ }
11
+
12
+ module.exports = { initMetrics };
@@ -0,0 +1,52 @@
1
+ // src/schema/event.js
2
+ const os = require("os");
3
+ const { getContext } = require("../utils/context");
4
+ const { newId } = require("../utils/ids");
5
+
6
+ function createEventEmitter(config) {
7
+ return function emitEvent(partial = {}) {
8
+ const ctx = getContext();
9
+
10
+ // pick only safe/known top-level from partial
11
+ const {
12
+ level,
13
+ event_name,
14
+ message,
15
+ request_id,
16
+ trace_id,
17
+ span_id,
18
+ ...details
19
+ } = partial;
20
+
21
+ const event = {
22
+ schema_version: "1.0",
23
+ source: "observe-sdk",
24
+ event_id: newId(),
25
+ timestamp: new Date().toISOString(),
26
+
27
+ app: config.app,
28
+ env: config.env,
29
+
30
+ // nice to have for prod
31
+ service: config.service || config.app,
32
+ version: config.version,
33
+ host: os.hostname(),
34
+ pid: process.pid,
35
+
36
+ level: level || "info",
37
+ event_name,
38
+ message,
39
+
40
+ request_id: ctx?.request_id || request_id,
41
+ trace_id: ctx?.trace_id || trace_id,
42
+ span_id: ctx?.span_id || span_id,
43
+
44
+ // extra blocks: http, error, db, log, meta...
45
+ ...details,
46
+ };
47
+
48
+ config.write(event);
49
+ };
50
+ }
51
+
52
+ module.exports = { createEventEmitter };
package/src/start.js ADDED
@@ -0,0 +1,54 @@
1
+ // src/start.js
2
+ const { loadConfig } = require("./config");
3
+ const { initWriter } = require("./logger/writer");
4
+ const { initCrashHandlers } = require("./logger/crash");
5
+ const { initMetrics } = require("./metrics/registry");
6
+ const { initHttpMetrics } = require("./metrics/http");
7
+ const { expressMiddleware } = require("./http/express");
8
+ const { metricsExpress } = require("./http/metricsExpress");
9
+ const { initConsolePatch } = require("./logger/console");
10
+
11
+ function start(userConfig = {}) {
12
+ const config = loadConfig(userConfig);
13
+
14
+ initWriter(config);
15
+ initConsolePatch(config);
16
+ initCrashHandlers(config);
17
+ initMetrics(config);
18
+ initHttpMetrics(config);
19
+
20
+ // ✅ create object first
21
+ const observe = {
22
+ __config: config,
23
+ emit: config.emitEvent,
24
+ express: null,
25
+ metrics: null,
26
+ shutdown: null,
27
+ };
28
+
29
+ // ✅ then assign functions
30
+ observe.express = () => expressMiddleware(observe);
31
+ observe.metrics = () => metricsExpress(observe);
32
+ observe.shutdown = () => config._closeWriter?.();
33
+
34
+ // events
35
+ config.emitEvent({ event_name: "process.start", level: "info" });
36
+
37
+ config.emitEvent({
38
+ event_name: "observe.writer",
39
+ level: "info",
40
+ message: `observe logging to: ${config._logFilePath}`,
41
+ });
42
+
43
+ // graceful exit
44
+ const onExit = () => {
45
+ try { config._closeWriter?.(); } catch {}
46
+ process.exit(0);
47
+ };
48
+ process.on("SIGINT", onExit);
49
+ process.on("SIGTERM", onExit);
50
+
51
+ return observe;
52
+ }
53
+
54
+ module.exports = start;
@@ -0,0 +1,23 @@
1
+ // src/utils/context.js
2
+ const { AsyncLocalStorage } = require("async_hooks");
3
+
4
+ const als = new AsyncLocalStorage();
5
+
6
+ function runWithContext(ctx, fn) {
7
+ return als.run(ctx, fn);
8
+ }
9
+
10
+ function setContext(patch) {
11
+ const store = als.getStore() || {};
12
+ Object.assign(store, patch);
13
+ // if no store exists, create one
14
+ if (!als.getStore()) {
15
+ als.enterWith(store);
16
+ }
17
+ }
18
+
19
+ function getContext() {
20
+ return als.getStore();
21
+ }
22
+
23
+ module.exports = { runWithContext, setContext, getContext };
@@ -0,0 +1,20 @@
1
+ // src/utils/env.js
2
+ const fs = require("fs");
3
+
4
+ function isDocker() {
5
+ try {
6
+ if (fs.existsSync("/.dockerenv")) return true;
7
+ const cgroup = fs.readFileSync("/proc/1/cgroup", "utf8");
8
+ return cgroup.includes("docker") || cgroup.includes("kubepods") || cgroup.includes("containerd");
9
+ } catch {
10
+ return false;
11
+ }
12
+ }
13
+
14
+ function runtime() {
15
+ if (process.env.KUBERNETES_SERVICE_HOST) return "k8s";
16
+ if (isDocker()) return "docker";
17
+ return "host";
18
+ }
19
+
20
+ module.exports = { isDocker, runtime };
@@ -0,0 +1,8 @@
1
+ // src/utils/ids.js
2
+ const crypto = require("crypto");
3
+
4
+ function newId() {
5
+ return crypto.randomUUID();
6
+ }
7
+
8
+ module.exports = { newId };
@@ -0,0 +1,11 @@
1
+ // src/utils/time.js
2
+ function nowHr() {
3
+ return process.hrtime.bigint();
4
+ }
5
+
6
+ function msSince(startHr) {
7
+ const end = process.hrtime.bigint();
8
+ return Number(end - startHr) / 1_000_000;
9
+ }
10
+
11
+ module.exports = { nowHr, msSince };