Skip to content

Edge Runtimes

autotel-edge is a lightweight, vendor-agnostic OpenTelemetry implementation for edge runtimes. It’s the foundation under autotel-cloudflare and works directly on Vercel Edge, Netlify Edge, Deno Deploy, and any runtime with fetch() and AsyncLocalStorage. There are no Node.js APIs and no auto-instrumentation libraries. The core SDK ships with sampling, events, logger, and testing helpers.

Bundle Size
Core (autotel-edge) ~20KB minified, ~8KB gz
All entry points combined ~25KB minified, ~10KB gz

Cloudflare Workers users: use autotel-cloudflare instead. It builds on this same foundation and adds complete bindings instrumentation.

Terminal window
npm install autotel-edge
  • Vercel Edge Functions / Edge Middleware
  • Netlify Edge Functions
  • Deno Deploy
  • AWS Lambda@Edge (limited AsyncLocalStorage support)
  • Custom edge runtimes (e.g. building your own autotel-vercel)

There is no init(). Parse a config once per isolate, register a WorkerTracerProvider, and give its tracer the parsed head sampler. trace() and span() then use the global tracer.

import { resourceFromAttributes } from '@opentelemetry/resources'; // add as a direct dependency
import { parseConfig, WorkerTracerProvider, trace } from 'autotel-edge';
const config = parseConfig({
service: { name: 'my-edge-function' },
exporter: {
url: process.env.OTEL_ENDPOINT ?? 'http://localhost:4318/v1/traces',
},
});
const provider = new WorkerTracerProvider(
config.spanProcessors,
resourceFromAttributes({ 'service.name': config.service.name }),
);
provider.register(); // also installs the AsyncLocalStorage context manager
// Applies sampling.headSampler; without it every trace is head-sampled.
provider.getTracer('autotel-edge').setHeadSampler(config.sampling.headSampler);
export const handler = trace(async (request: Request) => {
return new Response('Hello World');
});

Without exporter or spanProcessors, parseConfig warns once and spans are dropped. A trace is exported when its local root span ends; for a trace that continues a remote parent, call await provider.getTracer('autotel-edge').forceFlush(traceId) (e.g. inside waitUntil).

Later examples show only the object passed to parseConfig(), or call this helper that wraps the provider lines above:

tracing.ts
import { resourceFromAttributes } from '@opentelemetry/resources';
import { WorkerTracerProvider, type parseConfig } from 'autotel-edge';
export function setupTracing(config: ReturnType<typeof parseConfig>) {
const provider = new WorkerTracerProvider(
config.spanProcessors,
resourceFromAttributes({ 'service.name': config.service.name }),
);
provider.register();
provider
.getTracer('autotel-edge')
.setHeadSampler(config.sampling.headSampler);
return provider;
}

trace(fn) / trace(name, fn) / trace.run(name, operation)

Section titled “trace(fn) / trace(name, fn) / trace.run(name, operation)”

trace wraps, trace.run runs. Every trace(...) form returns a wrapper; trace.run(...) runs the operation immediately with an injected context. Edge matches core exactly:

// Bare function
const handler = trace(async (request: Request) => new Response('OK'));
// Reusable function with a stable name
const handler = trace(
'fetch-handler',
async (request: Request) => new Response('OK'),
);
// Same wrapper, options form — needed when you want attributesFromArgs
const handler = instrument({
key: 'fetch-handler',
fn: async (request: Request) => new Response('OK'),
attributesFromArgs: ([request]) => ({ 'http.method': request.method }),
});
// One immediate named operation with context
const result = await trace.run('cache.refresh', async (ctx) => {
ctx.setAttribute('cache.region', 'eu');
return refreshCache();
});
// Factory pattern — receives ctx, returns the handler
const handler = withTracing({})((ctx) => async (request: Request) => {
ctx.setAttribute('custom.attr', 'value');
return new Response('OK');
});

Create a named child span around a code block:

import { span } from 'autotel-edge';
const rows = await span(
{ name: 'database.query', attributes: { table: 'users' } },
async (s) => {
const data = await db.query('SELECT * FROM users');
s.setAttribute('rows', data.length);
return data;
},
);

Parse the config once, at module load, then register the provider as in the quick start:

const config = parseConfig({
service: {
name: 'my-edge-function',
version: '1.0.0',
namespace: 'production',
},
exporter: {
url: 'https://api.honeycomb.io/v1/traces',
headers: { 'x-honeycomb-team': process.env.HONEYCOMB_API_KEY },
},
sampling: { tailSampler: SamplingPresets.production() },
});

createInitialiser() accepts a function that closes over the runtime environment:

import { createInitialiser } from 'autotel-edge';
const initialise = createInitialiser((env) => ({
service: { name: env.SERVICE_NAME },
exporter: { url: env.OTEL_ENDPOINT },
}));
const config = initialise(env, trigger); // same shape as parseConfig()'s result

Filter which paths are traced and map URL patterns to per-route service names. Handler wrappers (such as autotel-cloudflare’s) read these; plain trace() does not. To apply them yourself, use the route matching helpers.

parseConfig({
service: { name: 'edge-app' },
exporter: { url: 'https://otlp.example.com/v1/traces' },
handlers: {
fetch: {
include: ['/api/**'],
exclude: ['/api/internal/**', '/health'],
routes: {
'/api/auth/**': { service: 'auth-service' },
'/api/**': { service: 'api-service' },
},
},
},
});

Tail sampling decides after the trace finishes, so you can keep 100% of errors and slow requests while sampling everything else at any rate.

