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 }