@comprehend/telemetry-browser
Version:
Integration of comprehend.dev with OpenTelemetry in browser environments.
247 lines (223 loc) • 9.33 kB
text/typescript
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
};
}