From b9f17a6e5411c239ea3a75732110cb250d7778aa Mon Sep 17 00:00:00 2001 From: Chris Chudzicki Date: Fri, 24 Apr 2026 16:13:06 -0400 Subject: [PATCH 01/10] Add OTEL resource overrides and request span logs Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- env/frontend.env | 10 +- frontends/main/src/instrumentation-node.ts | 82 +++++++- frontends/main/src/otel-utils.test.ts | 211 +++++++++++++++++++++ frontends/main/src/otel-utils.ts | 161 ++++++++++++++++ 4 files changed, 455 insertions(+), 9 deletions(-) create mode 100644 frontends/main/src/otel-utils.test.ts create mode 100644 frontends/main/src/otel-utils.ts diff --git a/env/frontend.env b/env/frontend.env index 0ca5e8be54..70fd0a5b3d 100644 --- a/env/frontend.env +++ b/env/frontend.env @@ -44,11 +44,13 @@ GTM_COOKIES_WIN=${GTM_COOKIES_WIN} # OpenTelemetry tracing (server-side only — no NEXT_PUBLIC_ prefix needed) # These are read at runtime by the OTEL NodeSDK and injected by Kubernetes for # deployed environments. Sampling is disabled locally (0.0); set -# OTEL_EXPORTER_OTLP_ENDPOINT and OTEL_TRACES_SAMPLER_ARG in your K8s/Helm -# values to enable tracing in staging/production. +# OTEL_EXPORTER_OTLP_TRACES_ENDPOINT (or OTEL_EXPORTER_OTLP_ENDPOINT) and +# OTEL_TRACES_SAMPLER_ARG in your K8s/Helm values to enable tracing in +# staging/production. # # OTEL_SERVICE_NAME=mit-learn-frontend # OTEL_EXPORTER_OTLP_ENDPOINT=http://alloy.monitoring:4318 +# OTEL_EXPORTER_OTLP_TRACES_ENDPOINT=http://alloy.monitoring:4318/v1/traces # LOCAL TRACE TESTING (no Grafana Alloy required) # Uncomment both lines below to print every completed span as JSON to the @@ -65,3 +67,7 @@ GTM_COOKIES_WIN=${GTM_COOKIES_WIN} # } # # OTEL_TRACES_EXPORTER=console + +# Optional: emit one JSON log line per completed server request span. +# Useful for basic request/response timing stats in log aggregation systems. +# NEXT_SERVER_REQUEST_LOGGING=true diff --git a/frontends/main/src/instrumentation-node.ts b/frontends/main/src/instrumentation-node.ts index e9d16cab74..eede89d80f 100644 --- a/frontends/main/src/instrumentation-node.ts +++ b/frontends/main/src/instrumentation-node.ts @@ -4,13 +4,20 @@ // the separate sentry.server.config.ts that was generated by the Sentry wizard. import * as Sentry from "@sentry/nextjs" +import type { Context, Span } from "@opentelemetry/api" import { BatchSpanProcessor, ConsoleSpanExporter, SimpleSpanProcessor, } from "@opentelemetry/sdk-trace-base" -import type { SpanProcessor } from "@opentelemetry/sdk-trace-base" +import type { ReadableSpan, SpanProcessor } from "@opentelemetry/sdk-trace-base" import { OTLPTraceExporter } from "@opentelemetry/exporter-trace-otlp-http" +import { + applyResourceOverrides, + createRequestLogEntry, + parseServiceResourceOverrides, + type ServiceResourceOverrides, +} from "./otel-utils" import { parseSampleRate } from "./sentry-utils" /** @@ -24,17 +31,78 @@ import { parseSampleRate } from "./sentry-utils" * LOCAL TESTING (no Grafana Alloy required): * Set OTEL_TRACES_EXPORTER=console and OTEL_TRACES_SAMPLER_ARG=1.0 to print * completed spans as JSON to stdout. See env/frontend.env for details. + * + * REQUEST TIMING LOGS: + * Set NEXT_SERVER_REQUEST_LOGGING=true to print one structured JSON log line + * for each completed server request span (method, route, status, duration). */ function buildSpanProcessors(): SpanProcessor[] { + const processors: SpanProcessor[] = [] + + if ( + process.env.OTEL_SERVICE_NAME || + process.env.OTEL_RESOURCE_ATTRIBUTES || + process.env.NEXT_PUBLIC_VERSION + ) { + processors.push(new ResourceAttributeOverrideSpanProcessor(process.env)) + } + if (process.env.OTEL_EXPORTER_OTLP_ENDPOINT) { - // OTLPTraceExporter reads OTEL_EXPORTER_OTLP_ENDPOINT from env and appends - // /v1/traces automatically. - return [new BatchSpanProcessor(new OTLPTraceExporter())] + processors.push(new BatchSpanProcessor(new OTLPTraceExporter())) + } else if (process.env.OTEL_TRACES_EXPORTER === "console") { + processors.push(new SimpleSpanProcessor(new ConsoleSpanExporter())) + } + + if (process.env.NEXT_SERVER_REQUEST_LOGGING === "true") { + processors.push(new RequestLogSpanProcessor()) + } + + return processors +} + +class ResourceAttributeOverrideSpanProcessor implements SpanProcessor { + private readonly overrides: ServiceResourceOverrides + + constructor(env: NodeJS.ProcessEnv) { + this.overrides = parseServiceResourceOverrides(env) + } + + onStart(_span: Span, _parentContext: Context): void { + // no-op } - if (process.env.OTEL_TRACES_EXPORTER === "console") { - return [new SimpleSpanProcessor(new ConsoleSpanExporter())] + + onEnd(span: ReadableSpan): void { + applyResourceOverrides(span, this.overrides) + } + + shutdown(): Promise { + return Promise.resolve() + } + + forceFlush(): Promise { + return Promise.resolve() + } +} + +class RequestLogSpanProcessor implements SpanProcessor { + onStart(_span: Span, _parentContext: Context): void { + // no-op + } + + onEnd(span: ReadableSpan): void { + const entry = createRequestLogEntry(span) + if (entry) { + console.info(JSON.stringify(entry)) + } + } + + shutdown(): Promise { + return Promise.resolve() + } + + forceFlush(): Promise { + return Promise.resolve() } - return [] } // OTEL_TRACES_SAMPLER_ARG controls the OTEL sampler rate — i.e. what fraction diff --git a/frontends/main/src/otel-utils.test.ts b/frontends/main/src/otel-utils.test.ts new file mode 100644 index 0000000000..c3779b5287 --- /dev/null +++ b/frontends/main/src/otel-utils.test.ts @@ -0,0 +1,211 @@ +import { SpanKind, SpanStatusCode, TraceFlags } from "@opentelemetry/api" +import type { ReadableSpan } from "@opentelemetry/sdk-trace-base" +import { mergeOverrides, PartialFactory } from "ol-test-utilities" +import { + applyResourceOverrides, + createRequestLogEntry, + parseServiceResourceOverrides, +} from "./otel-utils" + +function makeResource( + attributes: ReadableSpan["resource"]["attributes"] = {}, +): ReadableSpan["resource"] { + const resource: ReadableSpan["resource"] = { + attributes, + merge(other) { + return other ?? resource + }, + getRawAttributes() { + return Object.entries(resource.attributes) + }, + } + return resource +} + +const makeReadableSpan: PartialFactory = (overrides = {}) => { + const base: ReadableSpan = { + name: "test span", + kind: SpanKind.INTERNAL, + spanContext: () => ({ + traceId: "trace-id", + spanId: "span-id", + traceFlags: TraceFlags.SAMPLED, + }), + startTime: [0, 0], + endTime: [0, 0], + status: { code: SpanStatusCode.UNSET }, + attributes: {}, + links: [], + events: [], + duration: [0, 0], + ended: true, + resource: makeResource(), + instrumentationScope: { name: "test-scope", version: "1.0.0" }, + droppedAttributesCount: 0, + droppedEventsCount: 0, + droppedLinksCount: 0, + } + + return mergeOverrides(base, overrides) +} + +describe("createRequestLogEntry", () => { + it("creates a request log entry for server spans", () => { + const span = makeReadableSpan({ + kind: SpanKind.SERVER, + name: "GET /courses", + attributes: { + "http.request.method": "GET", + "url.path": "/courses", + "http.response.status_code": 200, + }, + duration: [1, 250_000_000], + }) + + expect(createRequestLogEntry(span)).toEqual({ + message: "next_request", + method: "GET", + route: "/courses", + statusCode: 200, + durationMs: 1250, + traceId: "trace-id", + spanId: "span-id", + name: "GET /courses", + }) + }) + + it("returns null for non-server spans", () => { + const span = makeReadableSpan({ + kind: SpanKind.CLIENT, + name: "GET api", + duration: [0, 100_000], + }) + + expect(createRequestLogEntry(span)).toBeNull() + }) +}) + +describe("applyResourceOverrides", () => { + it("overrides resource service.name when OTEL_SERVICE_NAME is set", () => { + const span = makeReadableSpan({ + resource: makeResource({ + "service.name": "node", + "service.namespace": "sentry", + }), + }) + + const overrides = parseServiceResourceOverrides({ + OTEL_SERVICE_NAME: "learn-nextjs", + }) + applyResourceOverrides(span, overrides) + + expect(span.resource.attributes["service.name"]).toBe("learn-nextjs") + expect(span.resource.attributes["service.namespace"]).toBe("sentry") + }) + + it("does not change resource attributes when OTEL_SERVICE_NAME is unset", () => { + const span = makeReadableSpan({ + resource: makeResource({ + "service.name": "node", + }), + }) + + const overrides = parseServiceResourceOverrides({}) + applyResourceOverrides(span, overrides) + + expect(span.resource.attributes["service.name"]).toBe("node") + }) + + it("overrides service namespace and version from OTEL_RESOURCE_ATTRIBUTES", () => { + const span = makeReadableSpan({ + resource: makeResource({ + "service.name": "node", + "service.namespace": "sentry", + "service.version": "10.50.0", + }), + }) + + const overrides = parseServiceResourceOverrides({ + OTEL_RESOURCE_ATTRIBUTES: + "service.namespace=my-namespace,service.version=2026.04.24", + }) + applyResourceOverrides(span, overrides) + + expect(span.resource.attributes["service.name"]).toBe("node") + expect(span.resource.attributes["service.namespace"]).toBe("my-namespace") + expect(span.resource.attributes["service.version"]).toBe("2026.04.24") + }) + + it("prefers OTEL_SERVICE_NAME over service.name in OTEL_RESOURCE_ATTRIBUTES", () => { + const span = makeReadableSpan({ + resource: makeResource({ + "service.name": "node", + }), + }) + + const overrides = parseServiceResourceOverrides({ + OTEL_SERVICE_NAME: "env-service-name", + OTEL_RESOURCE_ATTRIBUTES: + "service.name=resource-attrs-name,service.namespace=my-namespace", + }) + applyResourceOverrides(span, overrides) + + expect(span.resource.attributes["service.name"]).toBe("env-service-name") + expect(span.resource.attributes["service.namespace"]).toBe("my-namespace") + }) + + it("applies arbitrary resource attributes from OTEL_RESOURCE_ATTRIBUTES", () => { + const span = makeReadableSpan({ + resource: makeResource({ + "service.name": "node", + }), + }) + + const overrides = parseServiceResourceOverrides({ + OTEL_RESOURCE_ATTRIBUTES: + "deployment.environment.name=prod,cloud.region=us-east-1", + }) + applyResourceOverrides(span, overrides) + + expect(span.resource.attributes["deployment.environment.name"]).toBe("prod") + expect(span.resource.attributes["cloud.region"]).toBe("us-east-1") + }) + + it("overrides service.version from NEXT_PUBLIC_VERSION", () => { + const span = makeReadableSpan({ + resource: makeResource({ + "service.version": "10.50.0", + }), + }) + + const overrides = parseServiceResourceOverrides({ + NEXT_PUBLIC_VERSION: "release-2026-04-24", + OTEL_RESOURCE_ATTRIBUTES: "service.version=resource-attrs-version", + }) + applyResourceOverrides(span, overrides) + + expect(span.resource.attributes["service.version"]).toBe( + "release-2026-04-24", + ) + }) +}) + +describe("parseServiceResourceOverrides", () => { + it("parses OTEL_RESOURCE_ATTRIBUTES and OTEL_SERVICE_NAME into one override object", () => { + expect( + parseServiceResourceOverrides({ + OTEL_SERVICE_NAME: "app-name", + NEXT_PUBLIC_VERSION: "release-1.2.3", + OTEL_RESOURCE_ATTRIBUTES: + "service.namespace=learn,service.version=1.2.3", + }), + ).toEqual({ + serviceName: "app-name", + serviceVersion: "release-1.2.3", + resourceAttributes: { + "service.namespace": "learn", + "service.version": "1.2.3", + }, + }) + }) +}) diff --git a/frontends/main/src/otel-utils.ts b/frontends/main/src/otel-utils.ts new file mode 100644 index 0000000000..26acc69fe3 --- /dev/null +++ b/frontends/main/src/otel-utils.ts @@ -0,0 +1,161 @@ +import { SpanKind } from "@opentelemetry/api" +import type { ReadableSpan } from "@opentelemetry/sdk-trace-base" + +const REQUEST_METHOD_KEYS = ["http.request.method", "http.method"] as const +const REQUEST_ROUTE_KEYS = [ + "http.route", + "url.path", + "http.target", + "url.full", +] as const +const RESPONSE_STATUS_KEYS = [ + "http.response.status_code", + "http.status_code", +] as const + +export type RequestLogEntry = { + message: "next_request" + method: string + route: string + statusCode: number | null + durationMs: number + traceId: string + spanId: string + name: string +} + +type ServiceNameEnvSubset = Readonly> +export type ServiceResourceOverrides = { + resourceAttributes: Record + serviceName?: string + serviceVersion?: string +} + +function getNonEmptyEnvValue(value: string | undefined): string | undefined { + const trimmed = value?.trim() + return trimmed ? trimmed : undefined +} + +function getResourceOverrides( + env: ServiceNameEnvSubset, +): Record { + const overrides: Record = {} + const attributes = getNonEmptyEnvValue(env.OTEL_RESOURCE_ATTRIBUTES) + if (!attributes) { + return overrides + } + + for (const assignment of attributes.split(",")) { + const trimmedAssignment = assignment.trim() + if (!trimmedAssignment) { + continue + } + const separatorIndex = trimmedAssignment.indexOf("=") + if (separatorIndex <= 0) { + continue + } + const key = trimmedAssignment.slice(0, separatorIndex).trim() + const value = getNonEmptyEnvValue( + trimmedAssignment.slice(separatorIndex + 1), + ) + if (!value) { + continue + } + + overrides[key] = value + } + + return overrides +} + +export function parseServiceResourceOverrides( + env: ServiceNameEnvSubset, +): ServiceResourceOverrides { + return { + resourceAttributes: getResourceOverrides(env), + serviceName: getNonEmptyEnvValue(env.OTEL_SERVICE_NAME), + serviceVersion: getNonEmptyEnvValue(env.NEXT_PUBLIC_VERSION), + } +} + +/** + * Returns the first non-empty string attribute for the provided keys, in order. + * Keys act as fallbacks to support multiple semantic conventions. + */ +function getStringAttribute( + span: ReadableSpan, + keys: readonly string[], +): string | undefined { + for (const key of keys) { + const value = span.attributes[key] + if (typeof value === "string" && value.length > 0) { + return value + } + } + return undefined +} + +/** + * Returns the first finite numeric attribute for the provided keys, in order. + * Keys act as fallbacks to support multiple semantic conventions. + */ +function getNumberAttribute( + span: ReadableSpan, + keys: readonly string[], +): number | undefined { + for (const key of keys) { + const value = span.attributes[key] + if (typeof value === "number" && Number.isFinite(value)) { + return value + } + } + return undefined +} + +function getDurationMs(duration: [number, number]): number { + // HrTime is [seconds, nanoseconds], with nanoseconds normalized to < 1e9. + // Convert each component to milliseconds and add. + return Math.round(duration[0] * 1_000 + duration[1] / 1_000_000) +} + +export function createRequestLogEntry( + span: ReadableSpan, +): RequestLogEntry | null { + if (span.kind !== SpanKind.SERVER) { + return null + } + + // Span attribute keys differ across OTEL semantic convention versions and + // instrumentation libraries, so we read from ordered fallback key lists. + const context = span.spanContext() + const method = getStringAttribute(span, REQUEST_METHOD_KEYS) ?? "UNKNOWN" + const route = getStringAttribute(span, REQUEST_ROUTE_KEYS) ?? span.name + const statusCode = getNumberAttribute(span, RESPONSE_STATUS_KEYS) ?? null + + return { + message: "next_request", + method, + route, + statusCode, + durationMs: getDurationMs(span.duration), + traceId: context.traceId, + spanId: context.spanId, + name: span.name, + } +} + +export function applyResourceOverrides( + span: ReadableSpan, + overrides: ServiceResourceOverrides, +): void { + for (const [key, value] of Object.entries(overrides.resourceAttributes)) { + span.resource.attributes[key] = value + } + + if (overrides.serviceName) { + span.resource.attributes["service.name"] = overrides.serviceName + } + if (overrides.serviceVersion) { + span.resource.attributes["service.version"] = overrides.serviceVersion + } +} From fed87991b67fc9006587ca201311401114baf8ff Mon Sep 17 00:00:00 2001 From: Chris Chudzicki Date: Fri, 24 Apr 2026 17:03:05 -0400 Subject: [PATCH 02/10] add a comment --- frontends/main/src/instrumentation-node.ts | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/frontends/main/src/instrumentation-node.ts b/frontends/main/src/instrumentation-node.ts index eede89d80f..feb32a356f 100644 --- a/frontends/main/src/instrumentation-node.ts +++ b/frontends/main/src/instrumentation-node.ts @@ -60,6 +60,13 @@ function buildSpanProcessors(): SpanProcessor[] { return processors } +/** + * Ensure resource.attributes are set correctly. + * Sentry hard-codes some values like `service.name` and ignores + * OTEL_RESOURCE_ATTRIBUTES. + * + * See https://github.com/getsentry/sentry-javascript/issues/20502 + */ class ResourceAttributeOverrideSpanProcessor implements SpanProcessor { private readonly overrides: ServiceResourceOverrides From ed5fe7dfa4169ca0ffdf94463f2c470f31c9e8ad Mon Sep 17 00:00:00 2001 From: Chris Chudzicki Date: Mon, 27 Apr 2026 10:30:03 -0400 Subject: [PATCH 03/10] support env.OTEL_EXPORTER_OTLP_TRACES_ENDPOINT --- frontends/main/src/instrumentation-node.ts | 3 ++- frontends/main/src/otel-utils.test.ts | 12 ++++++++++++ frontends/main/src/otel-utils.ts | 7 +++++++ 3 files changed, 21 insertions(+), 1 deletion(-) diff --git a/frontends/main/src/instrumentation-node.ts b/frontends/main/src/instrumentation-node.ts index feb32a356f..b060e4e225 100644 --- a/frontends/main/src/instrumentation-node.ts +++ b/frontends/main/src/instrumentation-node.ts @@ -15,6 +15,7 @@ import { OTLPTraceExporter } from "@opentelemetry/exporter-trace-otlp-http" import { applyResourceOverrides, createRequestLogEntry, + hasOtlpEndpointConfig, parseServiceResourceOverrides, type ServiceResourceOverrides, } from "./otel-utils" @@ -47,7 +48,7 @@ function buildSpanProcessors(): SpanProcessor[] { processors.push(new ResourceAttributeOverrideSpanProcessor(process.env)) } - if (process.env.OTEL_EXPORTER_OTLP_ENDPOINT) { + if (hasOtlpEndpointConfig(process.env)) { processors.push(new BatchSpanProcessor(new OTLPTraceExporter())) } else if (process.env.OTEL_TRACES_EXPORTER === "console") { processors.push(new SimpleSpanProcessor(new ConsoleSpanExporter())) diff --git a/frontends/main/src/otel-utils.test.ts b/frontends/main/src/otel-utils.test.ts index c3779b5287..8e7c1e1584 100644 --- a/frontends/main/src/otel-utils.test.ts +++ b/frontends/main/src/otel-utils.test.ts @@ -4,6 +4,7 @@ import { mergeOverrides, PartialFactory } from "ol-test-utilities" import { applyResourceOverrides, createRequestLogEntry, + hasOtlpEndpointConfig, parseServiceResourceOverrides, } from "./otel-utils" @@ -209,3 +210,14 @@ describe("parseServiceResourceOverrides", () => { }) }) }) + +describe("hasOtlpEndpointConfig", () => { + it("returns true when only OTEL_EXPORTER_OTLP_TRACES_ENDPOINT is set", () => { + expect( + hasOtlpEndpointConfig({ + OTEL_EXPORTER_OTLP_TRACES_ENDPOINT: + "http://alloy.monitoring:4318/v1/traces", + }), + ).toBe(true) + }) +}) diff --git a/frontends/main/src/otel-utils.ts b/frontends/main/src/otel-utils.ts index 26acc69fe3..5cf41755d8 100644 --- a/frontends/main/src/otel-utils.ts +++ b/frontends/main/src/otel-utils.ts @@ -78,6 +78,13 @@ export function parseServiceResourceOverrides( } } +export function hasOtlpEndpointConfig(env: ServiceNameEnvSubset): boolean { + return Boolean( + getNonEmptyEnvValue(env.OTEL_EXPORTER_OTLP_ENDPOINT) || + getNonEmptyEnvValue(env.OTEL_EXPORTER_OTLP_TRACES_ENDPOINT), + ) +} + /** * Returns the first non-empty string attribute for the provided keys, in order. * Keys act as fallbacks to support multiple semantic conventions. From 6d2a2c7d95e59fcade14afc0fc3dda628dfa6f22 Mon Sep 17 00:00:00 2001 From: Chris Chudzicki Date: Mon, 27 Apr 2026 11:31:23 -0400 Subject: [PATCH 04/10] Use OTEL envDetector for resource attributes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replaces the custom OTEL_RESOURCE_ATTRIBUTES parser with @opentelemetry/resources' envDetector, which is spec-compliant (handles percent-decoding, length checks, OTEL_SERVICE_NAME merging). Injects service.version into OTEL_RESOURCE_ATTRIBUTES at startup so NEXT_PUBLIC_VERSION flows through the SDK rather than being applied by custom override code. The override SpanProcessor remains because Sentry hardcodes service.name to "node" — see https://github.com/getsentry/sentry-javascript/issues/20502. Co-Authored-By: Claude Opus 4.7 (1M context) --- frontends/main/package.json | 1 + frontends/main/src/instrumentation-node.ts | 40 ++++--- frontends/main/src/otel-utils.test.ts | 127 +++++++-------------- frontends/main/src/otel-utils.ts | 86 +++++--------- yarn.lock | 1 + 5 files changed, 98 insertions(+), 157 deletions(-) diff --git a/frontends/main/package.json b/frontends/main/package.json index 0ab72842a4..acccf5759c 100644 --- a/frontends/main/package.json +++ b/frontends/main/package.json @@ -20,6 +20,7 @@ "@mui/material-nextjs": "^6.4.3", "@opentelemetry/api": "^1.9.1", "@opentelemetry/exporter-trace-otlp-http": "^0.214.0", + "@opentelemetry/resources": "^2.6.1", "@opentelemetry/sdk-trace-base": "^2.6.1", "@radix-ui/react-dropdown-menu": "^2.1.16", "@radix-ui/react-popover": "^1.1.15", diff --git a/frontends/main/src/instrumentation-node.ts b/frontends/main/src/instrumentation-node.ts index b060e4e225..69bbf3369f 100644 --- a/frontends/main/src/instrumentation-node.ts +++ b/frontends/main/src/instrumentation-node.ts @@ -11,16 +11,30 @@ import { SimpleSpanProcessor, } from "@opentelemetry/sdk-trace-base" import type { ReadableSpan, SpanProcessor } from "@opentelemetry/sdk-trace-base" +import type { DetectedResourceAttributes } from "@opentelemetry/resources" import { OTLPTraceExporter } from "@opentelemetry/exporter-trace-otlp-http" import { applyResourceOverrides, createRequestLogEntry, + detectResourceOverrides, hasOtlpEndpointConfig, - parseServiceResourceOverrides, - type ServiceResourceOverrides, } from "./otel-utils" import { parseSampleRate } from "./sentry-utils" +// Inject service.version into OTEL_RESOURCE_ATTRIBUTES so the OTEL SDK's +// EnvDetector picks it up alongside any other attributes set via env. +// Our deployment env is stored in a vault that can't interpolate $VERSION +// into OTEL_RESOURCE_ATTRIBUTES, so we prepend it here at startup. Prepending +// (rather than appending) lets an explicit OTEL_RESOURCE_ATTRIBUTES entry +// override this default — last-key-wins per the SDK parser. +if (process.env.NEXT_PUBLIC_VERSION) { + const prefix = `service.version=${encodeURIComponent(process.env.NEXT_PUBLIC_VERSION)}` + const existing = process.env.OTEL_RESOURCE_ATTRIBUTES + process.env.OTEL_RESOURCE_ATTRIBUTES = existing + ? `${prefix},${existing}` + : prefix +} + /** * Build the list of extra span processors injected into Sentry's OTEL provider. * @@ -40,12 +54,9 @@ import { parseSampleRate } from "./sentry-utils" function buildSpanProcessors(): SpanProcessor[] { const processors: SpanProcessor[] = [] - if ( - process.env.OTEL_SERVICE_NAME || - process.env.OTEL_RESOURCE_ATTRIBUTES || - process.env.NEXT_PUBLIC_VERSION - ) { - processors.push(new ResourceAttributeOverrideSpanProcessor(process.env)) + const overrides = detectResourceOverrides() + if (Object.keys(overrides).length > 0) { + processors.push(new ResourceAttributeOverrideSpanProcessor(overrides)) } if (hasOtlpEndpointConfig(process.env)) { @@ -62,17 +73,18 @@ function buildSpanProcessors(): SpanProcessor[] { } /** - * Ensure resource.attributes are set correctly. - * Sentry hard-codes some values like `service.name` and ignores - * OTEL_RESOURCE_ATTRIBUTES. + * Apply resource attributes from OTEL_SERVICE_NAME and OTEL_RESOURCE_ATTRIBUTES + * to every span at end time. Sentry hard-codes service.name to "node" and + * ignores OTEL_RESOURCE_ATTRIBUTES on its internal resource, so without this + * the spans we ship to Alloy/Tempo would be misattributed. * * See https://github.com/getsentry/sentry-javascript/issues/20502 */ class ResourceAttributeOverrideSpanProcessor implements SpanProcessor { - private readonly overrides: ServiceResourceOverrides + private readonly overrides: DetectedResourceAttributes - constructor(env: NodeJS.ProcessEnv) { - this.overrides = parseServiceResourceOverrides(env) + constructor(overrides: DetectedResourceAttributes) { + this.overrides = overrides } onStart(_span: Span, _parentContext: Context): void { diff --git a/frontends/main/src/otel-utils.test.ts b/frontends/main/src/otel-utils.test.ts index 8e7c1e1584..acd64ac279 100644 --- a/frontends/main/src/otel-utils.test.ts +++ b/frontends/main/src/otel-utils.test.ts @@ -4,8 +4,8 @@ import { mergeOverrides, PartialFactory } from "ol-test-utilities" import { applyResourceOverrides, createRequestLogEntry, + detectResourceOverrides, hasOtlpEndpointConfig, - parseServiceResourceOverrides, } from "./otel-utils" function makeResource( @@ -87,7 +87,7 @@ describe("createRequestLogEntry", () => { }) describe("applyResourceOverrides", () => { - it("overrides resource service.name when OTEL_SERVICE_NAME is set", () => { + it("copies overrides onto the span resource", () => { const span = makeReadableSpan({ resource: makeResource({ "service.name": "node", @@ -95,119 +95,78 @@ describe("applyResourceOverrides", () => { }), }) - const overrides = parseServiceResourceOverrides({ - OTEL_SERVICE_NAME: "learn-nextjs", + applyResourceOverrides(span, { + "service.name": "learn-nextjs", + "deployment.environment.name": "prod", }) - applyResourceOverrides(span, overrides) expect(span.resource.attributes["service.name"]).toBe("learn-nextjs") expect(span.resource.attributes["service.namespace"]).toBe("sentry") + expect(span.resource.attributes["deployment.environment.name"]).toBe("prod") }) - it("does not change resource attributes when OTEL_SERVICE_NAME is unset", () => { + it("leaves the resource unchanged when overrides is empty", () => { const span = makeReadableSpan({ - resource: makeResource({ - "service.name": "node", - }), + resource: makeResource({ "service.name": "node" }), }) - const overrides = parseServiceResourceOverrides({}) - applyResourceOverrides(span, overrides) + applyResourceOverrides(span, {}) expect(span.resource.attributes["service.name"]).toBe("node") }) - it("overrides service namespace and version from OTEL_RESOURCE_ATTRIBUTES", () => { + it("skips non-string values defensively", () => { const span = makeReadableSpan({ - resource: makeResource({ - "service.name": "node", - "service.namespace": "sentry", - "service.version": "10.50.0", - }), + resource: makeResource({ "service.name": "node" }), }) - const overrides = parseServiceResourceOverrides({ - OTEL_RESOURCE_ATTRIBUTES: - "service.namespace=my-namespace,service.version=2026.04.24", + applyResourceOverrides(span, { + "service.name": "learn-nextjs", + "broken.promise": Promise.resolve("ignored"), + "broken.array": ["a", "b"], }) - applyResourceOverrides(span, overrides) - expect(span.resource.attributes["service.name"]).toBe("node") - expect(span.resource.attributes["service.namespace"]).toBe("my-namespace") - expect(span.resource.attributes["service.version"]).toBe("2026.04.24") + expect(span.resource.attributes["service.name"]).toBe("learn-nextjs") + expect(span.resource.attributes["broken.promise"]).toBeUndefined() + expect(span.resource.attributes["broken.array"]).toBeUndefined() }) +}) - it("prefers OTEL_SERVICE_NAME over service.name in OTEL_RESOURCE_ATTRIBUTES", () => { - const span = makeReadableSpan({ - resource: makeResource({ - "service.name": "node", - }), - }) - - const overrides = parseServiceResourceOverrides({ - OTEL_SERVICE_NAME: "env-service-name", - OTEL_RESOURCE_ATTRIBUTES: - "service.name=resource-attrs-name,service.namespace=my-namespace", - }) - applyResourceOverrides(span, overrides) +describe("detectResourceOverrides", () => { + const originalEnv = { ...process.env } - expect(span.resource.attributes["service.name"]).toBe("env-service-name") - expect(span.resource.attributes["service.namespace"]).toBe("my-namespace") + afterEach(() => { + process.env = { ...originalEnv } }) - it("applies arbitrary resource attributes from OTEL_RESOURCE_ATTRIBUTES", () => { - const span = makeReadableSpan({ - resource: makeResource({ - "service.name": "node", - }), - }) + it("parses OTEL_SERVICE_NAME and OTEL_RESOURCE_ATTRIBUTES via the SDK", () => { + process.env.OTEL_SERVICE_NAME = "app-name" + process.env.OTEL_RESOURCE_ATTRIBUTES = + "service.namespace=learn,service.version=1.2.3" - const overrides = parseServiceResourceOverrides({ - OTEL_RESOURCE_ATTRIBUTES: - "deployment.environment.name=prod,cloud.region=us-east-1", + expect(detectResourceOverrides()).toEqual({ + "service.name": "app-name", + "service.namespace": "learn", + "service.version": "1.2.3", }) - applyResourceOverrides(span, overrides) - - expect(span.resource.attributes["deployment.environment.name"]).toBe("prod") - expect(span.resource.attributes["cloud.region"]).toBe("us-east-1") }) - it("overrides service.version from NEXT_PUBLIC_VERSION", () => { - const span = makeReadableSpan({ - resource: makeResource({ - "service.version": "10.50.0", - }), - }) + it("percent-decodes values per the OTEL spec", () => { + process.env.OTEL_RESOURCE_ATTRIBUTES = + "deployment.environment.name=us%2Ceast,service.version=1%3D2" + delete process.env.OTEL_SERVICE_NAME - const overrides = parseServiceResourceOverrides({ - NEXT_PUBLIC_VERSION: "release-2026-04-24", - OTEL_RESOURCE_ATTRIBUTES: "service.version=resource-attrs-version", + expect(detectResourceOverrides()).toEqual({ + "deployment.environment.name": "us,east", + "service.version": "1=2", }) - applyResourceOverrides(span, overrides) - - expect(span.resource.attributes["service.version"]).toBe( - "release-2026-04-24", - ) }) -}) -describe("parseServiceResourceOverrides", () => { - it("parses OTEL_RESOURCE_ATTRIBUTES and OTEL_SERVICE_NAME into one override object", () => { - expect( - parseServiceResourceOverrides({ - OTEL_SERVICE_NAME: "app-name", - NEXT_PUBLIC_VERSION: "release-1.2.3", - OTEL_RESOURCE_ATTRIBUTES: - "service.namespace=learn,service.version=1.2.3", - }), - ).toEqual({ - serviceName: "app-name", - serviceVersion: "release-1.2.3", - resourceAttributes: { - "service.namespace": "learn", - "service.version": "1.2.3", - }, - }) + it("returns an empty object when no env vars are set", () => { + delete process.env.OTEL_SERVICE_NAME + delete process.env.OTEL_RESOURCE_ATTRIBUTES + + expect(detectResourceOverrides()).toEqual({}) }) }) diff --git a/frontends/main/src/otel-utils.ts b/frontends/main/src/otel-utils.ts index 5cf41755d8..bd45076961 100644 --- a/frontends/main/src/otel-utils.ts +++ b/frontends/main/src/otel-utils.ts @@ -1,5 +1,7 @@ import { SpanKind } from "@opentelemetry/api" import type { ReadableSpan } from "@opentelemetry/sdk-trace-base" +import { envDetector } from "@opentelemetry/resources" +import type { DetectedResourceAttributes } from "@opentelemetry/resources" const REQUEST_METHOD_KEYS = ["http.request.method", "http.method"] as const const REQUEST_ROUTE_KEYS = [ @@ -24,67 +26,29 @@ export type RequestLogEntry = { name: string } -type ServiceNameEnvSubset = Readonly> -export type ServiceResourceOverrides = { - resourceAttributes: Record - serviceName?: string - serviceVersion?: string -} +type OtelEnvSubset = Readonly> function getNonEmptyEnvValue(value: string | undefined): string | undefined { const trimmed = value?.trim() return trimmed ? trimmed : undefined } -function getResourceOverrides( - env: ServiceNameEnvSubset, -): Record { - const overrides: Record = {} - const attributes = getNonEmptyEnvValue(env.OTEL_RESOURCE_ATTRIBUTES) - if (!attributes) { - return overrides - } - - for (const assignment of attributes.split(",")) { - const trimmedAssignment = assignment.trim() - if (!trimmedAssignment) { - continue - } - const separatorIndex = trimmedAssignment.indexOf("=") - if (separatorIndex <= 0) { - continue - } - const key = trimmedAssignment.slice(0, separatorIndex).trim() - const value = getNonEmptyEnvValue( - trimmedAssignment.slice(separatorIndex + 1), - ) - if (!value) { - continue - } - - overrides[key] = value - } - - return overrides -} - -export function parseServiceResourceOverrides( - env: ServiceNameEnvSubset, -): ServiceResourceOverrides { - return { - resourceAttributes: getResourceOverrides(env), - serviceName: getNonEmptyEnvValue(env.OTEL_SERVICE_NAME), - serviceVersion: getNonEmptyEnvValue(env.NEXT_PUBLIC_VERSION), - } -} - -export function hasOtlpEndpointConfig(env: ServiceNameEnvSubset): boolean { +export function hasOtlpEndpointConfig(env: OtelEnvSubset): boolean { return Boolean( getNonEmptyEnvValue(env.OTEL_EXPORTER_OTLP_ENDPOINT) || getNonEmptyEnvValue(env.OTEL_EXPORTER_OTLP_TRACES_ENDPOINT), ) } +/** + * Read OTEL_SERVICE_NAME and OTEL_RESOURCE_ATTRIBUTES from process.env using + * the OTEL SDK's spec-compliant parser. Handles percent-decoding, length + * checks, and merges OTEL_SERVICE_NAME into service.name. + */ +export function detectResourceOverrides(): DetectedResourceAttributes { + return envDetector.detect().attributes ?? {} +} + /** * Returns the first non-empty string attribute for the provided keys, in order. * Keys act as fallbacks to support multiple semantic conventions. @@ -151,18 +115,22 @@ export function createRequestLogEntry( } } +/** + * Copy detected resource attributes onto a span's resource. Used to work + * around Sentry's hardcoded service.name (and friends) — see + * https://github.com/getsentry/sentry-javascript/issues/20502. + * + * EnvDetector returns AttributeValue | Promise | undefined, + * but in practice OTEL_SERVICE_NAME and OTEL_RESOURCE_ATTRIBUTES yield only + * strings; non-strings are skipped defensively. + */ export function applyResourceOverrides( span: ReadableSpan, - overrides: ServiceResourceOverrides, + overrides: DetectedResourceAttributes, ): void { - for (const [key, value] of Object.entries(overrides.resourceAttributes)) { - span.resource.attributes[key] = value - } - - if (overrides.serviceName) { - span.resource.attributes["service.name"] = overrides.serviceName - } - if (overrides.serviceVersion) { - span.resource.attributes["service.version"] = overrides.serviceVersion + for (const [key, value] of Object.entries(overrides)) { + if (typeof value === "string") { + span.resource.attributes[key] = value + } } } diff --git a/yarn.lock b/yarn.lock index ba59f8d598..7db1909227 100644 --- a/yarn.lock +++ b/yarn.lock @@ -16400,6 +16400,7 @@ __metadata: "@mui/material-nextjs": "npm:^6.4.3" "@opentelemetry/api": "npm:^1.9.1" "@opentelemetry/exporter-trace-otlp-http": "npm:^0.214.0" + "@opentelemetry/resources": "npm:^2.6.1" "@opentelemetry/sdk-trace-base": "npm:^2.6.1" "@radix-ui/react-dropdown-menu": "npm:^2.1.16" "@radix-ui/react-popover": "npm:^1.1.15" From b218b2e5058d13a6147d3054028978c9fc45797d Mon Sep 17 00:00:00 2001 From: Chris Chudzicki Date: Mon, 27 Apr 2026 11:36:12 -0400 Subject: [PATCH 05/10] Include app version in structured request logs Logs and traces often live in different stores; inline version is more durable than relying on log shipper labels (which can drop during rolling deploys or label collisions). Read NEXT_PUBLIC_VERSION once at module load. Co-Authored-By: Claude Opus 4.7 (1M context) --- frontends/main/src/otel-utils.test.ts | 1 + frontends/main/src/otel-utils.ts | 4 ++++ 2 files changed, 5 insertions(+) diff --git a/frontends/main/src/otel-utils.test.ts b/frontends/main/src/otel-utils.test.ts index acd64ac279..0040f78bb6 100644 --- a/frontends/main/src/otel-utils.test.ts +++ b/frontends/main/src/otel-utils.test.ts @@ -72,6 +72,7 @@ describe("createRequestLogEntry", () => { traceId: "trace-id", spanId: "span-id", name: "GET /courses", + version: "test-version", }) }) diff --git a/frontends/main/src/otel-utils.ts b/frontends/main/src/otel-utils.ts index bd45076961..5cefb527c5 100644 --- a/frontends/main/src/otel-utils.ts +++ b/frontends/main/src/otel-utils.ts @@ -24,8 +24,11 @@ export type RequestLogEntry = { traceId: string spanId: string name: string + version: string | null } +const APP_VERSION = process.env.NEXT_PUBLIC_VERSION ?? null + type OtelEnvSubset = Readonly> function getNonEmptyEnvValue(value: string | undefined): string | undefined { @@ -112,6 +115,7 @@ export function createRequestLogEntry( traceId: context.traceId, spanId: context.spanId, name: span.name, + version: APP_VERSION, } } From ec3b1bc05ee3ac79cbb59cfb1ec5f12995271e2d Mon Sep 17 00:00:00 2001 From: Chris Chudzicki Date: Mon, 27 Apr 2026 11:37:32 -0400 Subject: [PATCH 06/10] Set NEXT_PUBLIC_VERSION in jest shared setup So tests that depend on the value behave consistently across machines and CI rather than inheriting whatever happens to be in the shell. Co-Authored-By: Claude Opus 4.7 (1M context) --- frontends/jest-shared-setup.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/frontends/jest-shared-setup.ts b/frontends/jest-shared-setup.ts index 5fcd380cd4..25ad241a22 100644 --- a/frontends/jest-shared-setup.ts +++ b/frontends/jest-shared-setup.ts @@ -27,6 +27,7 @@ process.env.NEXT_PUBLIC_MITX_ONLINE_LEGACY_BASE_URL = "http://mitxonline.odl.local:8065" process.env.NEXT_PUBLIC_ORIGIN = "http://test.learn.odl.local:8062" process.env.NEXT_PUBLIC_EMBEDLY_KEY = "fake-embedly-key" +process.env.NEXT_PUBLIC_VERSION = "test-version" // Pulled from the docs - see https://jestjs.io/docs/manual-mocks#mocking-methods-which-are-not-implemented-in-jsdom From e5eb18ebac740029f9b4b43a3821f33f7162901d Mon Sep 17 00:00:00 2001 From: Chris Chudzicki Date: Mon, 27 Apr 2026 17:00:59 -0400 Subject: [PATCH 07/10] Decouple request logging from OTEL via diagnostics_channel MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Move per-request structured logging off the OTEL span pipeline so that log emission is independent of OTEL_TRACES_SAMPLER_ARG. This lets the logs serve as ground truth for OTEL trace coverage — a null traceId in the log now means "OTEL never created a span for this request" rather than "the sampler dropped it." Subscribes to Node's built-in http.server.request.start / http.server.response.finish diagnostics channels (Experimental in Node 24/25, but the same surface Sentry/OTEL/Datadog use internally). traceId/spanId are read best-effort from the active OTEL context. Enabled by default; set NEXT_SERVER_REQUEST_LOGGING=false to disable. Co-Authored-By: Claude Opus 4.7 (1M context) --- env/frontend.env | 6 +- frontends/main/src/instrumentation-node.ts | 63 ++++++++----- frontends/main/src/otel-utils.test.ts | 102 ++++++++++---------- frontends/main/src/otel-utils.ts | 104 ++++++--------------- 4 files changed, 121 insertions(+), 154 deletions(-) diff --git a/env/frontend.env b/env/frontend.env index 70fd0a5b3d..9b7765130c 100644 --- a/env/frontend.env +++ b/env/frontend.env @@ -68,6 +68,6 @@ GTM_COOKIES_WIN=${GTM_COOKIES_WIN} # # OTEL_TRACES_EXPORTER=console -# Optional: emit one JSON log line per completed server request span. -# Useful for basic request/response timing stats in log aggregation systems. -# NEXT_SERVER_REQUEST_LOGGING=true +# One JSON log line per completed server request span (method, route, status, +# duration) is emitted by default. Set to "false" to disable. +# NEXT_SERVER_REQUEST_LOGGING=false diff --git a/frontends/main/src/instrumentation-node.ts b/frontends/main/src/instrumentation-node.ts index 69bbf3369f..fb68c6c27e 100644 --- a/frontends/main/src/instrumentation-node.ts +++ b/frontends/main/src/instrumentation-node.ts @@ -13,6 +13,8 @@ import { import type { ReadableSpan, SpanProcessor } from "@opentelemetry/sdk-trace-base" import type { DetectedResourceAttributes } from "@opentelemetry/resources" import { OTLPTraceExporter } from "@opentelemetry/exporter-trace-otlp-http" +import diagnosticsChannel from "node:diagnostics_channel" +import type { IncomingMessage, ServerResponse } from "node:http" import { applyResourceOverrides, createRequestLogEntry, @@ -46,10 +48,6 @@ if (process.env.NEXT_PUBLIC_VERSION) { * LOCAL TESTING (no Grafana Alloy required): * Set OTEL_TRACES_EXPORTER=console and OTEL_TRACES_SAMPLER_ARG=1.0 to print * completed spans as JSON to stdout. See env/frontend.env for details. - * - * REQUEST TIMING LOGS: - * Set NEXT_SERVER_REQUEST_LOGGING=true to print one structured JSON log line - * for each completed server request span (method, route, status, duration). */ function buildSpanProcessors(): SpanProcessor[] { const processors: SpanProcessor[] = [] @@ -65,10 +63,6 @@ function buildSpanProcessors(): SpanProcessor[] { processors.push(new SimpleSpanProcessor(new ConsoleSpanExporter())) } - if (process.env.NEXT_SERVER_REQUEST_LOGGING === "true") { - processors.push(new RequestLogSpanProcessor()) - } - return processors } @@ -104,25 +98,44 @@ class ResourceAttributeOverrideSpanProcessor implements SpanProcessor { } } -class RequestLogSpanProcessor implements SpanProcessor { - onStart(_span: Span, _parentContext: Context): void { - // no-op - } - - onEnd(span: ReadableSpan): void { - const entry = createRequestLogEntry(span) - if (entry) { - console.info(JSON.stringify(entry)) +/** + * Subscribe to Node's built-in HTTP server diagnostics channels and emit a + * structured JSON log line per completed request. This runs independently of + * the OTEL sampler — every request is logged regardless of OTEL_TRACES_SAMPLER_ARG + * — so the logs can be used as ground truth for verifying OTEL trace coverage. + * + * Enabled by default; set NEXT_SERVER_REQUEST_LOGGING=false to disable. + * + * The channels (`http.server.request.start`, `http.server.response.finish`) + * are marked Experimental in Node 24 and 25, but are the same surface that + * Sentry/OTEL/Datadog subscribe to internally; the API has been stable in + * practice for years. + */ +function subscribeRequestLogger(): void { + const startTimes = new WeakMap() + + diagnosticsChannel.subscribe("http.server.request.start", (message) => { + const { request } = message as { request: IncomingMessage } + startTimes.set(request, process.hrtime.bigint()) + }) + + diagnosticsChannel.subscribe("http.server.response.finish", (message) => { + const { request, response } = message as { + request: IncomingMessage + response: ServerResponse } - } - - shutdown(): Promise { - return Promise.resolve() - } + const start = startTimes.get(request) + if (start === undefined) return + startTimes.delete(request) + const durationMs = Number((process.hrtime.bigint() - start) / 1_000_000n) + console.info( + JSON.stringify(createRequestLogEntry({ request, response, durationMs })), + ) + }) +} - forceFlush(): Promise { - return Promise.resolve() - } +if (process.env.NEXT_SERVER_REQUEST_LOGGING !== "false") { + subscribeRequestLogger() } // OTEL_TRACES_SAMPLER_ARG controls the OTEL sampler rate — i.e. what fraction diff --git a/frontends/main/src/otel-utils.test.ts b/frontends/main/src/otel-utils.test.ts index 0040f78bb6..cf88da8a90 100644 --- a/frontends/main/src/otel-utils.test.ts +++ b/frontends/main/src/otel-utils.test.ts @@ -1,6 +1,5 @@ -import { SpanKind, SpanStatusCode, TraceFlags } from "@opentelemetry/api" +import type { IncomingMessage, ServerResponse } from "node:http" import type { ReadableSpan } from "@opentelemetry/sdk-trace-base" -import { mergeOverrides, PartialFactory } from "ol-test-utilities" import { applyResourceOverrides, createRequestLogEntry, @@ -23,67 +22,70 @@ function makeResource( return resource } -const makeReadableSpan: PartialFactory = (overrides = {}) => { - const base: ReadableSpan = { - name: "test span", - kind: SpanKind.INTERNAL, - spanContext: () => ({ - traceId: "trace-id", - spanId: "span-id", - traceFlags: TraceFlags.SAMPLED, - }), - startTime: [0, 0], - endTime: [0, 0], - status: { code: SpanStatusCode.UNSET }, - attributes: {}, - links: [], - events: [], - duration: [0, 0], - ended: true, - resource: makeResource(), - instrumentationScope: { name: "test-scope", version: "1.0.0" }, - droppedAttributesCount: 0, - droppedEventsCount: 0, - droppedLinksCount: 0, - } - - return mergeOverrides(base, overrides) -} +const makeReadableSpan = ( + overrides: Partial = {}, +): ReadableSpan => ({ + name: "test span", + kind: 0, + spanContext: () => ({ + traceId: "trace-id", + spanId: "span-id", + traceFlags: 1, + }), + startTime: [0, 0], + endTime: [0, 0], + status: { code: 0 }, + attributes: {}, + links: [], + events: [], + duration: [0, 0], + ended: true, + resource: makeResource(), + instrumentationScope: { name: "test-scope", version: "1.0.0" }, + droppedAttributesCount: 0, + droppedEventsCount: 0, + droppedLinksCount: 0, + ...overrides, +}) describe("createRequestLogEntry", () => { - it("creates a request log entry for server spans", () => { - const span = makeReadableSpan({ - kind: SpanKind.SERVER, - name: "GET /courses", - attributes: { - "http.request.method": "GET", - "url.path": "/courses", - "http.response.status_code": 200, - }, - duration: [1, 250_000_000], - }) + it("builds a log entry from request and response", () => { + const request = { method: "GET", url: "/courses" } as IncomingMessage + const response = { statusCode: 200 } as ServerResponse - expect(createRequestLogEntry(span)).toEqual({ + expect( + createRequestLogEntry({ request, response, durationMs: 1250 }), + ).toEqual({ message: "next_request", method: "GET", route: "/courses", statusCode: 200, durationMs: 1250, - traceId: "trace-id", - spanId: "span-id", - name: "GET /courses", + traceId: null, + spanId: null, version: "test-version", }) }) - it("returns null for non-server spans", () => { - const span = makeReadableSpan({ - kind: SpanKind.CLIENT, - name: "GET api", - duration: [0, 100_000], - }) + it("strips the query string from the route", () => { + const request = { + method: "POST", + url: "/api/foo?bar=baz&qux=1", + } as IncomingMessage + const response = { statusCode: 201 } as ServerResponse - expect(createRequestLogEntry(span)).toBeNull() + expect( + createRequestLogEntry({ request, response, durationMs: 5 }).route, + ).toBe("/api/foo") + }) + + it("falls back to UNKNOWN when method is missing", () => { + const request = { url: "/" } as IncomingMessage + const response = { statusCode: 500 } as ServerResponse + + expect( + createRequestLogEntry({ request, response, durationMs: 1 }).method, + ).toBe("UNKNOWN") }) }) diff --git a/frontends/main/src/otel-utils.ts b/frontends/main/src/otel-utils.ts index 5cefb527c5..3d0e7a8e48 100644 --- a/frontends/main/src/otel-utils.ts +++ b/frontends/main/src/otel-utils.ts @@ -1,29 +1,17 @@ -import { SpanKind } from "@opentelemetry/api" +import { isSpanContextValid, trace } from "@opentelemetry/api" +import type { IncomingMessage, ServerResponse } from "node:http" import type { ReadableSpan } from "@opentelemetry/sdk-trace-base" import { envDetector } from "@opentelemetry/resources" import type { DetectedResourceAttributes } from "@opentelemetry/resources" -const REQUEST_METHOD_KEYS = ["http.request.method", "http.method"] as const -const REQUEST_ROUTE_KEYS = [ - "http.route", - "url.path", - "http.target", - "url.full", -] as const -const RESPONSE_STATUS_KEYS = [ - "http.response.status_code", - "http.status_code", -] as const - export type RequestLogEntry = { message: "next_request" method: string route: string - statusCode: number | null + statusCode: number durationMs: number - traceId: string - spanId: string - name: string + traceId: string | null + spanId: string | null version: string | null } @@ -53,68 +41,32 @@ export function detectResourceOverrides(): DetectedResourceAttributes { } /** - * Returns the first non-empty string attribute for the provided keys, in order. - * Keys act as fallbacks to support multiple semantic conventions. - */ -function getStringAttribute( - span: ReadableSpan, - keys: readonly string[], -): string | undefined { - for (const key of keys) { - const value = span.attributes[key] - if (typeof value === "string" && value.length > 0) { - return value - } - } - return undefined -} - -/** - * Returns the first finite numeric attribute for the provided keys, in order. - * Keys act as fallbacks to support multiple semantic conventions. + * Build a structured log entry for a finished HTTP request. The traceId/spanId + * come from the active OTEL context if one is present — a null traceId in the + * log is itself the diagnostic signal that the request was not traced. */ -function getNumberAttribute( - span: ReadableSpan, - keys: readonly string[], -): number | undefined { - for (const key of keys) { - const value = span.attributes[key] - if (typeof value === "number" && Number.isFinite(value)) { - return value - } - } - return undefined -} - -function getDurationMs(duration: [number, number]): number { - // HrTime is [seconds, nanoseconds], with nanoseconds normalized to < 1e9. - // Convert each component to milliseconds and add. - return Math.round(duration[0] * 1_000 + duration[1] / 1_000_000) -} - -export function createRequestLogEntry( - span: ReadableSpan, -): RequestLogEntry | null { - if (span.kind !== SpanKind.SERVER) { - return null - } - - // Span attribute keys differ across OTEL semantic convention versions and - // instrumentation libraries, so we read from ordered fallback key lists. - const context = span.spanContext() - const method = getStringAttribute(span, REQUEST_METHOD_KEYS) ?? "UNKNOWN" - const route = getStringAttribute(span, REQUEST_ROUTE_KEYS) ?? span.name - const statusCode = getNumberAttribute(span, RESPONSE_STATUS_KEYS) ?? null - +export function createRequestLogEntry({ + request, + response, + durationMs, +}: { + request: IncomingMessage + response: ServerResponse + durationMs: number +}): RequestLogEntry { + const ctx = trace.getActiveSpan()?.spanContext() + const hasTrace = ctx ? isSpanContextValid(ctx) : false return { message: "next_request", - method, - route, - statusCode, - durationMs: getDurationMs(span.duration), - traceId: context.traceId, - spanId: context.spanId, - name: span.name, + method: request.method ?? "UNKNOWN", + // Strip the query string so log routes group cleanly. We don't have access + // to the matched Next.js route template here (e.g. /courses/[id]); raw + // paths are good enough for "did every request get a trace" comparisons. + route: request.url?.split("?")[0] ?? "", + statusCode: response.statusCode, + durationMs, + traceId: hasTrace && ctx ? ctx.traceId : null, + spanId: hasTrace && ctx ? ctx.spanId : null, version: APP_VERSION, } } From 9c5667a17798fa2db1cca3e764d0fe92c7ac8a86 Mon Sep 17 00:00:00 2001 From: Chris Chudzicki Date: Mon, 27 Apr 2026 17:07:47 -0400 Subject: [PATCH 08/10] Guard request-logger subscription against double-evaluation Defensive flag on globalThis prevents stacked subscriptions if the instrumentation hook is ever re-evaluated (dev reloads, worker restarts). In normal operation the module body runs once per process, but the guard is cheap insurance against doubled log lines. Co-Authored-By: Claude Opus 4.7 (1M context) --- frontends/main/src/instrumentation-node.ts | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/frontends/main/src/instrumentation-node.ts b/frontends/main/src/instrumentation-node.ts index fb68c6c27e..0ed0ca2e87 100644 --- a/frontends/main/src/instrumentation-node.ts +++ b/frontends/main/src/instrumentation-node.ts @@ -98,6 +98,11 @@ class ResourceAttributeOverrideSpanProcessor implements SpanProcessor { } } +declare global { + // eslint-disable-next-line no-var + var __NEXT_REQUEST_LOGGER_SUBSCRIBED__: boolean | undefined +} + /** * Subscribe to Node's built-in HTTP server diagnostics channels and emit a * structured JSON log line per completed request. This runs independently of @@ -110,8 +115,15 @@ class ResourceAttributeOverrideSpanProcessor implements SpanProcessor { * are marked Experimental in Node 24 and 25, but are the same surface that * Sentry/OTEL/Datadog subscribe to internally; the API has been stable in * practice for years. + * + * Guarded against double-subscription via a globalThis flag — instrumentation + * hooks can be re-evaluated on dev reloads or worker restarts, and stacked + * subscriptions would duplicate every log line. */ function subscribeRequestLogger(): void { + if (globalThis.__NEXT_REQUEST_LOGGER_SUBSCRIBED__) return + globalThis.__NEXT_REQUEST_LOGGER_SUBSCRIBED__ = true + const startTimes = new WeakMap() diagnosticsChannel.subscribe("http.server.request.start", (message) => { From 89a35f20347e0b8c52891a5dc590738cb3fdf9c5 Mon Sep 17 00:00:00 2001 From: Chris Chudzicki Date: Mon, 27 Apr 2026 17:19:31 -0400 Subject: [PATCH 09/10] reword comment --- frontends/main/src/instrumentation-node.ts | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/frontends/main/src/instrumentation-node.ts b/frontends/main/src/instrumentation-node.ts index 0ed0ca2e87..379b51d9dc 100644 --- a/frontends/main/src/instrumentation-node.ts +++ b/frontends/main/src/instrumentation-node.ts @@ -25,10 +25,7 @@ import { parseSampleRate } from "./sentry-utils" // Inject service.version into OTEL_RESOURCE_ATTRIBUTES so the OTEL SDK's // EnvDetector picks it up alongside any other attributes set via env. -// Our deployment env is stored in a vault that can't interpolate $VERSION -// into OTEL_RESOURCE_ATTRIBUTES, so we prepend it here at startup. Prepending -// (rather than appending) lets an explicit OTEL_RESOURCE_ATTRIBUTES entry -// override this default — last-key-wins per the SDK parser. +// Simpler than interpolating OTEL_RESOURCE_ATTRIBUTES in ol-infrastructure. if (process.env.NEXT_PUBLIC_VERSION) { const prefix = `service.version=${encodeURIComponent(process.env.NEXT_PUBLIC_VERSION)}` const existing = process.env.OTEL_RESOURCE_ATTRIBUTES From 14ab0aa42a45bea1db61f3d354b23da7448b9abf Mon Sep 17 00:00:00 2001 From: Chris Chudzicki Date: Mon, 27 Apr 2026 17:27:41 -0400 Subject: [PATCH 10/10] Filter Next-internal paths from request logs and split out query Skip /_next/*, /__nextjs_*, and /favicon.ico in the structured request log. These never get OTEL traces (Sentry's HttpInstrumentation already filters them) so they're noise for the OTEL-coverage diagnostic, and in prod they mostly hit the CDN. RSC fetches go to real route paths (e.g. /courses?_rsc=...) and remain logged. Also split the URL into separate route and query fields. The route groups cleanly while the query stays available for filtering RSC vs non-RSC requests via the _rsc parameter. Co-Authored-By: Claude Opus 4.7 (1M context) --- frontends/main/src/instrumentation-node.ts | 8 ++++++++ frontends/main/src/otel-utils.test.ts | 9 +++++---- frontends/main/src/otel-utils.ts | 13 +++++++++---- 3 files changed, 22 insertions(+), 8 deletions(-) diff --git a/frontends/main/src/instrumentation-node.ts b/frontends/main/src/instrumentation-node.ts index 379b51d9dc..4170536032 100644 --- a/frontends/main/src/instrumentation-node.ts +++ b/frontends/main/src/instrumentation-node.ts @@ -100,6 +100,13 @@ declare global { var __NEXT_REQUEST_LOGGER_SUBSCRIBED__: boolean | undefined } +// Skip Next-internal paths (static chunks, HMR, dev endpoints) and the +// favicon. These never get OTEL traces (Sentry's HttpInstrumentation already +// filters them) so they're noise for the OTEL-coverage diagnostic, and in +// prod they mostly hit the CDN anyway. RSC fetches go to real route paths +// (e.g. /courses?_rsc=...) and are not filtered. +const NEXT_INTERNAL_PATH = /^\/(_next\/|__nextjs_|favicon\.ico)/ + /** * Subscribe to Node's built-in HTTP server diagnostics channels and emit a * structured JSON log line per completed request. This runs independently of @@ -136,6 +143,7 @@ function subscribeRequestLogger(): void { const start = startTimes.get(request) if (start === undefined) return startTimes.delete(request) + if (request.url && NEXT_INTERNAL_PATH.test(request.url)) return const durationMs = Number((process.hrtime.bigint() - start) / 1_000_000n) console.info( JSON.stringify(createRequestLogEntry({ request, response, durationMs })), diff --git a/frontends/main/src/otel-utils.test.ts b/frontends/main/src/otel-utils.test.ts index cf88da8a90..30ee334e0d 100644 --- a/frontends/main/src/otel-utils.test.ts +++ b/frontends/main/src/otel-utils.test.ts @@ -59,6 +59,7 @@ describe("createRequestLogEntry", () => { message: "next_request", method: "GET", route: "/courses", + query: null, statusCode: 200, durationMs: 1250, traceId: null, @@ -67,16 +68,16 @@ describe("createRequestLogEntry", () => { }) }) - it("strips the query string from the route", () => { + it("splits route and query when the URL has a query string", () => { const request = { method: "POST", url: "/api/foo?bar=baz&qux=1", } as IncomingMessage const response = { statusCode: 201 } as ServerResponse - expect( - createRequestLogEntry({ request, response, durationMs: 5 }).route, - ).toBe("/api/foo") + const entry = createRequestLogEntry({ request, response, durationMs: 5 }) + expect(entry.route).toBe("/api/foo") + expect(entry.query).toBe("bar=baz&qux=1") }) it("falls back to UNKNOWN when method is missing", () => { diff --git a/frontends/main/src/otel-utils.ts b/frontends/main/src/otel-utils.ts index 3d0e7a8e48..7cf3369d2b 100644 --- a/frontends/main/src/otel-utils.ts +++ b/frontends/main/src/otel-utils.ts @@ -8,6 +8,7 @@ export type RequestLogEntry = { message: "next_request" method: string route: string + query: string | null statusCode: number durationMs: number traceId: string | null @@ -56,13 +57,17 @@ export function createRequestLogEntry({ }): RequestLogEntry { const ctx = trace.getActiveSpan()?.spanContext() const hasTrace = ctx ? isSpanContextValid(ctx) : false + // Split the URL into path + query so the path can group cleanly while the + // query stays available for filtering (e.g. _rsc=... marks an RSC fetch). + const url = request.url ?? "" + const queryIdx = url.indexOf("?") + const route = queryIdx === -1 ? url : url.slice(0, queryIdx) + const query = queryIdx === -1 ? null : url.slice(queryIdx + 1) return { message: "next_request", method: request.method ?? "UNKNOWN", - // Strip the query string so log routes group cleanly. We don't have access - // to the matched Next.js route template here (e.g. /courses/[id]); raw - // paths are good enough for "did every request get a trace" comparisons. - route: request.url?.split("?")[0] ?? "", + route, + query, statusCode: response.statusCode, durationMs, traceId: hasTrace && ctx ? ctx.traceId : null,