Skip to content
Merged
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
12 changes: 8 additions & 4 deletions packages/next/src/server/lib/trace/tracer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -285,14 +285,18 @@ class NextTracerImpl implements NextTracer {
let spanContext = this.getSpanContext(
options?.parentSpan ?? this.getActiveScopeSpan()
)
let isRootSpan = false

if (!spanContext) {
spanContext = context?.active() ?? ROOT_CONTEXT
isRootSpan = true
} else if (trace.getSpanContext(spanContext)?.isRemote) {
isRootSpan = true
}
// Check if there's already a root span in the store for this trace
// We are intentionally not checking whether there is an active context
// from outside of nextjs to ensure that we can provide the same level
// of telemetry when using a custom server
const existingRootSpanId = spanContext.getValue(rootSpanIdKey)
const isRootSpan =
typeof existingRootSpanId !== 'number' ||
!rootSpanAttributesStore.has(existingRootSpanId)

const spanId = getSpanId()

Expand Down
49 changes: 49 additions & 0 deletions test/e2e/opentelemetry/instrumentation/custom-server.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
import { createServer } from 'http'
import { parse } from 'url'
import next from 'next'
import getPort from 'get-port'
import { trace } from '@opentelemetry/api'

import { register } from './instrumentation-custom-server'
register()

async function main() {
const port = await getPort()
const hostname = 'localhost'

const app = next({
dev: process.env.NODE_ENV === 'development',
hostname,
port,
dir: __dirname,
})
const handle = app.getRequestHandler()

await app.prepare()

const tracer = trace.getTracer('custom-server', '1.0.0')

createServer((req, res) => {
// Create a local parent span to simulate custom server behavior
tracer.startActiveSpan('custom-server-request', async (span) => {
try {
const parsedUrl = parse(req.url!, true)
await handle(req, res, parsedUrl)
span.end()
} catch (err) {
span.recordException(err as Error)
span.end()
res.statusCode = 500
res.end('Internal Server Error')
}
})
}).listen(port, undefined, (err?: Error) => {
if (err) throw err
console.log(`- Local: http://${hostname}:${port}`)
})
}

main().catch((err) => {
console.error(err)
process.exit(1)
})
Original file line number Diff line number Diff line change
@@ -0,0 +1,107 @@
import { Resource } from '@opentelemetry/resources'
import { SemanticResourceAttributes } from '@opentelemetry/semantic-conventions'
import { NodeTracerProvider } from '@opentelemetry/sdk-trace-node'
import { SimpleSpanProcessor } from '@opentelemetry/sdk-trace-base'
import {
ExportResult,
ExportResultCode,
hrTimeToMicroseconds,
} from '@opentelemetry/core'
import type { ReadableSpan, SpanExporter } from '@opentelemetry/sdk-trace-base'

interface SerializedSpan {
runtime: string | undefined
traceId: string
parentId: string | undefined
traceState: string | undefined
name: string
id: string
kind: number
timestamp: number
duration: number
attributes: Record<string, unknown>
status: { code: number; message?: string }
events: ReadableSpan['events']
links: ReadableSpan['links']
}

const serializeSpan = (span: ReadableSpan): SerializedSpan => ({
runtime: process.env.NEXT_RUNTIME,
traceId: span.spanContext().traceId,
parentId: span.parentSpanId,
traceState: span.spanContext().traceState?.serialize(),
name: span.name,
id: span.spanContext().spanId,
kind: span.kind,
timestamp: hrTimeToMicroseconds(span.startTime),
duration: hrTimeToMicroseconds(span.duration),
attributes: span.attributes,
status: span.status,
events: span.events,
links: span.links,
})

class TestExporter implements SpanExporter {
private port: string

constructor(port: string) {
this.port = port
}

async export(
spans: ReadableSpan[],
resultCallback: (result: ExportResult) => void
): Promise<void> {
try {
const response = await fetch(`http://localhost:${this.port}`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify(spans.map(serializeSpan)),
})
try {
await response.arrayBuffer()
} catch (e) {
// ignore
}
if (response.status >= 400) {
console.warn('WARN: TestExporter: response status:', response.status)
return resultCallback({
code: ExportResultCode.FAILED,
error: new Error(`http status ${response.status}`),
})
}
} catch (e) {
console.warn('WARN: TestExporter: error:', e)
return resultCallback({
code: ExportResultCode.FAILED,
error: e as Error,
})
}

resultCallback({ code: ExportResultCode.SUCCESS })
}

shutdown(): Promise<void> {
return Promise.resolve()
}
}

export function register() {
const provider = new NodeTracerProvider({
resource: new Resource({
[SemanticResourceAttributes.SERVICE_NAME]: 'custom-server-test',
}),
})

const port = process.env.TEST_OTEL_COLLECTOR_PORT

if (!port) {
throw new Error('TEST_OTEL_COLLECTOR_PORT is not set')
}

provider.addSpanProcessor(new SimpleSpanProcessor(new TestExporter(port)))

provider.register()
}
179 changes: 178 additions & 1 deletion test/e2e/opentelemetry/instrumentation/opentelemetry.test.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { nextTestSetup } from 'e2e-utils'
import { isNextDev, nextTestSetup } from 'e2e-utils'
import { check } from 'next-test-utils'
import { NEXT_RSC_UNION_QUERY } from 'next/dist/client/components/app-router-headers'

