@sentienguard/apm 1.0.9 → 1.0.11

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sentienguard/apm",
3
- "version": "1.0.9",
3
+ "version": "1.0.11",
4
4
  "description": "SentienGuard APM SDK - Minimal, production-safe application performance monitoring",
5
5
  "main": "src/index.js",
6
6
  "types": "src/index.d.ts",
package/src/config.js CHANGED
@@ -14,6 +14,7 @@ const config = {
14
14
  service: '',
15
15
  environment: 'production',
16
16
  endpoint: 'https://sentienguard-dev.the-algo.com/api/v1/apm/ingest',
17
+ tracesEndpoint: '',
17
18
  flushInterval: 10,
18
19
  maxRoutes: 100,
19
20
  maxPayloadSize: 1024 * 1024,
@@ -43,7 +44,12 @@ const config = {
43
44
  /** When true, outgoing HTTP to localhost is traced (for multi-service dev). Default false. */
44
45
  traceLocalHttp: false,
45
46
  /** Port -> display name for local peers, from SENTIENGUARD_PEER_SERVICE_MAP */
46
- peerServiceMap: {}
47
+ peerServiceMap: {},
48
+ /** Sampling rate for exporting raw spans (0..1). Parent-based. */
49
+ sampleRate: 0.05,
50
+ /** Span export queue + batch sizes (drop-on-pressure). */
51
+ maxQueueSize: 2048,
52
+ maxBatchSize: 256
47
53
  }
48
54
  };
49
55
 
