UNPKG

@mwcp/otel

Version:
492 lines (416 loc) 13.2 kB
/* eslint-disable @typescript-eslint/no-unnecessary-type-parameters */ import assert from 'node:assert' import type { IncomingHttpHeaders, IncomingMessage, OutgoingHttpHeaders, OutgoingMessage, } from 'node:http' import { getRouterInfo } from '@mwcp/share' import type { Context as WebContext } from '@mwcp/share' import { SpanKind, SpanStatusCode, createContextKey, propagation, } from '@opentelemetry/api' import type { Attributes, Context as TraceContext, Span } from '@opentelemetry/api' import { ATTR_HTTP_ROUTE } from '@opentelemetry/semantic-conventions' import type { Headers as UndiciHeaders } from 'undici' import { AttrNames } from './types.js' import type { Config } from './types.js' const defaultProperty: PropertyDescriptor = { configurable: true, enumerable: true, writable: false, } /** * span key */ const SPAN_KEY = createContextKey('OpenTelemetry Context Key SPAN') /** * Return the span if one exists * * @param context context to get span from */ export function getSpan(context: TraceContext): Span | undefined { return context.getValue(SPAN_KEY) as Span | undefined } /** * Set the span on a context * * @param context context to use as parent * @param span span to set active */ export function setSpan(context: TraceContext, span: Span): TraceContext { return context.setValue(SPAN_KEY, span) } /** * Remove current span stored in the context * * @param context context to delete span from */ export function deleteSpan(context: TraceContext): TraceContext { return context.deleteValue(SPAN_KEY) } export function isSpanEnded(span: Span): boolean { // @ts-expect-error if (typeof span.ended === 'boolean') { // @ts-expect-error return span.ended as boolean } /* c8 ignore start */ if (typeof span.isRecording === 'function') { return ! span.isRecording() } throw new Error('span.ended() and span.isRecording() are not functions, we cannot determine if the span is ended') /* c8 ignore stop */ } /** * Parse status code from HTTP response. */ export function parseResponseStatus(kind: SpanKind, statusCode?: number): SpanStatusCode { const upperBound = kind === SpanKind.CLIENT ? 400 : 500 // 1xx, 2xx, 3xx are OK on client and server // 4xx is OK on server if (statusCode && statusCode >= 100 && statusCode < upperBound) { return SpanStatusCode.UNSET } // All other codes are error return SpanStatusCode.ERROR } /** * Adds attributes for request content-length and content-encoding HTTP headers * @param { IncomingMessage } Request object whose headers will be analyzed * @param { Attributes } Attributes object to be modified */ function setRequestContentLengthAttribute( request: IncomingMessage, attributes: Attributes, ): void { const length = getContentLength(request.headers) if (length === null) { return } if (isCompressed(request.headers)) { attributes['http.request_content_length'] = length } else { attributes['http.request_content_length_uncompressed'] = length } } /** * Adds attributes for response content-length and content-encoding HTTP headers * @param { IncomingMessage } Response object whose headers will be analyzed * @param { Attributes } SpanAttributes object to be modified */ export function setResponseContentLengthAttribute( response: IncomingMessage, attributes: Attributes, ): void { const length = getContentLength(response.headers) if (length === null) { return } if (isCompressed(response.headers)) { attributes['http.response_content_length'] = length } else { attributes['http.response_content_length_uncompressed'] = length } } function getContentLength(headers: OutgoingHttpHeaders | IncomingHttpHeaders): number | null { const contentLengthHeader = headers['content-length'] if (contentLengthHeader === undefined) { return null } const contentLength = parseInt(contentLengthHeader as string, 10) if (Number.isNaN(contentLength)) { return null } return contentLength } function isCompressed(headers: OutgoingHttpHeaders | IncomingHttpHeaders): boolean { const encoding = headers['content-encoding'] return !! encoding && encoding !== 'identity' } /** * Returns attributes related to the kind of HTTP protocol used * @param {string} [kind] Kind of HTTP protocol used: "1.0", "1.1", "2", "SPDY" or "QUIC". */ function getAttributesFromHttpKind(kind?: string): Attributes { const attributes: Attributes = {} if (kind) { attributes['http.flavor'] = kind if (kind.toUpperCase() === 'QUIC') { attributes['net.transport'] = 'ip_udp' } else { attributes['net.transport'] = 'ip_tcp' } } return attributes } /** * Returns incoming request attributes scoped to the request data */ export async function getIncomingRequestAttributesFromWebContext( ctx: WebContext, config: Config, ): Promise<Attributes> { const routerInfo = await getRouterInfo(ctx) const attrs: Attributes = { ['http.host']: ctx.host, ['http.method']: ctx.method || 'GET', [ATTR_HTTP_ROUTE]: routerInfo?.fullUrl ?? 'unknown', ['http.scheme']: ctx.protocol, ['http.server_name']: config.serviceName ?? 'unknown', ['http.target']: ctx.path || '/', ['http.url']: ctx.href, ['net.host.name']: ctx.hostname, [AttrNames.ServiceName]: config.serviceName ?? 'unknown', [AttrNames.ServiceVersion]: config.serviceVersion ?? 'unknown', // [AttrNames.ServicePid]: process.pid, } let httpKindAttributes = {} if (typeof ctx[AttrNames.traceId] === 'string') { attrs[AttrNames.traceId] = ctx[AttrNames.traceId] } const { req } = ctx // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition if (req) { const userAgent = req.headers['user-agent'] const ips = req.headers['x-forwarded-for'] if (typeof ips === 'string') { attrs['http.client_ip'] = ips.split(',')[0] } if (typeof userAgent !== 'undefined') { attrs['http.user_agent'] = userAgent } // Object.defineProperty(attrs, 'http.request.header.content_type', { // enumerable: true, // writable: false, // value: headers['content-type'], // }) setRequestContentLengthAttribute(req, attrs) httpKindAttributes = getAttributesFromHttpKind(req.httpVersion) } return Object.assign(attrs, httpKindAttributes) } /** * Key format `http.{request|response}.header.{name}` * @param headersKeyMap Map<low-key, normalized-key> */ export function genAttributesFromHeader( type: 'request' | 'response', headersKeyMap: Map<string, string>, getHeader: (key: string) => string | number | undefined | string[], ): Attributes | undefined { const attrs: Attributes = {} for (const [lower, normalized] of headersKeyMap) { const data = getHeader(lower) if (typeof data === 'undefined' || data === '') { continue } const value = Array.isArray(data) ? data : data const key = `http.${type}.header.${normalized}` Object.defineProperty(attrs, key, { enumerable: true, writable: false, value, }) } if (Object.keys(attrs).length) { return attrs } return } /** * Returns map<lower string, lower and normalized string> */ export type NormalizedKeyMap = Map<string, string> export function normalizeHeaderKey(inputKeys: string[]): NormalizedKeyMap { const keys: [string, string][] = inputKeys.map((header) => { const str = header.toLowerCase() return [str, str.replace(/-/ug, '_')] }) const ret = new Map(keys) return ret } export function propagateOutgoingHeader( traceContext: TraceContext, message: OutgoingMessage, ): void { const headers = {} propagation.inject(traceContext, headers) Object.entries(headers).forEach(([key, val]) => { if (typeof val === 'string' || typeof val === 'number' || Array.isArray(val)) { message.setHeader(key, val) } }) } /** * Skip if header already exists */ export function propagateHeader<T extends Headers | UndiciHeaders = Headers>( traceContext: TraceContext, headers: T, ): void { const tmp = {} propagation.inject(traceContext, tmp) Object.entries(tmp).forEach(([key, val]) => { const curr = headers.get(key) if (typeof curr !== 'undefined' && curr !== null) { return } if (typeof val === 'string' || typeof val === 'number') { headers.set(key, val.toString()) } else if (Array.isArray(val)) { headers.set(key, val.join(',')) } }) } /** * * @param headersKey if omit then use inner prepared headers key */ export function setSpanWithRequestHeaders( span: Span, requestHeadersMap: Map<string, string> | undefined, getHeader: (key: string) => string | number | undefined | string[], headersKey?: string[], ): void { const keyMap = headersKey ? normalizeHeaderKey(headersKey) : requestHeadersMap if (! keyMap?.size) { return } const attrs = genAttributesFromHeader('request', keyMap, getHeader) if (attrs) { span.setAttributes(attrs) } } interface AddSpanEventWithIncomingRequestDataOptions { headers?: Headers | OutgoingHttpHeaders | UndiciHeaders query?: object /** request data */ requestBody: unknown span: Span } /** * String of JSON.stringify limited to 2048 characters */ export function addSpanEventWithIncomingRequestData(options: AddSpanEventWithIncomingRequestDataOptions): void { const { span, requestBody: data, headers, query } = options const attrs: Attributes = {} if (query) { if (typeof query === 'object' && Object.keys(query).length) { let value = 'object could not be stringified to JSON' try { value = truncateString(JSON.stringify(query, null, 2)) } catch (err) { void err } Object.defineProperty(attrs, AttrNames.Http_Request_Query, { ...defaultProperty, value, }) } } if (data && Object.keys(data).length) { let value = 'object could not be stringified to JSON' try { value = truncateString(JSON.stringify(data, null, 2)) } catch (err) { void err } Object.defineProperty(attrs, AttrNames.Http_Request_Body, { ...defaultProperty, value, }) } if (headers && typeof headers.get === 'function') { Object.defineProperty(attrs, 'http.request.header.content_length', { ...defaultProperty, value: headers.get('content-length'), }) Object.defineProperty(attrs, 'http.request.header.content_type', { ...defaultProperty, value: headers.get('content-type'), }) } if (Object.keys(attrs).length) { span.addEvent(AttrNames.Incoming_Request_data, attrs) } } export interface AddSpanEventWithOutgoingResponseDataOptions { /** return data */ body: unknown headers?: Headers | OutgoingHttpHeaders | UndiciHeaders span: Span /** response status code */ status: number } /** * String of JSON.stringify limited to 2048 characters */ export function addSpanEventWithOutgoingResponseData(options: AddSpanEventWithOutgoingResponseDataOptions): void { const { span, body, headers, status } = options const attrs: Attributes = {} let value = '' if (typeof body === 'object') { try { value = truncateString(JSON.stringify(body, null, 2)) } catch { value = 'object could not be stringified to JSON' } } else if (typeof body === 'string') { value = body ? truncateString(body) : '' } // else { // value = body ? truncateString(body.toString()) : '' // } Object.defineProperty(attrs, AttrNames.Http_Response_Body, { ...defaultProperty, value, }) Object.defineProperty(attrs, AttrNames.Http_Response_Code, { ...defaultProperty, value: status, }) if (headers && typeof headers.get === 'function') { Object.defineProperty(attrs, 'http.response.header.content_length', { ...defaultProperty, value: headers.get('content-length'), }) Object.defineProperty(attrs, 'http.response.header.content_type', { ...defaultProperty, value: headers.get('content-type'), }) } if (Object.keys(attrs).length) { span.addEvent(AttrNames.Outgoing_Response_data, attrs) } } export function truncateString(str: string, maxLength = 2048): string { if (str && str.length > maxLength) { return str.slice(0, maxLength) + '... LENGTH: ' + str.length.toString() + ' bytes' } return str } export interface GenRequestSpanNameOptions { /** ctx.request?.protocol */ protocol: string /** ctx.method */ method: string route: string } /** * Generate span name from request * @example * - 'HTTP GET /api/v1/user' * - 'RPC /helloworld.Greeter/SayHello' */ export function genRequestSpanName(options: GenRequestSpanNameOptions, maxLength = 128): string { const { protocol, method, route } = options assert(protocol, 'protocol is required') assert(method, 'method is required') if (protocol === 'grpc') { const spanName = `RPC ${route}` return spanName.slice(0, maxLength) } const spanName = `${protocol.toLocaleUpperCase()} ${method.toUpperCase()} ${route}` return spanName.slice(0, maxLength) }