@senzops/apm-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
@@ -0,0 +1,56 @@
1
+ # **@senzops/apm-node**
2
+
3
+ The official Node.js middleware for **Senzor APM**.
4
+
5
+ Zero-overhead, asynchronous, and robust monitoring for your Express/Next.js APIs.
6
+
7
+ ## **📦 Installation**
8
+ ```sh
9
+ npm install @senzops/apm-node
10
+ ```
11
+
12
+ ## **🚀 Usage**
13
+
14
+ ### **Express.js**
15
+
16
+ Add Senzor as the **first** middleware in your app to ensure accurate timing.
17
+ ```js
18
+ const express = require('express');
19
+ const senzor = require('@senzops/apm-node');
20
+
21
+ const app = express();
22
+
23
+ // 1\. Initialize
24
+ senzor.init({
25
+ apiKey: "sz_apm_...", // Get this from your Senzor Dashboard
26
+ // Optional config
27
+ // debug: true,
28
+ });
29
+
30
+ // 2\. Attach Request Handler
31
+ app.use(senzor.requestHandler());
32
+
33
+ // ... your routes ...
34
+ app.get('/users/:id', (req, res) => {
35
+ res.json({ id: req.params.id });
36
+ });
37
+
38
+ app.listen(3000);
39
+ ```
40
+
41
+ ## **⚙️ Configuration**
42
+
43
+ | Option | Type | Description |
44
+ | :------------ | :------ | :------------------------------------------------------------ |
45
+ | apiKey | string | **Required.** Your Service API Key. |
46
+ | batchSize | number | Max requests to buffer before sending (Default: 100). |
47
+ | flushInterval | number | Max time (ms) to wait before sending buffer (Default: 10000). |
48
+ | debug | boolean | Enable console logs for debugging connection issues. |
49
+
50
+ ## **🛡 Performance**
51
+
52
+ Senzor APM is designed to be **Production Safe**:
53
+
54
+ 1. **Non-Blocking:** Data transmission happens asynchronously outside the request-response cycle.
55
+ 2. **Fail-Open:** If Senzor ingestion is down, your API will continue to function normally without error.
56
+ 3. **Lightweight:** Uses native Node.js timers and buffers. No heavy dependencies.
package/package.json ADDED
@@ -0,0 +1,28 @@
1
+ {
2
+ "name": "@senzops/apm-node",
3
+ "version": "1.0.0",
4
+ "description": "Universal APM SDK for Senzor",
5
+ "main": "./dist/index.js",
6
+ "module": "./dist/index.mjs",
7
+ "types": "./dist/index.d.ts",
8
+ "exports": {
9
+ ".": {
10
+ "require": "./dist/index.js",
11
+ "import": "./dist/index.mjs",
12
+ "types": "./dist/index.d.ts"
13
+ }
14
+ },
15
+ "scripts": {
16
+ "build": "tsup",
17
+ "prepublishOnly": "npm run build"
18
+ },
19
+ "dependencies": {},
20
+ "devDependencies": {
21
+ "@types/node": "^20.0.0",
22
+ "tsup": "^8.0.0",
23
+ "typescript": "^5.0.0"
24
+ },
25
+ "engines": {
26
+ "node": ">=18.0.0"
27
+ }
28
+ }
@@ -0,0 +1,64 @@
1
+ import { Transport } from './transport';
2
+
3
+ export interface SenzorOptions {
4
+ apiKey: string;
5
+ endpoint?: string;
6
+ batchSize?: number;
7
+ flushInterval?: number;
8
+ debug?: boolean;
9
+ }
10
+
11
+ export class SenzorClient {
12
+ private transport: Transport | null = null;
13
+ private options: SenzorOptions | null = null;
14
+
15
+ public init(options: SenzorOptions) {
16
+ if (!options.apiKey) {
17
+ console.warn('[Senzor] API Key missing. SDK disabled.');
18
+ return;
19
+ }
20
+
21
+ this.options = {
22
+ endpoint: 'https://api.senzor.dev/api/ingest/apm',
23
+ batchSize: 100,
24
+ flushInterval: 10000,
25
+ debug: false,
26
+ ...options
27
+ };
28
+
29
+ this.transport = new Transport({
30
+ apiKey: this.options.apiKey,
31
+ endpoint: this.options.endpoint!,
32
+ batchSize: this.options.batchSize!,
33
+ flushInterval: this.options.flushInterval!,
34
+ debug: this.options.debug || false
35
+ });
36
+
37
+ if (this.options.debug) console.log('[Senzor] Initialized');
38
+ }
39
+
40
+ // --- Manual Tracking (For any framework) ---
41
+ public track(data: {
42
+ method: string;
43
+ route: string;
44
+ path: string;
45
+ status: number;
46
+ duration: number;
47
+ ip?: string;
48
+ userAgent?: string;
49
+ }) {
50
+ if (!this.transport) return;
51
+
52
+ this.transport.add({
53
+ ...data,
54
+ timestamp: new Date().toISOString()
55
+ });
56
+ }
57
+
58
+ // --- Force Flush (For Serverless/Lambda) ---
59
+ public async flush() {
60
+ if (this.transport) await this.transport.flush();
61
+ }
62
+ }
63
+
64
+ export const client = new SenzorClient();
@@ -0,0 +1,44 @@
1
+ /**
2
+ * Heuristic URL Normalizer
3
+ * Converts raw paths with IDs into generic patterns to prevent high cardinality.
4
+ * Example: /users/123/orders/abc-def -> /users/:id/orders/:uuid
5
+ */
6
+ export const normalizePath = (path: string): string => {
7
+ if (!path || path === '/') return '/';
8
+
9
+ return path
10
+ // Replace UUIDs (long alphanumeric strings)
11
+ .replace(
12
+ /[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}/g,
13
+ ':uuid'
14
+ )
15
+ // Replace MongoDB ObjectIds (24 hex chars)
16
+ .replace(/[0-9a-fA-F]{24}/g, ':objectId')
17
+ // Replace pure numeric IDs (e.g., /123)
18
+ .replace(/\/(\d+)(?=\/|$)/g, '/:id')
19
+ // Remove query strings
20
+ .split('?')[0];
21
+ };
22
+
23
+ /**
24
+ * Tries to extract route from Framework internals, falls back to heuristic
25
+ */
26
+ export const getRoute = (req: any, fallbackPath: string): string => {
27
+ // Express / Connect
28
+ if (req.route && req.route.path) {
29
+ return (req.baseUrl || '') + req.route.path;
30
+ }
31
+
32
+ // H3 / Nitro (Nuxt)
33
+ if (req.context && req.context.matchedRoute) {
34
+ return req.context.matchedRoute.path;
35
+ }
36
+
37
+ // Fastify
38
+ if (req.routerPath) {
39
+ return req.routerPath;
40
+ }
41
+
42
+ // Fallback: Heuristic Normalization
43
+ return normalizePath(fallbackPath);
44
+ };
@@ -0,0 +1,58 @@
1
+ export interface TransportConfig {
2
+ apiKey: string;
3
+ endpoint: string;
4
+ batchSize: number;
5
+ flushInterval: number;
6
+ debug: boolean;
7
+ }
8
+
9
+ export class Transport {
10
+ private queue: any[] = [];
11
+ private config: TransportConfig;
12
+ private timer: any = null;
13
+
14
+ constructor(config: TransportConfig) {
15
+ this.config = config;
16
+ // Only start timer in non-serverless environments (long running processes)
17
+ if (typeof setInterval !== 'undefined') {
18
+ this.timer = setInterval(() => this.flush(), this.config.flushInterval);
19
+ // Unref if in Node.js to allow process exit
20
+ if (this.timer && typeof this.timer.unref === 'function') {
21
+ this.timer.unref();
22
+ }
23
+ }
24
+ }
25
+
26
+ public add(event: any) {
27
+ this.queue.push(event);
28
+ if (this.queue.length >= this.config.batchSize) {
29
+ this.flush();
30
+ }
31
+ }
32
+
33
+ public async flush() {
34
+ if (this.queue.length === 0) return;
35
+
36
+ const batch = [...this.queue];
37
+ this.queue = [];
38
+
39
+ try {
40
+ // Use native fetch (Node 18+, Edge, Browser)
41
+ await fetch(this.config.endpoint, {
42
+ method: 'POST',
43
+ headers: {
44
+ 'Content-Type': 'application/json',
45
+ 'x-service-api-key': this.config.apiKey,
46
+ },
47
+ body: JSON.stringify(batch),
48
+ // keepalive ensures connection stays open even if function ends (vital for APM)
49
+ keepalive: true,
50
+ });
51
+
52
+ if (this.config.debug) console.log(`[Senzor] Flushed ${batch.length} traces`);
53
+ } catch (err) {
54
+ if (this.config.debug) console.error('[Senzor] Ingestion Error:', err);
55
+ // We drop data on failure to prevent memory leaks in the app
56
+ }
57
+ }
58
+ }
package/src/index.ts ADDED
@@ -0,0 +1,28 @@
1
+ import { client, SenzorOptions } from './core/client';
2
+ import { expressMiddleware } from './middleware/express';
3
+ import { wrapH3 } from './wrappers/h3';
4
+ import { wrapNextRoute, wrapNextPages } from './wrappers/next';
5
+ import { senzorPlugin } from './wrappers/fastify';
6
+
7
+ const Senzor = {
8
+ // Core
9
+ init: (options: SenzorOptions) => client.init(options),
10
+ flush: () => client.flush(),
11
+ track: client.track.bind(client),
12
+
13
+ // Express / Connect
14
+ requestHandler: expressMiddleware,
15
+
16
+ // Next.js
17
+ wrapNextRoute, // For App Router (Route Handlers)
18
+ wrapNextPages, // For Pages Router (API Routes)
19
+
20
+ // H3 / Nuxt / Nitro
21
+ wrapH3,
22
+
23
+ // Fastify
24
+ fastifyPlugin: senzorPlugin
25
+ };
26
+
27
+ export default Senzor;
28
+ export { Senzor };
@@ -0,0 +1,43 @@
1
+ import { client } from '../core/client';
2
+
3
+ export const expressMiddleware = () => {
4
+ return (req: any, res: any, next: () => void) => {
5
+ // Start Timer (High Precision)
6
+ const start = performance.now();
7
+
8
+ // Hook into response finish
9
+ res.once('finish', () => {
10
+ try {
11
+ const duration = performance.now() - start;
12
+
13
+ // Route Normalization Logic
14
+ // Express stores route info in req.route
15
+ let route = 'UNKNOWN';
16
+
17
+ if (req.route && req.route.path) {
18
+ // Combined baseUrl (if mounted on /api) + path (/:id)
19
+ route = (req.baseUrl || '') + req.route.path;
20
+ } else if (res.statusCode === 404) {
21
+ route = 'Not Found';
22
+ } else {
23
+ // Fallback for unmapped routes or static files
24
+ route = req.path || 'Wildcard';
25
+ }
26
+
27
+ client.track({
28
+ method: req.method,
29
+ route: route,
30
+ path: req.originalUrl || req.url,
31
+ status: res.statusCode,
32
+ duration: duration,
33
+ ip: req.ip || req.socket?.remoteAddress,
34
+ userAgent: req.headers['user-agent'],
35
+ });
36
+ } catch (e) {
37
+ // Fail open
38
+ }
39
+ });
40
+
41
+ next();
42
+ };
43
+ };
@@ -0,0 +1,39 @@
1
+ import { client } from '../core/client';
2
+ import { SenzorOptions } from '../core/client';
3
+
4
+ // We don't import Fastify types to keep zero-deps, but structure matches
5
+ export const senzorPlugin = (fastify: any, options: SenzorOptions, done: Function) => {
6
+
7
+ // Init if options provided inline, otherwise assume global init
8
+ if (options && options.apiKey) {
9
+ client.init(options);
10
+ }
11
+
12
+ // Hook: On Request (Start Timer)
13
+ fastify.addHook('onRequest', (request: any, reply: any, next: Function) => {
14
+ request.senzorStart = performance.now();
15
+ next();
16
+ });
17
+
18
+ // Hook: On Response (End Timer & Track)
19
+ fastify.addHook('onResponse', (request: any, reply: any, next: Function) => {
20
+ const duration = performance.now() - (request.senzorStart || performance.now());
21
+
22
+ // Fastify provides 'routerPath' (e.g. /user/:id)
23
+ const route = request.routeOptions?.url || request.routerPath;
24
+
25
+ client.track({
26
+ method: request.method,
27
+ route: route || 'UNKNOWN',
28
+ path: request.raw.url || request.url,
29
+ status: reply.statusCode,
30
+ duration: duration,
31
+ ip: request.ip,
32
+ userAgent: request.headers['user-agent']
33
+ });
34
+
35
+ next();
36
+ });
37
+
38
+ done();
39
+ };
@@ -0,0 +1,49 @@
1
+ import { client } from '../core/client';
2
+ import { getRoute } from '../core/normalizer';
3
+
4
+ // Types for H3 (Mocked to avoid heavy peer dependencies)
5
+ type EventHandler = (event: any) => any;
6
+
7
+ export const wrapH3 = (handler: EventHandler) => {
8
+ return async (event: any) => {
9
+ const start = performance.now();
10
+ let status = 200;
11
+ let error: any = null;
12
+
13
+ try {
14
+ const response = await handler(event);
15
+ // Try to determine status from response or event
16
+ if (event.node?.res?.statusCode) {
17
+ status = event.node.res.statusCode;
18
+ }
19
+ return response;
20
+ } catch (err: any) {
21
+ error = err;
22
+ status = err.statusCode || err.status || 500;
23
+ throw err;
24
+ } finally {
25
+ // Non-blocking collection
26
+ const duration = performance.now() - start;
27
+ const req = event.node.req;
28
+
29
+ const path = req.originalUrl || req.url || '/';
30
+
31
+ client.track({
32
+ method: req.method || 'GET',
33
+ route: getRoute(event, path), // H3 often attaches context to event
34
+ path: path,
35
+ status: status,
36
+ duration: duration,
37
+ ip: getIp(req),
38
+ userAgent: req.headers['user-agent'],
39
+ });
40
+
41
+ // If serverless, we might need to await flush, but for general H3 usage (Node preset)
42
+ // we assume the process stays alive or uses ctx.waitUntil
43
+ }
44
+ };
45
+ };
46
+
47
+ const getIp = (req: any) => {
48
+ return req.headers['x-forwarded-for'] || req.socket?.remoteAddress;
49
+ };
@@ -0,0 +1,74 @@
1
+ import { client } from '../core/client';
2
+ import { normalizePath } from '../core/normalizer';
3
+
4
+ // --- App Router Wrapper (GET, POST, etc.) ---
5
+ export const wrapNextRoute = (handler: Function) => {
6
+ return async (req: Request | any, context?: any) => {
7
+ const start = performance.now();
8
+ let status = 200;
9
+
10
+ try {
11
+ const response = await handler(req, context);
12
+ if (response && typeof response.status === 'number') {
13
+ status = response.status;
14
+ }
15
+ return response;
16
+ } catch (err: any) {
17
+ status = 500;
18
+ throw err;
19
+ } finally {
20
+ const duration = performance.now() - start;
21
+
22
+ // App Router Request is a standard Web Request object
23
+ const url = req.url ? new URL(req.url) : { pathname: '/' };
24
+
25
+ // In App Router, we often rely on file-system path, but context.params helps
26
+ // For now, robust heuristic normalization is best
27
+ const route = normalizePath(url.pathname);
28
+
29
+ client.track({
30
+ method: req.method || 'GET',
31
+ route: route,
32
+ path: url.pathname,
33
+ status: status,
34
+ duration: duration,
35
+ userAgent: req.headers.get ? req.headers.get('user-agent') : undefined,
36
+ // IP extraction from Web Request is tricky without headers
37
+ ip: req.headers.get ? req.headers.get('x-forwarded-for') : undefined,
38
+ });
39
+
40
+ // Vercel/Serverless Flush Safety
41
+ // We purposefully do NOT await flush here to avoid latency.
42
+ // Ideally user configures transport to sync flush or uses waitUntil
43
+ }
44
+ };
45
+ };
46
+
47
+ // --- Pages Router Wrapper (req, res) ---
48
+ export const wrapNextPages = (handler: Function) => {
49
+ return async (req: any, res: any) => {
50
+ const start = performance.now();
51
+
52
+ // Hook into response finish
53
+ const done = () => {
54
+ const duration = performance.now() - start;
55
+ const path = req.url || '/';
56
+
57
+ client.track({
58
+ method: req.method || 'GET',
59
+ route: normalizePath(path.split('?')[0]),
60
+ path: path,
61
+ status: res.statusCode || 200,
62
+ duration: duration,
63
+ ip: req.headers['x-forwarded-for'] || req.socket?.remoteAddress,
64
+ userAgent: req.headers['user-agent']
65
+ });
66
+ };
67
+
68
+ res.once('finish', done);
69
+ // Handle error case if next/error isn't used
70
+ res.once('close', done);
71
+
72
+ return handler(req, res);
73
+ };
74
+ };
package/tsconfig.json ADDED
@@ -0,0 +1,14 @@
1
+ {
2
+ "compilerOptions": {
3
+ "target": "ES2020",
4
+ "module": "CommonJS",
5
+ "moduleResolution": "node",
6
+ "declaration": true,
7
+ "strict": true,
8
+ "esModuleInterop": true,
9
+ "skipLibCheck": true,
10
+ "forceConsistentCasingInFileNames": true
11
+ },
12
+ "include": ["src/**/*"],
13
+ "exclude": ["node_modules", "dist"]
14
+ }
package/tsup.config.ts ADDED
@@ -0,0 +1,11 @@
1
+ import { defineConfig } from 'tsup';
2
+
3
+ export default defineConfig({
4
+ entry: ['src/index.ts'],
5
+ format: ['cjs', 'esm'],
6
+ dts: true,
7
+ clean: true,
8
+ minify: true,
9
+ sourcemap: true,
10
+ splitting: false,
11
+ });
package/wiki.md ADDED
@@ -0,0 +1,97 @@
1
+ ## How to Use this (Production Ready)
2
+ This design resolves the "Express-only" limitation.
3
+
4
+ #### **Scenario A: Express / NestJS (Standard Node)**
5
+ ```js
6
+ import { Senzor } from '@senzops/apm-node';
7
+
8
+ Senzor.init({ apiKey: "sz_apm_..." });
9
+ app.use(Senzor.requestHandler());
10
+ ```
11
+
12
+ #### **Scenario B: Next.js (Server Actions / API Routes)**
13
+ Next.js doesn't use middleware the same way. Users can wrap handlers or use a manual track.
14
+
15
+ ```javascript
16
+ // pages/api/user.ts
17
+ import { Senzor } from '@senzops/apm-node';
18
+
19
+ export default async function handler(req, res) {
20
+ const start = performance.now();
21
+
22
+ // ... logic ...
23
+
24
+ // Track manually at end
25
+ Senzor.track({
26
+ method: req.method,
27
+ route: '/api/user',
28
+ path: req.url,
29
+ status: res.statusCode,
30
+ duration: performance.now() - start
31
+ });
32
+
33
+ // Critical for Vercel/Serverless: Wait for flush
34
+ await Senzor.flush();
35
+ }
36
+ ```
37
+
38
+ #### **Scenario C: Nitro / H3 (NuxtJS)**
39
+ ```javascript
40
+ // server/middleware/senzor.ts
41
+ import { Senzor } from '@senzops/apm-node';
42
+
43
+ Senzor.init({ apiKey: "..." });
44
+
45
+ export default defineEventHandler(async (event) => {
46
+ const start = performance.now();
47
+
48
+ // Process request
49
+ await callHandler(event);
50
+
51
+ Senzor.track({
52
+ method: event.method,
53
+ route: event.path, // Nitro might need regex to normalize
54
+ path: event.path,
55
+ status: event.node.res.statusCode,
56
+ duration: performance.now() - start
57
+ });
58
+ });
59
+ ```
60
+
61
+ ### **Build Instructions**
62
+ Run this inside the `apm-node` directory:
63
+ ```bash
64
+ npm install
65
+ npm run build
66
+ This will produce a lightweight `dist/` folder ready for publishing to NPM.
67
+ ```
68
+
69
+ ### **Using Wrappers**
70
+ **1. Express (Standard)**
71
+ ```js
72
+ app.use(Senzor.requestHandler());
73
+ ```
74
+
75
+ **2. Next.js App Router (`app/api/route.ts`)**
76
+ ```javascript
77
+ import { Senzor } from '@senzops/apm-node';
78
+
79
+ export const GET = Senzor.wrapNextRoute(async (request) => {
80
+ return Response.json({ success: true });
81
+ });
82
+ ```
83
+ **3. Nuxt / Nitro (`server/api/test.ts`)**
84
+ ```javascript
85
+ import { Senzor } from '@senzops/apm-node';
86
+
87
+ export default Senzor.wrapH3(defineEventHandler((event) => {
88
+ return { hello: 'world' }
89
+ }));
90
+ ```
91
+ **4. Fastify**
92
+ ```javascript
93
+ import { Senzor } from '@senzops/apm-node';
94
+
95
+ fastify.register(Senzor.fastifyPlugin, { apiKey: '...' });
96
+ ```
97
+ This approach provides **native robustness** for each framework. It captures errors (500s), 404s (Route not found), and correct timing without the user manually writing `track()` calls.