Skip to content
28 changes: 28 additions & 0 deletions src/app/api/config/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
import {NextResponse} from "next/server"
import {ClientOtelConfig} from "@core/util/open-telemetry"

// Evaluate on every request so the values reflect the current runtime
// environment rather than being cached from build/first render.
export const dynamic = "force-dynamic"

/**
* Serves the browser OpenTelemetry configuration from server-side environment
* variables at request time. This lets the client telemetry endpoints be
* configured at runtime instead of being inlined into the bundle at build time.
*/
export function GET() {

const config: ClientOtelConfig = {
tracesEndpoint: process.env.NEXT_PUBLIC_OTEL_TRACES_ENDPOINT ?? null,
logsEndpoint: process.env.NEXT_PUBLIC_OTEL_LOGS_ENDPOINT ?? null,
header: process.env.NEXT_PUBLIC_OTEL_HEADER ?? null,
serviceName: process.env.NEXT_PUBLIC_OTEL_SERVICE_NAME ?? null,
environment: process.env.NEXT_PUBLIC_OTEL_ENVIRONMENT ?? null,
version: process.env.NEXT_PUBLIC_SCULPTOR_VERSION ?? "0.0.0",
edition: process.env.NEXT_PUBLIC_EDITION ?? "edition",
}

return NextResponse.json(config, {
headers: {"Cache-Control": "no-store"},
})
}
24 changes: 15 additions & 9 deletions src/instrumentation-client.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,17 @@
import "@opentelemetry/api-logs"
import initializeLogs, {openTelemetryClientLogsProvider} from "@core/util/open-telemetry-logs"
import initializeTraces, {openTelemetryClientTracesProvider} from "@core/util/open-telemetry-traces"
import {logs} from "@opentelemetry/api-logs"
import {trace} from "@opentelemetry/api"
import {ClientOtelConfig} from "@core/util/open-telemetry"
import {initializeClientLogs} from "@core/util/open-telemetry-logs"
import {initializeClientTraces} from "@core/util/open-telemetry-traces"

initializeLogs("client")
if (openTelemetryClientLogsProvider) logs.setGlobalLoggerProvider(openTelemetryClientLogsProvider)

initializeTraces("client")
if (openTelemetryClientTracesProvider) trace.setGlobalTracerProvider(openTelemetryClientTracesProvider)
// The browser OpenTelemetry configuration is fetched from the server at runtime
// (see /api/config) instead of relying on build-time inlined NEXT_PUBLIC_*
// variables, so the collector endpoints can be changed without rebuilding.
fetch("/api/config")
.then((response) => response.json())
.then((config: ClientOtelConfig) => {
initializeClientLogs(config)
initializeClientTraces(config)
})
.catch(() => {
// Telemetry is best-effort; ignore config fetch/initialization failures.
})
18 changes: 18 additions & 0 deletions src/instrumentation-node.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
import {logs} from '@opentelemetry/api-logs'
import {metrics, trace} from "@opentelemetry/api"
import initializeMetrics, {openTelemetryMetricProvider} from "@core/util/open-telemetry-metrics"
import initializeLogs, {openTelemetryServerLogsProvider} from "@core/util/open-telemetry-logs"
import initializeTraces, {openTelemetryServerTracesProvider} from "@core/util/open-telemetry-traces"

// Loaded exclusively from `instrumentation.ts` when NEXT_RUNTIME === "nodejs".
// Keeping the Node OTel SDK behind a dynamic import prevents it from being
// pulled into the Edge Runtime bundle (see instrumentation.ts).

if (openTelemetryMetricProvider) metrics.setGlobalMeterProvider(openTelemetryMetricProvider)
initializeMetrics()

if (openTelemetryServerLogsProvider) logs.setGlobalLoggerProvider(openTelemetryServerLogsProvider)
initializeLogs()

if (openTelemetryServerTracesProvider) trace.setGlobalTracerProvider(openTelemetryServerTracesProvider)
initializeTraces()
33 changes: 11 additions & 22 deletions src/instrumentation.ts
Original file line number Diff line number Diff line change
@@ -1,24 +1,13 @@
import {logs} from '@opentelemetry/api-logs'
import {metrics, trace} from "@opentelemetry/api"
import initializeMetrics, {openTelemetryMetricProvider} from "@core/util/open-telemetry-metrics"
import initializeLogs, {openTelemetryServerLogsProvider} from "@core/util/open-telemetry-logs"
import initializeTraces, {openTelemetryServerTracesProvider} from "@core/util/open-telemetry-traces"

