# Supabase Edge Functions

> One shared file on Sentry's Deno SDK: each function's errors are sent before it answers, query { data, error } results included.

Supabase Edge Functions run on Deno, one worker serving many requests. Belvador uses the **official Sentry SDK for Deno** with your Belvador DSN and Belvador's own scrubbing, in one shared file that every function imports. Each function's handler is wrapped so an error is sent **before** the function answers — an Edge Function can be stopped as soon as it has responded, and an error still waiting in memory would be lost.

> [!NOTE]
> `@sentry/deno` is Sentry's SDK, not a Belvador package. Belvador runs this exact recipe on the Supabase Edge Runtime 1.77.8 against a real node: an error thrown by a handler, an error you handle, a `{ data, error }` result and an upstream timeout each arrive as their own issue. Belvador has not run it on a hosted Supabase project yet.

## Before you start

- A project with platform **Other** and its DSN (Edge Functions use Sentry's Deno SDK).
- The Supabase CLI for `supabase secrets set` and `supabase functions deploy`.

## 1. Add the shared file

```ts title="supabase/functions/_shared/belvador.ts"
import * as Sentry from "npm:@sentry/deno@11.9.0";
import { captureResultWith, scrubBreadcrumb, scrubEvent } from "npm:@belvador/core@0.6.1/sentry";

Sentry.init({
  dsn: Deno.env.get("BELVADOR_DSN") ?? "https://<public-key>@ingest.belvador.dev/<project-id>",
  release: Deno.env.get("APP_VERSION"),
  environment: Deno.env.get("APP_ENV") ?? "production",
  // One worker serves many requests: no automatic integrations sharing one scope;
  // withBelvador and report put each request's context on its own event.
  defaultIntegrations: false,
  sendDefaultPii: false,
  tracesSampleRate: 0,
  maxBreadcrumbs: 50,
  maxValueLength: 8192,
  // Belvador's scrubbing: request bodies, cookies and credentials never leave the app.
  beforeSend: scrubEvent,
  beforeBreadcrumb: scrubBreadcrumb,
});

export const captureResult = captureResultWith(Sentry);

/** This request's context, on its own event (the worker's scope is shared). */
function context(name: string, req: Request) {
  return {
    tags: {
      function: name,
      region: Deno.env.get("SB_REGION") ?? "local",
      execution_id: Deno.env.get("SB_EXECUTION_ID") ?? "local",
      "http.request.method": req.method,
    },
  };
}

/** Reports an error you handled and waits (2 s at most) until it is sent. */
export async function report(error: unknown, name: string, req: Request): Promise<void> {
  Sentry.captureException(error, context(name, req));
  await Sentry.flush(2000);
}

/** Wraps a handler: an error it throws is reported, then answered with a 500. */
export function withBelvador(name: string, handler: (req: Request) => Response | Promise<Response>) {
  return async (req: Request): Promise<Response> => {
    try {
      const res = await handler(req);
      // Sends what captureResult queued during the request, after the response.
      (globalThis as { EdgeRuntime?: { waitUntil(p: Promise<unknown>): void } }).EdgeRuntime?.waitUntil(Sentry.flush(2000));
      return res;
    } catch (error) {
      await report(error, name, req);
      return Response.json({ error: "Internal Server Error" }, { status: 500 });
    }
  };
}
```

`Sentry.init` runs once per worker. What the options do:

| Option | Why |
|---|---|
| `defaultIntegrations: false` | A worker serves many requests at once with one shared scope; with automatic integrations, one request's tags and breadcrumbs would land on another request's error. `withBelvador` and `report` put each request's context on its own event instead. |
| `sendDefaultPii: false`, `tracesSampleRate: 0` | No IP addresses or user details added by the SDK; no spans |
| `maxBreadcrumbs: 50`, `maxValueLength: 8192` | Same bounds as Belvador's own SDKs |
| `beforeSend: scrubEvent`, `beforeBreadcrumb: scrubBreadcrumb` | Belvador's scrubbing, the same code its SDKs run: request bodies, cookies, auth headers and credentials in text never leave the function |

Every event carries the tags `function`, `region` and `execution_id` (from Supabase's `SB_REGION` and `SB_EXECUTION_ID`), so you can tell which function and which run failed.

## 2. Wrap each function

```ts title="supabase/functions/list-invoices/index.ts"
import { createClient } from "npm:@supabase/supabase-js@2";
import { captureResult, withBelvador } from "../_shared/belvador.ts";

Deno.serve(
  withBelvador("list-invoices", async (req) => {
    const supabase = createClient(Deno.env.get("SUPABASE_URL")!, Deno.env.get("SUPABASE_ANON_KEY")!, {
      global: { headers: { Authorization: req.headers.get("Authorization") ?? "" } },
    });
    const { data, error } = captureResult(await supabase.from("invoices").select("id, total"), {
      operation: "select invoices",
    });
    if (error) return Response.json({ error: "Could not load invoices" }, { status: 503 });
    return Response.json(data);
  }),
);
```

`withBelvador(name, handler)`:

- An error the handler throws is reported, sent (waiting at most 2 seconds), then answered with a `500`.
- `captureResult` reports the `error` of a Supabase query typed by its Postgres code (`db.permission_denied`, `db.query_canceled`, …) and hands the result back unchanged — see [Database errors](https://belvador.dev/en/docs/sdk/database-errors). What it queued is sent right after the response with `EdgeRuntime.waitUntil`.

## 3. Report errors you handle

```ts title="supabase/functions/send-receipt/index.ts"
import { report, withBelvador } from "../_shared/belvador.ts";

Deno.serve(
  withBelvador("send-receipt", async (req) => {
    try {
      await sendReceipt(await req.json(), { signal: AbortSignal.timeout(5000) });
    } catch (error) {
      await report(error, "send-receipt", req); // sent before the function answers
      return Response.json({ error: "Receipt not sent" }, { status: 502 });
    }
    return new Response(null, { status: 204 });
  }),
);
```

`report(error, name, req)` sends the error and waits for it before your function answers. Bound slow calls with `AbortSignal.timeout` below Supabase's own limit, so your code — not the platform — decides when to give up and the error is sent first.

## 4. Set the DSN and deploy

```bash
supabase secrets set BELVADOR_DSN="https://<public-key>@ingest.belvador.dev/<project-id>" APP_ENV=production
```

Then deploy as usual (`supabase functions deploy <name>`). Locally, `supabase functions serve` reads the same variables from `supabase/functions/.env`.

## Set it up with an AI agent

Paste this into Claude Code, Cursor, Lovable or another coding agent. It contains the files above and the rules it must follow:

````text
Belvador errors: Supabase Edge Functions (@sentry/deno)

Tested with: @sentry/* 11.9.0
Guide: https://belvador.dev/en/docs/sdk/supabase-edge

Goal
Report this server's errors to Belvador with the official Sentry SDK and Belvador's scrubbing, exactly as in the files below. Keep any other error SDK out: one Sentry.init per process.

Rules
Copy the files as they are and keep every Sentry.init option: sendDefaultPii: false, tracesSampleRate: 0, maxBreadcrumbs: 50, beforeSend: scrubEvent, beforeBreadcrumb: scrubBreadcrumb. Read the DSN from BELVADOR_DSN; do not invent other environment variables. Do not change authentication, routing or business logic. Report errors the code already handles with captureException (report() on Supabase Edge Functions) and wrap Supabase or PostgREST results that return an error instead of throwing with captureResult, without changing what the code does with the result.

supabase/functions/_shared/belvador.ts
```typescript
import * as Sentry from "npm:@sentry/deno@11.9.0";
import { captureResultWith, scrubBreadcrumb, scrubEvent } from "npm:@belvador/core@0.6.1/sentry";

Sentry.init({
  dsn: Deno.env.get("BELVADOR_DSN") ?? "https://<public-key>@ingest.belvador.dev/<project-id>",
  release: Deno.env.get("APP_VERSION"),
  environment: Deno.env.get("APP_ENV") ?? "production",
  // One worker serves many requests: no automatic integrations sharing one scope;
  // withBelvador and report put each request's context on its own event.
  defaultIntegrations: false,
  sendDefaultPii: false,
  tracesSampleRate: 0,
  maxBreadcrumbs: 50,
  maxValueLength: 8192,
  // Belvador's scrubbing: request bodies, cookies and credentials never leave the app.
  beforeSend: scrubEvent,
  beforeBreadcrumb: scrubBreadcrumb,
});

export const captureResult = captureResultWith(Sentry);

/** This request's context, on its own event (the worker's scope is shared). */
function context(name: string, req: Request) {
  return {
    tags: {
      function: name,
      region: Deno.env.get("SB_REGION") ?? "local",
      execution_id: Deno.env.get("SB_EXECUTION_ID") ?? "local",
      "http.request.method": req.method,
    },
  };
}

/** Reports an error you handled and waits (2 s at most) until it is sent. */
export async function report(error: unknown, name: string, req: Request): Promise<void> {
  Sentry.captureException(error, context(name, req));
  await Sentry.flush(2000);
}

/** Wraps a handler: an error it throws is reported, then answered with a 500. */
export function withBelvador(name: string, handler: (req: Request) => Response | Promise<Response>) {
  return async (req: Request): Promise<Response> => {
    try {
      const res = await handler(req);
      // Sends what captureResult queued during the request, after the response.
      (globalThis as { EdgeRuntime?: { waitUntil(p: Promise<unknown>): void } }).EdgeRuntime?.waitUntil(Sentry.flush(2000));
      return res;
    } catch (error) {
      await report(error, name, req);
      return Response.json({ error: "Internal Server Error" }, { status: 500 });
    }
  };
}
```

supabase/functions/list-invoices/index.ts
```typescript
import { createClient } from "npm:@supabase/supabase-js@2";
import { captureResult, withBelvador } from "../_shared/belvador.ts";

Deno.serve(
  withBelvador("list-invoices", async (req) => {
    const supabase = createClient(Deno.env.get("SUPABASE_URL")!, Deno.env.get("SUPABASE_ANON_KEY")!, {
      global: { headers: { Authorization: req.headers.get("Authorization") ?? "" } },
    });
    const { data, error } = captureResult(await supabase.from("invoices").select("id, total"), {
      operation: "select invoices",
    });
    if (error) return Response.json({ error: "Could not load invoices" }, { status: 503 });
    return Response.json(data);
  }),
);
```

supabase/functions/send-receipt/index.ts
```typescript
import { report, withBelvador } from "../_shared/belvador.ts";

Deno.serve(
  withBelvador("send-receipt", async (req) => {
    try {
      await sendReceipt(await req.json(), { signal: AbortSignal.timeout(5000) });
    } catch (error) {
      await report(error, "send-receipt", req); // sent before the function answers
      return Response.json({ error: "Receipt not sent" }, { status: 502 });
    }
    return new Response(null, { status: 204 });
  }),
);
```

```bash
supabase secrets set BELVADOR_DSN="https://<public-key>@ingest.belvador.dev/<project-id>" APP_ENV=production
```

Verify
Start the app locally with BELVADOR_DSN set and trigger one test error (a route or a one-off script that throws). Confirm it appears as an issue in the Belvador project. Do not send test errors to production unless the user says so.

Report
List the files you changed, the packages added with exact versions, the environment variables the app now needs, the test error you saw arrive, and anything you could not verify.
````

## Limits to know

- **Only wrapped handlers report errors.** An error in a timer, an un-awaited promise or module code outside the handler is not reported: Supabase's runtime has no global hook that reliably runs before the worker stops. Await your work inside the handler.
- **The platform can stop a function first.** Supabase stops a function at its CPU, memory or wall-clock limit (see [Supabase's limits](https://supabase.com/docs/guides/functions/limits)); an error still unsent at that moment is lost. `EdgeRuntime.waitUntil` does not extend those limits.
- **No disk:** errors are not kept across a restart (`@belvador/spool` needs a filesystem). If your node is unreachable, the SDK retries for a few seconds and then drops the error.
- Supabase's own platform logs (Postgres, Auth, Edge Functions) go through a [log drain](https://belvador.dev/en/docs/otel/supabase-log-drains), not this recipe.
