diff --git a/app/plugins/render-tracer.server.ts b/app/plugins/render-tracer.server.ts new file mode 100644 index 0000000..cfa6fd6 --- /dev/null +++ b/app/plugins/render-tracer.server.ts @@ -0,0 +1,23 @@ +import { trace } from '@opentelemetry/api' +import { finishRenderSpan, getRenderTrace, startRenderSpan } from '../../utils/render-trace' + +export default defineNuxtPlugin({ + name: 'comark-render-tracer', + enforce: 'pre', + setup(nuxtApp) { + const event = nuxtApp.ssrContext?.event + if (!event) return + + const state = getRenderTrace(event) + if (!state?.render) return + + nuxtApp.hook('app:rendered', () => { + finishRenderSpan(state, 'vue') + state.finalize = startRenderSpan( + trace.getTracer('comark-content'), + state.isPayload ? 'nuxt:render:payload' : 'nuxt:render:finalize', + state.render + ) + }) + }, +}) diff --git a/server/plugins/tracer.ts b/server/plugins/tracer.ts index 91266a7..a87f027 100644 --- a/server/plugins/tracer.ts +++ b/server/plugins/tracer.ts @@ -1,5 +1,13 @@ -import { context, propagation } from '@opentelemetry/api' -import { ensureLocalTracer, localTraceEnabled, shutdownLocalTracer } from '../utils/tracer' +import { context, propagation, SpanStatusCode } from '@opentelemetry/api' +import type { H3Event } from 'h3' +import { + createRenderTrace, + finishRenderSpan, + getRenderTrace, + startRenderSpan, + type RenderTraceState, +} from '../../utils/render-trace' +import { contentTracer, ensureLocalTracer, localTraceEnabled, shutdownLocalTracer } from '../utils/tracer' import { registerOTel } from '@vercel/otel' export default defineNitroPlugin((nitro) => { @@ -18,4 +26,92 @@ export default defineNitroPlugin((nitro) => { const requestContext = propagation.extract(context.active(), getRequestHeaders(event)) return context.with(requestContext, () => handler(event)) } + + const tracer = contentTracer() + if (!tracer) return + + const startRequestTrace = (event: H3Event) => { + const path = event.path + const isPayload = /(?:^|\/)_payload\.json(?:\?|$)/.test(path) + const state = createRenderTrace(event, isPayload) + state.request = startRenderSpan(tracer, 'nitro:request', undefined, { + 'http.request.method': event.method, + 'url.path': path, + 'nuxt.render.payload': isPayload, + }) + return state + } + + nitro.hooks.hook('request', (event) => { + const accept = getRequestHeader(event, 'accept') || '' + const isPayload = /(?:^|\/)_payload\.json(?:\?|$)/.test(event.path) + if (isPayload || accept.includes('text/html')) startRequestTrace(event) + }) + + nitro.hooks.hook('render:before', ({ event }) => { + const state = getRenderTrace(event) || startRequestTrace(event) + state.render = startRenderSpan(tracer, 'nuxt:render', state.request, { + 'nuxt.render.payload': state.isPayload, + 'nitro.cache.enabled': Boolean(event.context.cache), + }) + }) + + nitro.hooks.hook('render:route', ({ canStream, prefersStream }, { event }) => { + const state = getRenderTrace(event) + if (!state?.render) return + state.vue = startRenderSpan(tracer, 'nuxt:render:vue', state.render, { + 'nuxt.render.can_stream': canStream, + 'nuxt.render.prefers_stream': prefersStream, + }) + }) + + nitro.hooks.hook('render:html', (_html, { event, streaming }) => { + // Streaming calls this hook before Vue renders the body. ISR and payload + // responses are buffered, which gives us the post-render boundary below. + if (streaming) return + const state = getRenderTrace(event) + if (!state?.render) return + finishRenderSpan(state, 'finalize') + state.html = startRenderSpan(tracer, 'nuxt:render:html', state.render) + }) + + nitro.hooks.hook('render:response', (response, { event }) => { + const state = getRenderTrace(event) + if (!state) return + + finishRenderSpan(state, 'vue') + finishRenderSpan(state, 'finalize') + finishRenderSpan(state, 'html') + if (response.statusCode) state.render?.setAttribute('http.response.status_code', response.statusCode) + if (typeof response.body === 'string') { + state.render?.setAttribute('http.response.body.size', new TextEncoder().encode(response.body).byteLength) + } + finishRenderSpan(state, 'render') + state.response = startRenderSpan(tracer, 'nitro:response', state.request) + }) + + nitro.hooks.hook('afterResponse', (event) => { + const state = getRenderTrace(event) + if (!state) return + finishRenderSpan(state, 'response') + finishRenderSpan(state, 'request') + }) + + nitro.hooks.hook('error', (error, { event }) => { + if (!event) return + const state = getRenderTrace(event) + if (!state) return + markRenderTraceFailed(state, error) + }) }) + +function markRenderTraceFailed(state: RenderTraceState, error: unknown): void { + const exception = error instanceof Error ? error : String(error) + for (const key of ['vue', 'finalize', 'html', 'render', 'response', 'request'] as const) { + const span = state[key] + if (!span) continue + span.recordException(exception) + span.setStatus({ code: SpanStatusCode.ERROR }) + finishRenderSpan(state, key) + } +} diff --git a/utils/render-trace.ts b/utils/render-trace.ts new file mode 100644 index 0000000..841d78c --- /dev/null +++ b/utils/render-trace.ts @@ -0,0 +1,44 @@ +import { context, trace, type Attributes, type Span, type Tracer } from '@opentelemetry/api' +import type { H3Event } from 'h3' + +export interface RenderTraceState { + request?: Span + render?: Span + vue?: Span + finalize?: Span + html?: Span + response?: Span + isPayload: boolean +} + +type TracedEventContext = H3Event['context'] & { + _comarkRenderTrace?: RenderTraceState +} + +export function getRenderTrace(event: H3Event): RenderTraceState | undefined { + return (event.context as TracedEventContext)._comarkRenderTrace +} + +export function createRenderTrace(event: H3Event, isPayload: boolean): RenderTraceState { + const state = { isPayload } + ;(event.context as TracedEventContext)._comarkRenderTrace = state + return state +} + +export function startRenderSpan( + tracer: Tracer, + name: string, + parent?: Span, + attributes?: Attributes +): Span { + const parentContext = parent ? trace.setSpan(context.active(), parent) : context.active() + return tracer.startSpan(name, { attributes }, parentContext) +} + +export function finishRenderSpan( + state: RenderTraceState, + key: Exclude +): void { + state[key]?.end() + state[key] = undefined +}