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
10 changes: 8 additions & 2 deletions env/frontend.env
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
1 change: 1 addition & 0 deletions frontends/jest-shared-setup.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
1 change: 1 addition & 0 deletions frontends/main/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
134 changes: 126 additions & 8 deletions frontends/main/src/instrumentation-node.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
*
Expand All @@ -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

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This seems likely to be fixed soon, see

though i'd still like to have the data before their fix is merged/released.

*/
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<void> {
return Promise.resolve()
}

forceFlush(): Promise<void> {
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<IncomingMessage, bigint>()

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
Expand Down
186 changes: 186 additions & 0 deletions frontends/main/src/otel-utils.test.ts
Original file line number Diff line number Diff line change
@@ -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> = {},
): 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)
})
})
Loading
Loading