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
8 changes: 8 additions & 0 deletions RELEASE.rst
Original file line number Diff line number Diff line change
@@ -1,6 +1,14 @@
Release Notes
=============

Version 0.65.0
--------------

- Update dependency lxml to v6.1.0 [SECURITY] (#3232)
- Update dependency litellm to v1.83.7 [SECURITY] (#3248)
- Add externalLinkProps utility; open external CTA links in new tab only (#3254)
- Add simple nextjs request logger plus fix OTEL resource attributes (#3247)

Version 0.64.5 (Released April 28, 2026)
--------------

Expand Down
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
39 changes: 38 additions & 1 deletion frontends/main/src/common/utils.test.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,41 @@
import { convertToEmbedUrl } from "./utils"
import invariant from "tiny-invariant"
import { convertToEmbedUrl, externalLinkProps } from "./utils"

const NEXT_PUBLIC_ORIGIN = process.env.NEXT_PUBLIC_ORIGIN
invariant(NEXT_PUBLIC_ORIGIN, "NEXT_PUBLIC_ORIGIN must be defined")

describe("externalLinkProps", () => {
it("returns blank-target props for an external URL", () => {
expect(externalLinkProps("https://ocw.mit.edu/courses/123")).toEqual({
target: "_blank",
rel: "noopener noreferrer",
})
})

it("returns extra props only for external URLs", () => {
const extra = { endIcon: "external" }
expect(externalLinkProps("https://ocw.mit.edu/courses/123", extra)).toEqual(
{
target: "_blank",
rel: "noopener noreferrer",
...extra,
},
)
expect(externalLinkProps("/courses/123", extra)).toEqual({})
})

it("returns empty object for an internal absolute URL", () => {
expect(externalLinkProps(`${NEXT_PUBLIC_ORIGIN}/courses/123`)).toEqual({})
})

it("returns empty object for a relative URL", () => {
expect(externalLinkProps("/courses/123")).toEqual({})
})

it("returns empty object for a hash-only href", () => {
expect(externalLinkProps("#section")).toEqual({})
})
})

describe("convertToEmbedUrl", () => {
describe("invalid / unsupported input", () => {
Expand Down
31 changes: 31 additions & 0 deletions frontends/main/src/common/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -136,6 +136,36 @@ function hexToRgba(hex: string, alpha: number): string | undefined {
const isOcwPlaylist = (resource: VideoPlaylistResource | undefined) =>
resource?.offered_by?.code === "ocw" ? true : false

const ORIGIN = process.env.NEXT_PUBLIC_ORIGIN

/**
* Returns `{ target: "_blank", rel: "noopener noreferrer", ...extra }` for URLs
* whose origin differs from NEXT_PUBLIC_ORIGIN, or `{}` for internal / relative
* URLs. Pass `extra` to include additional props only for external links (e.g.,
* an endIcon).
*/
function externalLinkProps(href: string): {
target?: "_blank"
rel?: "noopener noreferrer"
}
function externalLinkProps<T extends Record<string, unknown>>(
href: string,
extra: T,
):
| ({ target?: "_blank"; rel?: "noopener noreferrer" } & T)
| Record<string, never>
function externalLinkProps(href: string, extra?: Record<string, unknown>) {
try {
const parsed = new URL(href, ORIGIN)
if (ORIGIN && parsed.origin === new URL(ORIGIN).origin) {
return {}
}
} catch {
return {}
}
return { target: "_blank", rel: "noopener noreferrer", ...extra }
}

export {
isInEnum,
matchOrganizationBySlug,
Expand All @@ -144,4 +174,5 @@ export {
convertToEmbedUrl,
hexToRgba,
isOcwPlaylist,
externalLinkProps,
}
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
*/
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
Loading
Loading