@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.
@@ -0,0 +1,563 @@
1
+ import {sha256} from "@noble/hashes/sha2";
2
+ import {utf8ToBytes} from "@noble/hashes/utils";
3
+ import {Context} from "@opentelemetry/api";
4
+ import {ReadableSpan, Span, SpanProcessor} from "@opentelemetry/sdk-trace-base";
5
+ import {
6
+ DatabaseQueryObservation,
7
+ HttpClientObservation,
8
+ HttpServerObservation, NewObservedDatabaseConnectionMessage,
9
+ NewObservedDatabaseMessage, NewObservedDatabaseQueryMessage,
10
+ NewObservedHttpRequestMessage,
11
+ NewObservedHttpRouteMessage,
12
+ NewObservedHttpServiceMessage,
13
+ NewObservedServiceMessage,
14
+ ObservationInputMessage
15
+ } from "./wire-protocol";
16
+ import {analyzeSQL} from "./sql-analyzer";
17
+ import {WebSocketConnection} from "./WebSocketConnection";
18
+
19
+ interface ObservedService {
20
+ name: string;
21
+ namespace?: string;
22
+ environment?: string;
23
+ hash: string;
24
+ httpRoutes: ObservedHTTPRoute[];
25
+ }
26
+
27
+ interface ObservedHTTPRoute {
28
+ method: string;
29
+ route: string;
30
+ hash: string;
31
+ }
32
+
33
+ interface ObservedDatabase {
34
+ system: string;
35
+ connection?: string; // Connection string, hopefully scrubbed of user info
36
+ host?: string;
37
+ port?: number;
38
+ name?: string;
39
+ hash: string;
40
+ }
41
+
42
+ interface ObservedHttpService {
43
+ protocol: string;
44
+ host: string;
45
+ port?: number;
46
+ hash: string;
47
+ }
48
+
49
+ interface ObservedHttpRequest {
50
+ hash: string;
51
+ }
52
+
53
+ interface ObservedDatabaseConnection {
54
+ hash: string;
55
+ connection?: string;
56
+ user?: string;
57
+ }
58
+
59
+ interface ObservedDatabaseQuery {
60
+ hash: string;
61
+ query: string;
62
+ }
63
+
64
+ const sqlDbSystems = new Set([
65
+ 'mysql', 'postgresql', 'mssql', 'oracle', 'db2', 'sqlite', 'hsqldb', 'h2',
66
+ 'informix', 'cockroachdb', 'redshift', 'tidb', 'trino', 'greenplum'
67
+ ]);
68
+
69
+ export class ComprehendDevSpanProcessor implements SpanProcessor {
70
+ private readonly connection: WebSocketConnection;
71
+ private observedServices: ObservedService[] = [];
72
+ private observedDatabases: ObservedDatabase[] = [];
73
+ private observedHttpServices: ObservedHttpService[] = [];
74
+ private observedInteractions: Map<string, Map<string, {
75
+ dbConnections: ObservedDatabaseConnection[],
76
+ dbQueries: ObservedDatabaseQuery[],
77
+ httpRequest?: ObservedHttpRequest
78
+ }>> = new Map();
79
+ private observationsSeq = 1;
80
+
81
+ constructor(options: { organization: string, token: string, debug?: boolean | ((message: string) => void) }) {
82
+ this.connection = new WebSocketConnection(options.organization, options.token,
83
+ options.debug === true ? console.log : options.debug === false ? undefined : options.debug);
84
+ }
85
+
86
+ onStart(span: Span, parentContext: Context): void {
87
+ }
88
+
89
+ onEnd(span: ReadableSpan): void {
90
+ const currentService = this.discoverService(span);
91
+ if (!currentService)
92
+ return;
93
+
94
+ const attrs = span.attributes;
95
+ if (span.kind === 1) {
96
+ // Server span, see if it's something to ingest.
97
+ if (attrs['http.route'] && attrs['http.method']) {
98
+ this.processHTTPRoute(currentService, attrs['http.route'] as string, attrs['http.method'] as string, span);
99
+ }
100
+ }
101
+ else if (span.kind === 2) {
102
+ // Client span, see if it's something to ingest.
103
+ if (attrs['http.url']) {
104
+ this.processHttpRequest(currentService, attrs['http.url'] as string, span);
105
+ }
106
+ }
107
+ if (attrs['db.system']) {
108
+ this.processDatabaseOperation(currentService, span);
109
+ }
110
+ }
111
+
112
+ private discoverService(span: ReadableSpan): ObservedService | undefined {
113
+ // Look for an existing matching entry.
114
+ const resAttrs = span.resource.attributes;
115
+ const name = resAttrs['service.name'] as string | undefined;
116
+ if (!name)
117
+ return;
118
+ const namespace = resAttrs['service.namespace'] as string | undefined;
119
+ const environment = resAttrs['deployment.environment'] as string | undefined;
120
+ const existing = this.observedServices.find(s =>
121
+ s.name === name &&
122
+ s.namespace === namespace &&
123
+ s.environment === environment
124
+ );
125
+ if (existing)
126
+ return existing;
127
+
128
+ // New; hash it and add it to the observed services.
129
+ const idString = `service:${name}:${namespace ?? ''}:${environment ?? ''}`;
130
+ const hash = hashIdString(idString);
131
+ const newService: ObservedService = {
132
+ name, namespace, environment, hash,
133
+ httpRoutes: []
134
+ };
135
+ this.observedServices.push(newService);
136
+
137
+ // Ingest its existence.
138
+ const message: NewObservedServiceMessage = {
139
+ event: "new-entity",
140
+ type: "service",
141
+ hash,
142
+ name,
143
+ ...(namespace ? { namespace } : {}),
144
+ ...(environment ? { environment } : {})
145
+ };
146
+ this.ingestMessage(message);
147
+ }
148
+
149
+ private processHTTPRoute(service: ObservedService, route: string, method: string, span: ReadableSpan): void {
150
+ // Check if this route+method already exists under the service
151
+ let observedRoute = service.httpRoutes.find(r =>
152
+ r.route === route && r.method === method
153
+ );
154
+ if (!observedRoute) {
155
+ // It's new; hash it and add it to our collection.
156
+ const idString = `http-route:${service.hash}:${method}:${route}`;
157
+ const hash = hashIdString(idString);
158
+ observedRoute = { method, route, hash };
159
+ service.httpRoutes.push(observedRoute);
160
+
161
+ // Emit observation message
162
+ const message: NewObservedHttpRouteMessage = {
163
+ event: "new-entity",
164
+ type: "http-route",
165
+ hash,
166
+ parent: service.hash,
167
+ method, route
168
+ };
169
+ this.ingestMessage(message);
170
+ }
171
+
172
+ // Extract the request path, making sure we only get that, without any query string.
173
+ const attrs = span.attributes;
174
+ let path: string;
175
+ if (attrs['http.target']) {
176
+ try {
177
+ // This might be just a path like "/search?q=foo"
178
+ const rawTarget = attrs['http.target'] as string;
179
+ const fakeUrl = new URL(rawTarget, 'http://placeholder'); // placeholder base
180
+ path = fakeUrl.pathname;
181
+ } catch {
182
+ path = '/';
183
+ }
184
+ }
185
+ else if (attrs['http.url']) {
186
+ try {
187
+ const rawUrl = attrs['http.url'] as string;
188
+ const parsed = new URL(rawUrl);
189
+ path = parsed.pathname;
190
+ } catch {
191
+ path = '/';
192
+ }
193
+ }
194
+ else {
195
+ path = '/';
196
+ }
197
+
198
+ // Build and ingest observation.
199
+ const status = attrs['http.status_code'] as number | undefined ?? 0;
200
+ const duration = span.duration;
201
+ const httpVersion = attrs['http.flavor'] as string | undefined;
202
+ const userAgent = attrs['http.user_agent'] as string | undefined;
203
+ const requestBytes = attrs['http.request_content_length'] as number | undefined;
204
+ const responseBytes = attrs['http.response_content_length'] as number | undefined;
205
+ const { message: errorMessage, type: errorType, stack: stack } = extractErrorInfo(span);
206
+ const observation: HttpServerObservation = {
207
+ type: 'http-server',
208
+ subject: observedRoute.hash,
209
+ timestamp: span.startTime,
210
+ path,
211
+ status,
212
+ duration,
213
+ ...(httpVersion ? { httpVersion } : {}),
214
+ ...(userAgent ? { userAgent } : {}),
215
+ ...(requestBytes !== undefined ? { requestBytes } : {}),
216
+ ...(responseBytes !== undefined ? { responseBytes } : {}),
217
+ ...(errorMessage ? { errorMessage } : {}),
218
+ ...(errorType ? { errorType } : {}),
219
+ ...(stack ? { stack } : {})
220
+ };
221
+ this.ingestMessage({
222
+ event: "observations",
223
+ seq: this.observationsSeq++,
224
+ observations: [observation]
225
+ });
226
+ }
227
+
228
+ private processDatabaseOperation(currentService: ObservedService, span: ReadableSpan): void {
229
+ // Parse the connection string.
230
+ const attrs = span.attributes;
231
+ const system = attrs['db.system'] as string;
232
+ const rawConnection = attrs['db.connection_string'] as string | undefined;
233
+ const parsed = rawConnection
234
+ ? parseDatabaseConnectionStringRaw(rawConnection)
235
+ : {
236
+ scrubbed: '',
237
+ user: undefined,
238
+ host: (attrs['net.peer.name'] ?? attrs['net.peer.ip']) as string | undefined,
239
+ port: (attrs['net.peer.port'] as number | undefined)?.toString(),
240
+ name: attrs['db.name'] as string | undefined,
241
+ };
242
+
243
+ // See if we already have an entry for this database.
244
+ const hash = hashIdString(`database:${system}:${parsed.host ?? ''}:${parsed.port ?? ''}:${parsed.name ?? ''}`);
245
+ let observedDatabase = this.observedDatabases.find(db => db.hash === hash);
246
+
247
+ // If we see it for the first time, add and ingest it.
248
+ if (!observedDatabase) {
249
+ observedDatabase = {
250
+ system,
251
+ host: parsed.host,
252
+ port: parsed.port ? parseInt(parsed.port) : undefined,
253
+ name: parsed.name,
254
+ hash
255
+ };
256
+ this.observedDatabases.push(observedDatabase);
257
+
258
+ // The existence of the database.
259
+ const message: NewObservedDatabaseMessage = {
260
+ event: "new-entity",
261
+ type: "database",
262
+ hash,
263
+ system,
264
+ ...(parsed.name ? { name: parsed.name } : {}),
265
+ ...(parsed.scrubbed ? { connection: parsed.scrubbed } : {}),
266
+ ...(parsed.host ? { host: parsed.host } : {}),
267
+ ...(parsed.port ? { port: parseInt(parsed.port) } : {}),
268
+ ...(parsed.user ? { user: parsed.user } : {})
269
+ }
270
+ this.ingestMessage(message);
271
+ }
272
+
273
+ // The connection to the database should have an interaction.
274
+ const interactions = this.getInteractions(currentService.hash, observedDatabase.hash);
275
+ let connectionInteraction = interactions.dbConnections.find(c => c.connection === parsed.scrubbed && c.user === parsed.user);
276
+ if (!connectionInteraction) {
277
+ connectionInteraction = {
278
+ hash: hashIdString(`db-connection:${currentService.hash}:${observedDatabase.hash}:${parsed.scrubbed ?? ''}:${parsed.user ?? ''}`),
279
+ connection: parsed.scrubbed,
280
+ user: parsed.user
281
+ };
282
+ interactions.dbConnections.push(connectionInteraction);
283
+ const message: NewObservedDatabaseConnectionMessage = {
284
+ event: "new-interaction",
285
+ type: "db-connection",
286
+ hash: connectionInteraction.hash,
287
+ from: currentService.hash,
288
+ to: observedDatabase.hash,
289
+ ...(connectionInteraction.connection ? { connection: connectionInteraction.connection } : {}),
290
+ ...(connectionInteraction.user ? { user: connectionInteraction.user } : {})
291
+ };
292
+ this.ingestMessage(message);
293
+ }
294
+
295
+ // The query of the database from the service (only for SQL for now).
296
+ if (sqlDbSystems.has(system) && attrs['db.statement']) {
297
+ // The interaction, based upon the normalized query.
298
+ let queryInfo = analyzeSQL(attrs['db.statement'] as string);
299
+ let queryInteraction = interactions.dbQueries.find(q => q.query === queryInfo.normalizedQuery);
300
+ if (!queryInteraction) {
301
+ queryInteraction = {
302
+ hash: hashIdString(`db-query:${currentService.hash}:${observedDatabase.hash}:${queryInfo.normalizedQuery}`),
303
+ query: queryInfo.normalizedQuery
304
+ };
305
+ interactions.dbQueries.push(queryInteraction);
306
+ const selects = getTablesWithOperation(queryInfo.tableOperations, 'SELECT');
307
+ const inserts = getTablesWithOperation(queryInfo.tableOperations, 'INSERT');
308
+ const updates = getTablesWithOperation(queryInfo.tableOperations, 'UPDATE');
309
+ const deletes = getTablesWithOperation(queryInfo.tableOperations, 'DELETE');
310
+ const message: NewObservedDatabaseQueryMessage = {
311
+ event: "new-interaction",
312
+ type: "db-query",
313
+ hash: queryInteraction.hash,
314
+ from: currentService.hash,
315
+ to: observedDatabase.hash,
316
+ query: queryInfo.presentableQuery,
317
+ ...(selects ? { selects } : {}),
318
+ ...(inserts ? { inserts } : {}),
319
+ ...(updates ? { updates } : {}),
320
+ ...(deletes ? { deletes } : {}),
321
+ };
322
+ this.ingestMessage(message);
323
+ }
324
+
325
+ // Build and ingest observation.
326
+ const duration = span.duration;
327
+ const returnedRows = (attrs['db.response.returned_rows'] as number | undefined)
328
+ ?? (attrs['db.sql.rows'] as number | undefined);
329
+ const { message: errorMessage, type: errorType, stack } = extractErrorInfo(span);
330
+ const observation: DatabaseQueryObservation = {
331
+ type: "db-query",
332
+ subject: queryInteraction.hash,
333
+ timestamp: span.startTime,
334
+ duration,
335
+ ...(errorMessage ? { errorMessage } : {}),
336
+ ...(errorType ? { errorType } : {}),
337
+ ...(stack ? { stack } : {}),
338
+ ...(returnedRows !== undefined ? { returnedRows } : {})
339
+ };
340
+ this.ingestMessage({
341
+ event: "observations",
342
+ seq: this.observationsSeq++,
343
+ observations: [observation]
344
+ });
345
+ }
346
+ }
347
+
348
+ private processHttpRequest(currentService: ObservedService, url: string, span: ReadableSpan): void {
349
+ // Build identity based upon protocol, host, and port.
350
+ const parsed = new URL(url);
351
+ const protocol = parsed.protocol.replace(':', ''); // Remove trailing colon
352
+ const host = parsed.hostname;
353
+ const port = parsed.port ? parseInt(parsed.port) : (protocol === 'https' ? 443 : 80);
354
+ const idString = `http-service:${protocol}:${host}:${port}`;
355
+ const hash = hashIdString(idString);
356
+
357
+ // Ingest it if it's not already observed.
358
+ let observedHttpService = this.observedHttpServices.find(s =>
359
+ s.protocol === protocol && s.host === host && s.port === port
360
+ );
361
+ if (!observedHttpService) {
362
+ observedHttpService = { protocol, host, port, hash };
363
+ this.observedHttpServices.push(observedHttpService);
364
+
365
+ // The existence of the service.
366
+ const message: NewObservedHttpServiceMessage = {
367
+ event: "new-entity",
368
+ type: "http-service",
369
+ hash,
370
+ protocol,
371
+ host,
372
+ port
373
+ };
374
+ this.ingestMessage(message);
375
+ }
376
+
377
+ // Ingest the interaction if first observed.
378
+ const interactions = this.getInteractions(currentService.hash, observedHttpService.hash);
379
+ if (!interactions.httpRequest) {
380
+ const idString = `http-request:${currentService.hash}:${observedHttpService.hash}`;
381
+ const hash = hashIdString(idString);
382
+ interactions.httpRequest = { hash };
383
+ const message: NewObservedHttpRequestMessage = {
384
+ event: "new-interaction",
385
+ type: "http-request",
386
+ hash,
387
+ from: currentService.hash,
388
+ to: observedHttpService.hash
389
+ };
390
+ this.ingestMessage(message);
391
+ }
392
+
393
+ // Build and ingest observation.
394
+ const attrs = span.attributes;
395
+ const path = parsed.pathname || '/';
396
+ const method = span.attributes['http.method'] as string;
397
+ if (!method) // Really should always be there
398
+ return;
399
+ const status = attrs['http.status_code'] as number | undefined;
400
+ const duration = span.duration;
401
+ const httpVersion = span.attributes['http.flavor'] as string | undefined;
402
+ const requestBytes = attrs['http.request_content_length'] as number | undefined;
403
+ const responseBytes = attrs['http.response_content_length'] as number | undefined;
404
+ const { message: errorMessage, type: errorType, stack } = extractErrorInfo(span);
405
+ const observation: HttpClientObservation = {
406
+ type: "http-client",
407
+ subject: interactions.httpRequest.hash,
408
+ timestamp: span.startTime,
409
+ path,
410
+ method,
411
+ ...(status !== undefined ? { status } : {}),
412
+ duration,
413
+ ...(httpVersion !== undefined ? { httpVersion } : {}),
414
+ ...(requestBytes !== undefined ? { requestBytes } : {}),
415
+ ...(responseBytes !== undefined ? { responseBytes } : {}),
416
+ ...(errorMessage ? { errorMessage } : {}),
417
+ ...(errorType ? { errorType } : {}),
418
+ ...(stack ? { stack } : {})
419
+ };
420
+ this.ingestMessage({
421
+ event: "observations",
422
+ seq: this.observationsSeq++,
423
+ observations: [observation]
424
+ });
425
+ }
426
+
427
+ private getInteractions(from: string, to: string) {
428
+ let fromMap = this.observedInteractions.get(from);
429
+ if (!fromMap) {
430
+ fromMap = new Map();
431
+ this.observedInteractions.set(from, fromMap);
432
+ }
433
+ let interactions = fromMap.get(to);
434
+ if (!interactions) {
435
+ interactions = { httpRequest: undefined, dbConnections: [], dbQueries: [] };
436
+ fromMap.set(to, interactions);
437
+ }
438
+ return interactions;
439
+ }
440
+
441
+ private ingestMessage(message: ObservationInputMessage) {
442
+ this.connection.sendMessage(message);
443
+ }
444
+
445
+ async forceFlush(): Promise<void> {
446
+ }
447
+
448
+ async shutdown(): Promise<void> {
449
+ this.connection.close()
450
+ }
451
+ }
452
+
453
+ function hashIdString(idString: string) {
454
+ return Array.from(sha256(utf8ToBytes(idString)))
455
+ .map(b => b.toString(16).padStart(2, '0'))
456
+ .join('');
457
+ }
458
+
459
+ /** Try to extract data from the database connection string. Many are URL based, fall back on
460
+ * some kind of comma-seperated style for the rest. */
461
+ function parseDatabaseConnectionStringRaw(conn: string): {
462
+ scrubbed: string;
463
+ user?: string;
464
+ host?: string;
465
+ port?: string;
466
+ name?: string;
467
+ } {
468
+ try {
469
+ // Try URL-style parsing first; scrub the user details if present.
470
+ const url = new URL(conn);
471
+ const user = url.username || undefined;
472
+ const host = url.hostname || undefined;
473
+ const port = url.port || undefined;
474
+ const dbName = url.pathname?.replace(/^\//, '') || undefined;
475
+ url.username = '';
476
+ url.password = '';
477
+ return {
478
+ scrubbed: url.toString(),
479
+ user,
480
+ host,
481
+ port,
482
+ name: dbName
483
+ };
484
+ } catch {
485
+ // Not a URL-style string; try semi-structured parsing
486
+ const parts = conn.split(';');
487
+ const kv: Record<string, string> = {};
488
+
489
+ for (const part of parts) {
490
+ const [k, v] = part.split('=');
491
+ if (k && v) kv[k.trim().toLowerCase()] = v.trim();
492
+ }
493
+
494
+ const user = kv['user id'] || kv['uid'];
495
+ const host = kv['server'] || kv['data source'] || kv['address'];
496
+ const port = kv['port'];
497
+ const name = kv['database'] || kv['initial catalog'];
498
+
499
+ // Reconstruct a scrubbed connection string without credentials
500
+ const scrubbed = Object.entries(kv)
501
+ .filter(([key]) => !['user id', 'uid', 'password', 'pwd'].includes(key))
502
+ .map(([k, v]) => `${k}=${v}`)
503
+ .join(';');
504
+
505
+ return {
506
+ scrubbed,
507
+ user,
508
+ host,
509
+ port,
510
+ name
511
+ };
512
+ }
513
+ }
514
+
515
+ /** Try pretty hard to get error info out of a span, if it has any. Handles it being there as an event,
516
+ * directly on the span with error semantics, and some other more ad-hoc cases. */
517
+ function extractErrorInfo(span: ReadableSpan): {
518
+ message?: string;
519
+ type?: string;
520
+ stack?: string;
521
+ } {
522
+ const attrs = span.attributes;
523
+
524
+ // Try to extract from a structured 'exception' event, as it should have more detail
525
+ const exceptionEvent = span.events.find(e => e.name === 'exception');
526
+ if (exceptionEvent?.attributes) {
527
+ const message = exceptionEvent.attributes['exception.message'] as string | undefined;
528
+ const type = exceptionEvent.attributes['exception.type'] as string | undefined;
529
+ const stack = exceptionEvent.attributes['exception.stacktrace'] as string | undefined;
530
+ if (message || type || stack) {
531
+ return { message, type, stack };
532
+ }
533
+ }
534
+
535
+ // Fallback to attributes directly on the span.
536
+ const isError = span.status.code === 2;
537
+ const message =
538
+ (attrs['exception.message'] as string | undefined) ??
539
+ (attrs['http.error_message'] as string | undefined) ??
540
+ (attrs['db.response.status_code'] as string | undefined) ??
541
+ (isError ? (attrs['otel.status_description'] as string | undefined) : undefined);
542
+ const type =
543
+ (attrs['exception.type'] as string | undefined) ??
544
+ (attrs['error.type'] as string | undefined) ??
545
+ (attrs['http.error_name'] as string | undefined);
546
+ const stack = attrs['exception.stacktrace'] as string | undefined;
547
+ return {
548
+ message,
549
+ type,
550
+ stack
551
+ };
552
+ }
553
+
554
+ function getTablesWithOperation(
555
+ tableOps: Record<string, string[]>,
556
+ operation: string
557
+ ): string[] | undefined {
558
+ const op = operation.toUpperCase();
559
+ const result = Object.entries(tableOps)
560
+ .filter(([_, ops]) => ops.includes(op))
561
+ .map(([table]) => table);
562
+ return result.length > 0 ? result : undefined;
563
+ }
@@ -0,0 +1,121 @@
1
+ import WebSocket from 'ws';
2
+ import {
3
+ InitMessage,
4
+ NewObservedEntityMessage,
5
+ NewObservedInteractionMessage,
6
+ ObservationInputMessage,
7
+ ObservationMessage,
8
+ ObservationOutputMessage,
9
+ } from './wire-protocol';
10
+
11
+ const INGESTION_ENDPOINT = 'wss://ingestion.comprehend.dev';
12
+
13
+ export class WebSocketConnection {
14
+ private readonly organization: string;
15
+ private readonly token: string;
16
+ private readonly logger?: (message: string) => void;
17
+ private readonly unacknowledgedObserved = new Map<string, NewObservedEntityMessage | NewObservedInteractionMessage>();
18
+ private readonly unacknowledgedObservations = new Map<number, ObservationMessage>();
19
+ private socket: WebSocket | null = null;
20
+ private reconnectDelay = 1000;
21
+ private shouldReconnect = true;
22
+ private authorized = false;
23
+
24
+ constructor(organization: string, token: string, logger?: (message: string) => void) {
25
+ this.organization = organization;
26
+ this.token = token;
27
+ this.logger = logger;
28
+ this.connect();
29
+ }
30
+
31
+ private log(message: string) {
32
+ if (this.logger) {
33
+ this.logger(message);
34
+ }
35
+ }
36
+
37
+ private connect(): void {
38
+ const webSocketUrl = `${INGESTION_ENDPOINT}/${this.organization}/observations`;
39
+ this.log(`Attempting to connect to ${webSocketUrl}...`);
40
+ this.socket = new WebSocket(webSocketUrl);
41
+
42
+ this.socket.on('open', () => this.onOpen());
43
+ this.socket.on('message', (data) => this.onMessage(data));
44
+ this.socket.on('close', (code, reason) => this.onClose(code, reason.toString()));
45
+ this.socket.on('error', (err) => this.onError(err));
46
+ }
47
+
48
+ private onOpen(): void {
49
+ this.log('WebSocket connected. Sending init/auth message.');
50
+ const init: InitMessage = {
51
+ event: 'init',
52
+ protocolVersion: 1,
53
+ token: this.token,
54
+ };
55
+ this.sendRaw(init);
56
+ }
57
+
58
+ private onMessage(data: WebSocket.Data): void {
59
+ try {
60
+ const msg: ObservationOutputMessage = JSON.parse(data.toString());
61
+
62
+ if (msg.type === 'ack-authorized') {
63
+ this.authorized = true;
64
+ this.log('Authorization acknowledged by server.');
65
+
66
+ for (const message of this.unacknowledgedObserved.values()) {
67
+ this.sendRaw(message);
68
+ }
69
+ for (const message of this.unacknowledgedObservations.values()) {
70
+ this.sendRaw(message);
71
+ }
72
+ }
73
+ else if (msg.type === 'ack-observed') {
74
+ this.unacknowledgedObserved.delete(msg.hash);
75
+ }
76
+ else if (msg.type === 'ack-observations') {
77
+ this.unacknowledgedObservations.delete(msg.seq);
78
+ }
79
+ } catch (e) {
80
+ this.log('Error parsing message from server: ' + (e instanceof Error ? e.message : String(e)));
81
+ }
82
+ }
83
+
84
+ private onClose(code: number, reason: string): void {
85
+ this.log(`WebSocket disconnected. Code: ${code}, Reason: ${reason}`);
86
+ this.authorized = false;
87
+ if (this.shouldReconnect) {
88
+ setTimeout(() => this.connect(), this.reconnectDelay);
89
+ }
90
+ }
91
+
92
+ private onError(error: Error): void {
93
+ this.log(`WebSocket encountered an error: ${error.message}`);
94
+ }
95
+
96
+ private sendRaw(message: ObservationInputMessage): void {
97
+ if (this.socket && this.socket.readyState === WebSocket.OPEN) {
98
+ this.socket.send(JSON.stringify(message));
99
+ }
100
+ }
101
+
102
+ public sendMessage(message: ObservationInputMessage): void {
103
+ if (message.event === 'new-entity' || message.event === 'new-interaction') {
104
+ this.unacknowledgedObserved.set(message.hash, message);
105
+ }
106
+ else if (message.event === 'observations') {
107
+ this.unacknowledgedObservations.set(message.seq, message);
108
+ }
109
+
110
+ if (this.authorized) {
111
+ this.sendRaw(message);
112
+ }
113
+ }
114
+
115
+ public close(): void {
116
+ this.shouldReconnect = false;
117
+ if (this.socket) {
118
+ this.socket.close();
119
+ }
120
+ }
121
+ }
package/src/index.ts ADDED
@@ -0,0 +1,2 @@
1
+ export {ComprehendDevSpanProcessor} from "./ComprehendDevSpanProcessor";
2
+