import { SamplingPresets } from 'autotel-edge/sampling';
parseConfig({
service: { name: 'my-app' },
exporter: { url: 'https://otlp.example.com/v1/traces' },
sampling: { tailSampler: SamplingPresets.production() },
});
Preset Behaviour
SamplingPresets.development() 100% sampling
SamplingPresets.production() 10% baseline + 100% errors + slow >1s
SamplingPresets.highTraffic() 1% baseline + 100% errors + slow >1s
SamplingPresets.debugging() Errors only
import { createCustomTailSampler } from 'autotel-edge/sampling';
import { SpanStatusCode } from '@opentelemetry/api';
const sampler = createCustomTailSampler((trace) => {
const span = trace.localRootSpan;
if (span.attributes['http.route']?.toString().startsWith('/api/'))
return true;
if (span.status.code === SpanStatusCode.ERROR) return true;
return false;
});
import { createEdgeLogger } from 'autotel-edge/logger';
const log = createEdgeLogger('my-service');
log.info('Processing request', { userId: '123' });
log.error('Request failed', { error });
// trace_id and span_id are attached automatically

The execution logger is a request-scoped accumulator: build context across the request, then emit once. Supports fork() for background work that should keep its own snapshot:

import { trace } from 'autotel-edge';
import { getExecutionLogger } from 'autotel-edge/logger';
export const handler = trace(async (request) => {
const log = getExecutionLogger();
log.set({ feature: 'checkout' });
log.info('Processing order', { orderId: '123' });
log.fork('send-email', async () => {
await sendEmail('123');
});
log.emitNow();
return Response.json({ ok: true });
});

Track product events with the active trace ID attached automatically:

import { createEdgeSubscribers } from 'autotel-edge/events';
const events = createEdgeSubscribers({
service: 'my-edge-function',
transport: async (event) => {
await fetch('https://analytics.example.com/events', {
method: 'POST',
body: JSON.stringify(event),
});
},
});
events.trackEvent('order.completed', { orderId: 'abc', amount: 99.99 });
// app/api/route.ts (or middleware.ts)
import { trace, parseConfig } from 'autotel-edge';
import { setupTracing } from './tracing';
setupTracing(
parseConfig({
service: { name: 'vercel-edge' },
exporter: { url: process.env.OTEL_ENDPOINT! },
}),
);
export const runtime = 'edge';
export const GET = trace(async (request: Request) => {
return Response.json({ ok: true });
});
netlify/edge-functions/hello.ts
import { trace, parseConfig } from 'autotel-edge';
import { setupTracing } from './tracing.ts';
setupTracing(
parseConfig({
service: { name: 'netlify-edge' },
exporter: { url: Deno.env.get('OTEL_ENDPOINT')! },
}),
);
export default trace(async (request: Request) => {
return new Response('Hello from the edge');
});
import { trace, parseConfig } from 'https://esm.sh/autotel-edge';
import { setupTracing } from './tracing.ts';
setupTracing(
parseConfig({
service: { name: 'deno-deploy' },
exporter: { url: Deno.env.get('OTEL_ENDPOINT')! },
}),
);
Deno.serve(trace(async (request) => new Response('OK')));

autotel-edge/testing has no runtime helpers yet. Pass an in-memory exporter to parseConfig(), register the provider as in the quick start, and read the finished spans back:

import { InMemorySpanExporter } from '@opentelemetry/sdk-trace-base';
import { parseConfig } from 'autotel-edge';
const exporter = new InMemorySpanExporter();
setupTracing(parseConfig({ service: { name: 'test' }, exporter }));
await myFunction();
expect(exporter.getFinishedSpans().map((s) => s.name)).toContain('myFunction');
  • No Node.js APIs. No fs, net, process (beyond process.env polyfills); use Web APIs (fetch, crypto.subtle) instead.
  • No auto-instrumentations. Edge runtimes don’t support @opentelemetry/auto-instrumentations-node. Instrument explicitly with trace() and span().
  • AsyncLocalStorage required. Some runtimes need it enabled (e.g. Cloudflare’s compatibility_flags = ["nodejs_compat"]).

The toolkit exports three functions for building your own middleware. They drive the route controls above and are useful when you wire instrumentation by hand.

import {
shouldInstrumentPath,
getServiceForPath,
runMiddlewareFinishPipeline,
} from 'autotel-edge';
// Skip noisy paths before opening a span.
const trace = shouldInstrumentPath('/api/users', {
exclude: ['/health', '/metrics'],
});
// Name the span's service from the route.
const service = getServiceForPath('/users/123', {
'/users/**': { service: 'users-api' },
'/admin/**': { service: 'admin-api' },
});
// After the handler runs, enrich the span and drain async work.
await runMiddlewareFinishPipeline(
{ event: request, status: 200 },
{
enrichers: [
(ctx) => span.setAttributes({ 'http.status_code': ctx.status }),
],
drains: [(ctx) => analytics.record(ctx)],
},
);

Each enricher and drain runs inside its own try/catch, so one failure does not cascade into the others.

Import Contents
autotel-edge Core + toolkit: trace, span, parseConfig, WorkerTracerProvider, definePlugin, createPluginRunner, shouldInstrumentPath, getServiceForPath, runMiddlewareFinishPipeline
autotel-edge/sampling Presets and custom samplers
autotel-edge/events createEdgeSubscribers, getEdgeSubscribers
autotel-edge/logger createEdgeLogger, execution logger
autotel-edge/testing Types only (no runtime helpers yet)
  • autotel-cloudflare: Cloudflare Workers with full bindings coverage
  • autotel-vercel: coming soon
  • autotel-netlify: coming soon