diff --git a/env/frontend.env b/env/frontend.env index 0ca5e8be54..9b7765130c 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 + +# 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/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 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 e9d16cab74..4170536032 100644 --- a/frontends/main/src/instrumentation-node.ts +++ b/frontends/main/src/instrumentation-node.ts @@ -4,15 +4,36 @@ // 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 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, + detectResourceOverrides, + hasOtlpEndpointConfig, +} 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. +// 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 + process.env.OTEL_RESOURCE_ATTRIBUTES = existing + ? `${prefix},${existing}` + : prefix +} + /** * Build the list of extra span processors injected into Sentry's OTEL provider. * @@ -26,15 +47,112 @@ import { parseSampleRate } from "./sentry-utils" * completed spans as JSON to stdout. See env/frontend.env for details. */ function buildSpanProcessors(): SpanProcessor[] { - 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())] + const processors: SpanProcessor[] = [] + + const overrides = detectResourceOverrides() + if (Object.keys(overrides).length > 0) { + processors.push(new ResourceAttributeOverrideSpanProcessor(overrides)) + } + + if (hasOtlpEndpointConfig(process.env)) { + processors.push(new BatchSpanProcessor(new OTLPTraceExporter())) + } else if (process.env.OTEL_TRACES_EXPORTER === "console") { + processors.push(new SimpleSpanProcessor(new ConsoleSpanExporter())) + } + + return processors +} + +/** + * 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: DetectedResourceAttributes + + constructor(overrides: DetectedResourceAttributes) { + this.overrides = overrides + } + + onStart(_span: Span, _parentContext: Context): void { + // no-op + } + + onEnd(span: ReadableSpan): void { + applyResourceOverrides(span, this.overrides) } - if (process.env.OTEL_TRACES_EXPORTER === "console") { - return [new SimpleSpanProcessor(new ConsoleSpanExporter())] + + shutdown(): Promise { + return Promise.resolve() + } + + forceFlush(): Promise { + return Promise.resolve() } - return [] +} + +declare global { + // eslint-disable-next-line no-var + 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 + * 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. + * + * 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) => { + 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 + } + 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 })), + ) + }) +} + +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 new file mode 100644 index 0000000000..30ee334e0d --- /dev/null +++ b/frontends/main/src/otel-utils.test.ts @@ -0,0 +1,186 @@ +import type { IncomingMessage, ServerResponse } from "node:http" +import type { ReadableSpan } from "@opentelemetry/sdk-trace-base" +import { + applyResourceOverrides, + createRequestLogEntry, + detectResourceOverrides, + hasOtlpEndpointConfig, +} 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 = ( + 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("builds a log entry from request and response", () => { + const request = { method: "GET", url: "/courses" } as IncomingMessage + const response = { statusCode: 200 } as ServerResponse + + expect( + createRequestLogEntry({ request, response, durationMs: 1250 }), + ).toEqual({ + message: "next_request", + method: "GET", + route: "/courses", + query: null, + statusCode: 200, + durationMs: 1250, + traceId: null, + spanId: null, + version: "test-version", + }) + }) + + 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 + + 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", () => { + const request = { url: "/" } as IncomingMessage + const response = { statusCode: 500 } as ServerResponse + + expect( + createRequestLogEntry({ request, response, durationMs: 1 }).method, + ).toBe("UNKNOWN") + }) +}) + +describe("applyResourceOverrides", () => { + it("copies overrides onto the span resource", () => { + const span = makeReadableSpan({ + resource: makeResource({ + "service.name": "node", + "service.namespace": "sentry", + }), + }) + + applyResourceOverrides(span, { + "service.name": "learn-nextjs", + "deployment.environment.name": "prod", + }) + + 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("leaves the resource unchanged when overrides is empty", () => { + const span = makeReadableSpan({ + resource: makeResource({ "service.name": "node" }), + }) + + applyResourceOverrides(span, {}) + + expect(span.resource.attributes["service.name"]).toBe("node") + }) + + it("skips non-string values defensively", () => { + const span = makeReadableSpan({ + resource: makeResource({ "service.name": "node" }), + }) + + applyResourceOverrides(span, { + "service.name": "learn-nextjs", + "broken.promise": Promise.resolve("ignored"), + "broken.array": ["a", "b"], + }) + + expect(span.resource.attributes["service.name"]).toBe("learn-nextjs") + expect(span.resource.attributes["broken.promise"]).toBeUndefined() + expect(span.resource.attributes["broken.array"]).toBeUndefined() + }) +}) + +describe("detectResourceOverrides", () => { + const originalEnv = { ...process.env } + + afterEach(() => { + process.env = { ...originalEnv } + }) + + 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" + + expect(detectResourceOverrides()).toEqual({ + "service.name": "app-name", + "service.namespace": "learn", + "service.version": "1.2.3", + }) + }) + + 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 + + expect(detectResourceOverrides()).toEqual({ + "deployment.environment.name": "us,east", + "service.version": "1=2", + }) + }) + + 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({}) + }) +}) + +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 new file mode 100644 index 0000000000..7cf3369d2b --- /dev/null +++ b/frontends/main/src/otel-utils.ts @@ -0,0 +1,97 @@ +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" + +export type RequestLogEntry = { + message: "next_request" + method: string + route: string + query: string | null + statusCode: number + durationMs: number + traceId: string | null + spanId: string | null + version: string | null +} + +const APP_VERSION = process.env.NEXT_PUBLIC_VERSION ?? null + +type OtelEnvSubset = Readonly> + +function getNonEmptyEnvValue(value: string | undefined): string | undefined { + const trimmed = value?.trim() + return trimmed ? trimmed : undefined +} + +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 ?? {} +} + +/** + * 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. + */ +export function createRequestLogEntry({ + request, + response, + durationMs, +}: { + request: IncomingMessage + response: ServerResponse + durationMs: number +}): 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", + route, + query, + statusCode: response.statusCode, + durationMs, + traceId: hasTrace && ctx ? ctx.traceId : null, + spanId: hasTrace && ctx ? ctx.spanId : null, + version: APP_VERSION, + } +} + +/** + * 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: DetectedResourceAttributes, +): void { + 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"