Deno Deploy error tracking your coding agent can read

Updated 2026-11-07

When a Deno.serve handler throws on Deno Deploy, the visitor gets a 500 and the stack trace is only in the app's logs. One small module wraps your handler and sends each exception to Runheed with fetch, with no package to install. It sends the report before the 500 goes out, so an instance that Deploy stops right after the request has nothing left to send. It also reports uncaught exceptions and unhandled rejections outside requests, and tags each error with the build and timeline it came from. Your coding agent then reads the error, the request and the stack trace over MCP.

Set it up

  1. 1.Add the pk_... part of your project DSN as a secret environment variable (or in the console: your app's Settings > Environment Variables).

    terminalbash
    deno deploy env add --secret RUNHEED_KEY pk_...
  2. 2.Add one module next to your entrypoint. It never lets reporting break a request.

    runheed.tstypescript
    const KEY = Deno.env.get('RUNHEED_KEY');
    const INGEST = 'https://runheed.com/api/ingest';
    // Deno Deploy sets DENO_TIMELINE to production, git-branch/<name> or preview/<id>; it is unset locally.
    const TIMELINE = Deno.env.get('DENO_TIMELINE');
    const ENVIRONMENT = Deno.env.get('RUNHEED_ENV') ?? (TIMELINE === 'production' ? 'production' : TIMELINE ? 'preview' : 'development');
    
    function frames(stack = '') {
      return stack.split('\n').slice(1).flatMap((line) => {
        const m = line.match(/at (?:(.+?) \()?(\S+?):(\d+):(\d+)\)?$/);
        if (!m) return [];
        return [{ function: m[1] ?? '<anonymous>', filename: m[2], lineno: Number(m[3]), colno: Number(m[4]), inApp: m[2].startsWith('file://') && !/node_modules|\/npm\//.test(m[2]) }];
      });
    }
    
    export async function reportError(err: unknown, req?: Request, extra: Record<string, unknown> = {}) {
      if (!KEY) return;
      const e = err instanceof Error ? err : new Error(String(err));
      try {
        await fetch(INGEST, {
          method: 'POST',
          headers: { 'Content-Type': 'application/json', 'X-Runheed-Key': KEY },
          body: JSON.stringify({
            errorType: e.name || 'Error',
            errorMessage: e.message || String(err),
            stacktrace: frames(e.stack),
            platform: 'deno-deploy',
            runtime: `deno ${Deno.version.deno}`,
            environment: ENVIRONMENT,
            release: Deno.env.get('DENO_DEPLOY_BUILD_ID'),
            request: req ? { method: req.method, url: req.url } : undefined,
            extra: { timeline: TIMELINE, ...extra },
            timestamp: new Date().toISOString(),
          }),
          signal: AbortSignal.timeout(3000),
        });
      } catch {
        // Reporting failed; the app still answers.
      }
    }
    
    export function withRunheed(handler: Deno.ServeHandler) {
      return async (req: Request, info: Deno.ServeHandlerInfo): Promise<Response> => {
        try {
          return await handler(req, info);
        } catch (err) {
          // Awaited, so the report is sent before the response and before Deploy can stop the instance.
          await reportError(err, req);
          return Response.json({ error: 'Internal error' }, { status: 500 });
        }
      };
    }
    
    // Outside a request: report, then exit as Deno would have.
    addEventListener('error', (event) => {
      event.preventDefault();
      reportError(event.error, undefined, { source: 'uncaught exception' }).finally(() => Deno.exit(1));
    });
    addEventListener('unhandledrejection', (event) => {
      event.preventDefault();
      reportError(event.reason, undefined, { source: 'unhandled rejection' }).finally(() => Deno.exit(1));
    });
  3. 3.Wrap your Deno.serve handler. Errors you catch yourself can be sent with await reportError(err, req, { anything: "useful" }).

    main.tstypescript
    import { withRunheed } from './runheed.ts';
    
    Deno.serve(withRunheed(async (req) => {
      const url = new URL(req.url);
      if (url.pathname === '/api/checkout' && req.method === 'POST') {
        const { cartId } = await req.json();
        // ...your code
        return Response.json({ ok: true, cartId });
      }
      return new Response('Not found', { status: 404 });
    }));
  4. 4.Deploy, then connect your agent with the server URL and a sign-in (Claude Code shown; Cursor, claude.ai and ChatGPT take the same URL).

    terminalbash
    deno deploy --prod
    claude mcp add --transport http runheed https://runheed.com/api/mcp

The npm and PyPI releases are not out yet, so these commands install the same package files straight from runheed.com. Nothing else changes when the registry versions ship.

What it catches

  • Any exception thrown inside the wrapped Deno.serve handler, with the method, URL, build ID, timeline and stack trace
  • Uncaught exceptions and unhandled promise rejections outside requests, such as in timers and startup code, sent before the process exits
  • Errors you report yourself with reportError, with whatever context you pass

What it does not do

  • Deno.cron jobs and other background work need their own try/catch with reportError
  • A request still running when the instance stops loses its report: Deploy stops instances with SIGINT, which ends a Deno process at once unless you listen for it, and running out of memory ends it too
  • Errors your code catches and turns into a response are not sent unless you call reportError
  • Browser errors are not captured; there is no browser SDK yet

Then ask your agent

> what broke in production since the last deploy? fix the top one and resolve it with the commit hash

Connect Runheed first: Claude Code, Cursor or another client. Then see how the agent fixes and resolves an error.

Common Deno Deploy errors

Plans from $29/moOther agents and editorsCancel any time from the billing portal