@mwcp/otel
Version:
midway component for open telemetry
625 lines (524 loc) • 17.6 kB
text/typescript
/* eslint-disable @typescript-eslint/no-unnecessary-condition */
import assert from 'node:assert'
import {
App,
ApplicationContext,
Init,
Inject,
Logger,
MidwayDecoratorService,
MidwayEnvironmentService,
MidwayInformationService,
Singleton,
} from '@midwayjs/core'
import { ILogger } from '@midwayjs/logger'
import {
Application,
IMidwayContainer,
MConfig,
} from '@mwcp/share'
import {
Attributes,
Context as TraceContext,
Span,
SpanKind,
SpanOptions,
SpanStatus,
SpanStatusCode,
TimeInput,
context,
trace,
} from '@opentelemetry/api'
import { AsyncHooksContextManager } from '@opentelemetry/context-async-hooks'
import { node } from '@opentelemetry/sdk-node'
import { ATTR_EXCEPTION_MESSAGE, ATTR_EXCEPTION_STACKTRACE, ATTR_EXCEPTION_TYPE } from '@opentelemetry/semantic-conventions'
import { genISO8601String, humanMemoryUsage } from '@waiting/shared-core'
import type { NpmPkg } from '@waiting/shared-types'
import { initTrace } from '##/helper/index.opentelemetry.js'
import { initSpanStatusOptions } from './config.js'
import {
AddEventOptions,
AttrNames,
Config,
ConfigKey,
InitTraceOptions,
SpanStatusOptions,
TraceScopeType,
} from './types.js'
import { getSpan, isSpanEnded, normalizeHeaderKey, setSpan } from './util.js'
import PKG from '#package.json' with { type: 'json' }
/** OpenTelemetry Component */
export class OtelComponent {
readonly app: Application
readonly applicationContext: IMidwayContainer
protected readonly config: Config
// @MConfig(ConfigKey.jaegerExporterConfig)
// protected readonly jaegerExporterConfig: InitTraceOptions['jaegerExporterConfig']
protected readonly otlpGrpcExporterConfig: InitTraceOptions['otlpGrpcExporterConfig']
protected readonly decoratorService: MidwayDecoratorService
protected readonly environmentService: MidwayEnvironmentService
protected readonly logger: ILogger
/** Active during Midway Lifecycle between onReady and onServerReady */
appInitProcessContext: TraceContext | undefined
/** Active during Midway Lifecycle between onReady and onServerReady */
appInitProcessSpan: Span | undefined
otelLibraryName: string
otelLibraryVersion: string
/* request|response -> Map<lower,norm> */
readonly captureHeadersMap = new Map<string, Map<string, string>>()
// ref: https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/WeakMap
readonly traceContextMap = new WeakMap<object | symbol, TraceContext[]>()
protected traceProvider: node.NodeTracerProvider | undefined
protected spanProcessors: node.SpanProcessor[] = []
instId = Symbol(Date.now())
// constructor(options?: { name: string, version: string }) {
// super()
// if (options?.name) {
// const { name, version } = options
// this.otelLibraryName = name
// this.otelLibraryVersion = version ?? ''
// }
// }
async init(): Promise<void> {
const key = `_${ConfigKey.componentName}`
// @ts-ignore
if (typeof this.app[key] === 'undefined') {
// @ts-ignore
this.app[key] = this
}
/* c8 ignore start */
// @ts-ignore
else if (this.app[key] !== this) {
// @ts-ignore
const id = (this.app[key] as OtelComponent).instId.toString()
const currentId = this.instId.toString()
throw new Error(`this.app.${key} not equal to otel, id: ${id}, currentId: ${currentId}.
Check if you have multiple otel instances in your project.`)
}
/* c8 ignore stop */
const contextManager = new AsyncHooksContextManager()
contextManager.enable()
context.setGlobalContextManager(contextManager) // it seems no effect
await this._init()
await this._app_init_start()
}
getActiveContext(): TraceContext {
return context.active()
}
getActiveSpan(traceContext?: TraceContext): Span | undefined {
/* c8 ignore next */
if (! this.config.enable) { return }
return trace.getSpan(traceContext ?? context.active())
}
getTraceId(): string | undefined {
/* c8 ignore next */
if (! this.config.enable) { return }
return this.getActiveSpan()?.spanContext().traceId
}
// #region startScopeActiveSpan
/**
* Starts a new {@link Span}. Start the span without setting it on context.
* This method do NOT modify the current Context.
*/
startSpan(name: string, options?: SpanOptions, traceContext?: TraceContext): { span: Span, traceContext: TraceContext } {
const ret = this.startSpanContext(name, options, traceContext)
return ret
}
/**
* Starts a new {@link Span}. Start the span without setting it on context.
*/
startSpanContext(name: string, options?: SpanOptions, traceContext?: TraceContext): { span: Span, traceContext: TraceContext } {
assert(name, 'name must be set')
const tracer = trace.getTracer(this.otelLibraryName, this.otelLibraryVersion)
const opts: SpanOptions = {
kind: SpanKind.CLIENT,
...options,
}
const span = tracer.startSpan(name, opts, traceContext)
const ctx = setSpan(traceContext ?? context.active(), span)
return { span, traceContext: ctx }
}
// #region startScopeActiveSpan
/**
* Starts a new {@link Span} and calls the given function passing it the created span as first argument.
* Additionally the new span gets set in context and this context is activated
* for the duration of the function call.
*/
startActiveSpan<F extends (
...args: [Span, TraceContext]
) => ReturnType<F>>(
name: string,
callback: F,
options?: SpanOptions,
traceContext?: TraceContext,
): ReturnType<F> {
assert(name, 'name must be set')
const tracer = trace.getTracer(this.otelLibraryName, this.otelLibraryVersion)
const opts: SpanOptions = {
kind: SpanKind.CLIENT,
// startTime: Date.now(),
...options,
}
const cb = (span: Span) => {
const activeCtx = context.active()
return callback(span, activeCtx)
}
const ret = traceContext
? tracer.startActiveSpan(name, opts, traceContext, cb)
: tracer.startActiveSpan(name, opts, cb)
return ret
}
async flush(): Promise<void> {
const pms: Promise<void>[] = []
this.spanProcessors.forEach(proc => pms.push(proc.forceFlush()))
await Promise.allSettled(pms)
await this.traceProvider?.forceFlush()
}
/* c8 ignore start */
async shutdown(): Promise<void> {
try {
const currSpan = this.getActiveSpan()
if (currSpan) {
currSpan.end()
}
}
catch (ex) {
this.logger.warn(ex)
}
await this.flush()
// await this.traceProvider?.shutdown()
}
/* c8 ignore stop */
/**
* Adds an event to the given span.
*/
addEvent(
span: Span,
input: Attributes,
options?: AddEventOptions,
): void {
/* c8 ignore next */
if (! this.config.enable) { return }
if (options?.traceEvent === false || ! this.config.traceEvent) { return }
const eventName = typeof input['event'] === 'string' || typeof input['event'] === 'number'
? String(input['event'])
: 'default'
const name = options?.eventName ?? eventName
delete input['event']
if (options?.logMemoryUsage ?? this.config.logMemoryUsage) {
input[AttrNames.ServiceMemoryUsage] = JSON.stringify(humanMemoryUsage(), null, 2)
}
if (options?.logCpuUsage ?? this.config.logCpuUsage) {
input[AttrNames.ServiceCpuUsage] = JSON.stringify(process.cpuUsage(), null, 2)
}
span.addEvent(name, input, options?.startTime)
}
addSpanEventWithError(span: Span, error?: Error): void {
/* c8 ignore next */
if (! this.config.enable) { return }
/* c8 ignore next */
if (! error) { return }
const { cause } = error
// @ts-ignore
if (cause instanceof Error || error[AttrNames.IsTraced]) {
return // avoid duplicated logs for the same error on the root span
}
const { name, message, stack } = error
const attrs: Attributes = {
[ATTR_EXCEPTION_TYPE]: 'exception',
[ATTR_EXCEPTION_MESSAGE]: message,
}
if (stack) {
attrs[ATTR_EXCEPTION_STACKTRACE] = stack
}
this.addEvent(span, attrs, {
eventName: `${name} Cause`,
}) // Error Cause
}
/**
* Sets the attributes to the given span.
*/
setAttributes(span: Span, input: Attributes): void {
/* c8 ignore next */
if (! this.config.enable) { return }
span.setAttributes(input)
}
setAttributesLater(span: Span, input: Attributes): void {
/* c8 ignore next */
if (! this.config.enable) { return }
setTimeout(() => {
try {
this.setAttributes(span, input)
}
catch (ex) {
console.error(ex)
}
})
}
/**
* Sets the span with the error passed in params, note span not ended.
*/
setSpanWithError(
rootSpan: Span | undefined,
span: Span,
error: Error | undefined,
eventName?: string,
): void {
/* c8 ignore next */
if (! this.config.enable) { return }
const time = genISO8601String()
const attrs: Attributes = {
time,
}
if (eventName) {
attrs['event'] = eventName
}
if (error) {
attrs[AttrNames.HTTP_ERROR_NAME] = error.name
attrs[AttrNames.HTTP_ERROR_MESSAGE] = error.message
span.setAttributes(attrs)
// this.addSpanEventWithError(span, error)
// @ts-ignore - IsTraced
if (error.cause instanceof Error || error[AttrNames.IsTraced]) {
if (rootSpan && span !== rootSpan) {
// error contains cause, then add events only
attrs[ATTR_EXCEPTION_MESSAGE] = 'skipping'
this.addEvent(span, attrs)
}
}
else { // if error contains no cause, add error stack to span
span.recordException(error)
}
Object.defineProperty(error, AttrNames.IsTraced, { value: true })
}
span.setStatus({ code: SpanStatusCode.ERROR, message: error?.message ?? 'unknown error' })
}
/**
* - ends the given span
* - set span with error if error passed in params
* - set span status
* - call span.end(), except span is root span
*/
endSpan(
rootSpan: Span | undefined,
span: Span,
spanStatusOptions: SpanStatusOptions = initSpanStatusOptions,
endTime?: TimeInput,
): void {
/* c8 ignore next */
if (! this.config.enable) { return }
const opts: SpanStatusOptions = {
...initSpanStatusOptions,
...spanStatusOptions,
}
const { code } = opts
if (code === SpanStatusCode.ERROR) {
this.setSpanWithError(rootSpan, span, spanStatusOptions.error)
}
else { // OK, UNSET
const status: SpanStatus = {
code,
}
if (opts.message) {
status.message = opts.message
}
span.setStatus(status)
}
// root span not end here
if (! rootSpan || span !== rootSpan) {
span.end(endTime)
}
}
endRootSpan(
rootSpan: Span,
spanStatusOptions: SpanStatusOptions = initSpanStatusOptions,
endTime?: TimeInput,
): void {
/* c8 ignore next */
if (! this.config.enable) { return }
this.endSpan(rootSpan, rootSpan, spanStatusOptions, endTime)
rootSpan.end(endTime)
}
addAppInitEvent(
input: Attributes,
options?: AddEventOptions,
/** if omit, use this.appInitProcessSpan */
span?: Span,
): void {
const spanToUse = span ?? this.appInitProcessSpan
if (spanToUse) {
const addEventOptions = {
traceEvent: true,
logCpuUsage: true,
logMemoryUsage: true,
...options,
}
this.addEvent(spanToUse, input, addEventOptions)
}
}
/** Called when onServerReady */
endAppInitEvent(): void {
if (this.appInitProcessSpan) {
this.endRootSpan(this.appInitProcessSpan)
this.appInitProcessContext = void 0
this.appInitProcessSpan = void 0
}
}
// #region traceContextMap
getScopeRootTraceContext(scope: TraceScopeType): TraceContext | undefined {
/* c8 ignore next */
if (! this.config.enable) { return }
const tp = typeof scope
assert(tp === 'object' || tp === 'symbol', 'scope must be an object or symbol')
const arr = this.traceContextMap.get(scope)
if (arr?.length) {
return arr[0]
}
}
getScopeActiveContext(scope: TraceScopeType): TraceContext | undefined {
/* c8 ignore next */
if (! this.config.enable) { return }
const tp = typeof scope
assert(tp === 'object' || tp === 'symbol', 'scope must be an object or symbol')
const arr = this.traceContextMap.get(scope)
if (arr?.length) {
return this.getActiveContextFromArray(arr)
}
}
setScopeActiveContext(scope: TraceScopeType, ctx: TraceContext): void {
/* c8 ignore next */
if (! this.config.enable) { return }
const currCtx = this.getScopeActiveContext(scope)
if (currCtx === ctx) { return }
const arr = this.traceContextMap.get(scope)
if (arr?.length) {
if (arr.at(-1) !== ctx) {
arr.push(ctx)
}
return
}
this.traceContextMap.set(scope, [ctx])
}
emptyScopeActiveContext(scope: TraceScopeType): void {
/* c8 ignore next */
if (! this.config.enable) { return }
const tp = typeof scope
assert(tp === 'object' || tp === 'symbol', 'scope must be an object or symbol')
const arr = this.traceContextMap.get(scope)
if (arr) {
arr.length = 0
}
this.traceContextMap.delete(scope)
}
getRootSpan(scope: TraceScopeType): Span | undefined {
const rootTraceContext = this.getScopeRootTraceContext(scope)
if (! rootTraceContext) { return }
return getSpan(rootTraceContext)
}
spanIsRootSpan(scope: TraceScopeType, span: Span): boolean {
const rootSpan = this.getRootSpan(scope)
return rootSpan === span
}
// #region protected
/**
* Inactive context will be removed from the array
*/
protected getActiveContextFromArray(input: TraceContext[]): TraceContext | undefined {
const len = input.length
if (len === 0) { return }
for (let i = len - 1; i >= 0; i -= 1) {
if (! input.length) { break }
const traceContext = input.at(-1)
if (traceContext) {
const span = getSpan(traceContext)
if (! span) {
input.pop()
continue
}
const ended = isSpanEnded(span)
if (! ended && span.spanContext()) {
return traceContext
}
}
input.pop()
}
}
protected prepareCaptureHeaders(type: 'request' | 'response', headersKey: string[]) {
const keys = normalizeHeaderKey(headersKey)
this.captureHeadersMap.set(type, keys)
}
protected async _init(): Promise<void> {
assert(
this.app,
'this.app undefined. If start for development, please set env first like `export MIDWAY_SERVER_ENV=local`',
)
let pkg: NpmPkg | undefined
const informationService = await this.app.getApplicationContext().getAsync(MidwayInformationService)
if (informationService) {
pkg = informationService.getPkg() as NpmPkg
}
let serviceName = this.config.serviceName
? this.config.serviceName
: pkg?.name ?? `unknown-${new Date().toLocaleDateString()}`
serviceName = serviceName.replace('@', '').replace(/\//ug, '-')
const ver = this.config.serviceVersion
? this.config.serviceVersion
: pkg?.version ?? ''
// for registerDecoratorHandler
this.config.serviceName = serviceName
this.config.serviceVersion = ver
if (! this.otelLibraryName) {
// const otelPkgPath = join(__dirname, '../../package.json')
// const otelPkgPath = join(__dirname, '../../package.json')
try {
// const { name, version } = await import(otelPkgPath) as NpmPkg
if (PKG.name) {
this.otelLibraryName = PKG.name
}
if (PKG.version) {
this.otelLibraryVersion = PKG.version
}
}
/* c8 ignore next 4 */
catch {
// this.logger.warn('Failed to load package.json: %s', otelPkgPath)
this.logger.warn('Failed to load package.json')
}
}
}
protected async _app_init_start(): Promise<void> {
const isDevelopmentEnvironment = this.environmentService.isDevelopmentEnvironment()
&& ! process.env['CI_BENCHMARK']
const { processors, provider } = initTrace({
otelConfig: this.config,
// jaegerExporterConfig: this.jaegerExporterConfig,
otlpGrpcExporterConfig: this.otlpGrpcExporterConfig,
isDevelopmentEnvironment,
})
this.traceProvider = provider
this.spanProcessors = processors
const opts: SpanOptions = {
root: true,
kind: SpanKind.INTERNAL,
}
const spanName = 'APP INIT'
const traceCtx = this.getActiveContext()
// this.appInitProcessSpan = this.startSpan(spanName, opts)
this.startActiveSpan(spanName, (span) => {
this.appInitProcessSpan = span
const ctxWithSpanSet = setSpan(traceCtx, span)
this.appInitProcessContext = ctxWithSpanSet
}, opts)
// const span = this.getGlobalCurrentSpan(this.appInitProcessContext)
// void traceCtx
// void span
this.prepareCaptureHeaders('request', this.config.captureRequestHeaders)
this.prepareCaptureHeaders('response', this.config.captureRequestHeaders)
this.addAppInitEvent({
event: `${ConfigKey.componentName}.init.end`,
})
}
}