Expand Down Expand Up @@ -1118,6 +1118,183 @@ describe('opentelemetry with disabled fetch tracing', () => {
)
})

describe('opentelemetry with custom server', () => {
const { next, skipped } = nextTestSetup({
files: __dirname,
skipDeployment: true,
dependencies: require('./package.json').dependencies,
startCommand: 'pnpm start',
packageJson: {
scripts: {
start: 'pnpm tsx custom-server.ts',
},
},
serverReadyPattern: /- Local:/,
env: {
TEST_OTEL_COLLECTOR_PORT: String(COLLECTOR_PORT),
NEXT_TELEMETRY_DISABLED: '1',
NODE_ENV: isNextDev ? 'development' : 'production',
},
})

if (skipped) {
return
}

let collector: Collector

function getCollector(): Collector {
return collector
}

beforeEach(async () => {
collector = await connectCollector({ port: COLLECTOR_PORT })
})

afterEach(async () => {
await collector.shutdown()
})

it('should set attributes correctly on handleRequest span', async () => {
await next.fetch('/app/param/rsc-fetch')

await expectTrace(getCollector(), [
{
name: 'custom-server-request',
traceId: '[trace-id]',
parentId: undefined,
spans: [
{
name: 'GET /app/[param]/rsc-fetch',
attributes: {
'http.method': 'GET',
'http.route': '/app/[param]/rsc-fetch',
'http.status_code': 200,
'http.target': '/app/param/rsc-fetch',
'next.route': '/app/[param]/rsc-fetch',
'next.rsc': false,
'next.span_name': 'GET /app/[param]/rsc-fetch',
'next.span_type': 'BaseServer.handleRequest',
},
kind: 1,
status: { code: 0 },
spans: [
{
name: 'render route (app) /app/[param]/rsc-fetch',
attributes: {
'next.route': '/app/[param]/rsc-fetch',
'next.span_name': 'render route (app) /app/[param]/rsc-fetch',
'next.span_type': 'AppRender.getBodyResult',
},
kind: 0,
status: { code: 0 },
spans: [
{
name: 'build component tree',
attributes: {
'next.span_name': 'build component tree',
'next.span_type': 'NextNodeServer.createComponentTree',
},
kind: 0,
status: { code: 0 },
spans: [
{
name: 'resolve segment modules',
attributes: {
'next.segment': '__PAGE__',
'next.span_name': 'resolve segment modules',
'next.span_type':
'NextNodeServer.getLayoutOrPageModule',
},
kind: 0,
status: { code: 0 },
},
{
name: 'resolve segment modules',
attributes: {
'next.segment': '[param]',
'next.span_name': 'resolve segment modules',
'next.span_type':
'NextNodeServer.getLayoutOrPageModule',
},
kind: 0,
status: { code: 0 },
},
],
},
{
name: 'fetch GET https://example.vercel.sh/',
attributes: {
'http.method': 'GET',
'http.url': 'https://example.vercel.sh/',
'net.peer.name': 'example.vercel.sh',
'next.span_name': 'fetch GET https://example.vercel.sh/',
'next.span_type': 'AppRender.fetch',
},
kind: 2,
status: { code: 0 },
},
{
name: 'generateMetadata /app/[param]/layout',
attributes: {
'next.page': '/app/[param]/layout',
'next.span_name': 'generateMetadata /app/[param]/layout',
'next.span_type': 'ResolveMetadata.generateMetadata',
},
kind: 0,
status: { code: 0 },
},
{
name: 'generateMetadata /app/[param]/rsc-fetch/page',
attributes: {
'next.page': '/app/[param]/rsc-fetch/page',
'next.span_name':
'generateMetadata /app/[param]/rsc-fetch/page',
'next.span_type': 'ResolveMetadata.generateMetadata',
},
kind: 0,
status: { code: 0 },
},
{
attributes: {
'next.clientComponentLoadCount': isNextDev ? 7 : 6,
'next.span_type': 'NextNodeServer.clientComponentLoading',
},
kind: 0,
name: 'NextNodeServer.clientComponentLoading',
status: {
code: 0,
},
},
{
name: 'start response',
attributes: {
'next.span_name': 'start response',
'next.span_type': 'NextNodeServer.startResponse',
},
kind: 0,
status: { code: 0 },
},
],
},
{
name: 'resolve page components',
attributes: {
'next.route': '/app/[param]/rsc-fetch',
'next.span_name': 'resolve page components',
'next.span_type': 'NextNodeServer.findPageComponents',
},
kind: 0,
status: { code: 0 },
},
],
},
],
},
])
})
})

type HierSavedSpan = SavedSpan & { spans?: HierSavedSpan[] }
type SpanMatch = Omit<Partial<HierSavedSpan>, 'spans'> & { spans?: SpanMatch[] }

Expand Down
2 changes: 2 additions & 0 deletions test/e2e/opentelemetry/instrumentation/package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
{
"dependencies": {
"fs-extra": "^8.0.0",
"tsx": "4.20.6",
"get-port": "5.1.1",
"@types/fs-extra": "^8.0.0",
"@opentelemetry/api": "^1.7.0",
"@opentelemetry/context-async-hooks": "^1.21.0",
Expand Down
Loading