DEV Community

Randolf J.
Randolf J.

Posted on

Readable stack traces in production: source maps + OpenTelemetry

Two years ago I left Sentry and moved to OpenTelemetry. The one thing I had to rebuild was source map resolution. I built smapped-traces internally to do it, and we are open sourcing it now that it has run in production for two years.


The problem with naive approaches

Serving source maps from your build output exposes your source code publicly. Restricting access requires your error handler to authenticate against a source map server at resolution time, which is complexity you do not want in that path. Bundling source maps into your collector configuration does not work either; the collector has no stable way to associate a map with a minified file across deployments.


Debug IDs

The approach I landed on uses debug IDs, a mechanism now supported by Turbopack natively and by webpack via the TC39 proposal. At build time, the bundler generates a UUID per source file and writes it into both the compiled output and the corresponding .js.map as a debugId field. It also injects a runtime global (_debugIds for Turbopack, __DEBUG_IDS__ for webpack) mapping source URLs to those UUIDs.

Any stack frame URL resolves to its source map without scanning or path matching.


Architecture

Browser                     Your server             OTel collector
  │                              │                       │
  │  SourceMappedSpanExporter    │                       │
  │  (attaches debug IDs to      │                       │
  │   exception events)          │                       │
  │──────────────────────────────▶  createTracesHandler  │
  │                              │  (resolves stack      │
  │                              │   traces via store)   │
  │                              │──────────────────────▶│
Enter fullscreen mode Exit fullscreen mode

@smapped-traces/nextjs hooks into runAfterProductionCompile, extracts the debugId from every .js.map, uploads the content to a store, and deletes the map files from the build output.

On the client, SourceMappedSpanExporter wraps your span exporter. On exception events, it reads the debug ID globals, resolves each frame URL to a debug ID, and attaches them as span attributes before forwarding as standard OTLP.

createTracesHandler() runs on your server. It receives OTLP traces, resolves each debug ID against the store, rewrites the stack frames with original file and line information, and forwards to your collector. The minified trace is preserved as exception.stacktrace.original.


Setup (Next.js)

npm install smapped-traces @smapped-traces/nextjs @smapped-traces/sqlite
Enter fullscreen mode Exit fullscreen mode
// next.config.mjs
import { withSourceMaps } from "@smapped-traces/nextjs";
import { createSqliteStore } from "@smapped-traces/sqlite";
import { join } from "node:path";

export default withSourceMaps(
  {
    /* your existing config */
  },
  { store: (distDir) => createSqliteStore(join(distDir, "sourcemaps.db")) },
);
Enter fullscreen mode Exit fullscreen mode
// instrumentation-client.ts
import { SourceMappedSpanExporter } from "smapped-traces/client";
import { SimpleSpanProcessor } from "@opentelemetry/sdk-trace-base";
import { WebTracerProvider } from "@opentelemetry/sdk-trace-web";

const exporter = new SourceMappedSpanExporter("/api/sourcemaps");
new WebTracerProvider({
  spanProcessors: [new SimpleSpanProcessor(exporter)],
}).register();
Enter fullscreen mode Exit fullscreen mode
// app/api/sourcemaps/route.ts
import { OTLPTraceExporter } from "@opentelemetry/exporter-trace-otlp-http";
import { createTracesHandler } from "smapped-traces/route";
import { createSqliteStore } from "@smapped-traces/sqlite";
import { join } from "node:path";

export const POST = createTracesHandler({
  exporter: new OTLPTraceExporter({
    url: `${process.env.OTEL_EXPORTER_OTLP_ENDPOINT ?? "http://localhost:4318"}/v1/traces`,
  }),
  store: createSqliteStore(join(process.cwd(), ".next/sourcemaps.db")),
});
Enter fullscreen mode Exit fullscreen mode

Storage

SourceMapStore is an interface over get(debugId) and put(debugId, content). Three implementations are included:

  • createSqliteStore(path) — local SQLite via better-sqlite3
  • createS3Store({ client, bucket, prefix? }) — any S3-compatible backend (AWS, Cloudflare R2, GCS)
  • createHttpStore(url) — HTTP client against a createStoreHandler() deployment

For distributed or serverless environments, point both the build plugin and the handler at the same shared store.


Runtime compatibility

The handler uses standard Web Request/Response and has no Node.js dependencies, so it runs in Bun, Deno, Cloudflare Workers, or any edge runtime. For non-Next.js builds, source map collection is a loop over build artifacts calling store.put(debugId, content).


Requires OTel SDK v2+ and Next.js 16+ for @smapped-traces/nextjs.

GitHub: https://github.com/jrandolf/smapped-traces

Turbopack and webpack are supported. Vite and esbuild are not; support depends on whether those bundlers implement the ECMA-426 debug ID spec.

Top comments (0)