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@latestThe 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.
| Package | Import path | Purpose |
|---|---|---|
symbolicator | github.com/tracewayapp/traceway/backend/app/symbolicator | The resolver: source map + bundle in, original locations out |
jsstack | github.com/tracewayapp/traceway/backend/app/symbolicator/jsstack | Browser stack trace parsing (V8, Firefox, Safari formats) |
scopes | github.com/tracewayapp/traceway/backend/app/symbolicator/scopes | Bundle parser selection (goja default, oxc optional) |
twcache | github.com/tracewayapp/traceway/backend/app/symbolicator/twcache | Mmap-backed disk cache of compiled .tw resolvers |
otelprocessor | github.com/tracewayapp/traceway/backend/app/symbolicator/otelprocessor | The 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
StackTraceFramecarries one-basedLineandCol, ready to print. Fnis 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
.twbehind. - Corrupt files are deleted on
Openso your rebuild path replaces them. - The LRU survives restarts: the directory is scanned on
Newand 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 oxcbuild links a static library from thescopespackage's own source directory (oxc-shim/target/release/liboxc_shim.a), which the read-only module cache thatgo getpopulates never contains. To enable it, build against a writable checkout: clone the repo, runscripts/build-oxc-shim.shonce, add areplace github.com/tracewayapp/traceway/backend => /path/to/traceway/backenddirective to yourgo.mod(or vendor the module and build the shim insidevendor/), 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
| Symbol | Signature | Notes |
|---|---|---|
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 | () []byte | serialize to .tw |
symbolicator.OpenTW | (data []byte) (*Resolver, error) | validates fully; zero-copy over mmapped data |
(*Resolver).ApproxSize | () int64 | retained footprint for cache accounting |
jsstack.ParseFrames | (trace string) []Frame | Frame{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 / .Stats | mmap-backed .tw files by relative name |