Monitor Neon Functions with Sentry
Add error tracking, structured logs, request tracing, and AI agent observability to your Neon Functions with the Sentry Node SDK.
Before you start
SDKs & packages
- Node.js 20 or later (v24 recommended)
- Neon CLI installed globally and authenticated
- @sentry/node (installed in Step 2)
Accounts & access
- A free Neon account
- A free Sentry account
Knowledge
- Basic familiarity with Node.js and TypeScript
- Basic familiarity with the Hono web framework
1 Create a Sentry project and copy your DSN
A Neon Function is a long-lived Node.js process running a web-standard request/response handler, so the standard Sentry Node SDK works unchanged — no wrapper or separate runtime needed.
Log in to your Sentry dashboard and click Create Project. Select Node.js as the platform, name it something like neon-functions-api, and create it. Then open the project's settings and click Copy DSN.
The DSN looks like https://examplePublicKey@o0.ingest.us.sentry.io/0. Keep it handy — you'll add it as an environment variable in Step 3.
2 Scaffold the Neon Functions project
Create a project directory and initialize it with the Neon CLI:
neon initsets up AI skills, the MCP server, and the VS Code extension using the default promptsneon linkconnects the workspace to a Neon project — pick a new project (e.g.neon-sentry-demo), the AWS US East 2 (Ohio) region, and the Functions service- Confirm manage this project's Neon setup as code to generate a
neon.tsfile, which you'll edit in Step 3
mkdir neon-sentry-demo && cd neon-sentry-demo
neon init
neon link
3 Install dependencies and configure environment variables
neon link scaffolds a placeholder hello.ts function and a .env.local file with your Neon connection details. Since this guide uses Hono, remove the placeholder and create a src directory:
rm hello.ts
mkdir src
npm install hono @sentry/node
npm install --save-dev esbuild @types/node typescript
4 Add a tsconfig.json and Sentry environment variables
TypeScript needs a tsconfig.json so the linter resolves types correctly:
- Then append the Sentry variables to
.env.local, after the Neon-managed ones SENTRY_DSN— the DSN you copied in Step 1. When unset, the SDK stays fully disabled, which keeps local dev silentSENTRY_TRACES_SAMPLE_RATE— sample rate from 0 to 1 (default1); a low-throughput function makes every request worth capturingPRODUCTION_BRANCH— your default branch name (usuallymain), so it reports as theproductionenvironment instead of its branch name
{
"compilerOptions": {
"target": "ES2022",
"module": "NodeNext",
"moduleResolution": "NodeNext",
"types": ["node"],
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true
}
}
5 Initialize Sentry before your handler loads
Sentry.init must run before any other code in your process — your framework, database pool, and handlers. Put the init in its own module and import it as the very first import of your entry file, so everything that follows is instrumented from the start.
Create src/instrument.ts:
enabledturns the SDK into a no-op whenSENTRY_DSNis missing, keeping unconfigured branches silentenableLogsswitches on theSentry.logger.*structured logging API, off by defaulthttpIntegration({ disableIncomingRequestSpans: true })stops the SDK from adding a duplicate root span — your handler is invoked through Neon's ingress, notnode:http, and you'll build the root span yourself in Step 6environmentcomparesNEON_BRANCH(injected on every branch) toPRODUCTION_BRANCH, so your default branch reports asproductionwhile preview branches use their own name
import * as Sentry from "@sentry/node";
import { parseEnv } from "@neon/env";
import { config } from "../neon";
const env = parseEnv(config, "api");
Sentry.init({
dsn: env.function.SENTRY_DSN,
enabled: Boolean(env.function.SENTRY_DSN),
enableLogs: true,
tracesSampleRate: Number(env.function.SENTRY_TRACES_SAMPLE_RATE ?? 1),
integrations: [
// The request root span comes from the Hono middleware, so skip the SDK's own.
Sentry.httpIntegration({ disableIncomingRequestSpans: true }),
],
release: env.function.SENTRY_RELEASE,
environment:
env.branch && env.branch.name !== env.function.PRODUCTION_BRANCH
? env.branch.name
: "production",
});
process.on("SIGTERM", () => void Sentry.flush(2000));
process.on("SIGINT", () => void Sentry.flush(2000));
export { Sentry };
6 Build the app and the request root span middleware
Create src/index.ts, starting with the app and a middleware that wraps every request in a root span. This is the one block you'd copy into any instrumented Neon Function:
Sentry.withIsolationScope(...)gives each request its own scope, so concurrent requests never mix up trace contextSentry.startSpan(...)opens the root span named after the route; every log, error, and child span the handler emits attaches to it automatically.finally(() => Sentry.flush(2000))ships buffered telemetry before the request ends, since Neon Functions can suspend an idle process at any moment
import "./instrument";
import { Sentry } from "./instrument";
import { Hono } from "hono";
const app = new Hono();
app.use("*", (c, next) =>
Sentry.withIsolationScope(() =>
Sentry.startSpan(
{
op: "http.server",
name: `${c.req.method} ${c.req.path}`,
forceTransaction: true,
attributes: { "http.request.method": c.req.method, "url.path": c.req.path },
},
async (span) => {
await next();
span.setAttribute("http.response.status_code", c.res.status);
},
).finally(() => Sentry.flush(2000)),
),
);
app.get("/health", (c) => c.json({ status: "ok" }));
7 Capture logs and errors in your API routes
Add an orders route that "charges" an order through two fake payment providers. A recoverable failure (the first provider declines) earns a log; a terminal failure (every provider fails) earns a captured error. This is the judgment call at the center of good instrumentation — reporting every recovered retry as an error would bury the failures that matter.
Sentry.logger.info(...)andSentry.logger.warn(...)record the narrative with flat, searchable attributes — you'll query these exact fields in Explore > LogsSentry.startSpan({ name: "order.charge" }, ...)wraps the charge attempt in a child span, so the waterfall shows how long it took under the request root spanapp.onErroris Hono's global error handler — any exception a route doesn't catch lands here and gets reported withSentry.captureExceptionGET /debug-sentryexists purely to prove the wiring works: an uncaught error that the global handler reports to Sentry
app.post("/api/orders", async (c) => {
const body = await c.req.json();
const orderId = crypto.randomUUID();
if (!body.items || !Array.isArray(body.items) || body.items.length === 0) {
return c.json({ error: "orders require a non-empty items array" }, 400);
}
Sentry.logger.info("order received", { component: "api", orderId, items: body.items.length });
try {
const provider = await Sentry.startSpan(
{ name: "order.charge", attributes: { orderId } },
() => chargeOrder(orderId, body.force_failure === true),
);
Sentry.logger.info("order charged", { component: "api", orderId, provider });
return c.json({ orderId, status: "confirmed", provider });
} catch (err) {
// Terminal: every provider failed, so this one becomes an issue.
Sentry.captureException(err, {
tags: { component: "api", phase: "charge" },
contexts: { order: { orderId, items: body.items.length } },
});
return c.json({ error: "charge_failed" }, 502);
}
});
function chargeOrder(orderId: string, forceFailure: boolean) {
const providers = ["stripe", "polar"];
let lastError: unknown;
for (const provider of providers) {
try {
callProvider(provider, orderId, forceFailure);
return provider;
} catch (err) {
lastError = err;
// Recoverable: the next provider gets a shot, so this is a log, not an issue.
Sentry.logger.warn("payment provider failed, trying the next one", {
component: "api",
phase: "charge-attempt",
provider,
error: String(err),
});
}
}
throw lastError ?? new Error("all payment providers failed");
}
function callProvider(name: string, orderId: string, forceFailure: boolean) {
if (forceFailure && name === "stripe") {
throw new Error("provider declined the charge");
}
return { transactionId: crypto.randomUUID() };
}
app.get("/debug-sentry", () => {
throw new Error("sentry test: unhandled route error");
});
app.onError((err, c) => {
Sentry.captureException(err);
return c.json({ error: "internal_error" }, 500);
});
export default app;
8 Configure neon.ts and deploy
Update the neon.ts file neon link created to register the function and pass the Sentry variables as deploy-time environment variables:
- Deploy with
neon deploy --env .env.local— the--envflag loads.env.localso theprocess.envreferences resolve at deploy time - The CLI bundles your code and returns your deployment's live HTTPS URL, e.g.
https://br-damp-voice-xxx-api.compute.c-3.us-east-2.aws.neon.tech - Trigger
GET /debug-sentryto confirm a grouped issue appears under Issues in Sentry - POST to
/api/orderswith"force_failure":trueto confirm the recovered-failure warning appears in Explore > Logs, and without it to confirm thePOST /api/orderstrace appears in Explore > Traces
import { defineConfig } from "@neon/config/v1";
export const config = defineConfig({
branch: (branch) => {
if (branch.isDefault) { return {}; }
if (!branch.exists) { return { ttl: "7d" }; }
return {};
},
preview: {
functions: {
api: {
name: "Sentry-Instrumented API",
source: "./src/index.ts",
env: {
SENTRY_DSN: process.env.SENTRY_DSN!,
SENTRY_RELEASE: process.env.SENTRY_RELEASE ?? "",
SENTRY_TRACES_SAMPLE_RATE: process.env.SENTRY_TRACES_SAMPLE_RATE ?? "1",
PRODUCTION_BRANCH: process.env.PRODUCTION_BRANCH ?? "main",
},
}
},
},
});
export default config;
9 Trace a streaming AI agent (bonus)
The same instrumentation transfers unchanged to AI workloads. Install the AI SDK dependencies, then add Sentry.vercelAIIntegration({ force: true }) and traceLifecycle: "stream" to src/instrument.ts so gen_ai spans stream in batches as the model responds.
A POST /chat route using the Vercel AI SDK's streamText with tool calling then produces a trace where the request root span carries a gen_ai hierarchy: the agent, a gen_ai.generate_content span for the model call, and a gen_ai.execute_tool span for each tool run — with token usage per call. Because streamText never throws (failures surface as error parts inside the stream), an onError callback that calls Sentry.captureException is what keeps a failed agent run from looking like a silent empty reply.
app.post("/chat", async (c) => {
const { messages } = await c.req.json();
Sentry.setConversationId(c.req.header("x-conversation-id") ?? crypto.randomUUID());
const result = streamText({
model: neon(MODEL),
system: "You are a concise assistant. Use tools when they help.",
messages,
tools: {
getServerTime: tool({
description: "Get the current server time in ISO format.",
inputSchema: z.object({}),
execute: async () => ({ now: new Date().toISOString() }),
}),
},
telemetry: { isEnabled: true },
onError: ({ error }) => {
Sentry.captureException(error, { tags: { component: "agent", phase: "chat-stream" } });
},
});
// gen_ai spans only end with the stream, so flush from the stream's finalizer.
const stream = result.textStream
.pipeThrough(
new TransformStream<string, string>({
async flush() {
await Sentry.flush(2000);
},
}),
)
.pipeThrough(new TextEncoderStream());
return new Response(stream, { headers: { "content-type": "text/plain; charset=utf-8" } });
});
That's it.
One trace ID ties it all together.
An alert fires on a new issue, you jump to the logs and spans of that same trace, and you know exactly what happened, when, and why — without leaving the dashboard.
- Initialized Sentry before your Neon Function's handler starts serving requests
- Built a request root span so every log, error, and child span attaches to one trace
- Distinguished a recoverable failure (a log) from a terminal failure (a captured error)
- Verified errors, logs, and traces landing in the correct Sentry dashboard pages
- Traced a streaming AI agent's model calls and tool executions
Pro tips
- 💡 Set
SENTRY_RELEASEto your commit SHA (git rev-parse --short HEAD) on every deploy, so Sentry can tell you exactly which release introduced or resurfaced an issue. - 💡 Add Sentry alert rules on new issues and on log patterns like
phase:charge-attempt, so failures page you instead of waiting for a user report. - 💡 Deploy from a preview branch first and confirm its events land tagged with the branch name — this keeps preview noise out of production dashboards.
- 💡 If you add a high-traffic route, lower
SENTRY_TRACES_SAMPLE_RATEfor it specifically while keeping1for low-volume interactive routes where every request matters.
Common pitfalls
- ⚠️ Reporting every recovered retry with
captureExceptionburies the failures that matter in noise — reserve captured exceptions for failures you'd actually want to be woken up for, and useSentry.logger.warnfor recoverable ones. - ⚠️ Neon Functions bundling doesn't currently emit source maps, so stack traces show minified positions (e.g.
index.mjs:56) rather than yoursrc/lines. Errors, logs, and traces are still captured correctly, but pinpointing the exact source line requires bundling manually with esbuild and uploading source maps yourself. - ⚠️
streamTextnever throws on model or tool failures — without anonErrorcallback, a failed AI agent run looks like a silently empty response instead of a captured error. - ⚠️ Forgetting
Sentry.flush()before a Neon Function's process suspends means buffered telemetry from the last request can be dropped — always flush in a.finally()or stream finalizer.
Frequently asked questions
@sentry/node) works unchanged. There's no separate wrapper or runtime-specific package required.NEON_BRANCH against PRODUCTION_BRANCH in your Sentry.init environment field, as shown in Step 5. Preview branches then report under their own branch name as the Sentry environment, so you can filter them out of production alerts and views.neon deploy flow, since Neon Functions bundling doesn't currently emit source maps. If you need source-mapped stack traces, bundle manually with esbuild's --sourcemap flag, upload the source maps to Sentry, and deploy directly via the Neon Functions API.Sentry.vercelAIIntegration({ force: true }) and set telemetry: { isEnabled: true } on the AI SDK call to get the gen_ai span hierarchy. Because streaming responses only finish emitting spans when the stream ends, you flush from the stream's finalizer instead of after the handler returns.What's next?
Fix it, don't observe it.
Get started with the only application monitoring platform that empowers developers to fix application problems without compromising on velocity.