diff --git a/README.md b/README.md index db164bd..9c5b102 100644 --- a/README.md +++ b/README.md @@ -301,9 +301,10 @@ RELEASE="$RELEASE" OTEL_URL="https://otel.example.com" \ ``` The receiver stores uploaded maps under `OTEL_SOURCEMAP_DIR` (default -`./sourcemaps`, `/sourcemaps` in the Kubernetes deployment). Upload maps for -each release before investigating its errors. The API never returns map -contents or original source files. +`./sourcemaps`, `/sourcemaps` in the Kubernetes deployment). Maps are reloaded on start, so keep the directory on persistent storage. See +`instrumentation.md` (*Source maps → Requirements checklist*) for everything a +project needs for stacks to resolve. `GET /api/sourcemaps` lists loaded maps. +The API never returns map files themselves. `build.sh` passes the release into the Docker UI build. If `OTEL_URL` is set, it also builds the matching UI locally and uploads all generated JavaScript @@ -362,6 +363,18 @@ already points at it. The UI's Trends tab (and live issue updates) appear automatically once the stats endpoints answer; on file-store deployments the tab hides itself. SPA routes (/issues, /traces, /logs, /metrics, /trends, /setup) are served client-side by wouter and survive the OIDC login redirect. +Selection lives in the URL too, so every step can be reloaded, stepped with +back/forward, and shared (each detail view has a link chip that copies its +permalink): + +| Path | Shows | +| --- | --- | +| `/issues/` | one issue with its traces | +| `/issues//trace/` | the same, with a trace selected | +| `/patterns/[/]` | a pattern card open, optionally reviewing one member | +| `/traces/[/]` | a trace, optionally with a span selected | +| `/rum/session/` | a RUM session | +| `/rum/errors[/]` | JS errors, optionally one error expanded (`errorId` is a hash of type + message) | Currently, the history buffer size is hardcoded to 100 entries. This can be made configurable in future versions. diff --git a/instrumentation.md b/instrumentation.md index 37a00b4..a320e5a 100644 --- a/instrumentation.md +++ b/instrumentation.md @@ -150,9 +150,59 @@ For example, if the deployed JavaScript is available at `source=/assets/main-abc123.js`. The source-map filename on the build machine does not need to match the URL, but the `source` field must. -Maps are parsed and retained server-side. Issue responses include a resolved -stack when `service.release` and the generated bundle path match. Original -source files and map contents are never returned by the API. +Maps are parsed, written to `OTEL_SOURCEMAP_DIR`, and reloaded on receiver +start. Issue responses include a resolved stack when `service.release` and the +generated bundle path match. Uploaded maps are never returned, but when a map +embeds `sourcesContent` the RUM "JS errors" view shows a few lines of original +source around each mapped frame to signed-in users. + +### Requirements checklist + +A stack only resolves when every one of these holds. When it does not, check +them in this order: + +1. **The build emits maps, with sources embedded.** Enable source maps in the + bundler (Vite `build.sourcemap: true`, webpack `devtool: "source-map"`). + Keep `sourcesContent` (the default) or the code view has nothing to show. +2. **The browser reports a release.** Pass the release to `OtelRUM.init({ release })` + (or `data-release` on the script tag), injected at build time. An event + with no release, or `dev`, can never match an upload. +3. **The uploaded release is byte-for-byte the same string.** Use one value for + both the bundle build and the upload (a git short SHA works). +4. **`source` matches the path in the browser stack.** Everything before + `/assets/` and any `?query` is ignored, so `source=/assets/main-abc123.js` + matches `https://app.example.com/assets/main-abc123.js`. Content-hashed + names must match exactly, and chunks each need their own upload. +5. **The map belongs to the deployed bundle.** Upload the maps from the very + build that produced the deployed files. A second local rebuild can differ + (dependencies, Node version) and silently mismatch. In Docker, copy the maps + out of the build stage (`docker build --target `, then `docker cp`) + instead of rebuilding. `build.sh` does this. +6. **Every chunk is uploaded.** Loop over all `*.js.map` files and fail the + deploy when none are found, since an empty loop otherwise succeeds silently. +7. **The receiver keeps its maps.** `OTEL_SOURCEMAP_DIR` must be on persistent + storage (a PVC in Kubernetes, not `emptyDir`). Maps written to an ephemeral + directory vanish on restart. +8. **Maps are not published with the site.** Delete `*.map` from the served + image unless public maps are intended (see below). + +### Verifying a deploy + +List what the receiver has loaded (requires a signed-in session or token): + +```sh +curl -H "Authorization: Bearer $TOKEN" https://otel.example.com/api/sourcemaps +# [{"release":"2026.09.18-a1b2c3","source":"/assets/main-abc123.js"}, ...] +``` + +Then trigger a real uncaught error from application code, for example a +button that runs `setTimeout(() => { throw new Error("source-map check") })` +(this repository's RUM "JS errors" view has a *Generate test error* button), +and confirm the card shows **Source-mapped** with the original file and line. + +Frame status in the stack explorer explains a miss: `no-release` (event has no +release), `no-map` (nothing uploaded for that release and file), or `unmapped` +(a map exists but has no mapping for that position). The Docker build removes `.map` files from production images by default. For development or staging, public maps can be enabled for Chrome DevTools: @@ -170,6 +220,7 @@ production unless that is intentional. 2. Trigger a controlled backend error span or ERROR log. 3. Confirm both appear under Issues as separate frontend/backend issues. 4. Confirm the issue sample contains the expected release. -5. Confirm a mapped frontend stack shows original file and line information. +5. Confirm a mapped frontend stack shows original file and line information + (see *Requirements checklist* under Source maps if it does not). 6. Confirm trace IDs connect frontend requests to backend traces where headers were propagated. diff --git a/otel-ui/src/components/IssueOccurrences.tsx b/otel-ui/src/components/IssueOccurrences.tsx index 4ca73f2..314e323 100644 --- a/otel-ui/src/components/IssueOccurrences.tsx +++ b/otel-ui/src/components/IssueOccurrences.tsx @@ -1,4 +1,5 @@ import { useMemo, useState } from "react" +import { CopyRef } from "./common/CopyRef" import { MasterDetail } from "./MasterDetail" import { BackButton } from "./common/BackButton" import { SectionLabel } from "./common/DetailField" @@ -26,15 +27,18 @@ interface Row { /** * Focused view of one issue: an overview of every trace it occurred in * (server-side summaries, paged), with the selected trace's waterfall and its - * occurrences below. `initialTraceId` preselects the trace the user came from. + * occurrences below. The selected trace comes from the URL (`traceId`). */ export const IssueOccurrences = ({ issue, - initialTraceId, + traceId: routeTraceId, + onTraceChange, onBack, }: { issue: Issue - initialTraceId?: string + /** Trace named by the URL; the URL is the source of truth for the selection. */ + traceId?: string + onTraceChange?: (traceId: string | null) => void onBack: () => void }) => { const { traces, untraced, total, complete, hasMore, loading, error, loadMore } = useIssueTraces(issue.id) @@ -59,7 +63,12 @@ export const IssueOccurrences = ({ ) }, [traces, untraced, sort]) - const [picked, setPicked] = useState(initialTraceId ?? null) + const [pickedLocal, setPickedLocal] = useState(null) + const picked = routeTraceId ?? pickedLocal + const setPicked = (key: string) => { + setPickedLocal(key) + onTraceChange?.(rows.find((r) => r.key === key)?.traceId ?? null) + } const selected = rows.find((r) => r.key === picked) ?? rows[0] const selectedTraceId = selected?.traceId @@ -81,7 +90,19 @@ export const IssueOccurrences = ({
} - eyebrow={`service=${issue.service}`} + eyebrow={ + <> + service={issue.service} + + {selectedTraceId && ( + + )} + + } title={issue.title} stats={ <> diff --git a/otel-ui/src/components/IssuesVisualizer.tsx b/otel-ui/src/components/IssuesVisualizer.tsx index 79e847e..51e6ba2 100644 --- a/otel-ui/src/components/IssuesVisualizer.tsx +++ b/otel-ui/src/components/IssuesVisualizer.tsx @@ -32,6 +32,8 @@ import { strongestCorrelate, type SeverityTone, } from "../lib/issue-utils" +import { useSubRoute } from "../lib/routes" +import { CopyRef } from "./common/CopyRef" import { IssueOccurrences } from "./IssueOccurrences" import { NeedleTestModal } from "./NeedleTestModal" import { FilterBar, type FilterChip } from "./FilterBar" @@ -343,6 +345,7 @@ const IssueCard = ({
{jsFocus ? ( - + go(id ? ["errors", id] : ["errors"])} /> ) : sorted.length === 0 ? (