sourcemaps
This commit is contained in:
1 parent
97743ea21c
commit
1aa264675e
11 files changed
+538
-17
No files matched your search
@@ -21,16 +21,27 @@ kubectl rollout status deployment/otel-debug-receiver -n monitoring
|
|||||||
|
|
||||||
# Docker builds intentionally omit public source maps. If the public receiver
|
# 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
|
# URL is present, build the same release locally and upload maps to the running
|
||||||
# receiver. The Kubernetes source-map volume is currently
|
# receiver. Maps persist on the otel-sourcemaps PVC and are reloaded on start;
|
||||||
# emptyDir, so upload after the rollout rather than before it.
|
# verify with: node scripts/dev-auth.mjs get /api/sourcemaps
|
||||||
if [[ -n "${OTEL_URL:-}" ]]; then
|
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
|
while IFS= read -r -d '' map; do
|
||||||
|
found=1
|
||||||
bundle="${map%.map}"
|
bundle="${map%.map}"
|
||||||
RELEASE="$RELEASE" OTEL_URL="$OTEL_URL" \
|
RELEASE="$RELEASE" OTEL_URL="$OTEL_URL" \
|
||||||
./scripts/upload-sourcemap.sh "$bundle"
|
./scripts/upload-sourcemap.sh "$bundle"
|
||||||
done < <(find otel-ui/dist -type f -name '*.js.map' -print0)
|
done < <(find "$maps_dir" -type f -name '*.js.map' -print0)
|
||||||
find otel-ui/dist -type f -name '*.map' -delete
|
[[ "$found" == 1 ]] || { echo "no source maps found in UI build" >&2; exit 1; }
|
||||||
fi
|
fi
|
||||||
|
|
||||||
kubectl logs -f deployment/otel-debug-receiver -n monitoring
|
kubectl logs -f deployment/otel-debug-receiver -n monitoring
|
||||||
@@ -293,6 +293,7 @@ func main() {
|
|||||||
// Source-map uploads are intentionally public so the release build script
|
// Source-map uploads are intentionally public so the release build script
|
||||||
// can upload maps without an OIDC session or bearer token.
|
// can upload maps without an OIDC session or bearer token.
|
||||||
uiMux.HandleFunc("POST /api/sourcemaps", coll.HandleSourceMap)
|
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.Handle("POST /api/sourcemaps/resolve", oidcAuth.RequireAuth(http.HandlerFunc(coll.HandleResolveStacks)))
|
||||||
uiMux.HandleFunc("GET /api/needle/status", coll.HandleNeedleStatus)
|
uiMux.HandleFunc("GET /api/needle/status", coll.HandleNeedleStatus)
|
||||||
uiMux.HandleFunc("POST /api/needle/classify", coll.HandleNeedleClassify)
|
uiMux.HandleFunc("POST /api/needle/classify", coll.HandleNeedleClassify)
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import (
|
|||||||
"encoding/base64"
|
"encoding/base64"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"git.tornberg.me/go-otel/internal/sourcemaps"
|
||||||
"io"
|
"io"
|
||||||
"log"
|
"log"
|
||||||
"net/http"
|
"net/http"
|
||||||
@@ -500,6 +501,17 @@ func (c *Collector) HandleSourceMap(w http.ResponseWriter, r *http.Request) {
|
|||||||
w.WriteHeader(http.StatusCreated)
|
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
|
// HandleResolveStacks maps raw browser stacktraces back to source using the
|
||||||
// uploaded source maps. Body: {"items":[{"release":"...","stacktrace":"..."}]};
|
// uploaded source maps. Body: {"items":[{"release":"...","stacktrace":"..."}]};
|
||||||
// the response has one resolved stacktrace per item, in order.
|
// 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
|
return
|
||||||
}
|
}
|
||||||
out := make([]string, len(req.Items))
|
out := make([]string, len(req.Items))
|
||||||
|
frames := make([][]sourcemaps.Frame, len(req.Items))
|
||||||
for i, it := range req.Items {
|
for i, it := range req.Items {
|
||||||
out[i] = it.Stacktrace
|
out[i] = it.Stacktrace
|
||||||
|
frames[i] = []sourcemaps.Frame{}
|
||||||
if c.sourceMaps != nil {
|
if c.sourceMaps != nil {
|
||||||
out[i] = c.sourceMaps.ResolveStack(it.Release, it.Stacktrace)
|
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")
|
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.
|
// HandleIssueByID returns a single issue by id, including its samples.
|
||||||
|
|||||||
@@ -1,11 +1,13 @@
|
|||||||
package sourcemaps
|
package sourcemaps
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"encoding/base64"
|
||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"regexp"
|
"regexp"
|
||||||
|
"sort"
|
||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
"sync"
|
"sync"
|
||||||
@@ -28,7 +30,76 @@ func New(dir string) (*Resolver, error) {
|
|||||||
if err := os.MkdirAll(dir, 0750); err != nil {
|
if err := os.MkdirAll(dir, 0750); err != nil {
|
||||||
return nil, err
|
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) }
|
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 {
|
if err != nil {
|
||||||
return fmt.Errorf("invalid source map: %w", err)
|
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 {
|
if err := os.WriteFile(name, b, 0600); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
@@ -104,3 +175,67 @@ func (r *Resolver) ResolveStack(release, stack string) string {
|
|||||||
}
|
}
|
||||||
return strings.Join(lines, "\n")
|
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
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
+13
-1
@@ -179,10 +179,22 @@ spec:
|
|||||||
- name: log-dir
|
- name: log-dir
|
||||||
emptyDir: {}
|
emptyDir: {}
|
||||||
- name: sourcemap-dir
|
- name: sourcemap-dir
|
||||||
emptyDir: {}
|
persistentVolumeClaim:
|
||||||
|
claimName: otel-sourcemaps
|
||||||
|
|
||||||
---
|
---
|
||||||
apiVersion: v1
|
apiVersion: v1
|
||||||
|
kind: PersistentVolumeClaim
|
||||||
|
metadata:
|
||||||
|
name: otel-sourcemaps
|
||||||
|
namespace: monitoring
|
||||||
|
spec:
|
||||||
|
accessModes: ["ReadWriteOnce"]
|
||||||
|
resources:
|
||||||
|
requests:
|
||||||
|
storage: 1Gi
|
||||||
|
---
|
||||||
|
apiVersion: v1
|
||||||
kind: ServiceAccount
|
kind: ServiceAccount
|
||||||
metadata:
|
metadata:
|
||||||
name: otel-debug-receiver
|
name: otel-debug-receiver
|
||||||
|
|||||||
Generated
+137
@@ -8,6 +8,11 @@
|
|||||||
"name": "otel-ui",
|
"name": "otel-ui",
|
||||||
"version": "0.0.0",
|
"version": "0.0.0",
|
||||||
"dependencies": {
|
"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-accordion": "1.2.2",
|
||||||
"@radix-ui/react-alert-dialog": "1.1.4",
|
"@radix-ui/react-alert-dialog": "1.1.4",
|
||||||
"@radix-ui/react-aspect-ratio": "1.1.1",
|
"@radix-ui/react-aspect-ratio": "1.1.1",
|
||||||
@@ -355,6 +360,79 @@
|
|||||||
"node": ">=6.9.0"
|
"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": {
|
"node_modules/@esbuild/aix-ppc64": {
|
||||||
"version": "0.25.12",
|
"version": "0.25.12",
|
||||||
"resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.12.tgz",
|
"resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.12.tgz",
|
||||||
@@ -867,6 +945,47 @@
|
|||||||
"@jridgewell/sourcemap-codec": "^1.4.14"
|
"@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": {
|
"node_modules/@radix-ui/number": {
|
||||||
"version": "1.1.0",
|
"version": "1.1.0",
|
||||||
"resolved": "https://registry.npmjs.org/@radix-ui/number/-/number-1.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/@radix-ui/number/-/number-1.1.0.tgz",
|
||||||
@@ -3611,6 +3730,12 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"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": {
|
"node_modules/csstype": {
|
||||||
"version": "3.1.3",
|
"version": "3.1.3",
|
||||||
"resolved": "https://registry.npmjs.org/csstype/-/csstype-3.1.3.tgz",
|
"resolved": "https://registry.npmjs.org/csstype/-/csstype-3.1.3.tgz",
|
||||||
@@ -5425,6 +5550,12 @@
|
|||||||
"node": ">=0.10.0"
|
"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": {
|
"node_modules/swr": {
|
||||||
"version": "2.5.1",
|
"version": "2.5.1",
|
||||||
"resolved": "https://registry.npmjs.org/swr/-/swr-2.5.1.tgz",
|
"resolved": "https://registry.npmjs.org/swr/-/swr-2.5.1.tgz",
|
||||||
@@ -5886,6 +6017,12 @@
|
|||||||
"url": "https://github.com/sponsors/jonschlinkert"
|
"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": {
|
"node_modules/wouter": {
|
||||||
"version": "3.11.0",
|
"version": "3.11.0",
|
||||||
"resolved": "https://registry.npmjs.org/wouter/-/wouter-3.11.0.tgz",
|
"resolved": "https://registry.npmjs.org/wouter/-/wouter-3.11.0.tgz",
|
||||||
|
|||||||
@@ -10,6 +10,11 @@
|
|||||||
"preview": "vite preview"
|
"preview": "vite preview"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"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-accordion": "1.2.2",
|
||||||
"@radix-ui/react-alert-dialog": "1.1.4",
|
"@radix-ui/react-alert-dialog": "1.1.4",
|
||||||
"@radix-ui/react-aspect-ratio": "1.1.1",
|
"@radix-ui/react-aspect-ratio": "1.1.1",
|
||||||
|
|||||||
@@ -1,7 +1,9 @@
|
|||||||
import { useEffect, useMemo, useState } from "react"
|
import { useEffect, useMemo, useState } from "react"
|
||||||
import { Bug, ChevronDown, ChevronUp } from "lucide-react"
|
import { Bug, ChevronDown, ChevronUp } from "lucide-react"
|
||||||
|
import { triggerTestError } from "../lib/test-error"
|
||||||
import { authFetch } from "../lib/auth"
|
import { authFetch } from "../lib/auth"
|
||||||
import { formatRelativeTime } from "../lib/timeUtils"
|
import { formatRelativeTime } from "../lib/timeUtils"
|
||||||
|
import { SourceFrameViewer, type StackFrame } from "./SourceFrameViewer"
|
||||||
import type { RumErrorEvent, RumSession } from "../lib/rum-utils"
|
import type { RumErrorEvent, RumSession } from "../lib/rum-utils"
|
||||||
|
|
||||||
interface JsErrorGroup {
|
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. */
|
/** Maps each group's sample stacktrace through the server's source maps; falls back to the raw stack. */
|
||||||
const useResolvedStacks = (groups: JsErrorGroup[]) => {
|
const useResolvedStacks = (groups: JsErrorGroup[]) => {
|
||||||
const [resolved, setResolved] = useState<Record<string, string>>({})
|
const [resolved, setResolved] = useState<Record<string, string>>({})
|
||||||
|
const [frames, setFrames] = useState<Record<string, StackFrame[]>>({})
|
||||||
const items = useMemo(
|
const items = useMemo(
|
||||||
() => groups.filter((g) => g.sample?.stacktrace).map((g) => ({ key: g.key, release: g.sample!.release ?? "", stacktrace: g.sample!.stacktrace! })),
|
() => groups.filter((g) => g.sample?.stacktrace).map((g) => ({ key: g.key, release: g.sample!.release ?? "", stacktrace: g.sample!.stacktrace! })),
|
||||||
[groups],
|
[groups],
|
||||||
@@ -55,9 +58,10 @@ const useResolvedStacks = (groups: JsErrorGroup[]) => {
|
|||||||
body: JSON.stringify({ items: items.map(({ release, stacktrace }) => ({ release, stacktrace })) }),
|
body: JSON.stringify({ items: items.map(({ release, stacktrace }) => ({ release, stacktrace })) }),
|
||||||
})
|
})
|
||||||
if (!response.ok) return
|
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
|
if (cancelled) return
|
||||||
setResolved(Object.fromEntries(items.map((item, i) => [item.key, body.resolved[i] ?? item.stacktrace])))
|
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 {
|
} catch {
|
||||||
/* raw stacks still render */
|
/* raw stacks still render */
|
||||||
}
|
}
|
||||||
@@ -66,15 +70,16 @@ const useResolvedStacks = (groups: JsErrorGroup[]) => {
|
|||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
}, [signature])
|
}, [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 [expanded, setExpanded] = useState(false)
|
||||||
const [showRaw, setShowRaw] = useState(false)
|
const [showRaw, setShowRaw] = useState(false)
|
||||||
const raw = group.sample?.stacktrace
|
const raw = group.sample?.stacktrace
|
||||||
const mapped = Boolean(raw && resolvedStack && resolvedStack !== raw)
|
const mapped = Boolean(raw && resolvedStack && resolvedStack !== raw)
|
||||||
const stack = showRaw || !mapped ? raw : resolvedStack
|
const stack = showRaw || !mapped ? raw : resolvedStack
|
||||||
|
const explorable = frames.length > 0 && frames.some((f) => f.status === "mapped")
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<article className="flex flex-col gap-3 rounded-[14px] border border-ds-line border-l-[3px] border-l-ds-critical bg-ds-surface">
|
<article className="flex flex-col gap-3 rounded-[14px] border border-ds-line border-l-[3px] border-l-ds-critical bg-ds-surface">
|
||||||
@@ -106,13 +111,19 @@ const JsErrorCard = ({ group, resolvedStack }: { group: JsErrorGroup; resolvedSt
|
|||||||
<button type="button" onClick={() => setShowRaw((v) => !v)} className="text-[11.5px] text-ds-text-3 hover:text-ds-text-2">{showRaw ? "Show mapped" : "Show raw"}</button>
|
<button type="button" onClick={() => setShowRaw((v) => !v)} className="text-[11.5px] text-ds-text-3 hover:text-ds-text-2">{showRaw ? "Show mapped" : "Show raw"}</button>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
{stack ? (
|
{explorable && !showRaw ? (
|
||||||
|
<SourceFrameViewer frames={frames} />
|
||||||
|
) : stack ? (
|
||||||
<pre className="max-h-72 overflow-auto whitespace-pre-wrap break-all rounded-[10px] border border-ds-line bg-ds-bg p-3 font-[family-name:var(--font-data)] text-[11px] leading-relaxed text-ds-text-2">{stack}</pre>
|
<pre className="max-h-72 overflow-auto whitespace-pre-wrap break-all rounded-[10px] border border-ds-line bg-ds-bg p-3 font-[family-name:var(--font-data)] text-[11px] leading-relaxed text-ds-text-2">{stack}</pre>
|
||||||
) : (
|
) : (
|
||||||
<p className="text-[12px] text-ds-text-3">No stacktrace was reported for this error.</p>
|
<p className="text-[12px] text-ds-text-3">No stacktrace was reported for this error.</p>
|
||||||
)}
|
)}
|
||||||
{!mapped && raw && !group.sample?.release && (
|
{!explorable && raw && (
|
||||||
<p className="text-[11.5px] text-ds-text-3">No release on this event, so source maps can't be applied.</p>
|
<p className="text-[11.5px] text-ds-text-3">
|
||||||
|
{!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."}
|
||||||
|
</p>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -120,17 +131,29 @@ const JsErrorCard = ({ group, resolvedStack }: { group: JsErrorGroup; resolvedSt
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const TestErrorButton = () => (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={triggerTestError}
|
||||||
|
title="Throws an uncaught error from this UI to verify RUM reporting and source maps end to end"
|
||||||
|
className="inline-flex h-8 items-center gap-1.5 self-end rounded-[9px] border border-ds-line-strong px-2.5 text-[12px] font-medium text-ds-text-2 hover:text-ds-text"
|
||||||
|
>
|
||||||
|
<Bug className="h-3.5 w-3.5" aria-hidden="true" /> Generate test error
|
||||||
|
</button>
|
||||||
|
)
|
||||||
|
|
||||||
export const RumJsErrors = ({ sessions }: { sessions: RumSession[] }) => {
|
export const RumJsErrors = ({ sessions }: { sessions: RumSession[] }) => {
|
||||||
const groups = useMemo(() => groupJsErrors(sessions), [sessions])
|
const groups = useMemo(() => groupJsErrors(sessions), [sessions])
|
||||||
const resolved = useResolvedStacks(groups)
|
const { resolved, frames } = useResolvedStacks(groups)
|
||||||
|
|
||||||
if (groups.length === 0) {
|
if (groups.length === 0) {
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col items-center justify-center gap-2 rounded-[14px] border border-dashed border-ds-line py-16 text-center">
|
<div className="flex flex-col items-center justify-center gap-2 rounded-[14px] border border-dashed border-ds-line py-16 text-center">
|
||||||
<Bug className="h-8 w-8 text-ds-ok/60" aria-hidden="true" />
|
<Bug className="h-8 w-8 text-ds-ok/60" aria-hidden="true" />
|
||||||
<p className="text-[14px] font-medium text-ds-text">No JavaScript errors</p>
|
<p className="text-[14px] font-medium text-ds-text">No JavaScript errors</p>
|
||||||
|
<TestErrorButton />
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
return <div className="flex flex-col gap-3">{groups.map((g) => <JsErrorCard key={g.key} group={g} resolvedStack={resolved[g.key]} />)}</div>
|
return <div className="flex flex-col gap-3"><TestErrorButton />{groups.map((g) => <JsErrorCard key={g.key} group={g} resolvedStack={resolved[g.key]} frames={frames[g.key] ?? []} />)}</div>
|
||||||
}
|
}
|
||||||
@@ -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<StackFrame["status"], string> = {
|
||||||
|
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<StackFrame["status"], string> = {
|
||||||
|
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<Decoration>()
|
||||||
|
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<HTMLDivElement>(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 <div ref={host} className="overflow-hidden rounded-[10px] border border-ds-line" />
|
||||||
|
}
|
||||||
|
|
||||||
|
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<number | null>(null)
|
||||||
|
const index = picked ?? firstMapped
|
||||||
|
const frame = frames[index]
|
||||||
|
if (!frame) return null
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col gap-3 lg:flex-row">
|
||||||
|
<ol className="flex max-h-80 min-w-0 flex-col gap-1 overflow-auto lg:w-[42%] lg:shrink-0">
|
||||||
|
{frames.map((f, i) => (
|
||||||
|
<li key={i}>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setPicked(i)}
|
||||||
|
aria-current={i === index ? "true" : undefined}
|
||||||
|
className={`flex w-full items-start gap-2 rounded-[9px] border px-2.5 py-1.5 text-left hover:border-ds-accent/40 ${i === index ? "border-ds-accent/50 bg-ds-surface-2" : "border-ds-line bg-ds-surface"}`}
|
||||||
|
>
|
||||||
|
<span className={`mt-1.5 h-2 w-2 shrink-0 rounded-full ${statusDot[f.status]}`} aria-hidden="true" />
|
||||||
|
<span className="flex min-w-0 flex-col">
|
||||||
|
<span className="truncate font-[family-name:var(--font-data)] text-[12px] font-semibold text-ds-text">{f.name || f.function || "<anonymous>"}</span>
|
||||||
|
<span className="break-all font-[family-name:var(--font-data)] text-[11px] text-ds-text-3">
|
||||||
|
{f.status === "mapped" ? `${f.source}:${f.srcLine}:${f.srcColumn}` : `${f.file.replace(/^https?:\/\/[^/]+/, "")}:${f.line}:${f.column}`}
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ol>
|
||||||
|
<div className="flex min-w-0 flex-1 flex-col gap-2">
|
||||||
|
{frame.status === "mapped" && frame.context ? (
|
||||||
|
<>
|
||||||
|
<p className="break-all font-[family-name:var(--font-data)] text-[11.5px] text-ds-text-2">{frame.source}</p>
|
||||||
|
<Editor frame={frame} />
|
||||||
|
</>
|
||||||
|
) : null}
|
||||||
|
<p className="flex items-start gap-1.5 text-[11.5px] text-ds-text-3">
|
||||||
|
<Info className="mt-0.5 h-3.5 w-3.5 shrink-0" aria-hidden="true" />
|
||||||
|
<span>
|
||||||
|
{frame.status === "mapped" && !frame.context
|
||||||
|
? "Mapped, but the source map has no embedded sourcesContent. Rebuild with sourcesContent enabled to see the code here."
|
||||||
|
: statusHint[frame.status]}
|
||||||
|
{frame.status === "mapped" && (
|
||||||
|
<span className="font-[family-name:var(--font-data)]"> · generated {frame.file.replace(/^https?:\/\/[^/]+/, "")}:{frame.line}:{frame.column}</span>
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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}`)
|
||||||
|
}
|
||||||
Reference in new issue
Block a user