Symbolicator
Library

Library

Traceway's JS symbolicator is an importable Go library. Everything the ingest pipeline uses to turn minified stack traces back into source locations (source map parsing, bundle scope analysis for function names, the .tw resolver format, and the mmap-backed disk cache) lives in public packages of the backend module, so you can use the engine in your own tooling without running Traceway at all.

It's pure Go by default: no cgo, no C libraries, works in a scratch image.

If you want this functionality inside an OpenTelemetry Collector pipeline instead of your own code, use the prebuilt collector processor. For how the engine works internally, see Architecture and JavaScript.

Installation

go get github.com/tracewayapp/traceway/backend@latest

The symbolicator packages first ship in backend release v1.8.0, so @latest resolves to a version that has them. The import paths below are stable public packages; everything under backend/app/symbolicator is importable.

PackageImport pathPurpose
symbolicatorgithub.com/tracewayapp/traceway/backend/app/symbolicatorThe resolver: source map + bundle in, original locations out
jsstackgithub.com/tracewayapp/traceway/backend/app/symbolicator/jsstackBrowser stack trace parsing (V8, Firefox, Safari formats)
scopesgithub.com/tracewayapp/traceway/backend/app/symbolicator/scopesBundle parser selection (goja default, oxc optional)
twcachegithub.com/tracewayapp/traceway/backend/app/symbolicator/twcacheMmap-backed disk cache of compiled .tw resolvers
otelprocessorgithub.com/tracewayapp/traceway/backend/app/symbolicator/otelprocessorThe OTel Collector processor wrapping the engine

Resolving a single frame

Build a resolver from a source map and (optionally) its minified bundle, then look positions up. The bundle is what enables function names through scope analysis; pass nil and you still get files, lines, and columns.

package main
 
import (
	"fmt"
	"os"
 
	"github.com/tracewayapp/traceway/backend/app/symbolicator"
)
 
func main() {
	mapBytes, err := os.ReadFile("dist/app.min.js.map")
	if err != nil {
		panic(err)
	}
	bundleBytes, _ := os.ReadFile("dist/app.min.js") // optional, enables function names
 
	resolver, err := symbolicator.NewResolver(mapBytes, bundleBytes)
	if err != nil {
		panic(err)
	}
 
	// Lookup takes zero-based generated positions. A browser frame like
	// "app.min.js:1:13337" is line 0, column 13336 here.
	frame, ok := resolver.Lookup(0, 13336)
	if !ok {
		fmt.Println("no mapping at this position")
		return
	}
	fmt.Printf("%s:%d:%d", frame.File, frame.Line, frame.Col) // src/render.ts:42:7
	if frame.Fn != "" {
		fmt.Printf(" in %s()", frame.Fn)
	}
	fmt.Println()
}

The coordinate conventions, precisely:

  • Lookup(genLine, genCol uint32) takes zero-based positions, matching the source map spec's internal encoding.
  • Browser stack traces print one-based positions, so subtract 1 from each before calling Lookup.
  • The returned StackTraceFrame carries one-based Line and Col, ready to print.
  • Fn is the enclosing original function name, or "" when the bundle was not provided or the position is not inside a known scope.

Symbolicating a whole stack trace

jsstack.ParseFrames parses the raw error.stack formats browsers actually produce: V8 (at fn (url:line:col), including async, new, [as alias], and eval frames), Firefox, and Safari (fn@url:line:col). It returns frames with the one-based positions as printed.

package main
 
import (
	"fmt"
 
	"github.com/tracewayapp/traceway/backend/app/symbolicator"
	"github.com/tracewayapp/traceway/backend/app/symbolicator/jsstack"
)
 
func symbolicateStack(rawStack string, resolverFor func(url string) *symbolicator.Resolver) {
	for _, f := range jsstack.ParseFrames(rawStack) {
		r := resolverFor(f.URL)
		if r == nil {
			continue
		}
		frame, ok := r.Lookup(f.Line-1, f.Col-1)
		if !ok {
			continue
		}
		fmt.Printf("%s at %s:%d:%d\n", f.Function, frame.File, frame.Line, frame.Col)
	}
}

resolverFor is yours to implement: typically a map from bundle URL basename to the resolver built from that bundle's artifacts.

If you need Traceway's canonical frame format (function name on one line, file:line:col on the next, the format described in JavaScript), use jsstack.Canonicalize:

canonical, converted := jsstack.Canonicalize(rawStack)

