@sentienguard/apm 1.0.18 → 1.0.21

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.
@@ -1,232 +1,232 @@
1
- /**
2
- * OpenTelemetry SpanExporter — classifies finished spans into the same
3
- * in-memory metrics as the legacy HTTP/Mongo patches (mirrors sdk-python _exporter.py).
4
- */
5
-
6
- import { SpanKind, SpanStatusCode } from '@opentelemetry/api';
7
- import { hrTimeToMilliseconds, ExportResultCode } from '@opentelemetry/core';
8
- import { getAggregator } from './aggregator.js';
9
- import { normalizeRoute } from './normalizer.js';
10
- import { getServiceName, getDependencyType } from './dependencies.js';
11
- import config, { debug, getConfig } from './config.js';
12
-
13
- const HTTP_METHOD = 'http.method';
14
- const HTTP_ROUTE = 'http.route';
15
- const HTTP_TARGET = 'http.target';
16
- const HTTP_URL = 'http.url';
17
- const HTTP_STATUS = 'http.status_code';
18
- const HTTP_HOST = 'http.host';
19
- const NET_PEER_NAME = 'net.peer.name';
20
- const NET_PEER_PORT = 'net.peer.port';
21
- const DB_SYSTEM = 'db.system';
22
- const DB_COLLECTION = 'db.mongodb.collection';
23
- const DB_OPERATION = 'db.operation';
24
-
25
- function attr(attrs, key) {
26
- if (!attrs) return undefined;
27
- if (typeof attrs.get === 'function') return attrs.get(key);
28
- return attrs[key];
29
- }
30
-
31
- function spanLatencyMs(span) {
32
- if (span.endTime && span.startTime) {
33
- const d = hrTimeToMilliseconds(span.endTime) - hrTimeToMilliseconds(span.startTime);
34
- return d >= 0 ? d : 0;
35
- }
36
- return 0;
37
- }
38
-
39
- function isErrorSpan(span) {
40
- if (span.status?.code === SpanStatusCode.ERROR) return true;
41
- return false;
42
- }
43
-
44
- function isLocalHost(host) {
45
- return host === 'localhost' || host === '127.0.0.1' || host === '::1';
46
- }
47
-
48
- function shouldSkipIngestHost(host) {
49
- if (!host) return true;
50
- try {
51
- const endpointHost = new URL(config.endpoint).hostname;
52
- if (host === endpointHost) return true;
53
- } catch {
54
- // ignore
55
- }
56
- return false;
57
- }
58
-
59
- /** HTTP client port from OTel attrs (for localhost peer labels). */
60
- function extractHttpPeerPort(attrs) {
61
- const p = attr(attrs, NET_PEER_PORT);
62
- if (p != null && p !== '') return String(p);
63
- const url = attr(attrs, HTTP_URL);
64
- if (url) {
65
- try {
66
- const u = new URL(url);
67
- if (u.port) return u.port;
68
- } catch {
69
- // ignore
70
- }
71
- }
72
- return '';
73
- }
74
-
75
- /**
76
- * Resolve outgoing HTTP peer label and whether to skip (ingest, OpenAI, local when disabled).
77
- * Caller service is always config.service (see flush payload); this is the callee label.
78
- */
79
- export function resolveHttpPeerForSpan(attrs) {
80
- const host = hostFromClientSpan(attrs);
81
- if (shouldSkipIngestHost(host)) return { skip: true, peerLabel: '' };
82
-
83
- const cfg = getConfig();
84
- const traceLocal = cfg.tracing?.traceLocalHttp === true;
85
- const peerMap = cfg.tracing?.peerServiceMap || {};
86
-
87
- if (host && isLocalHost(host)) {
88
- if (!traceLocal) return { skip: true, peerLabel: '' };
89
- const port = extractHttpPeerPort(attrs);
90
- const label = (port && peerMap[port]) || (port ? `localhost:${port}` : host);
91
- return { skip: false, peerLabel: label };
92
- }
93
-
94
- if (host && /openai\.com/i.test(host)) return { skip: true, peerLabel: '' };
95
-
96
- const peerLabel = host ? getServiceName(host) : 'unknown';
97
- return { skip: false, peerLabel };
98
- }
99
-
100
- function hostFromClientSpan(attrs) {
101
- let host = attr(attrs, NET_PEER_NAME) || attr(attrs, HTTP_HOST) || '';
102
- if (!host) {
103
- const url = attr(attrs, HTTP_URL);
104
- if (url) {
105
- try {
106
- host = new URL(url).hostname || '';
107
- } catch {
108
- host = '';
109
- }
110
- }
111
- }
112
- return host;
113
- }
114
-
115
- function normalizeMongoOp(op) {
116
- const o = String(op || '').toLowerCase();
117
- if (o.includes('find')) return 'find';
118
- if (o.includes('insert')) return 'insert';
119
- if (o.includes('update')) return 'update';
120
- if (o.includes('delete') || o.includes('remove')) return 'delete';
121
- if (o === 'aggregate') return 'aggregate';
122
- if (o.includes('count')) return 'count';
123
- if (o.includes('index')) return 'index';
124
- return o || 'unknown';
125
- }
126
-
127
- function dbDepInfo(system) {
128
- const mapping = {
129
- postgresql: ['PostgreSQL', 'db'],
130
- mysql: ['MySQL', 'db'],
131
- sqlite: ['SQLite', 'db'],
132
- mssql: ['SQL Server', 'db'],
133
- oracle: ['Oracle', 'db'],
134
- redis: ['Redis', 'cache'],
135
- memcached: ['Memcached', 'cache']
136
- };
137
- return mapping[system] || [system, 'db'];
138
- }
139
-
140
- /**
141
- * Map one OTel span into SentienGuard aggregator buckets (same rules as Python SDK).
142
- */
143
- export function classifyAndRecordSpan(span) {
144
- const attrs = span.attributes || {};
145
- const latency = spanLatencyMs(span);
146
- let error = isErrorSpan(span);
147
- const kind = span.kind;
148
-
149
- if (kind === SpanKind.SERVER) {
150
- const method = attr(attrs, HTTP_METHOD) || 'GET';
151
- let route = attr(attrs, HTTP_ROUTE) || attr(attrs, HTTP_TARGET) || '/';
152
- route = normalizeRoute(typeof route === 'string' ? route : '/');
153
- const status = parseInt(String(attr(attrs, HTTP_STATUS) || 0), 10);
154
- if (status >= 400) error = true;
155
- getAggregator().recordRequest(method, route, latency, error);
156
- return;
157
- }
158
-
159
- if (kind === SpanKind.CLIENT) {
160
- const dbSystem = attr(attrs, DB_SYSTEM);
161
-
162
- if (dbSystem === 'mongodb') {
163
- const collection = attr(attrs, DB_COLLECTION) || 'unknown';
164
- const operation = normalizeMongoOp(attr(attrs, DB_OPERATION) || span.name);
165
- getAggregator().recordMongoOperation(collection, operation, latency, error);
166
- return;
167
- }
168
-
169
- if (dbSystem) {
170
- const [name, dtype] = dbDepInfo(dbSystem);
171
- getAggregator().recordDependency(name, dtype, latency, error);
172
- return;
173
- }
174
-
175
- const { skip, peerLabel } = resolveHttpPeerForSpan(attrs);
176
- if (skip) return;
177
-
178
- const host = hostFromClientSpan(attrs);
179
- const dtype = getDependencyType(host || '', '');
180
- const status = parseInt(String(attr(attrs, HTTP_STATUS) || 0), 10);
181
- if (status >= 400) error = true;
182
- debug(
183
- `Service call: ${config.service} -> ${peerLabel} ${latency.toFixed(2)}ms` +
184
- (status ? ` (HTTP ${status})` : '')
185
- );
186
- getAggregator().recordDependency(peerLabel, dtype, latency, error);
187
- return;
188
- }
189
-
190
- const nameLower = (span.name || '').toLowerCase();
191
- if (nameLower.includes('openai') || attr(attrs, 'gen_ai.system') === 'openai') {
192
- const operation = attr(attrs, 'gen_ai.operation.name') || span.name;
193
- const model = attr(attrs, 'gen_ai.request.model') || 'unknown';
194
- const promptTokens = parseInt(String(attr(attrs, 'gen_ai.usage.prompt_tokens') || 0), 10);
195
- const completionTokens = parseInt(String(attr(attrs, 'gen_ai.usage.completion_tokens') || 0), 10);
196
- const totalTokens = promptTokens + completionTokens;
197
- getAggregator().recordOpenAIOperation({
198
- operation,
199
- model,
200
- latency,
201
- promptTokens,
202
- completionTokens,
203
- totalTokens,
204
- cost: 0,
205
- error: error ? 'error' : null,
206
- statusCode: parseInt(String(attr(attrs, HTTP_STATUS) || 200), 10)
207
- });
208
- }
209
- }
210
-
211
- export class SentienGuardSpanExporter {
212
- export(spans, resultCallback) {
213
- try {
214
- for (const span of spans) {
215
- try {
216
- classifyAndRecordSpan(span);
217
- } catch {
218
- // never break the app
219
- }
220
- }
221
- resultCallback({ code: ExportResultCode.SUCCESS });
222
- } catch (err) {
223
- resultCallback({ code: ExportResultCode.FAILED, error: err });
224
- }
225
- }
226
-
227
- shutdown() {
228
- return Promise.resolve();
229
- }
230
- }
231
-
232
- export default SentienGuardSpanExporter;
1
+ /**
2
+ * OpenTelemetry SpanExporter — classifies finished spans into the same
3
+ * in-memory metrics as the legacy HTTP/Mongo patches (mirrors sdk-python _exporter.py).
4
+ */
5
+
6
+ import { SpanKind, SpanStatusCode } from '@opentelemetry/api';
7
+ import { hrTimeToMilliseconds, ExportResultCode } from '@opentelemetry/core';
8
+ import { getAggregator } from './aggregator.js';
9
+ import { normalizeRoute } from './normalizer.js';
10
+ import { getServiceName, getDependencyType } from './dependencies.js';
11
+ import config, { debug, getConfig } from './config.js';
12
+
13
+ const HTTP_METHOD = 'http.method';
14
+ const HTTP_ROUTE = 'http.route';
15
+ const HTTP_TARGET = 'http.target';
16
+ const HTTP_URL = 'http.url';
17
+ const HTTP_STATUS = 'http.status_code';
18
+ const HTTP_HOST = 'http.host';
19
+ const NET_PEER_NAME = 'net.peer.name';
20
+ const NET_PEER_PORT = 'net.peer.port';
21
+ const DB_SYSTEM = 'db.system';
22
+ const DB_COLLECTION = 'db.mongodb.collection';
23
+ const DB_OPERATION = 'db.operation';
24
+
25
+ function attr(attrs, key) {
26
+ if (!attrs) return undefined;
27
+ if (typeof attrs.get === 'function') return attrs.get(key);
28
+ return attrs[key];
29
+ }
30
+
31
+ function spanLatencyMs(span) {
32
+ if (span.endTime && span.startTime) {
33
+ const d = hrTimeToMilliseconds(span.endTime) - hrTimeToMilliseconds(span.startTime);
34
+ return d >= 0 ? d : 0;
35
+ }
36
+ return 0;
37
+ }
38
+
39
+ function isErrorSpan(span) {
40
+ if (span.status?.code === SpanStatusCode.ERROR) return true;
41
+ return false;
42
+ }
43
+
44
+ function isLocalHost(host) {
45
+ return host === 'localhost' || host === '127.0.0.1' || host === '::1';
46
+ }
47
+
48
+ function shouldSkipIngestHost(host) {
49
+ if (!host) return true;
50
+ try {
51
+ const endpointHost = new URL(config.endpoint).hostname;
52
+ if (host === endpointHost) return true;
53
+ } catch {
54
+ // ignore
55
+ }
56
+ return false;
57
+ }
58
+
59
+ /** HTTP client port from OTel attrs (for localhost peer labels). */
60
+ function extractHttpPeerPort(attrs) {
61
+ const p = attr(attrs, NET_PEER_PORT);
62
+ if (p != null && p !== '') return String(p);
63
+ const url = attr(attrs, HTTP_URL);
64
+ if (url) {
65
+ try {
66
+ const u = new URL(url);
67
+ if (u.port) return u.port;
68
+ } catch {
69
+ // ignore
70
+ }
71
+ }
72
+ return '';
73
+ }
74
+
75
+ /**
76
+ * Resolve outgoing HTTP peer label and whether to skip (ingest, OpenAI, local when disabled).
77
+ * Caller service is always config.service (see flush payload); this is the callee label.
78
+ */
79
+ export function resolveHttpPeerForSpan(attrs) {
80
+ const host = hostFromClientSpan(attrs);
81
+ if (shouldSkipIngestHost(host)) return { skip: true, peerLabel: '' };
82
+
83
+ const cfg = getConfig();
84
+ const traceLocal = cfg.tracing?.traceLocalHttp === true;
85
+ const peerMap = cfg.tracing?.peerServiceMap || {};
86
+
87
+ if (host && isLocalHost(host)) {
88
+ if (!traceLocal) return { skip: true, peerLabel: '' };
89
+ const port = extractHttpPeerPort(attrs);
90
+ const label = (port && peerMap[port]) || (port ? `localhost:${port}` : host);
91
+ return { skip: false, peerLabel: label };
92
+ }
93
+
94
+ if (host && /openai\.com/i.test(host)) return { skip: true, peerLabel: '' };
95
+
96
+ const peerLabel = host ? getServiceName(host) : 'unknown';
97
+ return { skip: false, peerLabel };
98
+ }
99
+
100
+ function hostFromClientSpan(attrs) {
101
+ let host = attr(attrs, NET_PEER_NAME) || attr(attrs, HTTP_HOST) || '';
102
+ if (!host) {
103
+ const url = attr(attrs, HTTP_URL);
104
+ if (url) {
105
+ try {
106
+ host = new URL(url).hostname || '';
107
+ } catch {
108
+ host = '';
109
+ }
110
+ }
111
+ }
112
+ return host;
113
+ }
114
+
115
+ function normalizeMongoOp(op) {
116
+ const o = String(op || '').toLowerCase();
117
+ if (o.includes('find')) return 'find';
118
+ if (o.includes('insert')) return 'insert';
119
+ if (o.includes('update')) return 'update';
120
+ if (o.includes('delete') || o.includes('remove')) return 'delete';
121
+ if (o === 'aggregate') return 'aggregate';
122
+ if (o.includes('count')) return 'count';
123
+ if (o.includes('index')) return 'index';
124
+ return o || 'unknown';
125
+ }
126
+
127
+ function dbDepInfo(system) {
128
+ const mapping = {
129
+ postgresql: ['PostgreSQL', 'db'],
130
+ mysql: ['MySQL', 'db'],
131
+ sqlite: ['SQLite', 'db'],
132
+ mssql: ['SQL Server', 'db'],
133
+ oracle: ['Oracle', 'db'],
134
+ redis: ['Redis', 'cache'],
135
+ memcached: ['Memcached', 'cache']
136
+ };
137
+ return mapping[system] || [system, 'db'];
138
+ }
139
+
140
+ /**
141
+ * Map one OTel span into SentienGuard aggregator buckets (same rules as Python SDK).
142
+ */
143
+ export function classifyAndRecordSpan(span) {
144
+ const attrs = span.attributes || {};
145
+ const latency = spanLatencyMs(span);
146
+ let error = isErrorSpan(span);
147
+ const kind = span.kind;
148
+
149
+ if (kind === SpanKind.SERVER) {
150
+ const method = attr(attrs, HTTP_METHOD) || 'GET';
151
+ let route = attr(attrs, HTTP_ROUTE) || attr(attrs, HTTP_TARGET) || '/';
152
+ route = normalizeRoute(typeof route === 'string' ? route : '/');
153
+ const status = parseInt(String(attr(attrs, HTTP_STATUS) || 0), 10);
154
+ if (status >= 400) error = true;
155
+ getAggregator().recordRequest(method, route, latency, error);
156
+ return;
157
+ }
158
+
159
+ if (kind === SpanKind.CLIENT) {
160
+ const dbSystem = attr(attrs, DB_SYSTEM);
161
+
162
+ if (dbSystem === 'mongodb') {
163
+ const collection = attr(attrs, DB_COLLECTION) || 'unknown';
164
+ const operation = normalizeMongoOp(attr(attrs, DB_OPERATION) || span.name);
165
+ getAggregator().recordMongoOperation(collection, operation, latency, error);
166
+ return;
167
+ }
168
+
169
+ if (dbSystem) {
170
+ const [name, dtype] = dbDepInfo(dbSystem);
171
+ getAggregator().recordDependency(name, dtype, latency, error);
172
+ return;
173
+ }
174
+
175
+ const { skip, peerLabel } = resolveHttpPeerForSpan(attrs);
176
+ if (skip) return;
177
+
178
+ const host = hostFromClientSpan(attrs);
179
+ const dtype = getDependencyType(host || '', '');
180
+ const status = parseInt(String(attr(attrs, HTTP_STATUS) || 0), 10);
181
+ if (status >= 400) error = true;
182
+ debug(
183
+ `Service call: ${config.service} -> ${peerLabel} ${latency.toFixed(2)}ms` +
184
+ (status ? ` (HTTP ${status})` : '')
185
+ );
186
+ getAggregator().recordDependency(peerLabel, dtype, latency, error);
187
+ return;
188
+ }
189
+
190
+ const nameLower = (span.name || '').toLowerCase();
191
+ if (nameLower.includes('openai') || attr(attrs, 'gen_ai.system') === 'openai') {
192
+ const operation = attr(attrs, 'gen_ai.operation.name') || span.name;
193
+ const model = attr(attrs, 'gen_ai.request.model') || 'unknown';
194
+ const promptTokens = parseInt(String(attr(attrs, 'gen_ai.usage.prompt_tokens') || 0), 10);
195
+ const completionTokens = parseInt(String(attr(attrs, 'gen_ai.usage.completion_tokens') || 0), 10);
196
+ const totalTokens = promptTokens + completionTokens;
197
+ getAggregator().recordOpenAIOperation({
198
+ operation,
199
+ model,
200
+ latency,
201
+ promptTokens,
202
+ completionTokens,
203
+ totalTokens,
204
+ cost: 0,
205
+ error: error ? 'error' : null,
206
+ statusCode: parseInt(String(attr(attrs, HTTP_STATUS) || 200), 10)
207
+ });
208
+ }
209
+ }
210
+
211
+ export class SentienGuardSpanExporter {
212
+ export(spans, resultCallback) {
213
+ try {
214
+ for (const span of spans) {
215
+ try {
216
+ classifyAndRecordSpan(span);
217
+ } catch {
218
+ // never break the app
219
+ }
220
+ }
221
+ resultCallback({ code: ExportResultCode.SUCCESS });
222
+ } catch (err) {
223
+ resultCallback({ code: ExportResultCode.FAILED, error: err });
224
+ }
225
+ }
226
+
227
+ shutdown() {
228
+ return Promise.resolve();
229
+ }
230
+ }
231
+
232
+ export default SentienGuardSpanExporter;