Send data / OpenTelemetry
Framework guides
Copy-paste tracing setups for NestJS, Hono, Fastify, Bun, FastAPI and loading the browser script from a build.
Anything that speaks OpenTelemetry works with knotel. This page collects ready-made setups, one section per framework: NestJS, Hono, Fastify, Bun, FastAPI and the browser. The general rules behind all of them are on the OpenTelemetry page: OTLP over HTTP in either encoding, JSON or protobuf, and the ingest key in the x-knotel-key header.
opentelemetry-exporter-otlp-proto-http, which posts application/x-protobuf - there is no JSON encoding to switch to, whatever an error message suggests. Ingest takes both, so a FastAPI or Django service needs nothing special. A backend that accepts JSON only answers 415 and the SDK drops that batch: tracing looks configured, the logs look healthy, and no span ever arrives.OTLPTraceExporter at https://knotel.example.com/v1/traces with the x-knotel-key header and add it alongside the existing one. Both backends receive the same spans, and you can drop the old one whenever you like.NestJS
NestJS runs on Node, so the Node SDK does the work. The auto- instrumentations include @opentelemetry/instrumentation-nestjs-core, which traces app bootstrap, each request's controller context and each handler, alongside HTTP, database and Redis clients.
npm install @opentelemetry/sdk-node @opentelemetry/api \ @opentelemetry/auto-instrumentations-node \ @opentelemetry/exporter-trace-otlp-http
import { NodeSDK } from "@opentelemetry/sdk-node";
import { OTLPTraceExporter } from "@opentelemetry/exporter-trace-otlp-http";
import { getNodeAutoInstrumentations } from "@opentelemetry/auto-instrumentations-node";
const sdk = new NodeSDK({
serviceName: "orders-api",
traceExporter: new OTLPTraceExporter({
url: "https://knotel.example.com/v1/traces",
headers: { "x-knotel-key": process.env.KNOTEL_KEY! },
}),
instrumentations: [getNodeAutoInstrumentations()],
});
sdk.start();
process.once("beforeExit", async () => {
await sdk.shutdown();
});Load it before Nest boots, so the instrumentations can patch modules as they're required:
# package.json "start": "node --import ./dist/instrumentation.js dist/main.js"
NestFactory.create, which is why it goes in --import rather than in main.ts.Naming the service
serviceName is what the dashboard groups by, so give each deployable its own (orders-api, admin-api). Add deployment.environment.name as a resource attribute if staging and production share one project.
Hono
Where Hono runs decides the setup, because the OpenTelemetry Node SDK doesn't run on Cloudflare Workers.
Hono on Cloudflare Workers
Use the knotel Workers SDK. It traces every request, cron and queue batch, and outgoing fetch() calls, with no OpenTelemetry dependencies. Wrap the object you export, not the Hono app itself:
import { Hono } from "hono";
import { instrument, trace } from "./knotel/worker";
const app = new Hono<{ Bindings: Env }>();
app.get("/orders/:id", async (c) => {
const order = await trace(
"postgresql SELECT orders",
{ "db.system.name": "postgresql", "db.operation.name": "SELECT", "db.collection.name": "orders" },
() => db.getOrder(c.req.param("id")),
);
return c.json(order);
});
export default instrument(
{ fetch: app.fetch },
(env) => ({
endpoint: "https://knotel.example.com",
key: env.KNOTEL_KEY,
service: "api",
}),
);Root spans are named GET /orders/123. To group by route instead, add Hono middleware that sets the matched route on the current span:
import { currentSpan } from "./knotel/worker";
app.use(async (c, next) => {
await next();
const span = currentSpan();
if (span && c.req.routePath) {
span.name = `${c.req.method} ${c.req.routePath}`;
span.setAttribute("http.route", c.req.routePath);
}
});See Cloudflare Workers for the full SDK, including the ignore option for health checks.
Hono on Node or Bun
Use the Node SDK exactly as in the NestJS example above, and optionally add @hono/otel for Hono-aware request spans:
import { Hono } from "hono";
import { httpInstrumentationMiddleware } from "@hono/otel";
const app = new Hono();
app.use(
httpInstrumentationMiddleware({
serviceName: "api",
captureRequestHeaders: ["user-agent"],
}),
);@hono/otel works on Workers too, but it needs @microlabs/otel-cf-workers as the tracer provider, since the Node SDK isn't available there. The knotel Workers SDK is the smaller path unless you already have an OpenTelemetry setup you want to keep.Fastify (ESM)
Two things differ from the NestJS setup. An ESM app needs the loader hook, or the instrumentations register and patch nothing. And the app must be imported after the SDK starts, so start it first and load the app dynamically.
npm install @opentelemetry/sdk-node @opentelemetry/api \ @opentelemetry/exporter-trace-otlp-http @opentelemetry/instrumentation-http \ @opentelemetry/instrumentation-undici @opentelemetry/instrumentation-pg @fastify/otel
import { register } from "node:module";
register("@opentelemetry/instrumentation/hook.mjs", import.meta.url);
const { NodeSDK } = await import("@opentelemetry/sdk-node");
const { OTLPTraceExporter } = await import("@opentelemetry/exporter-trace-otlp-http");
const { HttpInstrumentation } = await import("@opentelemetry/instrumentation-http");
const { UndiciInstrumentation } = await import("@opentelemetry/instrumentation-undici");
const { PgInstrumentation } = await import("@opentelemetry/instrumentation-pg");
const { FastifyOtelInstrumentation } = await import("@fastify/otel");
const endpoint = process.env.KNOTEL_ENDPOINT?.replace(/\/+$/, "");
if (endpoint && process.env.KNOTEL_KEY) {
const sdk = new NodeSDK({
serviceName: "billing-api",
traceExporter: new OTLPTraceExporter({
url: `${endpoint}/v1/traces`,
headers: { "x-knotel-key": process.env.KNOTEL_KEY },
}),
instrumentations: [
new HttpInstrumentation({
ignoreIncomingRequestHook: (req) => req.url === "/health",
ignoreOutgoingRequestHook: (o) => o.hostname === new URL(endpoint).hostname,
}),
new UndiciInstrumentation(),
new PgInstrumentation(),
// Names spans by route; the SERVER span stays http's.
new FastifyOtelInstrumentation({ registerOnInitialization: true }),
],
});
sdk.start();
process.once("SIGTERM", () => void sdk.shutdown());
}
// Only now: fastify, pg and your logger load patched.
await import("./app.js");With both registered, @fastify/otel makes its own span INTERNAL, so the request's SERVER span is the HTTP instrumentation's - put per-request attributes there. For incoming requests, its responseHook runs as the request starts; use applyCustomAttributesOnSpan for anything known at the end.
Bun
Bun.serve never goes through node:http, so HttpInstrumentation registers, reports itself enabled and records no server span. Bun's fetch isn't undici either. The SDK itself works - including AsyncLocalStorageContextManager - so the fix is a few lines of middleware in place of the auto-instrumentation. With Hono:
import { context, propagation, trace, SpanKind, SpanStatusCode } from "@opentelemetry/api";
import { AsyncLocalStorageContextManager } from "@opentelemetry/context-async-hooks";
import { W3CTraceContextPropagator } from "@opentelemetry/core";
import { BasicTracerProvider, BatchSpanProcessor } from "@opentelemetry/sdk-trace-base";
import { OTLPTraceExporter } from "@opentelemetry/exporter-trace-otlp-http";
import { resourceFromAttributes } from "@opentelemetry/resources";
import type { MiddlewareHandler } from "hono";
const provider = new BasicTracerProvider({
resource: resourceFromAttributes({ "service.name": "notifications" }),
spanProcessors: [new BatchSpanProcessor(new OTLPTraceExporter({
url: `${process.env.KNOTEL_ENDPOINT}/v1/traces`,
headers: { "x-knotel-key": process.env.KNOTEL_KEY! },
}))],
});
// BasicTracerProvider registers nothing on its own.
trace.setGlobalTracerProvider(provider);
context.setGlobalContextManager(new AsyncLocalStorageContextManager().enable());
propagation.setGlobalPropagator(new W3CTraceContextPropagator());
const tracer = trace.getTracer("http");
/** Register first, before CORS, so preflights and rejections are traced too. */
export const traced: MiddlewareHandler = async (c, next) => {
const parent = propagation.extract(context.active(), c.req.raw.headers, {
get: (h, k) => h.get(k) ?? undefined,
keys: (h) => [...h.keys()],
});
const span = tracer.startSpan(c.req.method, { kind: SpanKind.SERVER }, parent);
await context.with(trace.setSpan(parent, span), next);
// Route, not URL: one name per endpoint, not one per id.
const route = c.req.routePath && c.req.routePath !== "/*" ? c.req.routePath : "(unmatched)";
span.updateName(`${c.req.method} ${route}`);
span.setAttributes({ "http.request.method": c.req.method, "http.route": route, "http.response.status_code": c.res.status });
if (c.res.status >= 500) span.setStatus({ code: SpanStatusCode.ERROR });
span.end();
};For outgoing calls, wrap globalThis.fetch in a function that starts a CLIENT span and injects traceparent. Three details from running it: skip knotel's own host, or each export traces itself; inject the header only for your own domains, since third-party APIs can reject unknown headers; and leave the query string out of url.full, where tokens live. Object.assign(wrapped, fetch) keeps Bun's fetch.preconnect. Clients like postgres.js have no OpenTelemetry hook, so wrap their calls with tracer.startActiveSpan and the database attributes.
FastAPI
Python's exporter posts protobuf, which ingest takes as is. Set it up where the app is created; under uvicorn --workers each worker imports the app and builds its own provider, which is what you want. Under gunicorn with --preload, do it in a post_fork hook instead: a provider created before the fork has a batch thread that the workers don't inherit.
pip install opentelemetry-sdk opentelemetry-exporter-otlp-proto-http \ opentelemetry-instrumentation-fastapi opentelemetry-instrumentation-sqlalchemy \ opentelemetry-instrumentation-requests opentelemetry-instrumentation-logging
import logging, os
log = logging.getLogger(__name__)
def init_tracing(app, engine=None):
endpoint = (os.getenv("KNOTEL_ENDPOINT") or "").strip().rstrip("/")
key = (os.getenv("KNOTEL_KEY") or "").strip()
if not endpoint or not key:
log.warning("[knotel] tracing OFF (KNOTEL_ENDPOINT or KNOTEL_KEY not set)")
return
try:
from opentelemetry import trace
from opentelemetry.sdk.resources import Resource
from opentelemetry.sdk.trace import TracerProvider
from opentelemetry.sdk.trace.export import BatchSpanProcessor
from opentelemetry.exporter.otlp.proto.http.trace_exporter import OTLPSpanExporter
from opentelemetry.instrumentation.fastapi import FastAPIInstrumentor
from opentelemetry.instrumentation.requests import RequestsInstrumentor
from opentelemetry.instrumentation.sqlalchemy import SQLAlchemyInstrumentor
from opentelemetry.instrumentation.logging import LoggingInstrumentor
provider = TracerProvider(resource=Resource.create({
"service.name": os.getenv("OTEL_SERVICE_NAME", "reports-api"),
"service.version": os.getenv("SERVICE_VERSION", ""),
"deployment.environment.name": os.getenv("ENVIRONMENT", "development"),
}))
provider.add_span_processor(BatchSpanProcessor(OTLPSpanExporter(
endpoint=f"{endpoint}/v1/traces", headers={"x-knotel-key": key})))
trace.set_tracer_provider(provider)
FastAPIInstrumentor.instrument_app(app, excluded_urls="health")
RequestsInstrumentor().instrument()
if engine is not None:
SQLAlchemyInstrumentor().instrument(engine=engine)
# Trace ids on log records; doesn't export them.
LoggingInstrumentor().instrument(set_logging_format=False)
# Tracebacks for connections a load balancer closed; spans still arrive.
logging.getLogger("opentelemetry.sdk.trace.export").setLevel(logging.CRITICAL)
import atexit
atexit.register(provider.shutdown) # flush the last batch
log.info("[knotel] tracing on -> %s", endpoint)
except Exception:
# A missing package or a malformed endpoint costs traces, not the service.
log.exception("[knotel] tracing OFF (setup failed)")1.x release and the instrumentations' matching 0.xxb0 (for example 1.27.0 with 0.48b0) must move as a pair.Adding the browser side
Backend spans join up with the page that caused them when the browser script propagates a traceparent header to your API's host. Put the API host in data-propagate, and allow the traceparent header in the API's CORS configuration:
app.enableCors({
origin: "https://example.com",
allowedHeaders: ["content-type", "traceparent", "tracestate"],
});import { cors } from "hono/cors";
app.use(
"/api/*",
cors({ origin: "https://example.com", allowHeaders: ["content-type", "traceparent", "tracestate"] }),
);Loading the script from a build
Ingest keys for the browser are usually limited to your production domains, so load the script only in production builds: a dev server then sends nothing, and localhost never shows up in your analytics. The key is write-only and meant to be public, but keep it out of the JavaScript bundle anyway by writing it into the HTML at build time.
export default defineConfig(({ command, mode }) => ({
plugins: [
{
name: "knotel",
transformIndexHtml(html) {
if (command !== "build" || mode !== "production") return html;
const endpoint = process.env.KNOTEL_ENDPOINT?.replace(/\/+$/, "");
const key = process.env.KNOTEL_KEY;
if (!endpoint || !key) throw new Error("KNOTEL_ENDPOINT and KNOTEL_KEY are required for production builds");
return html.replace("</head>",
`<script src="${endpoint}/t.js" data-key="${key}" data-service="web-app" data-environment="production" defer></script></head>`);
},
},
],
}));const endpoint = (process.env.NEXT_PUBLIC_KNOTEL_ENDPOINT ?? "").replace(/\/+$/, "");
const key = process.env.NEXT_PUBLIC_KNOTEL_KEY;
// Inside <Head>: set the key only on the production deployment.
{key && <script src={`${endpoint}/t.js`} data-key={key} data-service="marketing-site" defer />}Call window.knotel?.identify() when your signed-in user loads - see People for the version that survives the script loading after your user request.
Checklist
- The exporter posts OTLP over HTTP, in either encoding, to a URL ending in
/v1/traces. - The ingest key is in
x-knotel-keyand comes from an environment variable, not the repository. - Each service has its own
serviceName. - Spans appear within a few seconds. If not, see Troubleshooting.