Learn how to use TypeScript and OpenTelemetry to build true end-to-end tracing from browser to Node.js, with clean context propagation and zero guesswork.
You know that feeling when a user says "the app is slow," and your whole team suddenly becomes amateur detectives?
Someone opens frontend logs. Someone else stares at API metrics. The backend person insists "p95 is fine here," while the database folks say, "we don't see anything unusual."
All of you are probably looking at different slices of the same slow request — but you can't prove it.
End-to-end tracing with TypeScript + OpenTelemetry is the antidote: one trace ID, one view, from browser click to Node handler to downstream services. No guesswork, no correlation by hand.
Why tracing breaks at the browser–backend boundary
Most teams get one side or the other:
- Frontend teams rely on RUM tools, console logging, maybe some custom event tracking.
- Backend teams have APM, logs, and database tracing.
The missing piece is context propagation:
- The browser sends a request with no trace metadata.
- Node happily creates a new trace.
- Your observability backend shows two unrelated timelines.
You end up correlating by timestamp, user ID, or URL — which feels like working in the dark with a flashlight and a lot of optimism.
OpenTelemetry gives you a unified model for traces, spans, and context. TypeScript gives you types so you don't wire the wrong headers or typo attribute names. Together, you can literally follow:
"User clicked the 'Upgrade Plan' button" → fetch → API gateway → Node service → DB query → outgoing API call.
All as one trace.
The architecture: one trace ID, many spans
Here's the mental model we're aiming for:
Browser (TS, OTel Web SDK)
└─ span: "click upgrade_button"
└─ span: "GET /api/plan-options"
(sends `traceparent` header)
│
▼
Node.js API (Express/Fastify/etc., OTel Node SDK)
└─ span: "HTTP GET /api/plan-options"
├─ span: "SELECT plan_options FROM db"
└─ span: "GET /billing/provider"The traceparent header (W3C Trace Context) is the key:
- Frontend: creates/continues a trace, attaches
traceparenton requests. - Backend: reads
traceparent, continues that same trace instead of starting a fresh one.
Everything else is just wiring.
Step 1: Instrument the browser with OpenTelemetry (TypeScript)
You can start with lightweight OTel in your frontend bundle. In a Vite/Next/SPA setup, this often lives in your app entrypoint.
// tracing-frontend.ts
import { WebTracerProvider } from '@opentelemetry/sdk-trace-web';
import { ConsoleSpanExporter, SimpleSpanProcessor } from '@opentelemetry/sdk-trace-base';
import { registerInstrumentations } from '@opentelemetry/instrumentation';
import { FetchInstrumentation } from '@opentelemetry/instrumentation-fetch';
import { diag, DiagConsoleLogger, DiagLogLevel } from '@opentelemetry/api';
// Optional debug logs
diag.setLogger(new DiagConsoleLogger(), DiagLogLevel.ERROR);
const provider = new WebTracerProvider();
// In real life, use OTLP/Jaeger exporter, not console
provider.addSpanProcessor(new SimpleSpanProcessor(new ConsoleSpanExporter()));
provider.register();
registerInstrumentations({
instrumentations: [
new FetchInstrumentation({
propagateTraceHeaderCorsUrls: [/^https:\/\/api\.myapp\.com/],
}),
],
});
export const tracer = provider.getTracer('frontend-app');What this gives you:
- A tracer instance you can use manually.
- Automatic instrumentation of
fetch, including tracecontext headers to your API domain.
Now you can wrap important UI flows in spans:
// somewhere in your UI code
import { tracer } from './tracing-frontend';
async function onUpgradeClick(planId: string) {
const span = tracer.startSpan('ui.upgrade_click', {
attributes: { planId },
});
try {
// Attach context so fetch instrumentation sees it
await tracer.startActiveSpan('fetch.plan-options', async (childSpan) => {
await fetch(`https://api.myapp.com/plan-options?plan=${planId}`);
childSpan.end();
});
} catch (err) {
span.recordException(err as Error);
throw err;
} finally {
span.end();
}
}Even if you only did this, you'd already have better visibility into which user actions trigger slow requests. But the real magic happens when the backend continues the trace.
Step 2: Instrument Node.js with OpenTelemetry (TypeScript)
On the Node side, we'll use the OTel Node SDK and auto-instrumentations.
// tracing-backend.ts
import { NodeSDK } from '@opentelemetry/sdk-node';
import { OTLPTraceExporter } from '@opentelemetry/exporter-trace-otlp-http';
import { getNodeAutoInstrumentations } from '@opentelemetry/auto-instrumentations-node';
import { Resource } from '@opentelemetry/resources';
import { SemanticResourceAttributes } from '@opentelemetry/semantic-conventions';
const sdk = new NodeSDK({
resource: new Resource({
[SemanticResourceAttributes.SERVICE_NAME]: 'api-service',
}),
traceExporter: new OTLPTraceExporter({
url: 'https://otel-collector.mycompany.com/v1/traces',
}),
instrumentations: [
getNodeAutoInstrumentations({
// Enables HTTP, Express, DB clients, etc. automatically
}),
],
});
// start before your app listens
sdk.start().then(() => {
console.log('OpenTelemetry initialized');
}).catch((err) => {
console.error('Error starting OpenTelemetry', err);
});Then in your main server entrypoint:
import './tracing-backend'; // must be first
import express from 'express';
const app = express();
app.get('/plan-options', async (req, res) => {
// The current span already exists thanks to HTTP instrumentation
// You can enrich it with domain-specific attributes
const { context, trace, SpanStatusCode } = await import('@opentelemetry/api');
const span = trace.getSpan(context.active());
span?.setAttribute('feature.flag', 'new-pricing-v2');
// Simulate doing work
// ... database calls, external APIs, etc.
res.json({ plans: [] });
});
app.listen(3000, () => console.log('API listening'));Because of auto-instrumentation:
- Incoming HTTP spans will pick up the
traceparentheader from the browser. - Outgoing calls (e.g.,
pg,mysql2,got,axios) will also be traced as child spans.
You now have a continuous trace from the user click to the Node handler to your dependencies.
Step 3: Check that context actually flows
Before you declare victory, you should verify that context propagation is working end-to-end.
Quick checks
- Open dev tools → Network → inspect your XHR/fetch requests.
- You should see a
traceparentheader going tohttps://api.myapp.com. - In your tracing backend (Tempo, Jaeger, Honeycomb, etc.):
- Search for a frontend span (
ui.upgrade_click). - You should see a child span for
HTTP GET /plan-optionsin the same trace.
If you see two separate traces, something's breaking:
- CORS or proxies might be stripping
traceparent. - Your backend auto-instrumentation might not be initialized early enough.
- You may be using a different propagation format than the browser (stick to W3C Trace Context unless you have a strong reason not to).
TypeScript's quiet superpower here: types everywhere
It's easy to think "this is all just wiring," but TypeScript helps a lot:
- Strongly-typed attributes: you can define helpers so spans always get consistent keys (
user.id,tenant.id,feature.flag). - Typed wrappers around your HTTP clients so trace context isn't accidentally dropped when you refactor.
- Safer initialization order: your tracing bootstrap and app bootstrap can be expressed in a type-safe way, rather than random imports.
For example, you might define:
type CommonSpanAttributes = {
'user.id'?: string;
'tenant.id'?: string;
'http.route'?: string;
};
function withTracing<F extends (...args: any[]) => Promise<any>>(
name: string,
fn: F,
) {
const { trace } = require('@opentelemetry/api');
return (async (...args: Parameters<F>): Promise<ReturnType<F>> => {
const tracer = trace.getTracer('api-service');
return tracer.startActiveSpan(name, async (span) => {
try {
const result = await fn(...args);
return result as ReturnType<F>;
} catch (err) {
span.recordException(err as Error);
span.setStatus({ code: 2 }); // ERROR
throw err;
} finally {
span.end();
}
});
});
}Now you can wrap business logic functions with withTracing and keep signatures intact.
Practical tips: sampling, PII, and not drowning in data
A few things you'll thank yourself for later:
- Sampling: start with head-based sampling (e.g., 10–20% of traces) so you don't blow up storage. For critical flows, use tail-based sampling in your collector if available.
- PII hygiene: don't spray raw emails, tokens, or card data into span attributes. Hash or pseudonymize where needed.
- Naming discipline: span names like
"GET /api/plan-options"and"db.query plan_options"are far more useful than"handler"and"query".
Traces are only as useful as the semantic structure you give them.
Wrapping up
End-to-end tracing isn't just a DevOps toy. For modern TypeScript stacks, it's how you debug reality:
- "What did the user actually click?"
- "Which backend call made it slow?"
- "Did the database, cache, or an external API cause the spike?"
With OpenTelemetry and TypeScript, you don't need two disjoint stories for frontend and backend. You get one trace that starts in the browser and ends when the work is truly done.
If you're tired of three teams arguing over whose graph is "the truth," start small: instrument a single high-value flow end-to-end. Once you see that clean waterfall from click → Node → DB in your tracing UI, it's hard to go back.