@sentienguard/apm 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,136 @@
1
+ # @sentienguard/apm
2
+
3
+ Minimal, production-safe APM SDK for Node.js applications. Zero-config setup with automatic instrumentation.
4
+
5
+ ## Installation
6
+
7
+ ```bash
8
+ npm install @sentienguard/apm
9
+ ```
10
+
11
+ ## Quick Start
12
+
13
+ ```js
14
+ // Add this as the FIRST import in your app
15
+ import '@sentienguard/apm';
16
+
17
+ // Your app code
18
+ import express from 'express';
19
+ const app = express();
20
+ // ...
21
+ ```
22
+
23
+ Set environment variables:
24
+
25
+ ```bash
26
+ SENTIENGUARD_APM_KEY=your-app-key
27
+ SENTIENGUARD_SERVICE=my-api
28
+ ```
29
+
30
+ That's it. The SDK automatically instruments your application and sends metrics to SentienGuard.
31
+
32
+ ## Configuration
33
+
34
+ All configuration is via environment variables:
35
+
36
+ | Variable | Required | Default | Description |
37
+ |----------|----------|---------|-------------|
38
+ | `SENTIENGUARD_APM_KEY` | Yes | - | Your application's APM key |
39
+ | `SENTIENGUARD_SERVICE` | Yes | - | Service name (e.g., `orders-api`) |
40
+ | `SENTIENGUARD_ENV` | No | `production` | Environment (`production`, `staging`, `development`) |
41
+ | `SENTIENGUARD_ENDPOINT` | No | `https://sentienguard-dev.the-algo.com/api/v1` | SentienGuard backend URL |
42
+ | `SENTIENGUARD_FLUSH_INTERVAL` | No | `10` | Metrics flush interval in seconds |
43
+
44
+ > **Note:** If `SENTIENGUARD_APM_KEY` or `SENTIENGUARD_SERVICE` is missing, the SDK disables itself silently without affecting your application.
45
+
46
+ ## What Gets Tracked
47
+
48
+ - **HTTP Requests** - Incoming requests with method, route, status, and latency
49
+ - **Dependencies** - Outgoing HTTP/HTTPS calls to external services
50
+ - **Errors** - Uncaught exceptions and unhandled rejections
51
+
52
+ ## Framework Integration
53
+
54
+ ### Express
55
+
56
+ For better route extraction, add the middleware:
57
+
58
+ ```js
59
+ import '@sentienguard/apm';
60
+ import { expressMiddleware, expressErrorMiddleware } from '@sentienguard/apm';
61
+ import express from 'express';
62
+
63
+ const app = express();
64
+
65
+ // Add early in middleware chain
66
+ app.use(expressMiddleware());
67
+
68
+ // Your routes
69
+ app.get('/users/:id', (req, res) => { ... });
70
+
71
+ // Add error middleware last
72
+ app.use(expressErrorMiddleware());
73
+ ```
74
+
75
+ ### Fastify
76
+
77
+ ```js
78
+ import '@sentienguard/apm';
79
+ import { fastifyPlugin, fastifyErrorHandler } from '@sentienguard/apm';
80
+ import Fastify from 'fastify';
81
+
82
+ const app = Fastify();
83
+
84
+ // Register plugin
85
+ app.register(fastifyPlugin);
86
+
87
+ // Add error handler
88
+ app.setErrorHandler(fastifyErrorHandler);
89
+ ```
90
+
91
+ ## API Reference
92
+
93
+ ### Functions
94
+
95
+ ```js
96
+ import {
97
+ shutdown, // Graceful shutdown (flushes pending metrics)
98
+ getStatus, // Get SDK status and stats
99
+ flush, // Force flush metrics now
100
+ isEnabled // Check if SDK is enabled
101
+ } from '@sentienguard/apm';
102
+ ```
103
+
104
+ ### Graceful Shutdown
105
+
106
+ The SDK automatically handles `SIGTERM` and `SIGINT` signals. For manual shutdown:
107
+
108
+ ```js
109
+ import { shutdown } from '@sentienguard/apm';
110
+
111
+ process.on('exit', async () => {
112
+ await shutdown();
113
+ });
114
+ ```
115
+
116
+ ### Check Status
117
+
118
+ ```js
119
+ import { getStatus } from '@sentienguard/apm';
120
+
121
+ console.log(getStatus());
122
+ // {
123
+ // enabled: true,
124
+ // initialized: true,
125
+ // config: { service: 'my-api', environment: 'production', flushInterval: 10 },
126
+ // stats: { requests: 150, dependencies: 45, errors: 2 }
127
+ // }
128
+ ```
129
+
130
+ ## Requirements
131
+
132
+ - Node.js >= 16.0.0
133
+
134
+ ## License
135
+
136
+ MIT
package/package.json ADDED
@@ -0,0 +1,36 @@
1
+ {
2
+ "name": "@sentienguard/apm",
3
+ "version": "1.0.0",
4
+ "description": "SentienGuard APM SDK - Minimal, production-safe application performance monitoring",
5
+ "main": "src/index.js",
6
+ "type": "module",
7
+ "exports": {
8
+ ".": "./src/index.js"
9
+ },
10
+ "scripts": {
11
+ "test": "node --experimental-vm-modules node_modules/jest/bin/jest.js",
12
+ "test:load": "node tests/load.test.js"
13
+ },
14
+ "keywords": [
15
+ "apm",
16
+ "monitoring",
17
+ "performance",
18
+ "metrics",
19
+ "sentienguard"
20
+ ],
21
+ "author": "SentienGuard",
22
+ "license": "MIT",
23
+ "publishConfig": {
24
+ "access": "public"
25
+ },
26
+ "engines": {
27
+ "node": ">=16.0.0"
28
+ },
29
+ "peerDependencies": {},
30
+ "devDependencies": {
31
+ "jest": "^29.7.0"
32
+ },
33
+ "files": [
34
+ "src/**/*"
35
+ ]
36
+ }
@@ -0,0 +1,219 @@
1
+ /**
2
+ * Metrics Aggregator
3
+ * Aggregates metrics in memory per interval.
4
+ * Never streams raw events - only sends aggregated data.
5
+ *
6
+ * Aggregation key: (service, method, route) for requests
7
+ * Aggregation key: (service, name, type) for dependencies
8
+ */
9
+
10
+ import { RouteRegistry } from './normalizer.js';
11
+ import config from './config.js';
12
+
13
+ /**
14
+ * Create an aggregation key for request metrics
15
+ */
16
+ function createRequestKey(method, route) {
17
+ return `${method}:${route}`;
18
+ }
19
+
20
+ /**
21
+ * Create an aggregation key for dependency metrics
22
+ */
23
+ function createDependencyKey(name, type) {
24
+ return `${name}:${type}`;
25
+ }
26
+
27
+ /**
28
+ * Metrics Aggregator class
29
+ * Maintains in-memory aggregated metrics that are flushed periodically
30
+ */
31
+ export class MetricsAggregator {
32
+ constructor() {
33
+ this.routeRegistry = new RouteRegistry(config.maxRoutes);
34
+ this.reset();
35
+ }
36
+
37
+ /**
38
+ * Reset all aggregated metrics (called after flush)
39
+ */
40
+ reset() {
41
+ // Request metrics: Map<key, {count, errorCount, latency: {sum, min, max}}>
42
+ this.requests = new Map();
43
+
44
+ // Dependency metrics: Map<key, {name, type, count, errorCount, latency: {sum, min, max}}>
45
+ this.dependencies = new Map();
46
+
47
+ // Error counter for unhandled exceptions
48
+ this.unhandledErrors = 0;
49
+ }
50
+
51
+ /**
52
+ * Record an incoming HTTP request
53
+ *
54
+ * @param {string} method - HTTP method (GET, POST, etc.)
55
+ * @param {string} route - Normalized route path
56
+ * @param {number} latency - Response time in milliseconds
57
+ * @param {boolean} isError - Whether the request resulted in an error (4xx/5xx)
58
+ */
59
+ recordRequest(method, route, latency, isError = false) {
60
+ // Register route with limit enforcement
61
+ const registeredRoute = this.routeRegistry.register(route);
62
+ const key = createRequestKey(method.toUpperCase(), registeredRoute);
63
+
64
+ let metric = this.requests.get(key);
65
+ if (!metric) {
66
+ metric = {
67
+ method: method.toUpperCase(),
68
+ route: registeredRoute,
69
+ count: 0,
70
+ errorCount: 0,
71
+ latency: {
72
+ sum: 0,
73
+ min: Infinity,
74
+ max: 0
75
+ }
76
+ };
77
+ this.requests.set(key, metric);
78
+ }
79
+
80
+ metric.count++;
81
+ if (isError) {
82
+ metric.errorCount++;
83
+ }
84
+
85
+ metric.latency.sum += latency;
86
+ metric.latency.min = Math.min(metric.latency.min, latency);
87
+ metric.latency.max = Math.max(metric.latency.max, latency);
88
+ }
89
+
90
+ /**
91
+ * Record an outgoing dependency call
92
+ *
93
+ * @param {string} name - Dependency name (e.g., "OpenAI API", "MongoDB")
94
+ * @param {string} type - Dependency type (http, db, cache)
95
+ * @param {number} latency - Response time in milliseconds
96
+ * @param {boolean} isError - Whether the call resulted in an error
97
+ */
98
+ recordDependency(name, type, latency, isError = false) {
99
+ const key = createDependencyKey(name, type);
100
+
101
+ let metric = this.dependencies.get(key);
102
+ if (!metric) {
103
+ metric = {
104
+ name,
105
+ type,
106
+ count: 0,
107
+ errorCount: 0,
108
+ latency: {
109
+ sum: 0,
110
+ min: Infinity,
111
+ max: 0
112
+ }
113
+ };
114
+ this.dependencies.set(key, metric);
115
+ }
116
+
117
+ metric.count++;
118
+ if (isError) {
119
+ metric.errorCount++;
120
+ }
121
+
122
+ metric.latency.sum += latency;
123
+ metric.latency.min = Math.min(metric.latency.min, latency);
124
+ metric.latency.max = Math.max(metric.latency.max, latency);
125
+ }
126
+
127
+ /**
128
+ * Record an unhandled error
129
+ */
130
+ recordError() {
131
+ this.unhandledErrors++;
132
+ }
133
+
134
+ /**
135
+ * Check if there's data to flush
136
+ */
137
+ hasData() {
138
+ return this.requests.size > 0 || this.dependencies.size > 0;
139
+ }
140
+
141
+ /**
142
+ * Get aggregated data for flushing to backend
143
+ * Returns the payload in the expected format and resets counters
144
+ */
145
+ flush() {
146
+ const payload = {
147
+ interval: `${config.flushInterval}s`,
148
+ service: config.service,
149
+ environment: config.environment,
150
+ requests: [],
151
+ dependencies: []
152
+ };
153
+
154
+ // Convert request metrics to array
155
+ for (const metric of this.requests.values()) {
156
+ payload.requests.push({
157
+ method: metric.method,
158
+ route: metric.route,
159
+ count: metric.count,
160
+ errorCount: metric.errorCount,
161
+ latency: {
162
+ sum: Math.round(metric.latency.sum),
163
+ min: metric.latency.min === Infinity ? 0 : Math.round(metric.latency.min),
164
+ max: Math.round(metric.latency.max)
165
+ }
166
+ });
167
+ }
168
+
169
+ // Convert dependency metrics to array
170
+ for (const metric of this.dependencies.values()) {
171
+ payload.dependencies.push({
172
+ name: metric.name,
173
+ type: metric.type,
174
+ count: metric.count,
175
+ errorCount: metric.errorCount,
176
+ latency: {
177
+ sum: Math.round(metric.latency.sum),
178
+ min: metric.latency.min === Infinity ? 0 : Math.round(metric.latency.min),
179
+ max: Math.round(metric.latency.max)
180
+ }
181
+ });
182
+ }
183
+
184
+ // Reset for next interval
185
+ this.reset();
186
+
187
+ return payload;
188
+ }
189
+
190
+ /**
191
+ * Get current metrics count (for monitoring/testing)
192
+ */
193
+ getStats() {
194
+ return {
195
+ requestMetrics: this.requests.size,
196
+ dependencyMetrics: this.dependencies.size,
197
+ uniqueRoutes: this.routeRegistry.size,
198
+ unhandledErrors: this.unhandledErrors
199
+ };
200
+ }
201
+ }
202
+
203
+ // Singleton instance
204
+ let instance = null;
205
+
206
+ /**
207
+ * Get the singleton aggregator instance
208
+ */
209
+ export function getAggregator() {
210
+ if (!instance) {
211
+ instance = new MetricsAggregator();
212
+ }
213
+ return instance;
214
+ }
215
+
216
+ export default {
217
+ MetricsAggregator,
218
+ getAggregator
219
+ };
package/src/config.js ADDED
@@ -0,0 +1,67 @@
1
+ /**
2
+ * SDK Configuration
3
+ * Environment-driven configuration with sensible defaults.
4
+ * No config → SDK disables itself silently.
5
+ */
6
+
7
+ const config = {
8
+ // API key for authentication (required)
9
+ apiKey: process.env.SENTIENGUARD_APM_KEY || '',
10
+
11
+ // Service name (required for data to be meaningful)
12
+ service: process.env.SENTIENGUARD_SERVICE || '',
13
+
14
+ // Environment (production, staging, development)
15
+ environment: process.env.SENTIENGUARD_ENV || 'production',
16
+
17
+ // Backend endpoint for data ingestion
18
+ endpoint: process.env.SENTIENGUARD_ENDPOINT || 'https://api.sentienguard.io/apm/ingest',
19
+
20
+ // Flush interval in seconds (default: 10s)
21
+ flushInterval: parseInt(process.env.SENTIENGUARD_FLUSH_INTERVAL, 10) || 10,
22
+
23
+ // Max unique routes to track per service (prevents memory bloat)
24
+ maxRoutes: parseInt(process.env.SENTIENGUARD_MAX_ROUTES, 10) || 100,
25
+
26
+ // Max payload size in bytes (prevent oversized payloads)
27
+ maxPayloadSize: parseInt(process.env.SENTIENGUARD_MAX_PAYLOAD_SIZE, 10) || 1024 * 1024, // 1MB
28
+
29
+ // Enable/disable SDK (auto-disabled if no API key)
30
+ enabled: process.env.SENTIENGUARD_ENABLED !== 'false',
31
+
32
+ // Debug mode (logs SDK activity)
33
+ debug: process.env.SENTIENGUARD_DEBUG === 'true'
34
+ };
35
+
36
+ /**
37
+ * Check if SDK is properly configured and should be active
38
+ */
39
+ export function isEnabled() {
40
+ // SDK disables itself silently if no API key or service name
41
+ return config.enabled && !!config.apiKey && !!config.service;
42
+ }
43
+
44
+ /**
45
+ * Get validated configuration
46
+ */
47
+ export function getConfig() {
48
+ return { ...config };
49
+ }
50
+
51
+ /**
52
+ * Log debug message if debug mode is enabled
53
+ */
54
+ export function debug(...args) {
55
+ if (config.debug) {
56
+ console.log('[SentienGuard APM]', ...args);
57
+ }
58
+ }
59
+
60
+ /**
61
+ * Log warning (always shown)
62
+ */
63
+ export function warn(...args) {
64
+ console.warn('[SentienGuard APM]', ...args);
65
+ }
66
+
67
+ export default config;
@@ -0,0 +1,236 @@
1
+ /**
2
+ * Dependency Tracking
3
+ * Automatically times outgoing HTTP calls (fetch, axios, node http/https).
4
+ *
5
+ * For each dependency:
6
+ * - name (e.g., "OpenAI API", "api.example.com")
7
+ * - type (http, db, cache)
8
+ * - response time
9
+ * - error flag
10
+ */
11
+
12
+ import http from 'http';
13
+ import https from 'https';
14
+ import { getAggregator } from './aggregator.js';
15
+ import { debug, getConfig } from './config.js';
16
+
17
+ let isInstrumented = false;
18
+ let originalHttpRequest = null;
19
+ let originalHttpsRequest = null;
20
+
21
+ // Known service patterns for better naming
22
+ const KNOWN_SERVICES = [
23
+ { pattern: /openai\.com/i, name: 'OpenAI API' },
24
+ { pattern: /anthropic\.com/i, name: 'Anthropic API' },
25
+ { pattern: /api\.stripe\.com/i, name: 'Stripe API' },
26
+ { pattern: /api\.sendgrid\.com/i, name: 'SendGrid' },
27
+ { pattern: /api\.twilio\.com/i, name: 'Twilio API' },
28
+ { pattern: /s3\.amazonaws\.com/i, name: 'AWS S3' },
29
+ { pattern: /dynamodb\..+\.amazonaws\.com/i, name: 'DynamoDB' },
30
+ { pattern: /sqs\..+\.amazonaws\.com/i, name: 'AWS SQS' },
31
+ { pattern: /sns\..+\.amazonaws\.com/i, name: 'AWS SNS' },
32
+ { pattern: /mongodb\.net/i, name: 'MongoDB Atlas' },
33
+ { pattern: /redis/i, name: 'Redis' },
34
+ { pattern: /postgresql|postgres/i, name: 'PostgreSQL' },
35
+ { pattern: /mysql/i, name: 'MySQL' }
36
+ ];
37
+
38
+ /**
39
+ * Extract a friendly name from hostname
40
+ */
41
+ function getServiceName(hostname) {
42
+ if (!hostname) return 'unknown';
43
+
44
+ // Check known services
45
+ for (const service of KNOWN_SERVICES) {
46
+ if (service.pattern.test(hostname)) {
47
+ return service.name;
48
+ }
49
+ }
50
+
51
+ // Default to hostname
52
+ return hostname;
53
+ }
54
+
55
+ /**
56
+ * Determine dependency type from hostname/path
57
+ */
58
+ function getDependencyType(hostname, path) {
59
+ const lowerHost = (hostname || '').toLowerCase();
60
+ const lowerPath = (path || '').toLowerCase();
61
+
62
+ // Database indicators
63
+ if (/mongodb|postgres|mysql|dynamodb|redis|memcache/i.test(lowerHost)) {
64
+ return 'db';
65
+ }
66
+
67
+ // Cache indicators
68
+ if (/redis|memcache|elasticache/i.test(lowerHost)) {
69
+ return 'cache';
70
+ }
71
+
72
+ // Storage indicators
73
+ if (/s3\.amazonaws|storage\.googleapis|blob\.core\.windows/i.test(lowerHost)) {
74
+ return 'storage';
75
+ }
76
+
77
+ // Default to HTTP
78
+ return 'http';
79
+ }
80
+
81
+ /**
82
+ * Check if this request should be excluded from tracking
83
+ */
84
+ function shouldExclude(hostname) {
85
+ const config = getConfig();
86
+
87
+ // Don't track our own APM endpoint
88
+ if (config.endpoint) {
89
+ try {
90
+ const endpointUrl = new URL(config.endpoint);
91
+ if (hostname === endpointUrl.hostname) {
92
+ return true;
93
+ }
94
+ } catch {
95
+ // Invalid endpoint URL, continue
96
+ }
97
+ }
98
+
99
+ // Exclude localhost by default (internal services)
100
+ if (hostname === 'localhost' || hostname === '127.0.0.1') {
101
+ return true;
102
+ }
103
+
104
+ return false;
105
+ }
106
+
107
+ /**
108
+ * Wrap http/https request to track dependencies
109
+ */
110
+ function wrapRequest(original, protocol) {
111
+ return function instrumentedRequest(options, callback) {
112
+ // Parse options to get hostname
113
+ let hostname = '';
114
+ let path = '/';
115
+
116
+ if (typeof options === 'string') {
117
+ try {
118
+ const url = new URL(options);
119
+ hostname = url.hostname;
120
+ path = url.pathname;
121
+ } catch {
122
+ // Invalid URL
123
+ }
124
+ } else if (options) {
125
+ hostname = options.hostname || options.host || '';
126
+ path = options.path || '/';
127
+ // Remove port from host if present
128
+ hostname = hostname.split(':')[0];
129
+ }
130
+
131
+ // Check exclusions
132
+ if (shouldExclude(hostname)) {
133
+ return original.apply(this, arguments);
134
+ }
135
+
136
+ const startTime = process.hrtime.bigint();
137
+ const serviceName = getServiceName(hostname);
138
+ const depType = getDependencyType(hostname, path);
139
+
140
+ // Call original
141
+ const req = original.apply(this, arguments);
142
+
143
+ // Track response
144
+ req.on('response', (res) => {
145
+ const endTime = process.hrtime.bigint();
146
+ const latencyMs = Number(endTime - startTime) / 1e6;
147
+ const isError = res.statusCode >= 400;
148
+
149
+ const aggregator = getAggregator();
150
+ aggregator.recordDependency(serviceName, depType, latencyMs, isError);
151
+
152
+ debug(`Dependency: ${serviceName} (${depType}) ${res.statusCode} ${latencyMs.toFixed(2)}ms`);
153
+ });
154
+
155
+ // Track errors
156
+ req.on('error', () => {
157
+ const endTime = process.hrtime.bigint();
158
+ const latencyMs = Number(endTime - startTime) / 1e6;
159
+
160
+ const aggregator = getAggregator();
161
+ aggregator.recordDependency(serviceName, depType, latencyMs, true);
162
+
163
+ debug(`Dependency error: ${serviceName} (${depType}) ${latencyMs.toFixed(2)}ms`);
164
+ });
165
+
166
+ return req;
167
+ };
168
+ }
169
+
170
+ /**
171
+ * Instrument outgoing HTTP requests
172
+ */
173
+ export function instrumentDependencies() {
174
+ if (isInstrumented) {
175
+ debug('Dependencies already instrumented, skipping');
176
+ return;
177
+ }
178
+
179
+ // Store originals
180
+ originalHttpRequest = http.request;
181
+ originalHttpsRequest = https.request;
182
+
183
+ // Patch http.request
184
+ http.request = wrapRequest(originalHttpRequest, 'http');
185
+ http.get = function (options, callback) {
186
+ const req = http.request(options, callback);
187
+ req.end();
188
+ return req;
189
+ };
190
+
191
+ // Patch https.request
192
+ https.request = wrapRequest(originalHttpsRequest, 'https');
193
+ https.get = function (options, callback) {
194
+ const req = https.request(options, callback);
195
+ req.end();
196
+ return req;
197
+ };
198
+
199
+ isInstrumented = true;
200
+ debug('Dependency instrumentation enabled');
201
+ }
202
+
203
+ /**
204
+ * Remove instrumentation (for testing/cleanup)
205
+ */
206
+ export function uninstrumentDependencies() {
207
+ if (!isInstrumented) return;
208
+
209
+ if (originalHttpRequest) {
210
+ http.request = originalHttpRequest;
211
+ http.get = function (options, callback) {
212
+ const req = http.request(options, callback);
213
+ req.end();
214
+ return req;
215
+ };
216
+ }
217
+
218
+ if (originalHttpsRequest) {
219
+ https.request = originalHttpsRequest;
220
+ https.get = function (options, callback) {
221
+ const req = https.request(options, callback);
222
+ req.end();
223
+ return req;
224
+ };
225
+ }
226
+
227
+ isInstrumented = false;
228
+ debug('Dependency instrumentation disabled');
229
+ }
230
+
231
+ export default {
232
+ instrumentDependencies,
233
+ uninstrumentDependencies,
234
+ getServiceName,
235
+ getDependencyType
236
+ };