@@ -60,6 +66,10 @@ export function loadConfig({ force = false } = {}) {
60
66
  config.service = process.env.SENTIENGUARD_SERVICE || '';
61
67
  config.environment = process.env.SENTIENGUARD_ENV || 'production';
62
68
  config.endpoint = process.env.SENTIENGUARD_ENDPOINT || 'https://sentienguard-dev.the-algo.com/api/v1/apm/ingest';
69
+ config.tracesEndpoint =
70
+ process.env.SENTIENGUARD_TRACES_ENDPOINT ||
71
+ deriveTracesEndpoint(config.endpoint) ||
72
+ 'https://sentienguard-dev.the-algo.com/api/v1/apm/traces';
63
73
  config.flushInterval = parseInt(process.env.SENTIENGUARD_FLUSH_INTERVAL, 10) || 10;
64
74
  config.maxRoutes = parseInt(process.env.SENTIENGUARD_MAX_ROUTES, 10) || 100;
65
75
  config.maxPayloadSize = parseInt(process.env.SENTIENGUARD_MAX_PAYLOAD_SIZE, 10) || 1024 * 1024;
@@ -82,6 +92,13 @@ export function loadConfig({ force = false } = {}) {
82
92
  config.openai.slowCallMs = parseInt(process.env.SENTIENGUARD_OPENAI_SLOW_CALL_MS, 10) || 5000;
83
93
 
84
94
  config.tracing.enabled = process.env.SENTIENGUARD_TRACING !== 'false';
95
+ const sampleRaw = process.env.SENTIENGUARD_TRACE_SAMPLE_RATE;
96
+ const sample = sampleRaw != null ? Number(sampleRaw) : NaN;
97
+ if (!Number.isNaN(sample) && sample >= 0 && sample <= 1) {
98
+ config.tracing.sampleRate = sample;
99
+ }
100
+ config.tracing.maxQueueSize = parseInt(process.env.SENTIENGUARD_TRACE_MAX_QUEUE_SIZE, 10) || config.tracing.maxQueueSize;
101
+ config.tracing.maxBatchSize = parseInt(process.env.SENTIENGUARD_TRACE_MAX_BATCH_SIZE, 10) || config.tracing.maxBatchSize;
85
102
  // Default behavior:
86
103
  // - production: do NOT record localhost dependency edges (noise + self-calls)
87
104
  // - non-production: DO record localhost edges (local multi-service dev "just works")
@@ -114,6 +131,17 @@ function parsePeerServiceMap(raw) {
114
131
  return map;
115
132
  }
116
133
 
134
+ function deriveTracesEndpoint(ingestEndpoint) {
135
+ try {
136
+ const u = new URL(String(ingestEndpoint));
137
+ // Common default: /api/v1/apm/ingest -> /api/v1/apm/traces
138
+ u.pathname = u.pathname.replace(/\/apm\/ingest\/?$/i, '/apm/traces');
139
+ return u.toString();
140
+ } catch {
141
+ return '';
142
+ }
143
+ }
144
+
117
145
  /**
118
146
  * Check if SDK is properly configured and should be active.
119
147
  * Triggers lazy config load if not yet loaded.
@@ -0,0 +1,99 @@
1
+ /**
2
+ * SpanExporter that ships sampled raw spans to SentienGuard trace ingest.
3
+ *
4
+ * This is intentionally "lossy": it enqueues serialized spans to an async transport
5
+ * and returns SUCCESS quickly to avoid blocking the app.
6
+ */
7
+
8
+ import { ExportResultCode, hrTimeToMilliseconds } from '@opentelemetry/core';
9
+ import { SpanStatusCode } from '@opentelemetry/api';
10
+ import { enqueueSpans } from './traceTransport.js';
11
+
12
+ function hrTimeToUnixNanoString(hrTime) {
13
+ // hrTime is [seconds, nanoseconds]
14
+ if (!Array.isArray(hrTime) || hrTime.length !== 2) return '';
15
+ const sec = BigInt(hrTime[0] || 0);
16
+ const ns = BigInt(hrTime[1] || 0);
17
+ return String(sec * 1000000000n + ns);
18
+ }
19
+
20
+ function statusForSpan(span) {
21
+ const code = span?.status?.code;
22
+ if (code === SpanStatusCode.ERROR) return { code: 'ERROR', message: span.status?.message || '' };
23
+ if (code === SpanStatusCode.OK) return { code: 'OK', message: span.status?.message || '' };
24
+ return { code: 'UNSET', message: span?.status?.message || '' };
25
+ }
26
+
27
+ function safeAttrs(attrs) {
28
+ if (!attrs) return {};
29
+ if (typeof attrs.get === 'function') {
30
+ const out = {};
31
+ for (const [k, v] of attrs.entries()) out[k] = v;
32
+ return out;
33
+ }
34
+ if (typeof attrs === 'object') return attrs;
35
+ return {};
36
+ }
37
+
38
+ function serializeSpan(span) {
39
+ const ctx = span?.spanContext?.();
40
+ if (!ctx?.traceId || !ctx?.spanId) return null;
41
+
42
+ const startNano = hrTimeToUnixNanoString(span.startTime);
43
+ const endNano = hrTimeToUnixNanoString(span.endTime);
44
+ if (!startNano || !endNano) return null;
45
+
46
+ const parentSpanId = span?.parentSpanId || span?.parentSpanContext?.spanId || null;
47
+ const status = statusForSpan(span);
48
+
49
+ const durationMs =
50
+ span.endTime && span.startTime
51
+ ? Math.max(0, hrTimeToMilliseconds(span.endTime) - hrTimeToMilliseconds(span.startTime))
52
+ : 0;
53
+
54
+ return {
55
+ trace_id: ctx.traceId,
56
+ span_id: ctx.spanId,
57
+ parent_span_id: parentSpanId || null,
58
+ name: span.name || '',
59
+ kind: span.kind != null ? String(span.kind) : undefined,
60
+ start_time_unix_nano: startNano,
61
+ end_time_unix_nano: endNano,
62
+ status,
63
+ attributes: safeAttrs(span.attributes),
64
+ events: Array.isArray(span.events) ? span.events : [],
65
+ links: Array.isArray(span.links) ? span.links : [],
66
+ duration_ms: Math.round(durationMs)
67
+ };
68
+ }
69
+
70
+ export class SentienGuardTraceSpanExporter {
71
+ export(spans, resultCallback) {
72
+ try {
73
+ const serialized = [];
74
+ for (const span of spans) {
75
+ try {
76
+ const s = serializeSpan(span);
77
+ if (s) serialized.push(s);
78
+ } catch {
79
+ // ignore
80
+ }
81
+ }
82
+
83
+ if (serialized.length) {
84
+ enqueueSpans(serialized);
85
+ }
86
+
87
+ resultCallback({ code: ExportResultCode.SUCCESS });
88
+ } catch (err) {
89
+ resultCallback({ code: ExportResultCode.FAILED, error: err });
90
+ }
91
+ }
92
+
93
+ shutdown() {
94
+ return Promise.resolve();
95
+ }
96
+ }
97
+
98
+ export default SentienGuardTraceSpanExporter;
99
+
@@ -0,0 +1,159 @@
1
+ /**
2
+ * Trace Transport (raw spans)
3
+ *
4
+ * Rules:
5
+ * - Never block app requests.
6
+ * - Best-effort delivery; data loss is acceptable.
7
+ * - Drop under sustained failure or memory pressure.
8
+ */
9
+
10
+ import https from 'https';
11
+ import http from 'http';
12
+ import { debug, warn, getConfig, isEnabled } from './config.js';
13
+
14
+ let queue = [];
15
+ let scheduled = false;
16
+ let consecutiveFailures = 0;
17
+
18
+ const MAX_CONSECUTIVE_FAILURES = 5;
19
+
20
+ function sendToBackend(payload) {
21
+ return new Promise((resolve, reject) => {
22
+ const cfg = getConfig();
23
+ const data = JSON.stringify(payload);
24
+
25
+ // Reuse the same payload size protection as metrics
26
+ const maxBytes = cfg.maxPayloadSize || 1024 * 1024;
27
+ if (Buffer.byteLength(data) > maxBytes) {
28
+ return reject(new Error('Payload too large'));
29
+ }
30
+
31
+ let url;
32
+ try {
33
+ url = new URL(cfg.tracesEndpoint);
34
+ } catch {
35
+ return reject(new Error('Invalid traces endpoint URL'));
36
+ }
37
+
38
+ const isHttps = url.protocol === 'https:';
39
+ const transport = isHttps ? https : http;
40
+
41
+ const options = {
42
+ hostname: url.hostname,
43
+ port: url.port || (isHttps ? 443 : 80),
44
+ path: url.pathname + url.search,
45
+ method: 'POST',
46
+ headers: {
47
+ 'Content-Type': 'application/json',
48
+ 'Content-Length': Buffer.byteLength(data),
49
+ 'X-APM-Key': cfg.apiKey,
50
+ 'X-Service': cfg.service,
51
+ 'User-Agent': '@sentienguard/apm/1.0.0'
52
+ },
53
+ timeout: 5000
54
+ };
55
+
56
+ const req = transport.request(options, (res) => {
57
+ let responseData = '';
58
+ res.on('data', (chunk) => {
59
+ responseData += chunk;
60
+ });
61
+ res.on('end', () => {
62
+ if (res.statusCode >= 200 && res.statusCode < 300) {
63
+ resolve({ statusCode: res.statusCode, data: responseData });
64
+ } else {
65
+ reject(new Error(`HTTP ${res.statusCode}: ${responseData}`));
66
+ }
67
+ });
68
+ });
69
+
70
+ req.on('error', (error) => {
71
+ const reason = error instanceof Error ? error : new Error(String(error));
72
+ reject(reason);
73
+ });
74
+
75
+ req.on('timeout', () => {
76
+ req.destroy();
77
+ reject(new Error('Request timeout'));
78
+ });
79
+
80
+ req.write(data);
81
+ req.end();
82
+ });
83
+ }
84
+
85
+ async function flushOnce(batch) {
86
+ if (!isEnabled()) return;
87
+ if (!batch.length) return;
88
+
89
+ const cfg = getConfig();
90
+ const payload = {
91
+ service: cfg.service,
92
+ environment: cfg.environment,
93
+ spans: batch
94
+ };
95
+
96
+ try {
97
+ await sendToBackend(payload);
98
+ consecutiveFailures = 0;
99
+ debug(`Trace flush ok: spans=${batch.length}`);
100
+ } catch (err) {
101
+ consecutiveFailures++;
102
+ warn(`Trace flush failed (attempt ${consecutiveFailures}): ${err.message}`);
103
+ if (consecutiveFailures >= MAX_CONSECUTIVE_FAILURES) {
104
+ // Stop retrying aggressively; drop future spans until backend recovers.
105
+ warn('Trace flush: max failures reached; dropping spans under backpressure');
106
+ }
107
+ }
108
+ }
109
+
110
+ function drainQueue() {
111
+ scheduled = false;
112
+ const cfg = getConfig();
113
+ const maxBatch = cfg.tracing?.maxBatchSize || 256;
114
+
115
+ // If backend is unhealthy, drop to protect app memory.
116
+ if (consecutiveFailures >= MAX_CONSECUTIVE_FAILURES) {
117
+ queue = [];
118
+ return;
119
+ }
120
+
121
+ // Send at most one batch per tick to keep exporter callbacks cheap.
122
+ const batch = queue.slice(0, maxBatch);
123
+ queue = queue.slice(batch.length);
124
+ void flushOnce(batch);
125
+
126
+ if (queue.length) {
127
+ scheduled = true;
128
+ setImmediate(drainQueue);
129
+ }
130
+ }
131
+
132
+ export function enqueueSpans(serializedSpans) {
133
+ const cfg = getConfig();
134
+ const maxQueue = cfg.tracing?.maxQueueSize || 2048;
135
+
136
+ if (!Array.isArray(serializedSpans) || serializedSpans.length === 0) return;
137
+ if (!isEnabled()) return;
138
+
139
+ // Drop-on-pressure.
140
+ const room = maxQueue - queue.length;
141
+ if (room <= 0) return;
142
+ if (serializedSpans.length > room) {
143
+ queue.push(...serializedSpans.slice(0, room));
144
+ } else {
145
+ queue.push(...serializedSpans);
146
+ }
147
+
148
+ if (!scheduled) {
149
+ scheduled = true;
150
+ setImmediate(drainQueue);
151
+ }
152
+ }
153
+
154
+ export function resetTraceQueueForTests() {
155
+ queue = [];
156
+ scheduled = false;
157
+ consecutiveFailures = 0;
158
+ }
159
+
package/src/tracing.js CHANGED
@@ -10,8 +10,10 @@ import { SEMRESATTRS_SERVICE_NAME, SEMRESATTRS_DEPLOYMENT_ENVIRONMENT } from '@o
10
10
  import { W3CTraceContextPropagator } from '@opentelemetry/core';
11
11
  import { HttpInstrumentation } from '@opentelemetry/instrumentation-http';
12
12
  import { ExpressInstrumentation } from '@opentelemetry/instrumentation-express';
13
+ import { BatchSpanProcessor, ParentBasedSampler, TraceIdRatioBasedSampler } from '@opentelemetry/sdk-trace-base';
13
14
  import { getConfig, debug } from './config.js';
14
15
  import { SentienGuardSpanExporter } from './spanExporter.js';
16
+ import { SentienGuardTraceSpanExporter } from './traceSpanExporter.js';
15
17
 
16
18
  let sdk = null;
17
19
  let tracingActive = false;
@@ -55,7 +57,8 @@ export function startTracing() {
55
57
  [SEMRESATTRS_DEPLOYMENT_ENVIRONMENT]: cfg.environment
56
58
  });
57
59
 
58
- const traceExporter = new SentienGuardSpanExporter();
60
+ const metricsExporter = new SentienGuardSpanExporter();
61
+ const traceExporter = new SentienGuardTraceSpanExporter();
59
62
 
60
63
  const httpInstrumentation = new HttpInstrumentation({
61
64
  ignoreOutgoingRequestHook: (requestOptions) => {
@@ -68,9 +71,15 @@ export function startTracing() {
68
71
 
69
72
  sdk = new NodeSDK({
70
73
  resource,
71
- traceExporter,
74
+ sampler: new ParentBasedSampler({
75
+ root: new TraceIdRatioBasedSampler(cfg.tracing?.sampleRate ?? 0.05)
76
+ }),
72
77
  textMapPropagator: new W3CTraceContextPropagator(),
73
78
  instrumentations: [httpInstrumentation, expressInstrumentation],
79
+ spanProcessors: [
80
+ new BatchSpanProcessor(metricsExporter),
81
+ new BatchSpanProcessor(traceExporter)
82
+ ],
74
83
  autoDetectResources: false
75
84
  });
76
85