@comprehend/telemetry-node 0.1.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/.idea/modules.xml +8 -0
- package/.idea/telemetry-node.iml +13 -0
- package/.idea/vcs.xml +6 -0
- package/dist/ComprehendDevSpanProcessor.d.ts +25 -0
- package/dist/ComprehendDevSpanProcessor.js +447 -0
- package/dist/WebSocketConnection.d.ts +22 -0
- package/dist/WebSocketConnection.js +102 -0
- package/dist/index.d.ts +1 -0
- package/dist/index.js +5 -0
- package/dist/sql-analyzer.d.ts +22 -0
- package/dist/sql-analyzer.js +287 -0
- package/dist/sql-analyzer.test.d.ts +1 -0
- package/dist/sql-analyzer.test.js +363 -0
- package/dist/wire-protocol.d.ts +110 -0
- package/dist/wire-protocol.js +2 -0
- package/jest.config.js +11 -0
- package/package.json +35 -0
- package/src/ComprehendDevSpanProcessor.ts +563 -0
- package/src/WebSocketConnection.ts +121 -0
- package/src/index.ts +2 -0
- package/src/sql-analyzer.test.ts +436 -0
- package/src/sql-analyzer.ts +316 -0
- package/src/wire-protocol.ts +134 -0
- package/tsconfig.json +13 -0
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
<?xml version="1.0" encoding="UTF-8"?>
|
|
2
|
+
<project version="4">
|
|
3
|
+
<component name="ProjectModuleManager">
|
|
4
|
+
<modules>
|
|
5
|
+
<module fileurl="file://$PROJECT_DIR$/.idea/telemetry-node.iml" filepath="$PROJECT_DIR$/.idea/telemetry-node.iml" />
|
|
6
|
+
</modules>
|
|
7
|
+
</component>
|
|
8
|
+
</project>
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
<?xml version="1.0" encoding="UTF-8"?>
|
|
2
|
+
<module type="WEB_MODULE" version="4">
|
|
3
|
+
<component name="NewModuleRootManager">
|
|
4
|
+
<content url="file://$MODULE_DIR$">
|
|
5
|
+
<excludeFolder url="file://$MODULE_DIR$/.tmp" />
|
|
6
|
+
<excludeFolder url="file://$MODULE_DIR$/dist" />
|
|
7
|
+
<excludeFolder url="file://$MODULE_DIR$/temp" />
|
|
8
|
+
<excludeFolder url="file://$MODULE_DIR$/tmp" />
|
|
9
|
+
</content>
|
|
10
|
+
<orderEntry type="inheritedJdk" />
|
|
11
|
+
<orderEntry type="sourceFolder" forTests="false" />
|
|
12
|
+
</component>
|
|
13
|
+
</module>
|
package/.idea/vcs.xml
ADDED
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import { Context } from "@opentelemetry/api";
|
|
2
|
+
import { ReadableSpan, Span, SpanProcessor } from "@opentelemetry/sdk-trace-base";
|
|
3
|
+
export declare class ComprehendDevSpanProcessor implements SpanProcessor {
|
|
4
|
+
private readonly connection;
|
|
5
|
+
private observedServices;
|
|
6
|
+
private observedDatabases;
|
|
7
|
+
private observedHttpServices;
|
|
8
|
+
private observedInteractions;
|
|
9
|
+
private observationsSeq;
|
|
10
|
+
constructor(options: {
|
|
11
|
+
organization: string;
|
|
12
|
+
token: string;
|
|
13
|
+
debug?: boolean | ((message: string) => void);
|
|
14
|
+
});
|
|
15
|
+
onStart(span: Span, parentContext: Context): void;
|
|
16
|
+
onEnd(span: ReadableSpan): void;
|
|
17
|
+
private discoverService;
|
|
18
|
+
private processHTTPRoute;
|
|
19
|
+
private processDatabaseOperation;
|
|
20
|
+
private processHttpRequest;
|
|
21
|
+
private getInteractions;
|
|
22
|
+
private ingestMessage;
|
|
23
|
+
forceFlush(): Promise<void>;
|
|
24
|
+
shutdown(): Promise<void>;
|
|
25
|
+
}
|
|
@@ -0,0 +1,447 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.ComprehendDevSpanProcessor = void 0;
|
|
4
|
+
const sha2_1 = require("@noble/hashes/sha2");
|
|
5
|
+
const utils_1 = require("@noble/hashes/utils");
|
|
6
|
+
const sql_analyzer_1 = require("./sql-analyzer");
|
|
7
|
+
const WebSocketConnection_1 = require("./WebSocketConnection");
|
|
8
|
+
const sqlDbSystems = new Set([
|
|
9
|
+
'mysql', 'postgresql', 'mssql', 'oracle', 'db2', 'sqlite', 'hsqldb', 'h2',
|
|
10
|
+
'informix', 'cockroachdb', 'redshift', 'tidb', 'trino', 'greenplum'
|
|
11
|
+
]);
|
|
12
|
+
class ComprehendDevSpanProcessor {
|
|
13
|
+
constructor(options) {
|
|
14
|
+
this.observedServices = [];
|
|
15
|
+
this.observedDatabases = [];
|
|
16
|
+
this.observedHttpServices = [];
|
|
17
|
+
this.observedInteractions = new Map();
|
|
18
|
+
this.observationsSeq = 1;
|
|
19
|
+
this.connection = new WebSocketConnection_1.WebSocketConnection(options.organization, options.token, options.debug === true ? console.log : options.debug === false ? undefined : options.debug);
|
|
20
|
+
}
|
|
21
|
+
onStart(span, parentContext) {
|
|
22
|
+
}
|
|
23
|
+
onEnd(span) {
|
|
24
|
+
const currentService = this.discoverService(span);
|
|
25
|
+
if (!currentService)
|
|
26
|
+
return;
|
|
27
|
+
const attrs = span.attributes;
|
|
28
|
+
if (span.kind === 1) {
|
|
29
|
+
// Server span, see if it's something to ingest.
|
|
30
|
+
if (attrs['http.route'] && attrs['http.method']) {
|
|
31
|
+
this.processHTTPRoute(currentService, attrs['http.route'], attrs['http.method'], span);
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
else if (span.kind === 2) {
|
|
35
|
+
// Client span, see if it's something to ingest.
|
|
36
|
+
if (attrs['http.url']) {
|
|
37
|
+
this.processHttpRequest(currentService, attrs['http.url'], span);
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
if (attrs['db.system']) {
|
|
41
|
+
this.processDatabaseOperation(currentService, span);
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
discoverService(span) {
|
|
45
|
+
// Look for an existing matching entry.
|
|
46
|
+
const resAttrs = span.resource.attributes;
|
|
47
|
+
const name = resAttrs['service.name'];
|
|
48
|
+
if (!name)
|
|
49
|
+
return;
|
|
50
|
+
const namespace = resAttrs['service.namespace'];
|
|
51
|
+
const environment = resAttrs['deployment.environment'];
|
|
52
|
+
const existing = this.observedServices.find(s => s.name === name &&
|
|
53
|
+
s.namespace === namespace &&
|
|
54
|
+
s.environment === environment);
|
|
55
|
+
if (existing)
|
|
56
|
+
return existing;
|
|
57
|
+
// New; hash it and add it to the observed services.
|
|
58
|
+
const idString = `service:${name}:${namespace ?? ''}:${environment ?? ''}`;
|
|
59
|
+
const hash = hashIdString(idString);
|
|
60
|
+
const newService = {
|
|
61
|
+
name, namespace, environment, hash,
|
|
62
|
+
httpRoutes: []
|
|
63
|
+
};
|
|
64
|
+
this.observedServices.push(newService);
|
|
65
|
+
// Ingest its existence.
|
|
66
|
+
const message = {
|
|
67
|
+
event: "new-entity",
|
|
68
|
+
type: "service",
|
|
69
|
+
hash,
|
|
70
|
+
name,
|
|
71
|
+
...(namespace ? { namespace } : {}),
|
|
72
|
+
...(environment ? { environment } : {})
|
|
73
|
+
};
|
|
74
|
+
this.ingestMessage(message);
|
|
75
|
+
}
|
|
76
|
+
processHTTPRoute(service, route, method, span) {
|
|
77
|
+
// Check if this route+method already exists under the service
|
|
78
|
+
let observedRoute = service.httpRoutes.find(r => r.route === route && r.method === method);
|
|
79
|
+
if (!observedRoute) {
|
|
80
|
+
// It's new; hash it and add it to our collection.
|
|
81
|
+
const idString = `http-route:${service.hash}:${method}:${route}`;
|
|
82
|
+
const hash = hashIdString(idString);
|
|
83
|
+
observedRoute = { method, route, hash };
|
|
84
|
+
service.httpRoutes.push(observedRoute);
|
|
85
|
+
// Emit observation message
|
|
86
|
+
const message = {
|
|
87
|
+
event: "new-entity",
|
|
88
|
+
type: "http-route",
|
|
89
|
+
hash,
|
|
90
|
+
parent: service.hash,
|
|
91
|
+
method, route
|
|
92
|
+
};
|
|
93
|
+
this.ingestMessage(message);
|
|
94
|
+
}
|
|
95
|
+
// Extract the request path, making sure we only get that, without any query string.
|
|
96
|
+
const attrs = span.attributes;
|
|
97
|
+
let path;
|
|
98
|
+
if (attrs['http.target']) {
|
|
99
|
+
try {
|
|
100
|
+
// This might be just a path like "/search?q=foo"
|
|
101
|
+
const rawTarget = attrs['http.target'];
|
|
102
|
+
const fakeUrl = new URL(rawTarget, 'http://placeholder'); // placeholder base
|
|
103
|
+
path = fakeUrl.pathname;
|
|
104
|
+
}
|
|
105
|
+
catch {
|
|
106
|
+
path = '/';
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
else if (attrs['http.url']) {
|
|
110
|
+
try {
|
|
111
|
+
const rawUrl = attrs['http.url'];
|
|
112
|
+
const parsed = new URL(rawUrl);
|
|
113
|
+
path = parsed.pathname;
|
|
114
|
+
}
|
|
115
|
+
catch {
|
|
116
|
+
path = '/';
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
else {
|
|
120
|
+
path = '/';
|
|
121
|
+
}
|
|
122
|
+
// Build and ingest observation.
|
|
123
|
+
const status = attrs['http.status_code'] ?? 0;
|
|
124
|
+
const duration = span.duration;
|
|
125
|
+
const httpVersion = attrs['http.flavor'];
|
|
126
|
+
const userAgent = attrs['http.user_agent'];
|
|
127
|
+
const requestBytes = attrs['http.request_content_length'];
|
|
128
|
+
const responseBytes = attrs['http.response_content_length'];
|
|
129
|
+
const { message: errorMessage, type: errorType, stack: stack } = extractErrorInfo(span);
|
|
130
|
+
const observation = {
|
|
131
|
+
type: 'http-server',
|
|
132
|
+
subject: observedRoute.hash,
|
|
133
|
+
timestamp: span.startTime,
|
|
134
|
+
path,
|
|
135
|
+
status,
|
|
136
|
+
duration,
|
|
137
|
+
...(httpVersion ? { httpVersion } : {}),
|
|
138
|
+
...(userAgent ? { userAgent } : {}),
|
|
139
|
+
...(requestBytes !== undefined ? { requestBytes } : {}),
|
|
140
|
+
...(responseBytes !== undefined ? { responseBytes } : {}),
|
|
141
|
+
...(errorMessage ? { errorMessage } : {}),
|
|
142
|
+
...(errorType ? { errorType } : {}),
|
|
143
|
+
...(stack ? { stack } : {})
|
|
144
|
+
};
|
|
145
|
+
this.ingestMessage({
|
|
146
|
+
event: "observations",
|
|
147
|
+
seq: this.observationsSeq++,
|
|
148
|
+
observations: [observation]
|
|
149
|
+
});
|
|
150
|
+
}
|
|
151
|
+
processDatabaseOperation(currentService, span) {
|
|
152
|
+
// Parse the connection string.
|
|
153
|
+
const attrs = span.attributes;
|
|
154
|
+
const system = attrs['db.system'];
|
|
155
|
+
const rawConnection = attrs['db.connection_string'];
|
|
156
|
+
const parsed = rawConnection
|
|
157
|
+
? parseDatabaseConnectionStringRaw(rawConnection)
|
|
158
|
+
: {
|
|
159
|
+
scrubbed: '',
|
|
160
|
+
user: undefined,
|
|
161
|
+
host: (attrs['net.peer.name'] ?? attrs['net.peer.ip']),
|
|
162
|
+
port: attrs['net.peer.port']?.toString(),
|
|
163
|
+
name: attrs['db.name'],
|
|
164
|
+
};
|
|
165
|
+
// See if we already have an entry for this database.
|
|
166
|
+
const hash = hashIdString(`database:${system}:${parsed.host ?? ''}:${parsed.port ?? ''}:${parsed.name ?? ''}`);
|
|
167
|
+
let observedDatabase = this.observedDatabases.find(db => db.hash === hash);
|
|
168
|
+
// If we see it for the first time, add and ingest it.
|
|
169
|
+
if (!observedDatabase) {
|
|
170
|
+
observedDatabase = {
|
|
171
|
+
system,
|
|
172
|
+
host: parsed.host,
|
|
173
|
+
port: parsed.port ? parseInt(parsed.port) : undefined,
|
|
174
|
+
name: parsed.name,
|
|
175
|
+
hash
|
|
176
|
+
};
|
|
177
|
+
this.observedDatabases.push(observedDatabase);
|
|
178
|
+
// The existence of the database.
|
|
179
|
+
const message = {
|
|
180
|
+
event: "new-entity",
|
|
181
|
+
type: "database",
|
|
182
|
+
hash,
|
|
183
|
+
system,
|
|
184
|
+
...(parsed.name ? { name: parsed.name } : {}),
|
|
185
|
+
...(parsed.scrubbed ? { connection: parsed.scrubbed } : {}),
|
|
186
|
+
...(parsed.host ? { host: parsed.host } : {}),
|
|
187
|
+
...(parsed.port ? { port: parseInt(parsed.port) } : {}),
|
|
188
|
+
...(parsed.user ? { user: parsed.user } : {})
|
|
189
|
+
};
|
|
190
|
+
this.ingestMessage(message);
|
|
191
|
+
}
|
|
192
|
+
// The connection to the database should have an interaction.
|
|
193
|
+
const interactions = this.getInteractions(currentService.hash, observedDatabase.hash);
|
|
194
|
+
let connectionInteraction = interactions.dbConnections.find(c => c.connection === parsed.scrubbed && c.user === parsed.user);
|
|
195
|
+
if (!connectionInteraction) {
|
|
196
|
+
connectionInteraction = {
|
|
197
|
+
hash: hashIdString(`db-connection:${currentService.hash}:${observedDatabase.hash}:${parsed.scrubbed ?? ''}:${parsed.user ?? ''}`),
|
|
198
|
+
connection: parsed.scrubbed,
|
|
199
|
+
user: parsed.user
|
|
200
|
+
};
|
|
201
|
+
interactions.dbConnections.push(connectionInteraction);
|
|
202
|
+
const message = {
|
|
203
|
+
event: "new-interaction",
|
|
204
|
+
type: "db-connection",
|
|
205
|
+
hash: connectionInteraction.hash,
|
|
206
|
+
from: currentService.hash,
|
|
207
|
+
to: observedDatabase.hash,
|
|
208
|
+
...(connectionInteraction.connection ? { connection: connectionInteraction.connection } : {}),
|
|
209
|
+
...(connectionInteraction.user ? { user: connectionInteraction.user } : {})
|
|
210
|
+
};
|
|
211
|
+
this.ingestMessage(message);
|
|
212
|
+
}
|
|
213
|
+
// The query of the database from the service (only for SQL for now).
|
|
214
|
+
if (sqlDbSystems.has(system) && attrs['db.statement']) {
|
|
215
|
+
// The interaction, based upon the normalized query.
|
|
216
|
+
let queryInfo = (0, sql_analyzer_1.analyzeSQL)(attrs['db.statement']);
|
|
217
|
+
let queryInteraction = interactions.dbQueries.find(q => q.query === queryInfo.normalizedQuery);
|
|
218
|
+
if (!queryInteraction) {
|
|
219
|
+
queryInteraction = {
|
|
220
|
+
hash: hashIdString(`db-query:${currentService.hash}:${observedDatabase.hash}:${queryInfo.normalizedQuery}`),
|
|
221
|
+
query: queryInfo.normalizedQuery
|
|
222
|
+
};
|
|
223
|
+
interactions.dbQueries.push(queryInteraction);
|
|
224
|
+
const selects = getTablesWithOperation(queryInfo.tableOperations, 'SELECT');
|
|
225
|
+
const inserts = getTablesWithOperation(queryInfo.tableOperations, 'INSERT');
|
|
226
|
+
const updates = getTablesWithOperation(queryInfo.tableOperations, 'UPDATE');
|
|
227
|
+
const deletes = getTablesWithOperation(queryInfo.tableOperations, 'DELETE');
|
|
228
|
+
const message = {
|
|
229
|
+
event: "new-interaction",
|
|
230
|
+
type: "db-query",
|
|
231
|
+
hash: queryInteraction.hash,
|
|
232
|
+
from: currentService.hash,
|
|
233
|
+
to: observedDatabase.hash,
|
|
234
|
+
query: queryInfo.presentableQuery,
|
|
235
|
+
...(selects ? { selects } : {}),
|
|
236
|
+
...(inserts ? { inserts } : {}),
|
|
237
|
+
...(updates ? { updates } : {}),
|
|
238
|
+
...(deletes ? { deletes } : {}),
|
|
239
|
+
};
|
|
240
|
+
this.ingestMessage(message);
|
|
241
|
+
}
|
|
242
|
+
// Build and ingest observation.
|
|
243
|
+
const duration = span.duration;
|
|
244
|
+
const returnedRows = attrs['db.response.returned_rows']
|
|
245
|
+
?? attrs['db.sql.rows'];
|
|
246
|
+
const { message: errorMessage, type: errorType, stack } = extractErrorInfo(span);
|
|
247
|
+
const observation = {
|
|
248
|
+
type: "db-query",
|
|
249
|
+
subject: queryInteraction.hash,
|
|
250
|
+
timestamp: span.startTime,
|
|
251
|
+
duration,
|
|
252
|
+
...(errorMessage ? { errorMessage } : {}),
|
|
253
|
+
...(errorType ? { errorType } : {}),
|
|
254
|
+
...(stack ? { stack } : {}),
|
|
255
|
+
...(returnedRows !== undefined ? { returnedRows } : {})
|
|
256
|
+
};
|
|
257
|
+
this.ingestMessage({
|
|
258
|
+
event: "observations",
|
|
259
|
+
seq: this.observationsSeq++,
|
|
260
|
+
observations: [observation]
|
|
261
|
+
});
|
|
262
|
+
}
|
|
263
|
+
}
|
|
264
|
+
processHttpRequest(currentService, url, span) {
|
|
265
|
+
// Build identity based upon protocol, host, and port.
|
|
266
|
+
const parsed = new URL(url);
|
|
267
|
+
const protocol = parsed.protocol.replace(':', ''); // Remove trailing colon
|
|
268
|
+
const host = parsed.hostname;
|
|
269
|
+
const port = parsed.port ? parseInt(parsed.port) : (protocol === 'https' ? 443 : 80);
|
|
270
|
+
const idString = `http-service:${protocol}:${host}:${port}`;
|
|
271
|
+
const hash = hashIdString(idString);
|
|
272
|
+
// Ingest it if it's not already observed.
|
|
273
|
+
let observedHttpService = this.observedHttpServices.find(s => s.protocol === protocol && s.host === host && s.port === port);
|
|
274
|
+
if (!observedHttpService) {
|
|
275
|
+
observedHttpService = { protocol, host, port, hash };
|
|
276
|
+
this.observedHttpServices.push(observedHttpService);
|
|
277
|
+
// The existence of the service.
|
|
278
|
+
const message = {
|
|
279
|
+
event: "new-entity",
|
|
280
|
+
type: "http-service",
|
|
281
|
+
hash,
|
|
282
|
+
protocol,
|
|
283
|
+
host,
|
|
284
|
+
port
|
|
285
|
+
};
|
|
286
|
+
this.ingestMessage(message);
|
|
287
|
+
}
|
|
288
|
+
// Ingest the interaction if first observed.
|
|
289
|
+
const interactions = this.getInteractions(currentService.hash, observedHttpService.hash);
|
|
290
|
+
if (!interactions.httpRequest) {
|
|
291
|
+
const idString = `http-request:${currentService.hash}:${observedHttpService.hash}`;
|
|
292
|
+
const hash = hashIdString(idString);
|
|
293
|
+
interactions.httpRequest = { hash };
|
|
294
|
+
const message = {
|
|
295
|
+
event: "new-interaction",
|
|
296
|
+
type: "http-request",
|
|
297
|
+
hash,
|
|
298
|
+
from: currentService.hash,
|
|
299
|
+
to: observedHttpService.hash
|
|
300
|
+
};
|
|
301
|
+
this.ingestMessage(message);
|
|
302
|
+
}
|
|
303
|
+
// Build and ingest observation.
|
|
304
|
+
const attrs = span.attributes;
|
|
305
|
+
const path = parsed.pathname || '/';
|
|
306
|
+
const method = span.attributes['http.method'];
|
|
307
|
+
if (!method) // Really should always be there
|
|
308
|
+
return;
|
|
309
|
+
const status = attrs['http.status_code'];
|
|
310
|
+
const duration = span.duration;
|
|
311
|
+
const httpVersion = span.attributes['http.flavor'];
|
|
312
|
+
const requestBytes = attrs['http.request_content_length'];
|
|
313
|
+
const responseBytes = attrs['http.response_content_length'];
|
|
314
|
+
const { message: errorMessage, type: errorType, stack } = extractErrorInfo(span);
|
|
315
|
+
const observation = {
|
|
316
|
+
type: "http-client",
|
|
317
|
+
subject: interactions.httpRequest.hash,
|
|
318
|
+
timestamp: span.startTime,
|
|
319
|
+
path,
|
|
320
|
+
method,
|
|
321
|
+
...(status !== undefined ? { status } : {}),
|
|
322
|
+
duration,
|
|
323
|
+
...(httpVersion !== undefined ? { httpVersion } : {}),
|
|
324
|
+
...(requestBytes !== undefined ? { requestBytes } : {}),
|
|
325
|
+
...(responseBytes !== undefined ? { responseBytes } : {}),
|
|
326
|
+
...(errorMessage ? { errorMessage } : {}),
|
|
327
|
+
...(errorType ? { errorType } : {}),
|
|
328
|
+
...(stack ? { stack } : {})
|
|
329
|
+
};
|
|
330
|
+
this.ingestMessage({
|
|
331
|
+
event: "observations",
|
|
332
|
+
seq: this.observationsSeq++,
|
|
333
|
+
observations: [observation]
|
|
334
|
+
});
|
|
335
|
+
}
|
|
336
|
+
getInteractions(from, to) {
|
|
337
|
+
let fromMap = this.observedInteractions.get(from);
|
|
338
|
+
if (!fromMap) {
|
|
339
|
+
fromMap = new Map();
|
|
340
|
+
this.observedInteractions.set(from, fromMap);
|
|
341
|
+
}
|
|
342
|
+
let interactions = fromMap.get(to);
|
|
343
|
+
if (!interactions) {
|
|
344
|
+
interactions = { httpRequest: undefined, dbConnections: [], dbQueries: [] };
|
|
345
|
+
fromMap.set(to, interactions);
|
|
346
|
+
}
|
|
347
|
+
return interactions;
|
|
348
|
+
}
|
|
349
|
+
ingestMessage(message) {
|
|
350
|
+
this.connection.sendMessage(message);
|
|
351
|
+
}
|
|
352
|
+
async forceFlush() {
|
|
353
|
+
}
|
|
354
|
+
async shutdown() {
|
|
355
|
+
this.connection.close();
|
|
356
|
+
}
|
|
357
|
+
}
|
|
358
|
+
exports.ComprehendDevSpanProcessor = ComprehendDevSpanProcessor;
|
|
359
|
+
function hashIdString(idString) {
|
|
360
|
+
return Array.from((0, sha2_1.sha256)((0, utils_1.utf8ToBytes)(idString)))
|
|
361
|
+
.map(b => b.toString(16).padStart(2, '0'))
|
|
362
|
+
.join('');
|
|
363
|
+
}
|
|
364
|
+
/** Try to extract data from the database connection string. Many are URL based, fall back on
|
|
365
|
+
* some kind of comma-seperated style for the rest. */
|
|
366
|
+
function parseDatabaseConnectionStringRaw(conn) {
|
|
367
|
+
try {
|
|
368
|
+
// Try URL-style parsing first; scrub the user details if present.
|
|
369
|
+
const url = new URL(conn);
|
|
370
|
+
const user = url.username || undefined;
|
|
371
|
+
const host = url.hostname || undefined;
|
|
372
|
+
const port = url.port || undefined;
|
|
373
|
+
const dbName = url.pathname?.replace(/^\//, '') || undefined;
|
|
374
|
+
url.username = '';
|
|
375
|
+
url.password = '';
|
|
376
|
+
return {
|
|
377
|
+
scrubbed: url.toString(),
|
|
378
|
+
user,
|
|
379
|
+
host,
|
|
380
|
+
port,
|
|
381
|
+
name: dbName
|
|
382
|
+
};
|
|
383
|
+
}
|
|
384
|
+
catch {
|
|
385
|
+
// Not a URL-style string; try semi-structured parsing
|
|
386
|
+
const parts = conn.split(';');
|
|
387
|
+
const kv = {};
|
|
388
|
+
for (const part of parts) {
|
|
389
|
+
const [k, v] = part.split('=');
|
|
390
|
+
if (k && v)
|
|
391
|
+
kv[k.trim().toLowerCase()] = v.trim();
|
|
392
|
+
}
|
|
393
|
+
const user = kv['user id'] || kv['uid'];
|
|
394
|
+
const host = kv['server'] || kv['data source'] || kv['address'];
|
|
395
|
+
const port = kv['port'];
|
|
396
|
+
const name = kv['database'] || kv['initial catalog'];
|
|
397
|
+
// Reconstruct a scrubbed connection string without credentials
|
|
398
|
+
const scrubbed = Object.entries(kv)
|
|
399
|
+
.filter(([key]) => !['user id', 'uid', 'password', 'pwd'].includes(key))
|
|
400
|
+
.map(([k, v]) => `${k}=${v}`)
|
|
401
|
+
.join(';');
|
|
402
|
+
return {
|
|
403
|
+
scrubbed,
|
|
404
|
+
user,
|
|
405
|
+
host,
|
|
406
|
+
port,
|
|
407
|
+
name
|
|
408
|
+
};
|
|
409
|
+
}
|
|
410
|
+
}
|
|
411
|
+
/** Try pretty hard to get error info out of a span, if it has any. Handles it being there as an event,
|
|
412
|
+
* directly on the span with error semantics, and some other more ad-hoc cases. */
|
|
413
|
+
function extractErrorInfo(span) {
|
|
414
|
+
const attrs = span.attributes;
|
|
415
|
+
// Try to extract from a structured 'exception' event, as it should have more detail
|
|
416
|
+
const exceptionEvent = span.events.find(e => e.name === 'exception');
|
|
417
|
+
if (exceptionEvent?.attributes) {
|
|
418
|
+
const message = exceptionEvent.attributes['exception.message'];
|
|
419
|
+
const type = exceptionEvent.attributes['exception.type'];
|
|
420
|
+
const stack = exceptionEvent.attributes['exception.stacktrace'];
|
|
421
|
+
if (message || type || stack) {
|
|
422
|
+
return { message, type, stack };
|
|
423
|
+
}
|
|
424
|
+
}
|
|
425
|
+
// Fallback to attributes directly on the span.
|
|
426
|
+
const isError = span.status.code === 2;
|
|
427
|
+
const message = attrs['exception.message'] ??
|
|
428
|
+
attrs['http.error_message'] ??
|
|
429
|
+
attrs['db.response.status_code'] ??
|
|
430
|
+
(isError ? attrs['otel.status_description'] : undefined);
|
|
431
|
+
const type = attrs['exception.type'] ??
|
|
432
|
+
attrs['error.type'] ??
|
|
433
|
+
attrs['http.error_name'];
|
|
434
|
+
const stack = attrs['exception.stacktrace'];
|
|
435
|
+
return {
|
|
436
|
+
message,
|
|
437
|
+
type,
|
|
438
|
+
stack
|
|
439
|
+
};
|
|
440
|
+
}
|
|
441
|
+
function getTablesWithOperation(tableOps, operation) {
|
|
442
|
+
const op = operation.toUpperCase();
|
|
443
|
+
const result = Object.entries(tableOps)
|
|
444
|
+
.filter(([_, ops]) => ops.includes(op))
|
|
445
|
+
.map(([table]) => table);
|
|
446
|
+
return result.length > 0 ? result : undefined;
|
|
447
|
+
}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import { ObservationInputMessage } from './wire-protocol';
|
|
2
|
+
export declare class WebSocketConnection {
|
|
3
|
+
private readonly organization;
|
|
4
|
+
private readonly token;
|
|
5
|
+
private readonly logger?;
|
|
6
|
+
private readonly unacknowledgedObserved;
|
|
7
|
+
private readonly unacknowledgedObservations;
|
|
8
|
+
private socket;
|
|
9
|
+
private reconnectDelay;
|
|
10
|
+
private shouldReconnect;
|
|
11
|
+
private authorized;
|
|
12
|
+
constructor(organization: string, token: string, logger?: (message: string) => void);
|
|
13
|
+
private log;
|
|
14
|
+
private connect;
|
|
15
|
+
private onOpen;
|
|
16
|
+
private onMessage;
|
|
17
|
+
private onClose;
|
|
18
|
+
private onError;
|
|
19
|
+
private sendRaw;
|
|
20
|
+
sendMessage(message: ObservationInputMessage): void;
|
|
21
|
+
close(): void;
|
|
22
|
+
}
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
3
|
+
return (mod && mod.__esModule) ? mod : { "default": mod };
|
|
4
|
+
};
|
|
5
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
6
|
+
exports.WebSocketConnection = void 0;
|
|
7
|
+
const ws_1 = __importDefault(require("ws"));
|
|
8
|
+
const INGESTION_ENDPOINT = 'wss://ingestion.comprehend.dev';
|
|
9
|
+
class WebSocketConnection {
|
|
10
|
+
constructor(organization, token, logger) {
|
|
11
|
+
this.unacknowledgedObserved = new Map();
|
|
12
|
+
this.unacknowledgedObservations = new Map();
|
|
13
|
+
this.socket = null;
|
|
14
|
+
this.reconnectDelay = 1000;
|
|
15
|
+
this.shouldReconnect = true;
|
|
16
|
+
this.authorized = false;
|
|
17
|
+
this.organization = organization;
|
|
18
|
+
this.token = token;
|
|
19
|
+
this.logger = logger;
|
|
20
|
+
this.connect();
|
|
21
|
+
}
|
|
22
|
+
log(message) {
|
|
23
|
+
if (this.logger) {
|
|
24
|
+
this.logger(message);
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
connect() {
|
|
28
|
+
const webSocketUrl = `${INGESTION_ENDPOINT}/${this.organization}/observations`;
|
|
29
|
+
this.log(`Attempting to connect to ${webSocketUrl}...`);
|
|
30
|
+
this.socket = new ws_1.default(webSocketUrl);
|
|
31
|
+
this.socket.on('open', () => this.onOpen());
|
|
32
|
+
this.socket.on('message', (data) => this.onMessage(data));
|
|
33
|
+
this.socket.on('close', (code, reason) => this.onClose(code, reason.toString()));
|
|
34
|
+
this.socket.on('error', (err) => this.onError(err));
|
|
35
|
+
}
|
|
36
|
+
onOpen() {
|
|
37
|
+
this.log('WebSocket connected. Sending init/auth message.');
|
|
38
|
+
const init = {
|
|
39
|
+
event: 'init',
|
|
40
|
+
protocolVersion: 1,
|
|
41
|
+
token: this.token,
|
|
42
|
+
};
|
|
43
|
+
this.sendRaw(init);
|
|
44
|
+
}
|
|
45
|
+
onMessage(data) {
|
|
46
|
+
try {
|
|
47
|
+
const msg = JSON.parse(data.toString());
|
|
48
|
+
if (msg.type === 'ack-authorized') {
|
|
49
|
+
this.authorized = true;
|
|
50
|
+
this.log('Authorization acknowledged by server.');
|
|
51
|
+
for (const message of this.unacknowledgedObserved.values()) {
|
|
52
|
+
this.sendRaw(message);
|
|
53
|
+
}
|
|
54
|
+
for (const message of this.unacknowledgedObservations.values()) {
|
|
55
|
+
this.sendRaw(message);
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
else if (msg.type === 'ack-observed') {
|
|
59
|
+
this.unacknowledgedObserved.delete(msg.hash);
|
|
60
|
+
}
|
|
61
|
+
else if (msg.type === 'ack-observations') {
|
|
62
|
+
this.unacknowledgedObservations.delete(msg.seq);
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
catch (e) {
|
|
66
|
+
this.log('Error parsing message from server: ' + (e instanceof Error ? e.message : String(e)));
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
onClose(code, reason) {
|
|
70
|
+
this.log(`WebSocket disconnected. Code: ${code}, Reason: ${reason}`);
|
|
71
|
+
this.authorized = false;
|
|
72
|
+
if (this.shouldReconnect) {
|
|
73
|
+
setTimeout(() => this.connect(), this.reconnectDelay);
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
onError(error) {
|
|
77
|
+
this.log(`WebSocket encountered an error: ${error.message}`);
|
|
78
|
+
}
|
|
79
|
+
sendRaw(message) {
|
|
80
|
+
if (this.socket && this.socket.readyState === ws_1.default.OPEN) {
|
|
81
|
+
this.socket.send(JSON.stringify(message));
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
sendMessage(message) {
|
|
85
|
+
if (message.event === 'new-entity' || message.event === 'new-interaction') {
|
|
86
|
+
this.unacknowledgedObserved.set(message.hash, message);
|
|
87
|
+
}
|
|
88
|
+
else if (message.event === 'observations') {
|
|
89
|
+
this.unacknowledgedObservations.set(message.seq, message);
|
|
90
|
+
}
|
|
91
|
+
if (this.authorized) {
|
|
92
|
+
this.sendRaw(message);
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
close() {
|
|
96
|
+
this.shouldReconnect = false;
|
|
97
|
+
if (this.socket) {
|
|
98
|
+
this.socket.close();
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
exports.WebSocketConnection = WebSocketConnection;
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export { ComprehendDevSpanProcessor } from "./ComprehendDevSpanProcessor";
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.ComprehendDevSpanProcessor = void 0;
|
|
4
|
+
var ComprehendDevSpanProcessor_1 = require("./ComprehendDevSpanProcessor");
|
|
5
|
+
Object.defineProperty(exports, "ComprehendDevSpanProcessor", { enumerable: true, get: function () { return ComprehendDevSpanProcessor_1.ComprehendDevSpanProcessor; } });
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
type Token = {
|
|
2
|
+
type: 'keyword' | 'identifier' | 'id-quote' | 'string' | 'comment' | 'punct' | 'operator' | 'whitespace' | 'unknown';
|
|
3
|
+
value: string;
|
|
4
|
+
};
|
|
5
|
+
export interface SQLAnalysisResult {
|
|
6
|
+
tableOperations: Record<string, string[]>;
|
|
7
|
+
normalizedQuery: string;
|
|
8
|
+
presentableQuery: string;
|
|
9
|
+
}
|
|
10
|
+
/** Performs a rough tokenization of the SQL, extracts the tables involved and the operations on them, and
|
|
11
|
+
* produces two versions of the query:
|
|
12
|
+
* - A normalized version for hashing purposes that does not account for whitespace, comments, and collapses
|
|
13
|
+
* IN clauses that might cause a cardinality explosion.
|
|
14
|
+
* - A presentable version that only does the IN clause collapsing */
|
|
15
|
+
export declare function analyzeSQL(sql: string): SQLAnalysisResult;
|
|
16
|
+
export declare function analyzeSQLTokens(tokens: Token[]): {
|
|
17
|
+
tableOperations: {
|
|
18
|
+
[k: string]: string[];
|
|
19
|
+
};
|
|
20
|
+
normalizedQuery: string;
|
|
21
|
+
};
|
|
22
|
+
export {};
|