← Back to Cookbook

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.

SDKs
Category Monitoring
Time
15–20 minutes
Difficulty
Intermediate
Steps
9 steps

Before you start

SDKs & packages
Accounts & access
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.

Sentry Node SDK documentation

2
Scaffold the Neon Functions project

Create a project directory and initialize it with the Neon CLI:

  • neon init sets up AI skills, the MCP server, and the VS Code extension using the default prompts
  • neon link connects 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.ts file, which you'll edit in Step 3
Bash
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:

Bash
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 silent
  • SENTRY_TRACES_SAMPLE_RATE — sample rate from 0 to 1 (default 1); a low-throughput function makes every request worth capturing
  • PRODUCTION_BRANCH — your default branch name (usually main), so it reports as the production environment instead of its branch name
JSON
{
  "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:

  • enabled turns the SDK into a no-op when SENTRY_DSN is missing, keeping unconfigured branches silent
  • enableLogs switches on the Sentry.logger.* structured logging API, off by default
  • httpIntegration({ disableIncomingRequestSpans: true }) stops the SDK from adding a duplicate root span — your handler is invoked through Neon's ingress, not node:http, and you'll build the root span yourself in Step 6
  • environment compares NEON_BRANCH (injected on every branch) to PRODUCTION_BRANCH, so your default branch reports as production while preview branches use their own name
Sentry structured logging for Node.js
TypeScript
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 context
  • Sentry.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
TypeScript
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(...) and Sentry.logger.warn(...) record the narrative with flat, searchable attributes — you'll query these exact fields in Explore > Logs
  • Sentry.startSpan({ name: "order.charge" }, ...) wraps the charge attempt in a child span, so the waterfall shows how long it took under the request root span
  • app.onError is Hono's global error handler — any exception a route doesn't catch lands here and gets reported with Sentry.captureException
  • GET /debug-sentry exists purely to prove the wiring works: an uncaught error that the global handler reports to Sentry
TypeScript
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 --env flag loads .env.local so the process.env references 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-sentry to confirm a grouped issue appears under Issues in Sentry
  • POST to /api/orders with "force_failure":true to confirm the recovered-failure warning appears in Explore > Logs, and without it to confirm the POST /api/orders trace appears in Explore > Traces
Sentry Trace Explorer documentation
TypeScript
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.

Sentry Vercel AI SDK integration
TypeScript
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_RELEASE to 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_RATE for it specifically while keeping 1 for low-volume interactive routes where every request matters.

Common pitfalls

  • ⚠️ Reporting every recovered retry with captureException buries the failures that matter in noise — reserve captured exceptions for failures you'd actually want to be woken up for, and use Sentry.logger.warn for recoverable ones.
  • ⚠️ Neon Functions bundling doesn't currently emit source maps, so stack traces show minified positions (e.g. index.mjs:56) rather than your src/ 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.
  • ⚠️ streamText never throws on model or tool failures — without an onError callback, 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

No. A Neon Function is a long-lived Node.js process running a web-standard request/response handler, so the standard Sentry Node SDK (@sentry/node) works unchanged. There's no separate wrapper or runtime-specific package required.

Because the route recovers by falling back to a second provider — the request still succeeds. Only when every provider fails does the function throw, and that throw is what becomes a captured issue. Logging every recovered retry as an error would bury the failures that actually need attention.

No, as long as you compare 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.

Not with the default 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.

The pattern is the same request root span, but you also add 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.

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.