Browse resources

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.

Python services send protobuf, and that is fine
The Python SDK ships only 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.
Already running OpenTelemetry?
Most Node services that had another tracing backend already have the SDK wired up. Adding knotel is then one more exporter, not a new setup: point an 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
instrumentation.ts
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"
Load order matters
If spans for HTTP or your database never appear, the file is being imported too late. It must run before 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 with OpenTelemetry on Workers
@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
server.ts
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:

tracing.ts
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
tracing.py
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)")
Old Python or FastAPI versions
OpenTelemetry drops old runtimes quickly. If the latest packages won't install next to a pinned FastAPI, pin them together: the SDK's 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:

NestJS
app.enableCors({
  origin: "https://example.com",
  allowedHeaders: ["content-type", "traceparent", "tracestate"],
});
Hono
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.

vite.config.ts
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>`);
      },
    },
  ],
}));
Next.js, pages/_document.tsx
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-key and comes from an environment variable, not the repository.
  • Each service has its own serviceName.
  • Spans appear within a few seconds. If not, see Troubleshooting.