It returns the input unchanged (converted == false) when the trace is not a recognizable browser stack, so it's safe to call on arbitrary text, including Go panics and already-canonical traces.

Caching compiled resolvers: the .tw format

Parsing a large source map and analyzing its bundle takes time you don't want to spend twice. A resolver serializes to Traceway's compact binary .tw format (format details) and loads back without reparsing anything:

data := resolver.MarshalTW()
// persist data wherever you like, then later:
resolver2, err := symbolicator.OpenTW(data)

OpenTW validates the input fully and, when the byte slice is memory-mapped, resolves lookups directly against the mapping with zero copying. That is exactly what twcache does for you:

package main
 
import (
	"github.com/tracewayapp/traceway/backend/app/symbolicator"
	"github.com/tracewayapp/traceway/backend/app/symbolicator/twcache"
)
 
// 2 GiB LRU of .tw files under /var/cache/tw; least recently used evicted first.
func newCache() (*twcache.Cache, error) {
	return twcache.New("/var/cache/tw", 2<<30, nil)
}
 
func cachedResolver(cache *twcache.Cache, name string, mapBytes, bundleBytes []byte) (*symbolicator.Resolver, error) {
	if r, err := cache.Open(name); err == nil {
		return r, nil // mmap hit
	}
	built, err := symbolicator.NewResolver(mapBytes, bundleBytes)
	if err != nil {
		return nil, err
	}
	if r, err := cache.Write(name, built.MarshalTW()); err == nil {
		return r, nil // persisted and mmapped
	}
	return built, nil // disk failed, serve the in-memory resolver
}

What twcache handles for you:

  • Names are slash-separated paths relative to the cache directory and must stay inside it; subdirectories are fine (project-a/app.min.js.tw).
  • Writes are atomic (temp file + rename), so a crash never leaves a partial .tw behind.
  • Corrupt files are deleted on Open so your rebuild path replaces them.
  • The LRU survives restarts: the directory is scanned on New and ordered by file mtime.
  • Remove(name) invalidates after a re-deploy; Stats() reports entries, bytes, hits, and evictions.
  • Resolvers stay valid after eviction deletes their file; the mapping is released only when the resolver is garbage collected.

resolver.ApproxSize() reports the retained footprint if you want to bound an in-memory cache on top.

Choosing the bundle parser

Function-name resolution parses the minified bundle into scopes. Two parsers produce identical output (a parity test asserts this across the fixture suite, see Bundle parsers):

  • goja (default): pure Go, zero setup.
  • oxc: a Rust parser behind cgo, several times faster on large bundles. The -tags oxc build links a static library from the scopes package's own source directory (oxc-shim/target/release/liboxc_shim.a), which the read-only module cache that go get populates never contains. To enable it, build against a writable checkout: clone the repo, run scripts/build-oxc-shim.sh once, add a replace github.com/tracewayapp/traceway/backend => /path/to/traceway/backend directive to your go.mod (or vendor the module and build the shim inside vendor/), then compile with -tags oxc.
import "github.com/tracewayapp/traceway/backend/app/symbolicator/scopes"
 
if err := scopes.SetParser("oxc"); err != nil {
	// not compiled in; still on goja
}

scopes.AvailableParsers() lists what your binary was built with. The parser affects resolver build speed only, never output, so .tw files are interchangeable between the two.

API summary

SymbolSignatureNotes
symbolicator.NewResolver(sourceMap, bundle []byte) (*Resolver, error)bundle may be nil (no function names)
(*Resolver).Lookup(genLine, genCol uint32) (StackTraceFrame, bool)zero-based in, one-based out
symbolicator.StackTraceFrame{File string; Line, Col uint32; Fn string}Fn may be empty
(*Resolver).MarshalTW() []byteserialize to .tw
symbolicator.OpenTW(data []byte) (*Resolver, error)validates fully; zero-copy over mmapped data
(*Resolver).ApproxSize() int64retained footprint for cache accounting
jsstack.ParseFrames(trace string) []FrameFrame{Function, URL string; Line, Col uint32}, one-based
jsstack.Canonicalize(trace string) (string, bool)passthrough on non-browser input
scopes.SetParser(name string) error"goja" or "oxc" (with -tags oxc)
twcache.New(dir string, maxBytes int64, warn func(error)) (*Cache, error)scans dir, LRU bounded by maxBytes
(*twcache.Cache).Open / .Write / .Remove / .Statsmmap-backed .tw files by relative name