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) │
│ │──────────────────────▶│
@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
// 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")) },
);
// 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();
// 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")),
});
Storage
SourceMapStore is an interface over get(debugId) and put(debugId, content). Three implementations are included:
-
createSqliteStore(path)— local SQLite viabetter-sqlite3 -
createS3Store({ client, bucket, prefix? })— any S3-compatible backend (AWS, Cloudflare R2, GCS) -
createHttpStore(url)— HTTP client against acreateStoreHandler()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)