Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
83 changes: 83 additions & 0 deletions packages/evlog/src/enrichers/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -195,6 +195,89 @@ export function createTraceContextEnricher(options: EnricherOptions = {}): (ctx:
}
}

/** Minimal types for accessing the active OTel span context from the global registry. */
interface OtelSpanContext {
traceId: string
spanId: string
traceFlags: number
}

interface OtelSpan {
spanContext(): OtelSpanContext
}

interface OtelTraceApi {
getActiveSpan(): OtelSpan | undefined
}

interface OtelApiGlobal {
trace?: OtelTraceApi
}

/** Symbol key used by @opentelemetry/api to register its global singleton. */
const OTEL_API_GLOBAL_KEY = Symbol.for('opentelemetry.js.api.1')

const INVALID_TRACE_ID = '0'.repeat(32)
const INVALID_SPAN_ID = '0'.repeat(16)

function isValidOtelSpanContext(ctx: OtelSpanContext): boolean {
return (
/^[\da-f]{32}$/i.test(ctx.traceId) && ctx.traceId !== INVALID_TRACE_ID
&& /^[\da-f]{16}$/i.test(ctx.spanId) && ctx.spanId !== INVALID_SPAN_ID
)
}

/**
* Enrich events with trace context from the active OpenTelemetry span.
* Sets `event.traceId` and `event.spanId` from the current OTel span context.
*
* This is a zero-dependency bridge: it reads from `@opentelemetry/api`'s global
* singleton when the OTel SDK is running, and is a no-op otherwise.
*
* Use this alongside the OTLP drain to correlate evlog events with OTel traces
* automatically — including inside background jobs and internal operations that
* don't pass through an HTTP layer.
*
* @example
* ```ts
* import { createOtelSpanEnricher } from 'evlog/enrichers'
*
* app.use(evlog({ enrich: createOtelSpanEnricher() }))
* ```
*/
export function createOtelSpanEnricher(options: EnricherOptions = {}): (ctx: EnrichContext) => void {
// Resolved once per enricher instance on first call; null means OTel is not present.
let traceApi: OtelTraceApi | null | undefined

return (ctx) => {
if (traceApi === undefined) {
try {
const g = globalThis as Record<symbol, OtelApiGlobal | undefined>
traceApi = g[OTEL_API_GLOBAL_KEY]?.trace ?? null
} catch {
traceApi = null
}
}
if (!traceApi) return

let spanCtx: OtelSpanContext | undefined
try {
spanCtx = traceApi.getActiveSpan()?.spanContext()
} catch {
return
}

if (!spanCtx || !isValidOtelSpanContext(spanCtx)) return

if (options.overwrite || ctx.event.traceId === undefined) {
ctx.event.traceId = spanCtx.traceId
}
if (options.overwrite || ctx.event.spanId === undefined) {
ctx.event.spanId = spanCtx.spanId
}
}
}

/**
* Compose every built-in enricher into a single async enricher, in the order
* `userAgent → geo → requestSize → traceContext`.
Expand Down
81 changes: 79 additions & 2 deletions packages/evlog/test/toolkit/enrichers.test.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { describe, expect, it } from 'vitest'
import { afterEach, describe, expect, it } from 'vitest'
import type { EnrichContext, WideEvent } from '../../src/types'
import type { GeoInfo, UserAgentInfo } from '../../src/enrichers'
import { createGeoEnricher, createRequestSizeEnricher, createTraceContextEnricher, createUserAgentEnricher, createDefaultEnrichers } from '../../src/enrichers'
import { createGeoEnricher, createOtelSpanEnricher, createRequestSizeEnricher, createTraceContextEnricher, createUserAgentEnricher, createDefaultEnrichers } from '../../src/enrichers'

function createContext(headers: Record<string, string>, responseHeaders?: Record<string, string>): EnrichContext {
const event: WideEvent = {
Expand Down Expand Up @@ -427,6 +427,83 @@ describe('enrichers - empty/missing headers (T8)', () => {
})
})

const OTEL_API_KEY = Symbol.for('opentelemetry.js.api.1')

function setOtelGlobal(traceId: string, spanId: string): void {
(globalThis as Record<symbol, unknown>)[OTEL_API_KEY] = {
trace: {
getActiveSpan: () => ({
spanContext: () => ({ traceId, spanId, traceFlags: 1 }),
}),
},
}
}

function clearOtelGlobal(): void {
delete (globalThis as Record<symbol, unknown>)[OTEL_API_KEY]
}

describe('createOtelSpanEnricher', () => {
afterEach(() => {
clearOtelGlobal()
})

it('is a no-op when OTel API is not present', () => {
clearOtelGlobal()
const ctx = createContext({})
createOtelSpanEnricher()(ctx)
expect(ctx.event.traceId).toBeUndefined()
expect(ctx.event.spanId).toBeUndefined()
})

it('sets traceId and spanId from active OTel span', () => {
setOtelGlobal('0af7651916cd43dd8448eb211c80319c', 'b7ad6b7169203331')
const ctx = createContext({})
createOtelSpanEnricher()(ctx)
expect(ctx.event.traceId).toBe('0af7651916cd43dd8448eb211c80319c')
expect(ctx.event.spanId).toBe('b7ad6b7169203331')
})

it('skips invalid span context (all-zero trace ID)', () => {
setOtelGlobal('0'.repeat(32), 'b7ad6b7169203331')
const ctx = createContext({})
createOtelSpanEnricher()(ctx)
expect(ctx.event.traceId).toBeUndefined()
})

it('skips invalid span context (all-zero span ID)', () => {
setOtelGlobal('0af7651916cd43dd8448eb211c80319c', '0'.repeat(16))
const ctx = createContext({})
createOtelSpanEnricher()(ctx)
expect(ctx.event.traceId).toBeUndefined()
})

it('preserves existing traceId when overwrite is false (default)', () => {
setOtelGlobal('0af7651916cd43dd8448eb211c80319c', 'b7ad6b7169203331')
const ctx = createContext({})
ctx.event.traceId = 'existing-trace-id'
createOtelSpanEnricher()(ctx)
expect(ctx.event.traceId).toBe('existing-trace-id')
})

it('overwrites existing traceId when overwrite is true', () => {
setOtelGlobal('0af7651916cd43dd8448eb211c80319c', 'b7ad6b7169203331')
const ctx = createContext({})
ctx.event.traceId = 'existing-trace-id'
createOtelSpanEnricher({ overwrite: true })(ctx)
expect(ctx.event.traceId).toBe('0af7651916cd43dd8448eb211c80319c')
})

it('is a no-op when getActiveSpan returns undefined', () => {
(globalThis as Record<symbol, unknown>)[OTEL_API_KEY] = {
trace: { getActiveSpan: () => undefined },
}
const ctx = createContext({})
createOtelSpanEnricher()(ctx)
expect(ctx.event.traceId).toBeUndefined()
})
})

describe('createDefaultEnrichers', () => {
it('composes user agent, geo, request size, and trace context enrichers', async () => {
const enrich = createDefaultEnrichers()
Expand Down
Loading
Loading