Framework guides
Node.js server
Integration for long-running Node.js servers (Express, Fastify, Hono, NestJS), and where initOtis goes in Remix, SvelteKit, Nuxt and Astro.
This guide is for long-running Node.js servers — Express, Fastify, Hono, NestJS, and the Node adapters of meta-frameworks (Remix, SvelteKit, Nuxt, Astro). If you're on Lambda, Vercel Functions, Cloudflare Workers, Netlify, or Firebase Functions, read the Serverless guide instead. Framework entry points shows where initOtis() goes in Remix, SvelteKit, Nuxt, Astro and NestJS.
Install
npm install @runotis/sdk1. Initialize once at startup
import { initOtis } from "@runotis/sdk";
export const otis = initOtis({
apiKey: process.env.OTIS_API_KEY!,
serviceName: "my-app",
});Import this module once at the top of your entry point so initOtis runs before any traced code:
import "./otis"; // Must be first
import express from "express";
import { startServer } from "./server";
startServer();Do not re-initialize per request. initOtis sets up a long-lived exporter and should run exactly once.
2. Graceful shutdown
initOtis() handles this for you. It registers SIGTERM and SIGINT handlers
that flush pending spans before the process exits, alongside a beforeExit
hook for the case where the event loop simply drains.
Both are needed: beforeExit does not fire on a signal, and SIGTERM is how
containers are stopped — so on an ordinary Kubernetes rollout or docker stop,
beforeExit alone flushed nothing.
After flushing, the handler removes itself and re-raises the signal, so your process still terminates the way it normally would. If your application registers its own SIGTERM handler, the SDK leaves the exit decision to it and does not re-raise. A second signal — Ctrl-C twice — stops waiting for the flush.
The defaults, shown explicitly:
const otis = initOtis({
apiKey: process.env.OTIS_API_KEY!,
serviceName: "my-service",
flushOnSignals: true,
signalFlushTimeoutMillis: 2000,
});Keep signalFlushTimeoutMillis well under your orchestrator's termination
grace period (Kubernetes defaults to 30s).
Handling shutdown yourself
Set flushOnSignals: false and call shutdown() from your own handler —
useful when you need to order the telemetry flush against other cleanup, such
as draining an HTTP server first.
// otis.ts
export const otis = initOtis({
apiKey: process.env.OTIS_API_KEY!,
serviceName: "my-service",
flushOnSignals: false,
});// server.ts
import { otis } from "./otis";
const server = app.listen(3000);
async function shutdown() {
await new Promise((resolve) => server.close(resolve));
await otis.shutdown();
process.exit(0);
}
process.on("SIGTERM", shutdown);
process.on("SIGINT", shutdown);otis.shutdown() flushes pending spans and closes the exporter.
3. Chat route (Express example)
import { otis } from "../otis";
import { contextFromChatRequest } from "@runotis/sdk";
import { streamText } from "ai";
import { anthropic } from "@ai-sdk/anthropic";
import type { Request, Response } from "express";
export async function handleChat(req: Request, res: Response) {
const { userId, sessionId } = req.session; // from your auth middleware
const ctx = contextFromChatRequest(req.body, {
userId,
sessionId,
});
await otis.withContext(ctx, async () => {
const { streamText: tracedStreamText } = otis.wrap(ai);
const result = await tracedStreamText({
model: anthropic("claude-sonnet-4-6"),
messages: req.body.messages,
});
result.pipeTextStreamToResponse(res);
});
}Long-running servers don't need serverless: true or waitUntil. The exporter batches spans in the background and the process stays alive.
If your API is on a separate host from your browser app
Pass the session ID through a header:
// Client sends: X-Otis-Session-Id: sess_xxx
const sessionId = req.get("x-otis-session-id");
const ctx = contextFromChatRequest(req.body, { userId, sessionId });Or have the browser include cookies on the cross-origin request (requires credentials: "include" on fetch and CORS setup on the server).
4. Browser side
If your server renders HTML or you have an SPA frontend, set up the browser SDK separately. See Browser and consent for the browser init pattern and GDPR handling. Framework entry points shows the browser code for Remix, SvelteKit, Nuxt and Astro.
5. Tracing your own code
Beyond AI calls, wrap any function to trace it:
import { otis } from "./otis";
const fetchContext = otis.traced(async function fetchContext(userId: string, span) {
span.log({ metadata: { source: "vector-store" } });
return await vectorStore.search(userId);
});
const handleChat = otis.traced(async function handleChat(msg: string, userId: string, span) {
const context = await fetchContext(userId); // auto-nests as child span
const { text } = await generateText({...}); // auto-nests as child span
span.log({ output: text });
return text;
});See Agent traces for the full traced() + span.log() API.
Framework entry points
A framework with its own server entry point calls initOtis() there, in place of the import "./otis" line in step 1. Most also start the browser SDK in their client entry. The sections below show both for each framework.
Deployed through its Node adapter, a framework runs as a long-running server, and the rest of this guide applies. Deployed through a serverless adapter, such as Vercel, Netlify or Cloudflare, it needs serverless: true on its server initOtis() and a flush before each response ends. Serverless functions covers both.
Remix and React Router v7
On the server, import the module that calls initOtis() first in entry.server.ts. If your app has no entry.server.ts, run npx remix reveal entry.server to create one.
import "./otis"; // first import, before any handlers load
// ...rest of entry.server.tsapp/otis.ts calls initOtis({ apiKey: process.env.OTIS_API_KEY, serverless: true }) on a Vercel or Cloudflare adapter, and leaves out serverless on the remix-serve Node adapter.
In the browser, start the SDK at the top of entry.client.ts. Remix builds with Vite, so the browser key is read from VITE_OTIS_API_KEY.
import { initOtis } from "@runotis/sdk";
import { hydrateRoot } from "react-dom/client";
initOtis({
apiKey: import.meta.env.VITE_OTIS_API_KEY,
serviceName: "my-app",
browser: { autoAnonymousUserId: true, autoSessionId: true },
});
hydrateRoot(/* ... */);SvelteKit
On the server, src/hooks.server.ts runs once when the server starts:
import { initOtis } from "@runotis/sdk";
import { OTIS_API_KEY } from "$env/static/private";
initOtis({
apiKey: OTIS_API_KEY,
serviceName: "my-app",
serverless: true, // on a Vercel, Netlify or Cloudflare adapter; leave out on the Node adapter
});
export const handle = async ({ event, resolve }) => resolve(event);In the browser, start the SDK in the root +layout.svelte. The browser key is read from PUBLIC_OTIS_API_KEY.
<script>
import { onMount } from "svelte";
import { initOtis } from "@runotis/sdk";
import { PUBLIC_OTIS_API_KEY } from "$env/static/public";
onMount(() => {
initOtis({
apiKey: PUBLIC_OTIS_API_KEY,
serviceName: "my-app",
browser: { autoAnonymousUserId: true, autoSessionId: true },
});
});
</script>
<slot />Nuxt
On the server, a Nitro plugin in server/plugins/otis.ts runs once when Nitro starts:
import { initOtis } from "@runotis/sdk";
export default defineNitroPlugin(() => {
const config = useRuntimeConfig();
initOtis({
apiKey: config.otisApiKey,
serviceName: "my-app",
serverless: true, // leave out on the node-server preset
});
});In the browser, start the SDK in a client plugin:
import { initOtis } from "@runotis/sdk";
export default defineNuxtPlugin(() => {
const config = useRuntimeConfig();
initOtis({
apiKey: config.public.otisApiKey,
serviceName: "my-app",
browser: { autoAnonymousUserId: true, autoSessionId: true },
});
});Register both keys in nuxt.config.ts. Nuxt reads the server key from OTIS_API_KEY and the browser key from NUXT_PUBLIC_OTIS_API_KEY.
export default defineNuxtConfig({
runtimeConfig: {
otisApiKey: "", // reads OTIS_API_KEY (server only)
public: { otisApiKey: "" }, // reads NUXT_PUBLIC_OTIS_API_KEY (browser)
},
});Astro
A static Astro site needs only the browser code below. For server rendering (output: 'server' or 'hybrid'), call initOtis() at module scope in src/middleware.ts, which runs once when the server starts:
import { initOtis } from "@runotis/sdk";
initOtis({
apiKey: import.meta.env.OTIS_API_KEY,
serviceName: "my-app",
serverless: true, // on a Vercel or Netlify adapter; leave out on the Node adapter
});
export const onRequest = async (_context, next) => next();In the browser, start the SDK in a script in your root layout, which runs once for each page load. The browser key is read from PUBLIC_OTIS_API_KEY.
---
---
<html>
<body>
<slot />
<script>
import { initOtis } from "@runotis/sdk";
initOtis({
apiKey: import.meta.env.PUBLIC_OTIS_API_KEY,
serviceName: "my-app",
browser: { autoAnonymousUserId: true, autoSessionId: true },
});
</script>
</body>
</html>NestJS
NestJS uses dependency injection, and the singleton from step 1 still applies. Create it before NestFactory.create(), so that it exists when your modules are instantiated:
import { initOtis } from "@runotis/sdk";
export const otis = initOtis({
apiKey: process.env.OTIS_API_KEY!,
serviceName: "my-app",
});
import { NestFactory } from "@nestjs/core";
import { AppModule } from "./app.module";
async function bootstrap() {
const app = await NestFactory.create(AppModule);
await app.listen(3000);
}
bootstrap();Import the otis singleton in the services that need it, or wrap it in a provider for dependency injection. The SDK flushes on shutdown by itself, as step 2 describes.
Express, Fastify and Hono
These follow the steps above as written: import ./otis at the top of the server entry, before any handler files load. For Hono on Cloudflare Workers, follow Cloudflare Workers instead.
Other runtimes and hosts
- Bun and Deno. Most code for these runtimes works as this guide describes. Runtime support lists the features that need
AsyncLocalStorage. - Heroku, Fly.io, Render and Railway. These run long-running Node servers, so this guide applies as written.
Integration checklist
-
OTIS_API_KEYset in env -
initOtis()runs once at startup, before traced code loads - Shutdown flush is in place — the default signal handlers, or your own with
flushOnSignals: false - Chat route uses
withContext+wrap(ai)+contextFromChatRequest - Auth middleware provides
userIdandsessionId - A test AI call produces a trace with
user.id,chat.id, and token usage
Next steps
- Agent traces — supported frameworks,
traced(),span.log - Custom events —
sendEvent - Errors and exceptions —
sendException - Sessions — sessionId, chatId, and auth provider pass-through
- Identity and properties — user and group identity, properties
- Feedback signals — record user feedback on AI responses
- Privacy — PII redaction and identifier hashing
- Customization —
beforeSend, debug logging, configuration