export function register() {

if (openTelemetryMetricProvider) metrics.setGlobalMeterProvider(openTelemetryMetricProvider)
initializeMetrics()

if (openTelemetryServerLogsProvider) logs.setGlobalLoggerProvider(openTelemetryServerLogsProvider)
initializeLogs()

if (openTelemetryServerTracesProvider) trace.setGlobalTracerProvider(openTelemetryServerTracesProvider)
initializeTraces()
export async function register() {

// The OpenTelemetry Node SDK (metrics, logs, traces) relies on Node.js APIs
// such as `process.cpuUsage`, which are not available in the Edge Runtime.
// Next.js runs `register()` for every runtime (including the Edge runtime
// used by `middleware.ts`), so we must only load the Node instrumentation
// when actually running under Node. Using a dynamic import keeps the Node
// SDK out of the Edge bundle entirely.
if (process.env.NEXT_RUNTIME === "nodejs") {
await import("./instrumentation-node")
}

}






59 changes: 40 additions & 19 deletions src/packages/core/src/util/open-telemetry-logs.ts
Original file line number Diff line number Diff line change
@@ -1,37 +1,27 @@
import {OTLPLogExporter} from "@opentelemetry/exporter-logs-otlp-http"
import {BatchLogRecordProcessor, LoggerProvider} from "@opentelemetry/sdk-logs"
import {resource, serverResource} from "@core/util/open-telemetry"
import {buildClientResource, ClientOtelConfig, serverResource} from "@core/util/open-telemetry"
import {recordException} from "@core/util/open-telemetry-exceptions"
import {logs} from "@opentelemetry/api-logs"
import {parseHeaders} from "@core/util/headers";

export const openTelemetryServerLogsReader = process.env.OTEL_LOGS_ENDPOINT ? new OTLPLogExporter({
url: process.env.OTEL_LOGS_ENDPOINT,
headers: parseHeaders(process.env.OTEL_HEADER),
}) : undefined

export const openTelemetryClientLogsReader = process.env.NEXT_PUBLIC_OTEL_LOGS_ENDPOINT ? new OTLPLogExporter({
url: process.env.NEXT_PUBLIC_OTEL_LOGS_ENDPOINT,
headers: parseHeaders(process.env.NEXT_PUBLIC_OTEL_HEADER),
}) : undefined

export const openTelemetryServerLogsProvider = openTelemetryServerLogsReader ? new LoggerProvider({
resource: serverResource,
processors: [new BatchLogRecordProcessor(openTelemetryServerLogsReader)]
}) : undefined

export const openTelemetryClientLogsProvider = openTelemetryClientLogsReader ? new LoggerProvider({
resource: resource,
processors: [new BatchLogRecordProcessor(openTelemetryClientLogsReader)]
}) : undefined

export default (level: 'server' | "client" = "server") => {
/**
* Patches the global `console` methods to forward messages to the given
* OpenTelemetry logger provider (in addition to the original console output).
*/
const instrumentConsole = (provider: LoggerProvider) => {

if (level === 'server' && !openTelemetryServerLogsProvider) return
if (level === 'client' && !openTelemetryClientLogsProvider) return

const logger = level === "server" ?
openTelemetryServerLogsProvider?.getLogger("default", "1.0.0") :
openTelemetryClientLogsProvider?.getLogger("default", "1.0.0")
const logger = provider.getLogger("default", "1.0.0")

const originalConsole = {
log: console.log,
Expand Down Expand Up @@ -147,4 +137,35 @@ export default (level: 'server' | "client" = "server") => {
originalConsole.debug.apply(console, args)
}

}
}

export default () => {

if (!openTelemetryServerLogsProvider) return
instrumentConsole(openTelemetryServerLogsProvider)
}

/**
* Builds and registers the browser logger provider from runtime configuration,
* then patches `console` to forward log records to it. Returns the provider, or
* undefined when logging is not configured.
*/
export const initializeClientLogs = (config: ClientOtelConfig) => {

if (!config.logsEndpoint) return undefined

const exporter = new OTLPLogExporter({
url: config.logsEndpoint,
headers: parseHeaders(config.header ?? undefined),
})

const provider = new LoggerProvider({
resource: buildClientResource(config),
processors: [new BatchLogRecordProcessor(exporter)]
})

logs.setGlobalLoggerProvider(provider)
instrumentConsole(provider)

return provider
}
72 changes: 43 additions & 29 deletions src/packages/core/src/util/open-telemetry-traces.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,53 +2,67 @@ import {OTLPTraceExporter} from '@opentelemetry/exporter-trace-otlp-http'
import {WebTracerProvider} from '@opentelemetry/sdk-trace-web'
import {BatchSpanProcessor} from '@opentelemetry/sdk-trace-base'
import {XMLHttpRequestInstrumentation} from '@opentelemetry/instrumentation-xml-http-request'
import {resource, serverResource} from "@core/util/open-telemetry"
import {buildClientResource, ClientOtelConfig, serverResource} from "@core/util/open-telemetry"
import {registerInstrumentations} from '@opentelemetry/instrumentation'
import {FetchInstrumentation} from '@opentelemetry/instrumentation-fetch'
import {ZoneContextManager} from '@opentelemetry/context-zone'
import {trace} from "@opentelemetry/api"
import {parseHeaders} from "@core/util/headers";

export const openTelemetryServerTracesReader = process.env.OTEL_TRACES_ENDPOINT ? new OTLPTraceExporter({
url: process.env.OTEL_TRACES_ENDPOINT,
headers: parseHeaders(process.env.OTEL_HEADER),
}) : undefined

export const openTelemetryClientTracesReader = process.env.NEXT_PUBLIC_OTEL_TRACES_ENDPOINT ? new OTLPTraceExporter({
url: process.env.NEXT_PUBLIC_OTEL_TRACES_ENDPOINT || "",
headers: parseHeaders(process.env.NEXT_PUBLIC_OTEL_HEADER),
}) : undefined

export const openTelemetryServerTracesProvider = openTelemetryServerTracesReader ? new WebTracerProvider({
resource: serverResource,
spanProcessors: [new BatchSpanProcessor(openTelemetryServerTracesReader)],
}) : undefined

export const openTelemetryClientTracesProvider = openTelemetryClientTracesReader ? new WebTracerProvider({
resource: resource,
spanProcessors: [new BatchSpanProcessor(openTelemetryClientTracesReader)],
}) : undefined

export default (level: 'server' | "client" = "server") => {
export default () => {

if (level === 'server' && openTelemetryServerTracesProvider) {
if (openTelemetryServerTracesProvider) {
openTelemetryServerTracesProvider.register({
contextManager: new ZoneContextManager(),
})
} else if (level === 'client' && openTelemetryClientTracesProvider) {
openTelemetryClientTracesProvider.register({
contextManager: new ZoneContextManager(),
})

registerInstrumentations({
instrumentations: [
new FetchInstrumentation({
propagateTraceHeaderCorsUrls: /.*/,
}),
new XMLHttpRequestInstrumentation({
propagateTraceHeaderCorsUrls: /.*/,
}),
],
})
}
}

/**
* Builds and registers the browser trace provider from runtime configuration.
* Returns the provider so the caller can install it as the global tracer
* provider, or undefined when tracing is not configured.
*/
export const initializeClientTraces = (config: ClientOtelConfig) => {

if (!config.tracesEndpoint) return undefined

const exporter = new OTLPTraceExporter({
url: config.tracesEndpoint,
headers: parseHeaders(config.header ?? undefined),
})

const provider = new WebTracerProvider({
resource: buildClientResource(config),
spanProcessors: [new BatchSpanProcessor(exporter)],
})

provider.register({
contextManager: new ZoneContextManager(),
})

registerInstrumentations({
instrumentations: [
new FetchInstrumentation({
propagateTraceHeaderCorsUrls: /.*/,
}),
new XMLHttpRequestInstrumentation({
propagateTraceHeaderCorsUrls: /.*/,
}),
],
})

trace.setGlobalTracerProvider(provider)

}
return provider
}
24 changes: 20 additions & 4 deletions src/packages/core/src/util/open-telemetry.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,25 @@
import {resourceFromAttributes} from "@opentelemetry/resources";

export const resource = resourceFromAttributes({
...(process.env.NEXT_PUBLIC_OTEL_SERVICE_NAME ? {'service.name': process.env.NEXT_PUBLIC_OTEL_SERVICE_NAME} : {}),
'service.version': (process.env.NEXT_PUBLIC_SCULPTOR_VERSION ?? "0.0.0") + "-" + (process.env.NEXT_PUBLIC_EDITION ?? "edition"),
...(process.env.NEXT_PUBLIC_OTEL_ENVIRONMENT ? {'deployment.environment.name': process.env.NEXT_PUBLIC_OTEL_ENVIRONMENT} : {}),
/**
* Runtime OpenTelemetry configuration for the browser. These values are served
* by the `/api/config` route handler and read on the server at request
* time, so they can be changed at runtime without rebuilding the app (unlike
* `NEXT_PUBLIC_*` variables, which are inlined at build time).
*/
export interface ClientOtelConfig {
tracesEndpoint?: string | null
logsEndpoint?: string | null
header?: string | null
serviceName?: string | null
environment?: string | null
version?: string | null
edition?: string | null
}

export const buildClientResource = (config: ClientOtelConfig) => resourceFromAttributes({
...(config.serviceName ? {'service.name': config.serviceName} : {}),
'service.version': (config.version ?? "0.0.0") + "-" + (config.edition ?? "edition"),
...(config.environment ? {'deployment.environment.name': config.environment} : {}),
});

export const serverResource = resourceFromAttributes({
Expand Down