UNPKG

@mwcp/otel

Version:
416 lines (343 loc) 11.7 kB
import assert from 'node:assert' import { makeHttpRequest } from '@midwayjs/core' import type { AttributeValue } from '@opentelemetry/api' import { SEMATTRS_HTTP_METHOD, SEMATTRS_HTTP_SCHEME, SEMATTRS_HTTP_SERVER_NAME, SEMATTRS_NET_HOST_NAME, } from '@opentelemetry/semantic-conventions' import { sleep } from '@waiting/shared-core' import type { Headers as UndiciHeaders } from 'undici' import { AttrNames } from '##/lib/attrnames.types.js' import { exporterEndpoint } from '##/lib/config.js' import type { Attributes, JaegerTraceInfo, JaegerTraceInfoLogField, JaegerTraceInfoSpan } from '##/lib/index.js' // #region retrieveTraceInfoFromRemote const agent = exporterEndpoint.replace(/:\d+$/u, '') export async function retrieveTraceInfoFromRemote(traceId: string, expectSpanNumber?: number): Promise<[JaegerTraceInfo]> { assert(agent, 'OTEL_EXPORTER_OTLP_ENDPOINT not set') let id = traceId if (traceId.includes('-')) { const txt = traceId.split('-').at(1) assert(txt) id = txt } await sleep(1000) // console.log('retrieveTraceInfoFromRemote: ', { traceId }) const tracePath = `${agent}:16686/api/traces/${id}?prettyPrint=true` let resp = await makeHttpRequest(tracePath, { method: 'GET', dataType: 'json', }) /* c8 ignore next 4 */ .catch((err) => { console.error(`retrieve trace info failed, check agent "${agent}" valid or OTEL_EXPORTER_OTLP_ENDPOINT is correct.`) throw err }) for (let i = 0; i < 30; i += 1) { assert(resp.status !== 401, `Expect not 401, trace: "${tracePath}"`) assert(resp.status !== 404, `Expect not 404, trace: "${tracePath}"`) assert(resp.status !== 500, `Expect not 500, trace: "${tracePath}"`) if (resp.status === 200 && resp.data) { break } /* c8 ignore start */ const { data } = resp.data as { data: [JaegerTraceInfo] } // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition if (data.length > 0) { break } // console.log('retry traceId...') await sleep(2000) resp = await makeHttpRequest(tracePath, { method: 'GET', dataType: 'json', }) /* c8 ignore stop */ } const { data } = resp.data as { data: [JaegerTraceInfo] } /* c8 ignore next 3 */ if (! expectSpanNumber) { return data } let { spans } = data[0] if (spans.length >= expectSpanNumber) { return data } /* c8 ignore start */ for (let i = 0; i < 30; i += 1) { // console.info('retry span... idx:', i) await sleep(2000) const resp2 = await retrieveTraceInfoFromRemote(traceId) spans = resp2[0].spans // console.log('spans.length:', spans.length) if (spans.length >= expectSpanNumber) { return resp2 } } assert(false, `spans.length: ${spans.length}, expectSpanNumber: ${expectSpanNumber}`) /* c8 ignore stop */ } // #region sortSpans // 对于 JaegerTraceInfo.spans 入参,对于 JaegerTraceInfo.spans 根据 span.reference 的 spanID 依赖关系进行排序 // 以确保 root span -> span0 -> span1 -> span2 的顺序 export function sortSpans(spans: JaegerTraceInfoSpan[]): JaegerTraceInfoSpan[] { assert(spans.length > 0, 'sortSpans() spans.length === 0') const map = new Map<string, JaegerTraceInfoSpan>() spans.forEach((span) => { map.set(span.spanID, span) }) const ret: JaegerTraceInfoSpan[] = [] // find root // spans.forEach((span) => { for (const span of spans) { const parentSpan = getParentSpan(span, map) if (! parentSpan) { // root span ret.push(span) break } } const parentSpan = ret.at(-1) assert(parentSpan) mergeSpans(parentSpan, spans, ret) return ret } function mergeSpans(parentSpan: JaegerTraceInfoSpan, srcSpans: JaegerTraceInfoSpan[], result: JaegerTraceInfoSpan[]): void { assert(parentSpan) const pid = parentSpan.spanID const childSpans = getChildren(pid, srcSpans) if (childSpans.length) { // has child node if (parentSpan !== result.at(-1)) { result.push(parentSpan) } childSpans.forEach((child) => { // not leaf node mergeSpans(child, srcSpans, result) }) } else if (parentSpan !== result.at(-1)) { // leaf node result.push(parentSpan) } } function getParentSpan( span: JaegerTraceInfo['spans'][0], map: Map<string, JaegerTraceInfoSpan>, ): JaegerTraceInfoSpan | undefined { if (! span.references.length) { return } const ref = span.references[0] /* c8 ignore next */ if (! ref) { return } return map.get(ref.spanID) } function getChildren( parentSpanId: string, spans: JaegerTraceInfo['spans'], ): JaegerTraceInfoSpan[] { const ret: JaegerTraceInfoSpan[] = [] assert(parentSpanId) spans.forEach((span) => { const ref = span.references[0] if (ref && ref.spanID === parentSpanId) { ret.push(span) } }) if (ret.length > 1) { sortSpansByStartTime(ret) } return ret } function sortSpansByStartTime(spans: JaegerTraceInfoSpan[]): JaegerTraceInfoSpan[] { return spans.sort((p1, p2) => { return p1.startTime - p2.startTime }) } // #region assertRootSpan type ExpectAttributes = Record<string, AttributeValue | RegExp> export interface AssertsRootOptions { /** * @default 'http' */ scheme?: 'http' | 'grpc' path: string span: JaegerTraceInfoSpan traceId: string operationName?: string tags?: ExpectAttributes logs?: (ExpectAttributes | false)[] /** * @default true */ mergeDefaultTags?: boolean /** * @default true */ mergeDefaultLogs?: boolean } export function assertRootSpan(options: AssertsRootOptions): void { const { path, span, tags } = options const scheme = options.scheme ?? 'http' const operationName = options.operationName ?? `HTTP GET ${path}` const httpMethod = scheme === 'http' ? operationName.includes('GET') ? 'GET' : 'POST' : 'RPC' const expectLogs = options.logs ?? [] const mergeDefaultTags = options.mergeDefaultTags ?? true const mergeDefaultLogs = options.mergeDefaultLogs ?? true const tags2 = mergeDefaultTags ? Object.assign({ [SEMATTRS_HTTP_METHOD]: httpMethod, [SEMATTRS_HTTP_SCHEME]: scheme, [SEMATTRS_HTTP_SERVER_NAME]: 'base-app', [SEMATTRS_NET_HOST_NAME]: '127.0.0.1', [AttrNames.ServiceName]: 'base-app', [AttrNames.ServiceVersion]: '1.0.0', 'span.kind': 'server', }, tags) : Object.assign({}, tags) const logBase = [ { event: AttrNames.RequestBegin }, { event: AttrNames.Incoming_Request_data }, { event: AttrNames.PreProcessFinish }, { event: AttrNames.PostProcessBegin }, { event: AttrNames.Outgoing_Response_data, [AttrNames.Http_Response_Code]: 200 }, { event: AttrNames.RequestEnd }, ] const logs: ExpectAttributes[] = [] if (mergeDefaultLogs) { for (let idx = 0; idx < Math.max(logBase.length, expectLogs.length, options.logs?.length ?? 0); idx += 1) { const log = logBase[idx] ?? {} const expectRow = expectLogs[idx] if (expectRow === false) { continue } if (expectRow && Object.keys(expectRow).length) { const row = Object.assign(log, expectRow) logs.push(row) continue } logs.push(log) } } else { options.logs?.forEach((log) => { if (log) { logs.push(log) } }) } const opt: AssertsOptions = { traceId: options.traceId, operationName, tags: tags2, logs, } assertsSpan(span, opt) } export interface AssertsOptions { traceId: string operationName: string tags?: ExpectAttributes logs?: ExpectAttributes[] } export function assertsSpan(span: JaegerTraceInfoSpan, options: AssertsOptions): void { assert(span, 'span is null') assert(options, 'options is null') assert(span.traceID === options.traceId) assert(span.operationName === options.operationName, `operationName: ${span.operationName} !== (expect) ${options.operationName}`) Object.entries(options.tags ?? {}).forEach(([key, expectValue]) => { const flag = span.tags.some((tag) => { if (tag['key'] !== key) { return false } assertJaegerTagItem(tag, key, expectValue) return true }) assert(flag, `${key}: ${expectValue.toString()} not found`) }) if (options.logs?.length) { options.logs.forEach((expectLog, idx) => { /* c8 ignore next 2 */ // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition if (! expectLog) { return } const log = span.logs[idx] assert(log, `log[${idx}] is null`) Object.entries(expectLog).forEach(([key, expectValue]) => { const flag = log.fields.some((field) => { if (field.key !== key) { return false } assertJaegerLogField(field, key, expectValue) return true }) assert(flag, `(${idx}) ${key}: ${expectValue.toString()} not found`) }) }) } } export function assertJaegerTagItem(tag: Attributes, key: string, expectValue: AttributeValue | RegExp): void { const tagVal = tag['value'] assert(typeof tagVal !== 'undefined', `${key}: tagVal from span is null`) if (expectValue instanceof RegExp) { assert(expectValue.test(tagVal.toString()), `${key}: ${tagVal.toString()} !== (expect) ${expectValue.toString()}`) } else { const res = tagVal === expectValue assert(res, `${key}: ${tagVal.toString()} !== (expect) ${expectValue.toString()}`) } } export function assertJaegerLogField(field: JaegerTraceInfoLogField, key: string, expectValue: AttributeValue | RegExp): void { const fieldVal = field.value assert(typeof fieldVal !== 'undefined', `${key}: fieldVal from span is null`) if (expectValue instanceof RegExp) { assert(expectValue.test(fieldVal.toString()), `${key}: ${expectValue.toString()} !== (expect) ${fieldVal.toString()}`) } else { const res = fieldVal === expectValue assert(res, `${key}: ${fieldVal.toString()} !== (expect) ${expectValue.toString()}`) } } // function sortSpansByStartTimeDesc(spans: JaegerTraceInfoSpan[]): JaegerTraceInfoSpan[] { // return spans.sort((a, b) => { // return b.startTime - a.startTime // }) // } export interface Traceparent { version: string traceId: string parentId: string traceFlags: string } export function retrieveTraceparentFromHeader(headers: Headers | UndiciHeaders | Record<string, unknown>): Traceparent | undefined { assert(headers, 'headers is null') // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment const traceparent: string = typeof headers.get === 'function' // eslint-disable-next-line @typescript-eslint/no-unsafe-call ? headers.get('traceparent') // @ts-ignore : headers['traceparent'] if (! traceparent) { return } const [version, traceId, parentId, traceFlags] = traceparent.split('-') return { version, traceId, parentId, traceFlags, } as Traceparent } // #region assertJaegerParentSpanArray export interface AssertJaegerParentSpanRow { parentSpan: JaegerTraceInfoSpan childSpan: JaegerTraceInfoSpan } export function assertJaegerParentSpanArray(input: AssertJaegerParentSpanRow[]): void { input.forEach((row) => { assertJaegerParentSpan(row.parentSpan, row.childSpan) }) } // #region assertJaegerParentSpan export function assertJaegerParentSpan( parentSpan: JaegerTraceInfoSpan, childSpan: JaegerTraceInfoSpan, ): void { assert(parentSpan, 'parentSpan is null') assert(childSpan, 'childSpan is null') assert( parentSpan.spanID === childSpan.references[0]?.spanID, `parentSpan.spanID: ${parentSpan.spanID}, childSpan: ${childSpan.spanID}, childSpan.parent spanID: ${childSpan.references[0]?.spanID}`, ) }