UNPKG

@comprehend/telemetry-browser

Version:

Integration of comprehend.dev with OpenTelemetry in browser environments.

247 lines (223 loc) 9.33 kB
import {sha256} from "@noble/hashes/sha2"; import {utf8ToBytes} from "@noble/hashes/utils"; import {Context} from "@opentelemetry/api"; import {ReadableSpan, Span, SpanProcessor} from "@opentelemetry/sdk-trace-web"; import { HttpClientObservation, NewObservedHttpRequestMessage, NewObservedHttpServiceMessage, NewObservedServiceMessage, ObservationInputMessage } from "./wire-protocol"; import {WebSocketConnection} from "./WebSocketConnection"; interface ObservedService { name: string; namespace?: string; environment?: string; hash: string; } interface ObservedHttpService { protocol: string; host: string; port?: number; hash: string; } interface ObservedHttpRequest { hash: string; } export class ComprehendDevSpanProcessor implements SpanProcessor { private readonly connection: WebSocketConnection; private observedServices: ObservedService[] = []; private observedHttpServices: ObservedHttpService[] = []; private observedInteractions: Map<string, Map<string, { httpRequest?: ObservedHttpRequest }>> = new Map(); private observationsSeq = 1; constructor(options: { organization: string, token: string, debug?: boolean | ((message: string) => void) }) { this.connection = new WebSocketConnection(options.organization, options.token, options.debug === true ? console.log : options.debug === false ? undefined : options.debug); } onStart(span: Span, parentContext: Context): void { } onEnd(span: ReadableSpan): void { const currentService = this.discoverService(span); if (!currentService) return; const attrs = span.attributes; if (span.kind === 2) { // CLIENT span kind if (attrs['http.url']) { this.processHttpRequest(currentService, attrs['http.url'] as string, span); } } } private discoverService(span: ReadableSpan): ObservedService | undefined { // Look for an existing matching entry. const resAttrs = span.resource.attributes; const name = resAttrs['service.name'] as string | undefined; if (!name) return; const namespace = resAttrs['service.namespace'] as string | undefined; const environment = resAttrs['deployment.environment'] as string | undefined; const existing = this.observedServices.find(s => s.name === name && s.namespace === namespace && s.environment === environment ); if (existing) return existing; // New; hash it and add it to the observed services. const idString = `service:${name}:${namespace ?? ''}:${environment ?? ''}`; const hash = hashIdString(idString); const newService: ObservedService = { name, namespace, environment, hash }; this.observedServices.push(newService); // Ingest its existence. const message: NewObservedServiceMessage = { event: "new-entity", type: "service", hash, name, ...(namespace ? { namespace } : {}), ...(environment ? { environment } : {}) }; this.ingestMessage(message); return newService; } private processHttpRequest(currentService: ObservedService, url: string, span: ReadableSpan): void { // Build identity based upon protocol, host, and port. const parsed = new URL(url); const protocol = parsed.protocol.replace(':', ''); // Remove trailing colon const host = parsed.hostname; const port = parsed.port ? parseInt(parsed.port) : (protocol === 'https' ? 443 : 80); const idString = `http-service:${protocol}:${host}:${port}`; const hash = hashIdString(idString); // Ingest it if it's not already observed. let observedHttpService = this.observedHttpServices.find(s => s.protocol === protocol && s.host === host && s.port === port ); if (!observedHttpService) { observedHttpService = { protocol, host, port, hash }; this.observedHttpServices.push(observedHttpService); // The existence of the service. const message: NewObservedHttpServiceMessage = { event: "new-entity", type: "http-service", hash, protocol, host, port }; this.ingestMessage(message); } // Ingest the interaction if first observed. const interactions = this.getInteractions(currentService.hash, observedHttpService.hash); if (!interactions.httpRequest) { const idString = `http-request:${currentService.hash}:${observedHttpService.hash}`; const hash = hashIdString(idString); interactions.httpRequest = { hash }; const message: NewObservedHttpRequestMessage = { event: "new-interaction", type: "http-request", hash, from: currentService.hash, to: observedHttpService.hash }; this.ingestMessage(message); } // Build and ingest observation. const attrs = span.attributes; const path = parsed.pathname || '/'; const method = span.attributes['http.method'] as string; if (!method) // Really should always be there return; const status = attrs['http.status_code'] as number | undefined; const duration = span.duration; const httpVersion = span.attributes['http.flavor'] as string | undefined; const requestBytes = attrs['http.request_content_length'] as number | undefined; const responseBytes = attrs['http.response_content_length'] as number | undefined; const { message: errorMessage, type: errorType, stack } = extractErrorInfo(span); const observation: HttpClientObservation = { type: "http-client", subject: interactions.httpRequest.hash, timestamp: span.startTime, path, method, ...(status !== undefined ? { status } : {}), duration, ...(httpVersion !== undefined ? { httpVersion } : {}), ...(requestBytes !== undefined ? { requestBytes } : {}), ...(responseBytes !== undefined ? { responseBytes } : {}), ...(errorMessage ? { errorMessage } : {}), ...(errorType ? { errorType } : {}), ...(stack ? { stack } : {}) }; this.ingestMessage({ event: "observations", seq: this.observationsSeq++, observations: [observation] }); } private getInteractions(from: string, to: string) { let fromMap = this.observedInteractions.get(from); if (!fromMap) { fromMap = new Map(); this.observedInteractions.set(from, fromMap); } let interactions = fromMap.get(to); if (!interactions) { interactions = { httpRequest: undefined }; fromMap.set(to, interactions); } return interactions; } private ingestMessage(message: ObservationInputMessage) { this.connection.sendMessage(message); } async forceFlush(): Promise<void> { } async shutdown(): Promise<void> { this.connection.close() } } function hashIdString(idString: string) { return Array.from(sha256(utf8ToBytes(idString))) .map(b => b.toString(16).padStart(2, '0')) .join(''); } /** Try pretty hard to get error info out of a span, if it has any. Handles it being there as an event, * directly on the span with error semantics, and some other more ad-hoc cases. */ function extractErrorInfo(span: ReadableSpan): { message?: string; type?: string; stack?: string; } { const attrs = span.attributes; // Try to extract from a structured 'exception' event, as it should have more detail const exceptionEvent = span.events.find(e => e.name === 'exception'); if (exceptionEvent?.attributes) { const message = exceptionEvent.attributes['exception.message'] as string | undefined; const type = exceptionEvent.attributes['exception.type'] as string | undefined; const stack = exceptionEvent.attributes['exception.stacktrace'] as string | undefined; if (message || type || stack) { return { message, type, stack }; } } // Fallback to attributes directly on the span. const isError = span.status.code === 2; const message = (attrs['exception.message'] as string | undefined) ?? (attrs['http.error_message'] as string | undefined) ?? (isError ? (attrs['otel.status_description'] as string | undefined) : undefined) ?? (isError ? (span.status.message as string | undefined) : undefined); const type = (attrs['exception.type'] as string | undefined) ?? (attrs['error.type'] as string | undefined) ?? (attrs['http.error_name'] as string | undefined); const stack = attrs['exception.stacktrace'] as string | undefined; return { message, type, stack }; }