@reopt-ai/data-sdk-server
v0.7.0
Published
reopt-data analytics SDK for Next.js server components, route handlers, proxy and plain Node
Readme
@reopt-ai/data-sdk-server
The server-side SDK for reopt-data. Use it in Next.js server components, route handlers, proxy.ts, instrumentation.ts, and in plain Node processes that are not Next.js at all (workers, crons, scripts).
For code that runs in the browser, use @reopt-ai/data-sdk-client. The reason the two packages are split is not bundle size but enforcing the boundary — this package's root imports server-only, so importing it from a client component by mistake fails the build, not the runtime. There is no way for clientSecret to ride out into a browser bundle.
Installation
pnpm add @reopt-ai/data-sdk-serverEntry points
| Entry point | Where you use it | In a client component |
| --------------------------------- | ------------------------------------------------------ | --------------------- |
| @reopt-ai/data-sdk-server | Server components · route handlers · instrumentation | Build error |
| @reopt-ai/data-sdk-server/proxy | proxy.ts (Next 16 — the old middleware.ts) | N/A |
| @reopt-ai/data-sdk-server/node | Workers · crons · scripts | Build error |
| @reopt-ai/data-sdk-server/ai | AI SDK (ai@7) telemetry · LLM observability | Build error |
1. The request-scoped client
Create it once, at module scope. The engine and the per-request resolver cache hang off it, so recreating it per request loses both batching and the session lookup cache.
// lib/reopt.ts
import "server-only";
import { createReopt } from "@reopt-ai/data-sdk-server";
import { auth } from "@/auth";
export const { getReopt, getBootstrap } = createReopt({
clientId: process.env.REOPT_CLIENT_ID!,
clientSecret: process.env.REOPT_CLIENT_SECRET!,
// derives the cookie name — must be identical to the browser SDK
writeKey: process.env.NEXT_PUBLIC_REOPT_WRITE_KEY!,
baseUrl: process.env.REOPT_BASE_URL!,
// identifies this build — rides on track events as `$release_id`
release: process.env.VERCEL_GIT_COMMIT_SHA,
getProfileId: async () => (await auth())?.user?.id ?? null,
});// app/api/checkout/route.ts
import { getReopt } from "@/lib/reopt";
export async function POST() {
const reopt = await getReopt();
reopt.track("checkout_started"); // deviceId/profileId bound automatically
}Three identifiers show up here and they are different things:
| Value | What it is |
| -------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| clientId | The id of the API client row (Client.id, a cuid). It is not the writeKey — it is issued together with the secret when you create a server client in the dashboard |
| clientSecret | The server secret for that row (shown in the dashboard as serverSecret). Never put it in a browser bundle |
| writeKey | The public key for the browser. On the server it is used only to derive the cookie name, not for authentication |
What getReopt() does:
- Reads the visitor's device and consent from
cookies(). - Returns a no-op client that sends nothing if the visitor opted out. It does not force a consent branch onto the call site — the one place you forget is the leak.
- Runs the
getProfileIdresolver once per request (a WeakMap keyed on theheaders()object). The result overrides the profileId in the cookie, because the cookie is written by the browser and is not the session. - Errors thrown by the resolver that are Next's control flow (
redirect(),notFound(), dynamic-render bailout) are rethrown as-is. Swallowing them turns a redirect into a blank page. Any other error only logs a warning and falls back to anonymous — analytics has no business returning a 500. - Detects
waitUntilautomatically. A Vercel function freezes the moment it returns a response, so without it a fire-and-forgettrack()is silently lost.
2. Bootstrap — server and browser see the same person from the first render
// app/layout.tsx (server component)
import { getBootstrap } from "@/lib/reopt";
import { ReoptProvider, ReoptPageView } from "@reopt-ai/data-sdk-client/next";
export default async function RootLayout({ children }: { children: React.ReactNode }) {
const bootstrap = await getBootstrap();
return (
<html>
<body>
<ReoptProvider
config={{ writeKey: process.env.NEXT_PUBLIC_REOPT_WRITE_KEY!, baseUrl: "/ingest" }}
bootstrap={bootstrap}
>
<ReoptPageView />
{children}
</ReoptProvider>
</body>
</html>
);
}This package exports no React components. If the server wrapped the client component for you, the boundary would hide inside the package. Here the RSC → client boundary stays visible in your own layout.tsx.
getBootstrap() returns null when there is no device cookie (first visit with seeding off, or outside the proxy matcher). If the server invented an id and handed it over, the browser would make its own as well and the two would never converge, so passing "I know nothing" through is the better answer.
3. proxy.ts — cookie seeding and 1st-party ingest
// proxy.ts (replaces middleware.ts in Next 16. Node is the default runtime)
import { reoptProxy } from "@reopt-ai/data-sdk-server/proxy";
export const proxy = reoptProxy({
writeKey: process.env.NEXT_PUBLIC_REOPT_WRITE_KEY!,
baseUrl: process.env.REOPT_BASE_URL!,
proxy: true, // /ingest/* → ${baseUrl}/* (the client sends /ingest/api/track)
});
export const config = { matcher: ["/((?!_next/static|_next/image|favicon.ico).*)"] };It does two things:
- Device cookie seeding — plants a uuidv7 on the first visit. Because it is planted before the render, server and browser use the same id from the very first pageview. If you cannot write a cookie before consent, use
seedDeviceCookie: false. - Cookie deletion on opt-out — withdrawing consent means "erase", not "stop writing". Leaving it behind keeps identifying the visitor on every request.
- Ingest proxy — receives
/ingest/*on your own domain and rewrites it. The client sends to${baseUrl}/api/track, so withbaseUrl: "/ingest"the actual request is/ingest/api/track, and stripping only the prefix gives<reopt-data>/api/track. - The destination is checked twice. Leaving the
baseUrlorigin (a protocol-relative path such as//evil.example) is a 400, and even inside the origin, anything not under/api/is a 400. BecauseURLnormalizes..away in advance,/ingest/../../dashboardlands on/dashboardof the same origin — but the dashboard, export and realtime are things to reach directly with their own session, not through a customer domain.
When composing with other proxy logic, pass the response as the second argument:
const withReopt = reoptProxy({ writeKey, baseUrl, proxy: true });
export function proxy(request: NextRequest) {
const response = NextResponse.next();
response.headers.set("x-custom", "value");
return withReopt(request, response);
}Why an argument and not an option: options are resolved once when the factory is created, while there is one NextResponse per request.
Adding it to an app that already has a proxy.ts
Next allows only one proxy function per file. Most apps already have proxy.ts occupied by host routing or an auth rewrite, so reoptProxy does not take over the default export — it goes in as the last step of your existing function.
// proxy.ts
import { NextResponse, type NextRequest } from "next/server";
import { reoptProxy } from "@reopt-ai/data-sdk-server/proxy";
import { resolveTenant } from "./lib/tenant";
const withReopt = reoptProxy({
writeKey: process.env.NEXT_PUBLIC_REOPT_WRITE_KEY!,
baseUrl: process.env.REOPT_BASE_URL!,
proxy: true,
});
export default function proxy(request: NextRequest) {
// 1) Existing logic first. Requests that end in a rewrite/redirect are
// returned as-is — the body is not from this app, so no cookie to seed.
const tenant = resolveTenant(request);
if (tenant.redirect) return NextResponse.redirect(tenant.redirect);
if (tenant.rewrite) return NextResponse.rewrite(tenant.rewrite);
// 2) Add reopt only to requests this app will render. Hand over a response
// with headers/cookies already set and reopt only adds the device cookie.
const response = NextResponse.next();
response.headers.set("x-tenant", tenant.id);
return withReopt(request, response);
}Why it goes last: if later logic touches the response withReopt returned, the ordering still holds, but if an earlier step returned a redirect/rewrite and you pass that to withReopt, the cookie gets attached to a response that never reaches the browser. The rule is to add it only to "requests this app renders".
When one deployment serves several projects
An app that serves a different brand per host cannot read the writeKey from env — the request decides which project it is. Pass a function to writeKey.
const withReopt = reoptProxy({
writeKey: (request) => lookupBrandWriteKey(request.headers.get("host")), // string | null | Promise<…>
baseUrl: process.env.REOPT_BASE_URL!,
proxy: true,
});If the API client differs per brand, pass the credentials as a resolver too. Then there is still just one factory.
// lib/reopt.ts — the request-scoped side uses the same option names
export const { getReopt, getBootstrap } = createReopt({
// one lookup usually returns all three — writeKey handles the cookie name,
// clientId/clientSecret handle batch authentication.
writeKey: async () => (await lookupBrand())?.writeKey ?? null,
credentials: async () => {
const brand = await lookupBrand(); // cached result within the same request
return brand && { clientId: brand.clientId, clientSecret: brand.clientSecret };
},
baseUrl: process.env.REOPT_BASE_URL!,
});credentials, when present, takes precedence over clientId/clientSecret. If you have only one brand, pass the two strings as before — with neither, one warning is logged and server events are turned off.
The engine is cached per resolved credentials. Even when one factory serves several projects, there is one engine per project, not one per request. Before this API existed, an app had to create a factory per tenant and memoize it at module scope itself, and getting that wrong — recreating it per request — threw away batching and this cache at the same time.
- The resolvers on
createReopttake no request argument. Readheaders()/cookies()directly; they run once per request via the same WeakMap asgetProfileId— these are usually DB lookups, and otherwise a layout plus route handlers would repeat the same lookup three times per page. credentialsuses the same WeakMap aswriteKeyandgetProfileId. Bundle the three into one lookup and it is a single DB round trip per request. If you return it half-filled (an empty secret, say) it is treated as having no credentials — sending an empty secret makes every batch fail authentication, and from the app's side that is indistinguishable from a network problem.- Returning
nullmeans "this request belongs to no project".getReopt()returns a client that sends nothing andgetBootstrap()returnsnull. Without even a cookie name to derive, there is nothing to read and nothing to send. - If a resolver throws, only that request goes untracked (with a warning log). But Next's dynamic-render signals are rethrown as-is — swallowing them would prerender a page with one brand's identity baked in for everyone.
- If the resolver returns
nullor fails in the proxy, only seeding is skipped. The/ingest/*rewrite proceeds — ingest authenticates with the batch's headers and the rewrite destination is pinned to thebaseUrlorigin, so looking up the brand here would put one DB lookup in front of every event batch the site sends. - If you pass static strings, the proxy handler runs fully synchronously. It returns
Promise<NextResponse>only when resolvers are in use, and Next's proxy accepts either.
A note on matchers: most apps exclude api/ from the matcher. To use /ingest/* with proxy: true, that path must pass the matcher — /ingest does not start with /api, so it usually does not hit the exclusion pattern, but an app that uses a whitelist matcher (["/", "/about", ...]) has to add "/ingest/:path*" explicitly. Miss it and the proxy never runs at all: the browser's /ingest/api/track requests fall through to a 404 and the SDK just retries silently.
export const config = {
matcher: [
"/((?!_next/static|_next/image|favicon.ico).*)",
"/ingest/:path*", // this line is required if you use a whitelist matcher
],
};4. Server errors as $exception
// instrumentation.ts
import { createOnRequestError } from "@reopt-ai/data-sdk-server";
export const onRequestError = createOnRequestError({
writeKey: process.env.NEXT_PUBLIC_REOPT_WRITE_KEY!,
clientId: process.env.REOPT_CLIENT_ID!,
clientSecret: process.env.REOPT_CLIENT_SECRET!,
baseUrl: process.env.REOPT_BASE_URL!,
});It attaches nextjs_route_path / nextjs_router_kind / nextjs_route_type / nextjs_render_source / nextjs_revalidate_reason / nextjs_error_digest. This overlaps with error tracking tools, but the product value is that you can look at the error rate on the same axes as your funnels and segments.
The same $exception_list as the browser SDK (the structured cause chain) rides along as well —
mechanism.type is nextjs_onRequestError and handled is false. A frame's
in_app excludes node_modules/ and node: built-ins, so the lines the stack panel
opens by default are code you can actually fix. The parser is the same implementation
as the browser SDK's — the same shape of stack must not group into different issues on
the server and the client. The field table and example JSON are in the
browser SDK README.
With beforeCapture you can merge properties (return {...}) or cancel the capture (return false). If the hook throws, the capture is skipped — not sending is safer than sending without knowing what the hook was supposed to mask.
Send the release along with it. Without $release_id, error tracking cannot tell "which build did this issue first appear in" from "did the fix I resolved it with actually work" — an issue resolved without a release looks like a regression from nothing more than an old event arriving late. createReopt({ release }) puts that value on track events, but createOnRequestError has no release option. Here the object returned by beforeCapture is merged into the properties, so one line does it:
export const onRequestError = createOnRequestError({
writeKey: process.env.NEXT_PUBLIC_REOPT_WRITE_KEY!,
clientId: process.env.REOPT_CLIENT_ID!,
clientSecret: process.env.REOPT_CLIENT_SECRET!,
baseUrl: process.env.REOPT_BASE_URL!,
beforeCapture: () => ({ $release_id: process.env.VERCEL_GIT_COMMIT_SHA }),
});It does not have to be the same value as --release on the sourcemap upload (section 4-1) — symbolication finds frames by the chunk's file, line and column, not by the release.
When one deployment serves several projects
A frontend where each brand has its own custom domain has different credentials per
request. Resolve them with resolve:
// instrumentation.ts
export const onRequestError = createOnRequestError({
baseUrl: process.env.REOPT_BASE_URL!,
resolve: async (request) => {
const host = readHeader(request.headers, "host");
const brand = host ? await findBrandByDomain(host) : null;
if (!brand) return null; // this request belongs to no project
return {
writeKey: brand.reoptWriteKey,
clientId: brand.reoptClientId,
clientSecret: brand.reoptClientSecret,
};
},
});You have to return writeKey too. Credentials alone authenticate the batch but do
not attribute it — the write key decides the name of the cookie that carries the
visitor's identity, so reading brand A's cookie with B's key finds nothing.
- Returning
null→ no capture. Requests outside a brand (marketing pages, health checks) legitimately land here. - Throwing → also no capture. It is the same reading as
beforeCapture: an unanswered question is not consent, and guessing the tenant and filing the issue under the wrong project is worse than not filing it. - When
resolveis present it takes precedence over the static options. Without it, the static options are used as-is.
It is not cached. createReopt's resolvers are cached once per request because one
page can ask for the client several times, but this one is once per error — already
rare, and a cache would outlive the request that justified it.
4-1. Sourcemap upload — @reopt-ai/data-cli
A production stack only tells you minified chunk names. Upload sourcemaps and the server
maps them back to the original locations. The upload is handled not by this package but by
a separate public CLI, @reopt-ai/data-cli
(reopt-data) — there is no reason to ship build-pipeline tooling inside a runtime SDK.
pnpm add -D @reopt-ai/data-cli
reopt-data sourcemap inject --dir .next/static # optional: keeps maps resolvable if URLs change
reopt-data sourcemap upload --dir .next/static \
--url-prefix https://app.example.com/_next/static \
--project-id <id> --release "$VERCEL_GIT_COMMIT_SHA" --delete-after-uploadIf one deployment serves several projects (symbolHostApp, sections 3 and 4 above),
upload once per host app, keyed on the path:
reopt-data sourcemap upload --dir .next/static --path-prefix /_next/static \
--host-app <host app> --organization-id <org id> --release "$VERCEL_GIT_COMMIT_SHA"The three targets (project / host app / platform), credentials, --dry-run and exit codes are
canonically documented in that package's README. This package's old reopt-data bin
(upload-sourcemaps / inject-chunk-ids) was removed in 0.5.0, and the same spellings work
unchanged in the new CLI.
5. When you only need the visitor's device id
An app that sends a server-confirmed conversion later — writing it to an outbox and having a cron forward it in batches — only needs to record "whose conversion this is" at request time.
import { readDeviceId } from "@reopt-ai/data-sdk-server";
import { cookies } from "next/headers";
const deviceId = readDeviceId(await cookies(), { writeKey: process.env.NEXT_PUBLIC_REOPT_WRITE_KEY! });
await db.analyticsOutbox.create({ data: { name: "form.submitted", deviceId, ... } });When the forwarder later puts that value on the event (the deviceId field, contract 0.6+), ingest attaches it per row to that visitor's active session. A single batch may mix several visitors — there is no need to split the batch, so the accepted + duplicates + rejected === sent invariant holds.
It accepts the result of cookies(), NextRequest.cookies, and a raw Cookie: header string. When there is nothing to read it returns null and does not throw — a checkout must not fail because of analytics. Cookies are an input a sibling subdomain can plant, so the value comes out validated.
Do not assemble the cookie name yourself. The name is derived from writeKey, and if that rule is written down in two repositories it drifts silently — one side writes and the other cannot find it, so every visitor looks new.
6. Plain Node
import { createReoptNode } from "@reopt-ai/data-sdk-server/node";
const reopt = createReoptNode({
clientId: process.env.REOPT_CLIENT_ID!,
clientSecret: process.env.REOPT_CLIENT_SECRET!,
baseUrl: process.env.REOPT_BASE_URL!,
});
await reopt.trackAndFlush("nightly_report_generated", { rows: 12_400 });
await reopt.close(); // required before the script exits — stops timers, drains the queueInside a Next.js app, use createReopt(). This client has no request to read, so it cannot tie events to a visitor.
7. AI observability — @reopt-ai/data-sdk-server/ai
A telemetry integration for the Vercel AI SDK (ai@7). Register it once globally and every call to generateText, streamText, generateObject, embed, rerank and ToolLoopAgent is captured — call sites are untouched.
// instrumentation.ts (or the entry point of an agent worker)
import { registerTelemetry } from "ai";
import { createReoptAiTelemetry } from "@reopt-ai/data-sdk-server/ai";
import { createReoptNode } from "@reopt-ai/data-sdk-server/node";
const reopt = createReoptNode({
clientId: process.env.REOPT_CLIENT_ID!,
clientSecret: process.env.REOPT_CLIENT_SECRET!,
baseUrl: process.env.REOPT_BASE_URL!,
});
export const aiTelemetry = createReoptAiTelemetry({ reopt });
registerTelemetry(aiTelemetry);For reopt we recommend a process-wide Reopt instance (createReoptNode). Whose call it is comes from runtimeContext, not from the request (see context below). The caveats for a request-scoped client are at the end of this section.
| What happened in the AI SDK | Event sent |
| ------------------------------------------------------------- | ------------------------------------------------------------------------ |
| One operation (streamText etc.) finished · aborted · failed | One $ai_trace — the sum of tokens and reported cost of its child calls |
| One model call (per step) | $ai_generation |
| One tool execution | $ai_span ($ai_tool_name, duration, success/failure) |
| One embedding · rerank model call | $ai_embedding |
| An operation started inside a tool or inside withAiTrace | $ai_span instead of $ai_trace — attached to the parent trace |
- Parent and child are linked by id. A model call made by a tool attaches under that tool's span, and the tool attaches under the generation that requested it. Nested calls inside a tool are tracked with
AsyncLocalStorage; on runtimes without it they simply become separate traces and everything else is the same. - From Vercel AI Gateway's
providerMetadata.gatewayit reads the actual cost ($ai_reported_cost_usd), thegenerationId, and, on fallback, the model that actually answered ($ai_response_model).$ai_provideris the AI SDK's provider string verbatim (gatewayand so on). The final cost ($ai_cost_usd) is decided by the server — the reported value if there is one, otherwise the price table. - Deterministic eventId. The event id is
aiEventId(traceId, spanId, kind), so the same callback firing twice, or a batch being resent, still aggregates once. - It does not break the host. Every callback swallows exceptions (only the first failure is
console.warned, or routed toonErrorif you supply one) and never waits on the network. Events go into the client queue andwaitUntilscheduling sends them. Records of calls that never finish are cleaned up by a cap (maxInFlight, default 10 000) and a one-hour TTL.
Whose call it is — context
It moves runtimeContext and functionId onto reopt's axes. The default mapping reads the Eve instrumentation.ts convention:
| runtimeContext key | Where it goes |
| ------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------- |
| reopt.session + reopt.turn | trace id ${session}:${turn} — one trace even when Eve runs a single turn as several steps |
| reopt.root_session ?? reopt.session | $ai_session_id — subagents group into the same conversation |
| reopt.principal | the event's profileId |
| reopt.session · reopt.turn · reopt.parent_session · reopt.channel | the agent_session_id · turn_id · parent_session_id · channel properties |
Within one trace, a $ai_trace may go out once per step (the query side merges them). A call started inside a tool or inside withAiTrace follows the outer trace rather than context's traceId. If you need different axes, supply context and it overrides the defaults:
createReoptAiTelemetry({
reopt,
context: (event) => ({
aiSessionId: event.runtimeContext.chatId as string | undefined,
profileId: event.runtimeContext.userId as string | undefined,
agent: event.runtimeContext.agentId as string | undefined,
promptVersion: "support-v7",
properties: { workspace_id: event.runtimeContext.workspaceId },
}),
});If context throws, only that dimension is dropped and the call is still recorded.
Passing runtimeContext to the integration — includeRuntimeContext
The AI SDK passes a runtimeContext key to the integration only if it is turned on with true in telemetry.includeRuntimeContext. Keys you did not turn on arrive at context as an empty object, and the session and user axes silently go blank.
Apps that call the AI SDK directly (reopt's chat route, for example) have to turn them on per call:
const telemetry = {
functionId: "chat",
includeRuntimeContext: { chatId: true, userId: true, workspaceId: true, agentId: true },
} as const;
const result = streamText({
model,
messages,
tools,
runtimeContext: { chatId, userId, workspaceId, agentId },
telemetry,
});Do not put personal data such as emails or names into the keys you turn on — a key you turn on is handed to context, and if context moves it into properties it rides on the event. The context example above moves these keys onto reopt's axes.
Choose what you put in properties. The contract truncates the length and count of $ai_* keys, but the keys you add ride along as-is. And this is not a per-call value — it is a value the context hook attaches automatically to every AI event. Put a request ID or a full URL in and a single call copies it onto every generation, span and trace, while the analytics side accumulates unbounded distinct values. An event over 400KB is rejected whole before it enters the queue and is reported through onError. Put in only low-cardinality values that can serve as an axis (workspace, brand, plan); for per-item identifiers $ai_trace_id is enough.
Eve agents (reopt-agent, angski) have nothing to do. Eve passes instrumentation.ts's runtimeContext with every key turned on, so as long as you follow the existing convention the default mapping just works:
// agent/instrumentation.ts — Eve
export default defineInstrumentation({
functionId: "reopt-agent",
recordInputs: false,
recordOutputs: false,
events: {
"step.started"({ channel, session, turn }) {
return {
runtimeContext: {
"reopt.channel": channel.kind,
"reopt.session": session.id,
"reopt.turn": turn.id,
"reopt.principal": session.auth.current?.principalId,
"reopt.parent_session": session.parent?.sessionId,
"reopt.root_session": session.parent?.rootSessionId,
},
};
},
},
});
// once, somewhere in the agent's entry point
registerTelemetry(createReoptAiTelemetry({ reopt }));Eve calls both its own OTel integration and globally registered integrations, so one line of registration is enough. recordInputs: false applies to this integration too, so content does not go out even if you turn captureContent on.
Prompt and response bodies
Nothing is sent by default. $ai_input/$ai_output ride along only when captureContent: true, and even then ingest drops them unless content collection is enabled in the project settings. Even with it on:
- If the call has
recordInputs: false/recordOutputs: false, that side is not sent. - It goes through
redact(content, ctx). Returningundefineddrops that field, and throwing drops it — it will not send something when it does not know what should have been masked. - It truncates to 32 000 characters per field and attaches
$ai_content_truncated(the contract'sclampAiProperties— the same function as the server's). Binaries (images, audio) are replaced with[binary N bytes]. - Truncation happens when the call ends. Until then it has to hold the original prompt, so for each in-flight call the entire conversation stays in memory. The number of in-flight calls is limited only by
maxInFlight(default 10 000), so when turning this on for a workload with large conversations, lowermaxInFlightas well. Serialization also runs synchronously on the callback thread, so turning it on with multi-MB messages spends that much CPU on every step.
Error messages
$ai_is_error, $ai_error_type and $ai_http_status are always sent. $ai_error_message is sent by default (up to 1 000 characters), but:
- With
captureErrorMessages: falseit is not sent. Turn it off wherever provider or tool errors can echo back the prompt or the tool input. - If
redactis present it applies to the message as well, regardless ofcaptureContent(ctx.kind === "error",contentis the message string). Return a string and that value is used; return anything else and the message is dropped; throw and only the message is dropped — the fact that an error occurred remains.
Sampling
sampleRate (0..1) is deterministic on the trace id. A trace survives whole or drops whole, and traces split across several instances make the same decision. Feedback is not sampled.
Calls outside the AI SDK — the manual API
await aiTelemetry.withAiTrace(
"voice-turn",
async ({ traceId }) => {
const text = await transcribe(audio); // not an AI SDK call
aiTelemetry.captureAiGeneration({ model: "gpt-4o-transcribe", provider: "openai", inputTokens, latencyMs });
await generateText({ model, prompt: text }); // becomes a child of this trace
},
{ aiSessionId: callId, profileId: userId }
);
aiTelemetry.captureAiFeedback({ traceId, score: 1, comment: "Got it right", feedbackId: row.id });withAiTracereturns the callback's return value or exception as-is, and on completion sends one$ai_tracewith the sum of its children.captureAiGeneration/captureAiSpanare idempotent across retries if you supplyspanId(without it you get a new id).- Every AI event (except feedback) carries
$ai_latency_ms. The server subtracts this value from the event timestamp (the send time) to determine the start time, so call manual captures right after the work finishes, together withlatencyMs(omitting it means 0). captureAiFeedbackis idempotent only when you supplyfeedbackId. Without it, every call is a new rating, so a user who flips their thumb is not silently ignored. In the browser,track("$ai_feedback", { $ai_trace_id, $ai_feedback_score })is enough.
Using a request-scoped client (getReopt) as the source — caution
You can pass a function such as getReopt to reopt, but it is not recommended. The function is called for every event, and getReopt reads headers()/cookies() each time:
streamText's$ai_traceand its last$ai_generationgo out when the stream ends. In a route that streams its response, that moment may be after the handler returned, and there is no guarantee the request store is readable then. If it cannot be read, that event is reported throughonErrorand dropped.- Outside a request (crons, queue workers, background work outside
after()) it always fails. - If the function returns
null(opted out, no project), that event is silently not recorded.
If you need to tie events to a visitor, use a process-wide instance and pass deviceId/profileId through runtimeContext from the request handler, then move them across in context.
Before a script exits: await aiTelemetry.flush() followed by reopt.close().
How sessions are continued
When a server event arrives carrying the visitor's device — whether from the request headers (getReopt() reads it from the cookie and attaches it automatically) or from the event's own deviceId field (a forwarder) — ingest joins it to that device's active session. If there is no active session (a webhook arriving hours later, say), it is stored without a session and no new one is created — a ghost session with zero pageviews would pollute the bounce rate.
A per-row deviceId takes precedence over the request header. On requests sent with browser credentials (writeKey) this field is ignored — there the header is the SDK's own cookie value while the body is whatever the page wrote, so trusting the body would let any page attribute events to a device of its choosing.
The reopt-session-id the browser sends is not trusted. Anyone can put an arbitrary value in it, and taking it at face value would allow injecting events into someone else's session. The session is always derived from the device.
Development
pnpm --filter @reopt-ai/data-sdk-server test # unit (tsx --test)
pnpm test:e2e:sdk # isolated full pipeline (vitest)
pnpm --filter @reopt-ai/data-sdk-server build # tsdown + dist verificationbuild inspects the output with scripts/check-dist-guards.mjs: whether the server entry point really imports server-only, and whether there is any DOM reference anywhere in dist. These are the kind of properties that hold in the source and then break silently in the bundle, which is why they are tied to the build.
Browser session replay forwarding
The browser replay feature posts /api/replay/start and /api/replay/chunk under its configured baseUrl. The existing reoptProxy({ proxy: true }) /ingest rewrite forwards both paths without a tenant lookup or injecting server credentials. Keep request bodies and the reopt-replay-token header out of application logs. The Node/server SDK does not record DOM or create upload grants. See the browser package README for explicit recording consent and the workspace release requirement.
