Files
mats 0ed2935f04
Build and Publish / Metadata (push) Successful in 8s
Build and Publish / BuildAndDeployAmd64 (push) Failing after 36s
alot more changes
2026-09-21 18:59:13 +02:00

308 lines
8.6 KiB
Go

package sourcemaps
import (
"container/list"
"encoding/base64"
"fmt"
"io"
"os"
"path/filepath"
"regexp"
"sort"
"strconv"
"strings"
"sync"
sm "github.com/go-sourcemap/sourcemap"
)
var frameRE = regexp.MustCompile(`(.*?)(https?://[^ )]+|[^ )]+):(\d+):(\d+)(\)?\s*)$`)
// defaultCacheBytes bounds the on-disk size of the maps kept parsed in memory.
// A parsed map costs several times its file size, so this is a heap budget of
// roughly 4-5x. Override with OTEL_SOURCEMAP_CACHE_MB.
const defaultCacheBytes = 64 << 20
// Resolver indexes uploaded maps by file name at startup and parses them lazily
// on first use, keeping the most recently used ones within a size budget. A
// release ships hundreds of chunks, so parsing them all up front does not fit
// in memory.
type Resolver struct {
dir string
mu sync.Mutex
index map[string]string // key(release, source) -> file name
cache map[string]*list.Element
lru *list.List // front = most recently used; values are *cacheEntry
cacheBytes int64
maxBytes int64
}
type cacheEntry struct {
key string
consumer *sm.Consumer
size int64
}
func New(dir string) (*Resolver, error) {
if dir == "" {
dir = "./sourcemaps"
}
if err := os.MkdirAll(dir, 0750); err != nil {
return nil, err
}
maxBytes := int64(defaultCacheBytes)
if mb, err := strconv.Atoi(os.Getenv("OTEL_SOURCEMAP_CACHE_MB")); err == nil && mb > 0 {
maxBytes = int64(mb) << 20
}
r := &Resolver{
dir: dir,
index: make(map[string]string),
cache: make(map[string]*list.Element),
lru: list.New(),
maxBytes: maxBytes,
}
r.scan()
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
}
// scan indexes previously uploaded maps by file name without reading them;
// legacy-named files are skipped.
func (r *Resolver) scan() {
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
}
r.index[key(release, source)] = e.Name()
}
}
// get returns the parsed map for k, loading it from disk on a cache miss. It
// returns nil when no map is indexed or the file is unreadable.
func (r *Resolver) get(k string) *sm.Consumer {
r.mu.Lock()
defer r.mu.Unlock()
if el, ok := r.cache[k]; ok {
r.lru.MoveToFront(el)
return el.Value.(*cacheEntry).consumer
}
name, ok := r.index[k]
if !ok {
return nil
}
b, err := os.ReadFile(filepath.Join(r.dir, name))
if err != nil {
return nil
}
_, source, _ := parseFileName(name)
consumer, err := sm.Parse(source+".map", b)
if err != nil {
return nil
}
r.put(k, consumer, int64(len(b)))
return consumer
}
// put caches a parsed map and evicts least recently used ones over budget,
// always keeping the newest entry. Callers hold r.mu.
func (r *Resolver) put(k string, consumer *sm.Consumer, size int64) {
if el, ok := r.cache[k]; ok {
r.cacheBytes -= el.Value.(*cacheEntry).size
r.lru.Remove(el)
}
r.cache[k] = r.lru.PushFront(&cacheEntry{key: k, consumer: consumer, size: size})
r.cacheBytes += size
for r.cacheBytes > r.maxBytes && r.lru.Len() > 1 {
last := r.lru.Back()
e := last.Value.(*cacheEntry)
r.lru.Remove(last)
delete(r.cache, e.key)
r.cacheBytes -= e.size
}
}
// 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.Lock()
defer r.mu.Unlock()
out := make([]Entry, 0, len(r.index))
for k := range r.index {
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 normalize(source string) string {
source = strings.Split(source, "?")[0]
if i := strings.Index(source, "/assets/"); i >= 0 {
return source[i:]
}
return strings.TrimPrefix(source, "./")
}
func (r *Resolver) Upload(release, source string, body io.Reader) error {
if strings.TrimSpace(release) == "" || strings.TrimSpace(source) == "" {
return fmt.Errorf("release and source are required")
}
b, err := io.ReadAll(io.LimitReader(body, 50<<20))
if err != nil {
return err
}
consumer, err := sm.Parse(source+".map", b)
if err != nil {
return fmt.Errorf("invalid source map: %w", err)
}
name := filepath.Join(r.dir, fileName(release, source))
if err := os.WriteFile(name, b, 0600); err != nil {
return err
}
k := key(release, source)
r.mu.Lock()
r.index[k] = fileName(release, source)
r.put(k, consumer, int64(len(b)))
r.mu.Unlock()
return nil
}
func safe(s string) string {
s = strings.ReplaceAll(s, "/", "_")
s = strings.ReplaceAll(s, "\\", "_")
return strings.Map(func(r rune) rune {
if (r >= 'a' && r <= 'z') || (r >= 'A' && r <= 'Z') || (r >= '0' && r <= '9') || strings.ContainsRune("._-", r) {
return r
}
return '_'
}, s)
}
func (r *Resolver) ResolveStack(release, stack string) string {
if release == "" || stack == "" {
return stack
}
lines := strings.Split(stack, "\n")
for i, line := range lines {
m := frameRE.FindStringSubmatch(line)
if m == nil {
continue
}
genLine, _ := strconv.Atoi(m[3])
genCol, _ := strconv.Atoi(m[4])
consumer := r.get(key(release, m[2]))
if consumer == nil {
continue
}
source, name, srcLine, srcCol, ok := consumer.Source(genLine, genCol)
if !ok {
continue
}
label := source
if name != "" {
label += " (" + name + ")"
}
lines[i] = fmt.Sprintf("%s%s:%d:%d%s [mapped from %s:%d:%d]", m[1], label, srcLine, srcCol, m[5], m[2], genLine, genCol)
}
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 {
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.get(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
}