Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: auto capture errors with nitroApp.captureError #1463

Merged
merged 11 commits into from
Jul 19, 2023
41 changes: 37 additions & 4 deletions src/runtime/app.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,8 @@ import {
Router,
toNodeListener,
fetchWithEvent,
H3Error,
isEvent,
} from "h3";
import { createFetch, Headers } from "ofetch";
import destr from "destr";
Expand All @@ -15,7 +17,7 @@ import {
createFetch as createLocalFetch,
} from "unenv/runtime/fetch/index";
import { createHooks, Hookable } from "hookable";
import type { NitroRuntimeHooks } from "./types";
import type { NitroRuntimeHooks, CaptureError } from "./types";
import { useRuntimeConfig } from "./config";
import { cachedEventHandler } from "./cache";
import { normalizeFetchResponse } from "./utils";
Expand All @@ -31,16 +33,37 @@ export interface NitroApp {
hooks: Hookable<NitroRuntimeHooks>;
localCall: ReturnType<typeof createCall>;
localFetch: ReturnType<typeof createLocalFetch>;
captureError: CaptureError;
}

function createNitroApp(): NitroApp {
const config = useRuntimeConfig();

const hooks = createHooks<NitroRuntimeHooks>();

const captureError: CaptureError = (error, context = {}) => {
const promise = hooks
.callHookParallel("error", error, context)
.catch((_err) => {
console.error("Error while capturing another error", _err);
});
if (context.event && isEvent(context.event)) {
const errors = context.event.context.nitro?.errors;
if (errors) {
errors.push({ error, context });
}
if (context.event.waitUntil) {
context.event.waitUntil(promise);
}
}
};

const h3App = createApp({
debug: destr(process.env.DEBUG),
onError: errorHandler,
onError: (error, event) => {
captureError(error, { event, tags: ["request"] });
return errorHandler(error as H3Error, event);
},
});

const router = createRouter();
Expand Down Expand Up @@ -68,7 +91,7 @@ function createNitroApp(): NitroApp {
h3App.use(
eventHandler((event) => {
// Init nitro context
event.context.nitro = event.context.nitro || {};
event.context.nitro = event.context.nitro || { errors: [] };

// Support platform context provided by local fetch
const envContext = (event.node.req as any).__unenv__;
Expand All @@ -94,6 +117,10 @@ function createNitroApp(): NitroApp {
envContext.waitUntil(promise);
}
};

event.captureError = (error, context) => {
captureError(error, { event, ...context });
};
})
);

Expand Down Expand Up @@ -127,10 +154,16 @@ function createNitroApp(): NitroApp {
router,
localCall,
localFetch,
captureError,
};

for (const plugin of plugins) {
plugin(app);
try {
plugin(app);
} catch (err) {
captureError(err, { tags: ["plugin"] });
throw err;
}
}

return app;
Expand Down
19 changes: 11 additions & 8 deletions src/runtime/cache.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import {
import type { H3Event } from "h3";
import { parseURL } from "ufo";
import { useStorage } from "./storage";
import { useNitroApp } from "./app";

export interface CacheEntry<T = any> {
value?: T;
Expand Down Expand Up @@ -57,7 +58,7 @@ export function defineCachedFunction<T, ArgsT extends unknown[] = unknown[]>(
key: string,
resolver: () => T | Promise<T>,
shouldInvalidateCache?: boolean,
waitUntil?: (promise: Promise<void>) => void
event?: H3Event
): Promise<CacheEntry<T>> {
// Use extension for key to avoid conflicting with parent namespace (foo/bar and foo/bar/baz)
const cacheKey = [opts.base, group, name, key + ".json"]
Expand Down Expand Up @@ -114,9 +115,11 @@ export function defineCachedFunction<T, ArgsT extends unknown[] = unknown[]>(
if (validate(entry)) {
const promise = useStorage()
.setItem(cacheKey, entry)
.catch((error) => console.error("[nitro] [cache]", error));
if (waitUntil) {
waitUntil(promise);
.catch((error) => {
useNitroApp().captureError(error, { event, tags: ["cache"] });
});
if (event && event.waitUntil) {
event.waitUntil(promise);
}
}
}
Expand All @@ -126,7 +129,9 @@ export function defineCachedFunction<T, ArgsT extends unknown[] = unknown[]>(

if (opts.swr && entry.value) {
// eslint-disable-next-line no-console
_resolvePromise.catch(console.error);
_resolvePromise.catch((error) => {
useNitroApp().captureError(error, { event, tags: ["cache"] });
});
// eslint-disable-next-line unicorn/no-useless-promise-resolve-reject
return entry;
}
Expand All @@ -141,13 +146,11 @@ export function defineCachedFunction<T, ArgsT extends unknown[] = unknown[]>(
}
const key = await (opts.getKey || getKey)(...args);
const shouldInvalidateCache = opts.shouldInvalidateCache?.(...args);
const waitUntil =
args[0] && isEvent(args[0]) ? args[0].waitUntil : undefined;
const entry = await get(
key,
() => fn(...args),
shouldInvalidateCache,
waitUntil
args[0] && isEvent(args[0]) ? args[0] : undefined
);
let value = entry.value;
if (opts.transform) {
Expand Down
13 changes: 12 additions & 1 deletion src/runtime/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,10 +11,21 @@ export type {
export type { NitroAppPlugin } from "./plugin";
export type { RenderResponse, RenderHandler } from "./renderer";

export type CapturedErrorContext = {
event?: H3Event;
[key: string]: unknown;
};

export type CaptureError = (
error: Error,
context: CapturedErrorContext
) => void;

export interface NitroRuntimeHooks {
close: () => void;
error: CaptureError;
"render:response": (
response: Partial<RenderResponse>,
context: { event: H3Event }
) => void;
close: () => void;
}
27 changes: 12 additions & 15 deletions src/runtime/utils.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import type { H3Event } from "h3";
import { getRequestHeader, splitCookiesString } from "h3";
import { useNitroApp } from "./app";

const METHOD_WITH_BODY_RE = /post|put|patch/i;
const TEXT_MIME_RE = /application\/text|text\/html/;
Expand Down Expand Up @@ -84,22 +85,18 @@ export function normalizeError(error: any) {
};
}

function _captureError(error: Error, type: string) {
console.error(`[nitro] [${type}]`, error);
useNitroApp().captureError(error, { tags: [type] });
}

export function trapUnhandledNodeErrors() {
if (process.env.DEBUG) {
process.on("unhandledRejection", (err) =>
console.error("[nitro] [unhandledRejection]", err)
);
process.on("uncaughtException", (err) =>
console.error("[nitro] [uncaughtException]", err)
);
} else {
process.on("unhandledRejection", (err) =>
console.error("[nitro] [unhandledRejection] " + err)
);
process.on("uncaughtException", (err) =>
console.error("[nitro] [uncaughtException] " + err)
);
}
process.on("unhandledRejection", (error: Error) =>
_captureError(error, "unhandledRejection")
);
process.on("uncaughtException", (error: Error) =>
_captureError(error, "uncaughtException")
);
}

export function joinHeaders(value: string | string[]) {
Expand Down
5 changes: 5 additions & 0 deletions src/types/h3.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import type { CaptureError, CapturedErrorContext } from "../runtime/types";
import type { NitroFetchRequest, $Fetch } from "./fetch";

export type H3EventFetch = (
Expand All @@ -15,10 +16,14 @@ declare module "h3" {
$fetch: H3Event$Fetch;
/** @experimental See https://github.com/unjs/nitro/issues/1420 */
waitUntil: (promise: Promise<unknown>) => void;
/** @experimental */
captureError: CaptureError;
}
interface H3Context {
nitro: {
_waitUntilPromises?: Promise<unknown>[];
/** @experimental */
errors: { error?: Error; context: CapturedErrorContext }[];
};
}
}
Expand Down
9 changes: 9 additions & 0 deletions test/fixture/api/errors.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
import { allErrors } from "../plugins/errors";

export default eventHandler((event) => {
return {
allErrors: allErrors.map((entry) => ({
message: entry.error.message,
})),
};
});
7 changes: 7 additions & 0 deletions test/fixture/plugins/errors.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
export const allErrors: { error: Error; context: any }[] = [];

export default defineNitroPlugin((app) => {
app.hooks.hook("error", (error, context) => {
allErrors.push({ error, context });
});
});
10 changes: 10 additions & 0 deletions test/tests.ts
Original file line number Diff line number Diff line change
Expand Up @@ -482,4 +482,14 @@ export function testNitro(
expect(headers["set-cookie"]).toMatchObject(expectedCookies);
});
});

describe("errors", () => {
it("captures errors", async () => {
const { data } = await callHandler({ url: "/api/errors" });
const allErrorMessages = (data.allErrors || []).map(
(entry) => entry.message
);
expect(allErrorMessages).to.includes("Service Unavailable");
});
});
}