diff --git a/build.sh b/build.sh index bc01852..d4f1c67 100755 --- a/build.sh +++ b/build.sh @@ -21,16 +21,27 @@ kubectl rollout status deployment/otel-debug-receiver -n monitoring # Docker builds intentionally omit public source maps. If the public receiver # URL is present, build the same release locally and upload maps to the running -# receiver. The Kubernetes source-map volume is currently -# emptyDir, so upload after the rollout rather than before it. +# receiver. Maps persist on the otel-sourcemaps PVC and are reloaded on start; +# verify with: node scripts/dev-auth.mjs get /api/sourcemaps if [[ -n "${OTEL_URL:-}" ]]; then - VITE_APP_RELEASE="$RELEASE" npm --prefix otel-ui run build + # Take the maps from the exact UI build that went into the image (same + # bundle hashes and positions as production), not from a second local build. + maps_dir="$(mktemp -d)" + trap 'rm -rf "$maps_dir"' EXIT + docker build --target ui-builder \ + --build-arg "APP_RELEASE=${RELEASE}" \ + -t otel-debug-ui-builder:"${RELEASE}" . + cid="$(docker create otel-debug-ui-builder:"${RELEASE}")" + docker cp "$cid:/app/dist" "$maps_dir/dist" + docker rm "$cid" >/dev/null + found=0 while IFS= read -r -d '' map; do + found=1 bundle="${map%.map}" RELEASE="$RELEASE" OTEL_URL="$OTEL_URL" \ ./scripts/upload-sourcemap.sh "$bundle" - done < <(find otel-ui/dist -type f -name '*.js.map' -print0) - find otel-ui/dist -type f -name '*.map' -delete + done < <(find "$maps_dir" -type f -name '*.js.map' -print0) + [[ "$found" == 1 ]] || { echo "no source maps found in UI build" >&2; exit 1; } fi kubectl logs -f deployment/otel-debug-receiver -n monitoring diff --git a/cmd/server/main.go b/cmd/server/main.go index c8cd0aa..23511c0 100644 --- a/cmd/server/main.go +++ b/cmd/server/main.go @@ -293,6 +293,7 @@ func main() { // Source-map uploads are intentionally public so the release build script // can upload maps without an OIDC session or bearer token. uiMux.HandleFunc("POST /api/sourcemaps", coll.HandleSourceMap) + uiMux.Handle("GET /api/sourcemaps", oidcAuth.RequireAuth(http.HandlerFunc(coll.HandleListSourceMaps))) uiMux.Handle("POST /api/sourcemaps/resolve", oidcAuth.RequireAuth(http.HandlerFunc(coll.HandleResolveStacks))) uiMux.HandleFunc("GET /api/needle/status", coll.HandleNeedleStatus) uiMux.HandleFunc("POST /api/needle/classify", coll.HandleNeedleClassify) diff --git a/internal/collector/handlers.go b/internal/collector/handlers.go index ce6b091..a8c9e42 100644 --- a/internal/collector/handlers.go +++ b/internal/collector/handlers.go @@ -7,6 +7,7 @@ import ( "encoding/base64" "encoding/json" "fmt" + "git.tornberg.me/go-otel/internal/sourcemaps" "io" "log" "net/http" @@ -500,6 +501,17 @@ func (c *Collector) HandleSourceMap(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusCreated) } +// HandleListSourceMaps lists the release/source pairs currently loaded, so a +// deploy can verify its uploads (and that they survived a restart). +func (c *Collector) HandleListSourceMaps(w http.ResponseWriter, r *http.Request) { + list := []sourcemaps.Entry{} + if c.sourceMaps != nil { + list = c.sourceMaps.List() + } + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(list) +} + // HandleResolveStacks maps raw browser stacktraces back to source using the // uploaded source maps. Body: {"items":[{"release":"...","stacktrace":"..."}]}; // the response has one resolved stacktrace per item, in order. @@ -515,14 +527,17 @@ func (c *Collector) HandleResolveStacks(w http.ResponseWriter, r *http.Request) return } out := make([]string, len(req.Items)) + frames := make([][]sourcemaps.Frame, len(req.Items)) for i, it := range req.Items { out[i] = it.Stacktrace + frames[i] = []sourcemaps.Frame{} if c.sourceMaps != nil { out[i] = c.sourceMaps.ResolveStack(it.Release, it.Stacktrace) + frames[i] = c.sourceMaps.ResolveFrames(it.Release, it.Stacktrace) } } w.Header().Set("Content-Type", "application/json") - _ = json.NewEncoder(w).Encode(map[string]any{"resolved": out}) + _ = json.NewEncoder(w).Encode(map[string]any{"resolved": out, "frames": frames}) } // HandleIssueByID returns a single issue by id, including its samples. diff --git a/internal/sourcemaps/store.go b/internal/sourcemaps/store.go index 8d92862..5755ac7 100644 --- a/internal/sourcemaps/store.go +++ b/internal/sourcemaps/store.go @@ -1,11 +1,13 @@ package sourcemaps import ( + "encoding/base64" "fmt" "io" "os" "path/filepath" "regexp" + "sort" "strconv" "strings" "sync" @@ -28,7 +30,76 @@ func New(dir string) (*Resolver, error) { if err := os.MkdirAll(dir, 0750); err != nil { return nil, err } - return &Resolver{dir: dir, maps: make(map[string]*sm.Consumer)}, nil + r := &Resolver{dir: dir, maps: make(map[string]*sm.Consumer)} + r.load() + return r, nil +} + +// fileName encodes release and source reversibly so maps can be reloaded +// from disk after a restart. +func fileName(release, source string) string { + enc := base64.RawURLEncoding + return enc.EncodeToString([]byte(release)) + "." + enc.EncodeToString([]byte(normalize(source))) + ".map" +} + +func parseFileName(name string) (release, source string, ok bool) { + parts := strings.Split(strings.TrimSuffix(name, ".map"), ".") + if len(parts) != 2 || !strings.HasSuffix(name, ".map") { + return "", "", false + } + rel, err1 := base64.RawURLEncoding.DecodeString(parts[0]) + src, err2 := base64.RawURLEncoding.DecodeString(parts[1]) + if err1 != nil || err2 != nil { + return "", "", false + } + return string(rel), string(src), true +} + +// load restores previously uploaded maps; unreadable or legacy-named files are skipped. +func (r *Resolver) load() { + entries, err := os.ReadDir(r.dir) + if err != nil { + return + } + for _, e := range entries { + release, source, ok := parseFileName(e.Name()) + if !ok || e.IsDir() { + continue + } + b, err := os.ReadFile(filepath.Join(r.dir, e.Name())) + if err != nil { + continue + } + consumer, err := sm.Parse(source+".map", b) + if err != nil { + continue + } + r.maps[key(release, source)] = consumer + } +} + +// Entry describes one loaded source map. +type Entry struct { + Release string `json:"release"` + Source string `json:"source"` +} + +// List returns every loaded map, sorted by release then source. +func (r *Resolver) List() []Entry { + r.mu.RLock() + defer r.mu.RUnlock() + out := make([]Entry, 0, len(r.maps)) + for k := range r.maps { + release, source, _ := strings.Cut(k, "\x00") + out = append(out, Entry{Release: release, Source: source}) + } + sort.Slice(out, func(i, j int) bool { + if out[i].Release != out[j].Release { + return out[i].Release < out[j].Release + } + return out[i].Source < out[j].Source + }) + return out } func key(release, source string) string { return release + "\x00" + normalize(source) } @@ -53,7 +124,7 @@ func (r *Resolver) Upload(release, source string, body io.Reader) error { if err != nil { return fmt.Errorf("invalid source map: %w", err) } - name := filepath.Join(r.dir, safe(release)+"-"+safe(normalize(source))+".map") + name := filepath.Join(r.dir, fileName(release, source)) if err := os.WriteFile(name, b, 0600); err != nil { return err } @@ -104,3 +175,67 @@ func (r *Resolver) ResolveStack(release, stack string) string { } return strings.Join(lines, "\n") } + +// Frame is one parsed stack frame, with original-source details when a map +// for its file was uploaded. Status explains why a frame is or is not mapped: +// "mapped", "no-release", "no-map" (no map uploaded for this file/release) or +// "unmapped" (a map exists but has no mapping for this position). +type Frame struct { + Raw string `json:"raw"` + Function string `json:"function,omitempty"` + File string `json:"file"` + Line int `json:"line"` + Column int `json:"column"` + Status string `json:"status"` + + Source string `json:"source,omitempty"` + Name string `json:"name,omitempty"` + SrcLine int `json:"srcLine,omitempty"` + SrcColumn int `json:"srcColumn,omitempty"` + Context []string `json:"context,omitempty"` // original source lines around SrcLine + ContextTop int `json:"contextTop,omitempty"` // 1-based line number of Context[0] +} + +const frameContext = 6 + +// ResolveFrames parses every frame in the stack and maps it through the +// uploaded source maps, including surrounding original source when the map +// embeds sourcesContent. +func (r *Resolver) ResolveFrames(release, stack string) []Frame { + r.mu.RLock() + defer r.mu.RUnlock() + var frames []Frame + for _, line := range strings.Split(stack, "\n") { + m := frameRE.FindStringSubmatch(line) + if m == nil { + continue + } + ln, _ := strconv.Atoi(m[3]) + col, _ := strconv.Atoi(m[4]) + fn := strings.TrimSpace(strings.TrimSuffix(strings.TrimPrefix(strings.TrimSpace(m[1]), "at "), "(")) + f := Frame{Raw: strings.TrimSpace(line), Function: fn, File: m[2], Line: ln, Column: col, Status: "mapped"} + switch consumer := r.maps[key(release, m[2])]; { + case release == "": + f.Status = "no-release" + case consumer == nil: + f.Status = "no-map" + default: + source, name, sl, sc, ok := consumer.Source(ln, col) + if !ok { + f.Status = "unmapped" + break + } + f.Source, f.Name, f.SrcLine, f.SrcColumn = source, name, sl, sc + if content := consumer.SourceContent(source); content != "" { + src := strings.Split(content, "\n") + top := max(sl-frameContext, 1) + bottom := min(sl+frameContext, len(src)) + if top <= bottom { + f.Context, f.ContextTop = src[top-1:bottom], top + } + } + } + frames = append(frames, f) + } + return frames +} diff --git a/internal/sourcemaps/store_test.go b/internal/sourcemaps/store_test.go new file mode 100644 index 0000000..e00e6b9 --- /dev/null +++ b/internal/sourcemaps/store_test.go @@ -0,0 +1,24 @@ +package sourcemaps + +import ( + "strings" + "testing" +) + +const testMap = `{"version":3,"file":"a.js","sources":["src/a.ts"],"sourcesContent":["const a = 1\nthrow new Error('x')\n"],"names":[],"mappings":"AAAA;AACA"}` + +func TestReloadFromDisk(t *testing.T) { + dir := t.TempDir() + r, _ := New(dir) + if err := r.Upload("rel1", "https://h/assets/a.js", strings.NewReader(testMap)); err != nil { + t.Fatal(err) + } + r2, _ := New(dir) // simulates a restart + if l := r2.List(); len(l) != 1 || l[0].Release != "rel1" || l[0].Source != "/assets/a.js" { + t.Fatalf("list after reload = %+v", l) + } + frames := r2.ResolveFrames("rel1", "Error: x\n at f (https://h/assets/a.js:2:1)") + if len(frames) != 1 || frames[0].Status != "mapped" || frames[0].Source != "src/a.ts" || len(frames[0].Context) == 0 { + t.Fatalf("frames = %+v", frames) + } +} diff --git a/k8s/deployment.yaml b/k8s/deployment.yaml index e146602..bee41eb 100644 --- a/k8s/deployment.yaml +++ b/k8s/deployment.yaml @@ -179,10 +179,22 @@ spec: - name: log-dir emptyDir: {} - name: sourcemap-dir - emptyDir: {} + persistentVolumeClaim: + claimName: otel-sourcemaps --- apiVersion: v1 +kind: PersistentVolumeClaim +metadata: + name: otel-sourcemaps + namespace: monitoring +spec: + accessModes: ["ReadWriteOnce"] + resources: + requests: + storage: 1Gi +--- +apiVersion: v1 kind: ServiceAccount metadata: name: otel-debug-receiver diff --git a/otel-ui/package-lock.json b/otel-ui/package-lock.json index 3c8ef3b..8c49941 100644 --- a/otel-ui/package-lock.json +++ b/otel-ui/package-lock.json @@ -8,6 +8,11 @@ "name": "otel-ui", "version": "0.0.0", "dependencies": { + "@codemirror/lang-javascript": "^6.2.5", + "@codemirror/language": "^6.12.4", + "@codemirror/state": "^6.7.5", + "@codemirror/view": "^6.43.12", + "@lezer/highlight": "^1.2.3", "@radix-ui/react-accordion": "1.2.2", "@radix-ui/react-alert-dialog": "1.1.4", "@radix-ui/react-aspect-ratio": "1.1.1", @@ -355,6 +360,79 @@ "node": ">=6.9.0" } }, + "node_modules/@codemirror/autocomplete": { + "version": "6.20.3", + "resolved": "https://registry.npmjs.org/@codemirror/autocomplete/-/autocomplete-6.20.3.tgz", + "integrity": "sha512-tlosUqb+3BbxCxZdu4tKeRghPFC+QM7q4X5YhKV2eCmPG+1r2F3f4AaSz5sCrFqUtX4Jh20VFTKecl16MgiV9g==", + "license": "MIT", + "dependencies": { + "@codemirror/language": "^6.0.0", + "@codemirror/state": "^6.0.0", + "@codemirror/view": "^6.17.0", + "@lezer/common": "^1.0.0" + } + }, + "node_modules/@codemirror/lang-javascript": { + "version": "6.2.5", + "resolved": "https://registry.npmjs.org/@codemirror/lang-javascript/-/lang-javascript-6.2.5.tgz", + "integrity": "sha512-zD4e5mS+50htS7F+TYjBPsiIFGanfVqg4HyUz6WNFikgOPf2BgKlx+TQedI1w6n/IqRBVBbBWmGFdLB/7uxO4A==", + "license": "MIT", + "dependencies": { + "@codemirror/autocomplete": "^6.0.0", + "@codemirror/language": "^6.6.0", + "@codemirror/lint": "^6.0.0", + "@codemirror/state": "^6.0.0", + "@codemirror/view": "^6.17.0", + "@lezer/common": "^1.0.0", + "@lezer/javascript": "^1.0.0" + } + }, + "node_modules/@codemirror/language": { + "version": "6.12.4", + "resolved": "https://registry.npmjs.org/@codemirror/language/-/language-6.12.4.tgz", + "integrity": "sha512-1q4PaT+o6PbgpkJt4Q8Fv5XJxTy4FUZ4MWETtyiDw3J0Pyr9E2vqcKL+k9wcvjNTIsauxvE7OfmWj3FRPHQ76A==", + "license": "MIT", + "dependencies": { + "@codemirror/state": "^6.0.0", + "@codemirror/view": "^6.23.0", + "@lezer/common": "^1.5.0", + "@lezer/highlight": "^1.0.0", + "@lezer/lr": "^1.0.0", + "style-mod": "^4.0.0" + } + }, + "node_modules/@codemirror/lint": { + "version": "6.9.7", + "resolved": "https://registry.npmjs.org/@codemirror/lint/-/lint-6.9.7.tgz", + "integrity": "sha512-28/+iWLYxKxsvGYhSYL7zaCZqLz5+FFFDq9tVsvGv9kv8RY4fFAchJ5WX9M3YrrRlTIsECjsXPqeNgnSmNP2dg==", + "license": "MIT", + "dependencies": { + "@codemirror/state": "^6.0.0", + "@codemirror/view": "^6.42.0", + "crelt": "^1.0.5" + } + }, + "node_modules/@codemirror/state": { + "version": "6.7.5", + "resolved": "https://registry.npmjs.org/@codemirror/state/-/state-6.7.5.tgz", + "integrity": "sha512-QjLbZmY1Au3JiRrDVYFLRD0BZ3SOKS9pR3yjIkd7u27YY8TFD9/Q9fhPnLV5l1mHFSo3hHU/N31vpwEJOx4owQ==", + "license": "MIT", + "dependencies": { + "@marijn/find-cluster-break": "^1.0.0" + } + }, + "node_modules/@codemirror/view": { + "version": "6.43.12", + "resolved": "https://registry.npmjs.org/@codemirror/view/-/view-6.43.12.tgz", + "integrity": "sha512-Nv0vxQ19NAqvB/c2pFzjIzFlzzJl7jmdtNkwOwGbn0Ks9mFAzibvumz7cQem5cRsFA2cEw2fg+uHZGbcHupLQQ==", + "license": "MIT", + "dependencies": { + "@codemirror/state": "^6.7.0", + "crelt": "^1.0.6", + "style-mod": "^4.1.0", + "w3c-keyname": "^2.2.4" + } + }, "node_modules/@esbuild/aix-ppc64": { "version": "0.25.12", "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.12.tgz", @@ -867,6 +945,47 @@ "@jridgewell/sourcemap-codec": "^1.4.14" } }, + "node_modules/@lezer/common": { + "version": "1.5.2", + "resolved": "https://registry.npmjs.org/@lezer/common/-/common-1.5.2.tgz", + "integrity": "sha512-sxQE460fPZyU3sdc8lafxiPwJHBzZRy/udNFynGQky1SePYBdhkBl1kOagA9uT3pxR8K09bOrmTUqA9wb/PjSQ==", + "license": "MIT" + }, + "node_modules/@lezer/highlight": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@lezer/highlight/-/highlight-1.2.3.tgz", + "integrity": "sha512-qXdH7UqTvGfdVBINrgKhDsVTJTxactNNxLk7+UMwZhU13lMHaOBlJe9Vqp907ya56Y3+ed2tlqzys7jDkTmW0g==", + "license": "MIT", + "dependencies": { + "@lezer/common": "^1.3.0" + } + }, + "node_modules/@lezer/javascript": { + "version": "1.5.4", + "resolved": "https://registry.npmjs.org/@lezer/javascript/-/javascript-1.5.4.tgz", + "integrity": "sha512-vvYx3MhWqeZtGPwDStM2dwgljd5smolYD2lR2UyFcHfxbBQebqx8yjmFmxtJ/E6nN6u1D9srOiVWm3Rb4tmcUA==", + "license": "MIT", + "dependencies": { + "@lezer/common": "^1.2.0", + "@lezer/highlight": "^1.1.3", + "@lezer/lr": "^1.3.0" + } + }, + "node_modules/@lezer/lr": { + "version": "1.4.10", + "resolved": "https://registry.npmjs.org/@lezer/lr/-/lr-1.4.10.tgz", + "integrity": "sha512-rnCpTIBafOx4mRp43xOxDJbFipJm/c0cia/V5TiGlhmMa+wsSdoGmUN3w5Bqrks/09Q/D4tNAmWaT8p6NRi77A==", + "license": "MIT", + "dependencies": { + "@lezer/common": "^1.0.0" + } + }, + "node_modules/@marijn/find-cluster-break": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/@marijn/find-cluster-break/-/find-cluster-break-1.0.4.tgz", + "integrity": "sha512-Wy0V7+SGUjnF9/TkiM1hKVDPj7jKXduPNboMVtHTA8dySMURWqfg/JZ9E2Sq8JgSJmkl7k7Qe9FLeMSrSraWmQ==", + "license": "MIT" + }, "node_modules/@radix-ui/number": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/@radix-ui/number/-/number-1.1.0.tgz", @@ -3611,6 +3730,12 @@ "dev": true, "license": "MIT" }, + "node_modules/crelt": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/crelt/-/crelt-1.0.7.tgz", + "integrity": "sha512-aK6BbWfhf4U/wCcLHKPJl/xa6VkVstRaPywWtMKGwuOLc/wZTyQYuoxgvZnNsBvv7Kg3YTBQYYBCggcviQczuA==", + "license": "MIT" + }, "node_modules/csstype": { "version": "3.1.3", "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.1.3.tgz", @@ -5425,6 +5550,12 @@ "node": ">=0.10.0" } }, + "node_modules/style-mod": { + "version": "4.1.4", + "resolved": "https://registry.npmjs.org/style-mod/-/style-mod-4.1.4.tgz", + "integrity": "sha512-XXWIQt633/EpAFx8aZDOTjBzrCaGmhvEQlQo6MVPfa2OzO2cWo+4hV9h+6UkHYlXGfy+ODXKUdP7Pthmcu5ATw==", + "license": "MIT" + }, "node_modules/swr": { "version": "2.5.1", "resolved": "https://registry.npmjs.org/swr/-/swr-2.5.1.tgz", @@ -5886,6 +6017,12 @@ "url": "https://github.com/sponsors/jonschlinkert" } }, + "node_modules/w3c-keyname": { + "version": "2.2.8", + "resolved": "https://registry.npmjs.org/w3c-keyname/-/w3c-keyname-2.2.8.tgz", + "integrity": "sha512-dpojBhNsCNN7T82Tm7k26A6G9ML3NkhDsnw9n/eoxSRlVBB4CEtIQ/KTCLI2Fwf3ataSXRhYFkQi3SlnFwPvPQ==", + "license": "MIT" + }, "node_modules/wouter": { "version": "3.11.0", "resolved": "https://registry.npmjs.org/wouter/-/wouter-3.11.0.tgz", diff --git a/otel-ui/package.json b/otel-ui/package.json index 30ad300..f43b27f 100644 --- a/otel-ui/package.json +++ b/otel-ui/package.json @@ -10,6 +10,11 @@ "preview": "vite preview" }, "dependencies": { + "@codemirror/lang-javascript": "^6.2.5", + "@codemirror/language": "^6.12.4", + "@codemirror/state": "^6.7.5", + "@codemirror/view": "^6.43.12", + "@lezer/highlight": "^1.2.3", "@radix-ui/react-accordion": "1.2.2", "@radix-ui/react-alert-dialog": "1.1.4", "@radix-ui/react-aspect-ratio": "1.1.1", diff --git a/otel-ui/src/components/RumJsErrors.tsx b/otel-ui/src/components/RumJsErrors.tsx index 98f4b51..71ccc38 100644 --- a/otel-ui/src/components/RumJsErrors.tsx +++ b/otel-ui/src/components/RumJsErrors.tsx @@ -1,7 +1,9 @@ import { useEffect, useMemo, useState } from "react" import { Bug, ChevronDown, ChevronUp } from "lucide-react" +import { triggerTestError } from "../lib/test-error" import { authFetch } from "../lib/auth" import { formatRelativeTime } from "../lib/timeUtils" +import { SourceFrameViewer, type StackFrame } from "./SourceFrameViewer" import type { RumErrorEvent, RumSession } from "../lib/rum-utils" interface JsErrorGroup { @@ -38,6 +40,7 @@ const groupJsErrors = (sessions: RumSession[]): JsErrorGroup[] => { /** Maps each group's sample stacktrace through the server's source maps; falls back to the raw stack. */ const useResolvedStacks = (groups: JsErrorGroup[]) => { const [resolved, setResolved] = useState>({}) + const [frames, setFrames] = useState>({}) const items = useMemo( () => groups.filter((g) => g.sample?.stacktrace).map((g) => ({ key: g.key, release: g.sample!.release ?? "", stacktrace: g.sample!.stacktrace! })), [groups], @@ -55,9 +58,10 @@ const useResolvedStacks = (groups: JsErrorGroup[]) => { body: JSON.stringify({ items: items.map(({ release, stacktrace }) => ({ release, stacktrace })) }), }) if (!response.ok) return - const body = (await response.json()) as { resolved: string[] } + const body = (await response.json()) as { resolved: string[]; frames?: StackFrame[][] } if (cancelled) return setResolved(Object.fromEntries(items.map((item, i) => [item.key, body.resolved[i] ?? item.stacktrace]))) + setFrames(Object.fromEntries(items.map((item, i) => [item.key, body.frames?.[i] ?? []]))) } catch { /* raw stacks still render */ } @@ -66,15 +70,16 @@ const useResolvedStacks = (groups: JsErrorGroup[]) => { // eslint-disable-next-line react-hooks/exhaustive-deps }, [signature]) - return resolved + return { resolved, frames } } -const JsErrorCard = ({ group, resolvedStack }: { group: JsErrorGroup; resolvedStack?: string }) => { +const JsErrorCard = ({ group, resolvedStack, frames }: { group: JsErrorGroup; resolvedStack?: string; frames: StackFrame[] }) => { const [expanded, setExpanded] = useState(false) const [showRaw, setShowRaw] = useState(false) const raw = group.sample?.stacktrace const mapped = Boolean(raw && resolvedStack && resolvedStack !== raw) const stack = showRaw || !mapped ? raw : resolvedStack + const explorable = frames.length > 0 && frames.some((f) => f.status === "mapped") return (
@@ -106,13 +111,19 @@ const JsErrorCard = ({ group, resolvedStack }: { group: JsErrorGroup; resolvedSt )} - {stack ? ( + {explorable && !showRaw ? ( + + ) : stack ? (
{stack}
) : (

No stacktrace was reported for this error.

)} - {!mapped && raw && !group.sample?.release && ( -

No release on this event, so source maps can't be applied.

+ {!explorable && raw && ( +

+ {!group.sample?.release + ? "No release on this event, so source maps can't be applied." + : "No source map matched this stack. Upload one for this release via POST /api/sourcemaps to see original source."} +

)} )} @@ -120,17 +131,29 @@ const JsErrorCard = ({ group, resolvedStack }: { group: JsErrorGroup; resolvedSt ) } +const TestErrorButton = () => ( + +) + export const RumJsErrors = ({ sessions }: { sessions: RumSession[] }) => { const groups = useMemo(() => groupJsErrors(sessions), [sessions]) - const resolved = useResolvedStacks(groups) + const { resolved, frames } = useResolvedStacks(groups) if (groups.length === 0) { return (
) } - return
{groups.map((g) => )}
+ return
{groups.map((g) => )}
} diff --git a/otel-ui/src/components/SourceFrameViewer.tsx b/otel-ui/src/components/SourceFrameViewer.tsx new file mode 100644 index 0000000..2cab698 --- /dev/null +++ b/otel-ui/src/components/SourceFrameViewer.tsx @@ -0,0 +1,144 @@ +import { useEffect, useMemo, useRef, useState } from "react" +import { EditorState, RangeSetBuilder } from "@codemirror/state" +import { Decoration, EditorView, lineNumbers, type DecorationSet } from "@codemirror/view" +import { HighlightStyle, syntaxHighlighting } from "@codemirror/language" +import { javascript } from "@codemirror/lang-javascript" +import { tags as t } from "@lezer/highlight" +import { Info } from "lucide-react" + +export interface StackFrame { + raw: string + function?: string + file: string + line: number + column: number + status: "mapped" | "no-release" | "no-map" | "unmapped" + source?: string + name?: string + srcLine?: number + srcColumn?: number + context?: string[] + contextTop?: number +} + +const statusHint: Record = { + mapped: "Mapped to original source", + "no-release": "This event has no release, so source maps can't be looked up.", + "no-map": "No source map uploaded for this file and release. POST it to /api/sourcemaps to see original source.", + unmapped: "A source map exists but has no mapping for this position.", +} + +const statusDot: Record = { + mapped: "bg-ds-ok", + "no-release": "bg-ds-text-3", + "no-map": "bg-ds-warn", + unmapped: "bg-ds-warn", +} + +const highlight = HighlightStyle.define([ + { tag: [t.keyword, t.modifier, t.operatorKeyword], color: "var(--ds-accent)" }, + { tag: [t.string, t.regexp], color: "var(--ds-ok)" }, + { tag: [t.number, t.bool, t.null], color: "var(--ds-warn)" }, + { tag: [t.comment], color: "var(--ds-text-3)", fontStyle: "italic" }, + { tag: [t.function(t.variableName), t.definition(t.variableName)], color: "var(--ds-text)" }, + { tag: [t.typeName, t.className], color: "var(--ds-accent)" }, +]) + +const theme = EditorView.theme({ + "&": { backgroundColor: "var(--ds-bg)", color: "var(--ds-text-2)", fontSize: "12px" }, + ".cm-content": { fontFamily: "var(--font-data, ui-monospace, monospace)", padding: "6px 0" }, + ".cm-gutters": { backgroundColor: "var(--ds-bg)", color: "var(--ds-text-3)", border: "none" }, + ".cm-scroller": { overflow: "auto" }, + ".cm-error-line": { backgroundColor: "var(--ds-critical-bg)", boxShadow: "inset 3px 0 0 var(--ds-critical)" }, + ".cm-error-col": { backgroundColor: "color-mix(in srgb, var(--ds-critical) 35%, transparent)", borderBottom: "2px solid var(--ds-critical)", borderRadius: "2px" }, +}) + +/** Highlights the throwing line and the exact column the source map points at. */ +const errorDecorations = (state: EditorState, line: number, col: number, len: number): DecorationSet => { + const b = new RangeSetBuilder() + if (line < 1 || line > state.doc.lines) return b.finish() + const l = state.doc.line(line) + b.add(l.from, l.from, Decoration.line({ class: "cm-error-line" })) + const from = Math.min(l.from + Math.max(col, 0), l.to) + const to = Math.min(from + Math.max(len, 1), l.to) + if (to > from) b.add(from, to, Decoration.mark({ class: "cm-error-col" })) + return b.finish() +} + +const Editor = ({ frame }: { frame: StackFrame }) => { + const host = useRef(null) + useEffect(() => { + if (!host.current || !frame.context || !frame.srcLine) return + const top = frame.contextTop ?? 1 + const relLine = frame.srcLine - top + 1 + const state = EditorState.create({ + doc: frame.context.join("\n"), + extensions: [ + EditorState.readOnly.of(true), + EditorView.editable.of(false), + lineNumbers({ formatNumber: (n) => String(n + top - 1) }), + javascript({ jsx: true, typescript: /\.tsx?$/.test(frame.source ?? "") }), + syntaxHighlighting(highlight), + theme, + EditorView.decorations.compute([], (s) => + errorDecorations(s, relLine, (frame.srcColumn ?? 1) - 1, frame.name?.length || 1), + ), + ], + }) + const view = new EditorView({ state, parent: host.current }) + return () => view.destroy() + }, [frame]) + return
+} + +export const SourceFrameViewer = ({ frames }: { frames: StackFrame[] }) => { + const firstMapped = useMemo(() => Math.max(frames.findIndex((f) => f.status === "mapped" && f.context), 0), [frames]) + const [picked, setPicked] = useState(null) + const index = picked ?? firstMapped + const frame = frames[index] + if (!frame) return null + + return ( +
+
    + {frames.map((f, i) => ( +
  1. + +
  2. + ))} +
+
+ {frame.status === "mapped" && frame.context ? ( + <> +

{frame.source}

+ + + ) : null} +

+

+
+
+ ) +} diff --git a/otel-ui/src/lib/test-error.ts b/otel-ui/src/lib/test-error.ts new file mode 100644 index 0000000..5e8c6fd --- /dev/null +++ b/otel-ui/src/lib/test-error.ts @@ -0,0 +1,14 @@ +/** + * Deliberately throws an uncaught error from application code so the RUM + * script reports it, letting a deploy verify the source-map pipeline end to + * end: the resolved stack should point at this file, not the bundle. + */ +export function triggerTestError(): void { + // Thrown from a timer so it surfaces as an uncaught window error, exactly + // like a real bug, instead of being swallowed by a React event handler. + setTimeout(() => throwTestError(new Date().toISOString()), 0) +} + +function throwTestError(at: string): never { + throw new Error(`Test error from otel-ui (source-map check) ${at}`) +}