diff --git a/README.md b/README.md index 9677e97..d496a89 100644 --- a/README.md +++ b/README.md @@ -291,6 +291,60 @@ const myBeforeSend = function (payload, exception, customData, request, tags) { Raygun.onBeforeSend(myBeforeSend); ``` +### Breadcrumbs + +Breadcrumbs can be sent to Raygun to provide additional information to look into and debug issues stemming from crash reports. + +Breadcrumbs can be created in two ways. + +#### Simple string: + +Call `client.addBreadcrumb(message)`, where message is just a string: + +```js +client.addBreadcrumb('test breadcrumb'); +``` + +#### Using `BreadcrumbMessage`: + +Create your own `BreadcrumbMessage` object and send more than just a message with `client.addBreadcrumb(BreadcrumbMessage)`. + +The structure of the type `BreadcrumbMessage` is as shown here: + +```js +BreadcrumbMessage: { + level: "debug" | "info" | "warning" | "error"; + category: string; + message: string; + customData?: CustomData; +} +``` + +Breadcrumbs can be cleared with `client.clearBreadcrumbs()`. + +#### Breadcrumbs and ExpressJS + +Raygun4Node provides a custom ExpressJS middleware that helps to scope Breadcrumbs to a specific request. +As well, this middleware will add a Breadcrumb with information about the performed request. + +To set up, add the Raygun Breadcrumbs ExpressJS handler before configuring any endpoints. + +```js +// Add the Raygun Breadcrumb ExpressJS handler +app.use(raygunClient.expressHandlerBreadcrumbs); + +// Setup the rest of the app, e.g. +app.use("/", routes); +``` + +This middleware can be user together with the provided ExpressJS error handler `expressHandler`. +The order in which the middlewares are configured is important. `expressHandlerBreadcrumbs` should go first to scope breadcrumbs correctly. + +```js +app.use(raygunClient.expressHandlerBreadcrumbs); +app.use(raygunClient.expressHandler); +``` + ### Batched error transport You can enable a batched transport mode for the Raygun client by passing `{batch: true}` when initializing. diff --git a/eslint.config.mjs b/eslint.config.mjs index aa0feab..1fa70b5 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -19,6 +19,7 @@ export default tseslint.config( // Add node globals to ignore undefined globals: { "__dirname": false, + "__filename": false, "console": false, "module": false, "process": false, diff --git a/examples/express-sample/README.md b/examples/express-sample/README.md index 7774d7d..615c68a 100644 --- a/examples/express-sample/README.md +++ b/examples/express-sample/README.md @@ -24,9 +24,12 @@ in the subdirectory where you found this README.md file. ## Interesting files to look +- `raygun.client.js` + - Setup of Raygun (lines 9-14) - `app.js` - - Setup of Raygun (lines 9-12) - - Sets the user (lines 27-29) - - Attaches Raygun to Express (line 60) + - Sets the user (lines 17-19) + - Attaches Raygun Breadcrumb middleware to Express (line 26) + - Attaches Raygun to Express (line 53) - `routes/index.js` - - Tries to use a fake object, which bounces up to the Express handler (lines 11-15) + - `/send` endpoint: Sends a custom error to Raygun (lines 11-34) + - `/error` endpoint: Tries to use a fake object, which bounces up to the Express handler (lines 36-49) diff --git a/examples/express-sample/app.js b/examples/express-sample/app.js index 2882b0d..bc85f69 100644 --- a/examples/express-sample/app.js +++ b/examples/express-sample/app.js @@ -1,24 +1,10 @@ -var config = require("config"); - -if (config.Raygun.Key === "YOUR_API_KEY") { - console.error( - "[Raygun4Node-Express-Sample] You need to set your Raygun API key in the config file", - ); - process.exit(1); -} - -// Setup Raygun -var raygun = require("raygun"); -var raygunClient = new raygun.Client().init({ - apiKey: config.Raygun.Key, -}); - var express = require("express"); var path = require("path"); var logger = require("morgan"); var cookieParser = require("cookie-parser"); var bodyParser = require("body-parser"); var sassMiddleware = require("node-sass-middleware"); +var raygunClient = require("./raygun.client"); var routes = require("./routes/index"); var users = require("./routes/users"); @@ -34,6 +20,9 @@ raygunClient.user = function (req) { app.set("views", path.join(__dirname, "views")); app.set("view engine", "ejs"); +// Add the Raygun breadcrumb Express handler +app.use(raygunClient.expressHandlerBreadcrumbs); + // uncomment after placing your favicon in /public // app.use(favicon(__dirname + '/public/favicon.ico')); app.use(logger("dev")); @@ -58,7 +47,9 @@ app.use(express.static(path.join(__dirname, "public"))); app.use("/", routes); app.use("/users", users); -// Add the Raygun Express handler +// Add the Raygun error Express handler app.use(raygunClient.expressHandler); +raygunClient.addBreadcrumb("Express Server started!"); + module.exports = app; diff --git a/examples/express-sample/package-lock.json b/examples/express-sample/package-lock.json index 0c063b5..c065f65 100644 --- a/examples/express-sample/package-lock.json +++ b/examples/express-sample/package-lock.json @@ -32,6 +32,8 @@ }, "devDependencies": { "@eslint/js": "^9.2.0", + "@stylistic/eslint-plugin": "^2.0.0", + "@stylistic/eslint-plugin-ts": "^2.0.0", "@types/node": "^20.12.8", "@types/stack-trace": "0.0.33", "@types/uuid": "^9.0.8", diff --git a/examples/express-sample/raygun.client.js b/examples/express-sample/raygun.client.js new file mode 100644 index 0000000..77314f8 --- /dev/null +++ b/examples/express-sample/raygun.client.js @@ -0,0 +1,16 @@ +var config = require("config"); + +if (config.Raygun.Key === "YOUR_API_KEY") { + console.error( + "[Raygun4Node-Express-Sample] You need to set your Raygun API key in the config file", + ); + process.exit(1); +} + +// Setup Raygun +var raygun = require("raygun"); +var raygunClient = new raygun.Client().init({ + apiKey: config.Raygun.Key, +}); + +module.exports = raygunClient; diff --git a/examples/express-sample/routes/index.js b/examples/express-sample/routes/index.js index 523d931..ea893fa 100644 --- a/examples/express-sample/routes/index.js +++ b/examples/express-sample/routes/index.js @@ -1,14 +1,50 @@ -var express = require("express"); -var router = express.Router(); +const express = require("express"); +const router = express.Router(); +const raygunClient = require("../raygun.client"); /* GET home page. */ router.get("/", function (req, res, next) { res.render("index", { - title: "Express", + title: "Raygun Express Example", }); }); +router.get("/send", function (req, res, next) { + raygunClient.addBreadcrumb({ + level: "debug", + category: "Example", + message: "Breadcrumb in /send endpoint", + customData: { + "custom-data": "data", + }, + }); + + raygunClient + .send("Custom Raygun Error in /send endpoint") + .then((message) => { + res.render("send", { + title: "Sent custom error to Raygun", + body: `Raygun status code: ${message.statusCode}`, + }); + }) + .catch((error) => { + res.render("send", { + title: "Failed to send custom error to Raygun", + body: error.toString(), + }); + }); +}); + router.get("/error", function (req, res, next) { + raygunClient.addBreadcrumb({ + level: "debug", + category: "Example", + message: "Breadcrumb in /error endpoint", + customData: { + "custom-data": "data", + }, + }); + // Call an object that doesn't exist to send an error to Raygun fakeObject.FakeMethod(); res.send(500); diff --git a/examples/express-sample/views/index.ejs b/examples/express-sample/views/index.ejs index a5ef3e5..9f3d474 100644 --- a/examples/express-sample/views/index.ejs +++ b/examples/express-sample/views/index.ejs @@ -8,6 +8,9 @@

<%= title %>

Welcome to <%= title %>

+
+ Send a custom error +
Throw an error
diff --git a/examples/express-sample/views/send.ejs b/examples/express-sample/views/send.ejs new file mode 100644 index 0000000..09be1db --- /dev/null +++ b/examples/express-sample/views/send.ejs @@ -0,0 +1,18 @@ + + + + <%= title %> + + + +

<%= title %>

+

<%= title %>

+
+ <%= body %> +
+ +
+ Back to index +
+ + diff --git a/examples/using-domains/app.js b/examples/using-domains/app.js index c78e2f5..e92a887 100644 --- a/examples/using-domains/app.js +++ b/examples/using-domains/app.js @@ -17,6 +17,7 @@ var appDomain = require("domain").create(); // Add the error handler so we can pass errors to Raygun when the domain // crashes appDomain.on("error", function (err) { + raygunClient.addBreadcrumb("Domain error caught!"); console.log(`[Raygun4Node-Domains-Sample] Domain error caught: ${err}`); // Try send data to Raygun raygunClient @@ -40,6 +41,7 @@ appDomain.on("error", function (err) { appDomain.run(function () { var fs = require("fs"); + raygunClient.addBreadcrumb("Running example app"); console.log("[Raygun4Node-Domains-Sample] Running example app"); // Try and read a file that doesn't exist diff --git a/examples/using-domains/package-lock.json b/examples/using-domains/package-lock.json index de945f5..9fdac34 100644 --- a/examples/using-domains/package-lock.json +++ b/examples/using-domains/package-lock.json @@ -25,6 +25,8 @@ }, "devDependencies": { "@eslint/js": "^9.2.0", + "@stylistic/eslint-plugin": "^2.0.0", + "@stylistic/eslint-plugin-ts": "^2.0.0", "@types/node": "^20.12.8", "@types/stack-trace": "0.0.33", "@types/uuid": "^9.0.8", diff --git a/lib/raygun.breadcrumbs.express.ts b/lib/raygun.breadcrumbs.express.ts new file mode 100644 index 0000000..b6e4bd8 --- /dev/null +++ b/lib/raygun.breadcrumbs.express.ts @@ -0,0 +1,34 @@ +import type { Request } from "express"; +import type { Breadcrumb } from "./types"; +import { getBreadcrumbs } from "./raygun.breadcrumbs"; + +const debug = require("debug")("raygun"); + +/** + * Parses an ExpressJS Request and adds it to the breadcrumbs store + * @param request + */ +export function addRequestBreadcrumb(request: Request) { + const crumbs = getBreadcrumbs(); + + if (!crumbs) { + debug( + "[raygun.breadcrumbs.express.ts] Add request breadcrumb skip, no store!", + ); + return; + } + + const internalCrumb: Breadcrumb = { + category: "http", + message: `${request.method} ${request.url}`, + level: "info", + timestamp: Number(new Date()), + type: "request", + }; + + debug( + `[raygun.breadcrumbs.express.ts] recorded request breadcrumb: ${internalCrumb}`, + ); + + crumbs.push(internalCrumb); +} diff --git a/lib/raygun.breadcrumbs.ts b/lib/raygun.breadcrumbs.ts new file mode 100644 index 0000000..84aca34 --- /dev/null +++ b/lib/raygun.breadcrumbs.ts @@ -0,0 +1,139 @@ +import type { AsyncLocalStorage } from "async_hooks"; +import type { BreadcrumbMessage, Breadcrumb } from "./types"; +const debug = require("debug")("raygun"); + +let asyncLocalStorage: AsyncLocalStorage | null = null; + +try { + asyncLocalStorage = new (require("async_hooks").AsyncLocalStorage)(); + debug("[raygun.breadcrumbs.ts] initialized successfully"); +} catch (e) { + debug( + "[raygun.breadcrumbs.ts] failed to load async_hooks.AsyncLocalStorage - initialization failed\n", + e, + ); +} + +type SourceFile = { + fileName: string; + functionName: string; + lineNumber: number | null; +}; + +function returnCallerSite( + err: Error, + callsites: NodeJS.CallSite[], +): SourceFile | null { + for (const callsite of callsites) { + const fileName = callsite.getFileName() || ""; + + if (fileName.startsWith(__dirname)) { + continue; + } + + return { + fileName: fileName, + functionName: callsite.getFunctionName() || "", + lineNumber: callsite.getLineNumber(), + }; + } + + return null; +} + +function getCallsite(): SourceFile | null { + const originalPrepareStacktrace = Error.prepareStackTrace; + + Error.prepareStackTrace = returnCallerSite; + + // Ignore use of any, required for captureStackTrace + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const output: any = {}; + + Error.captureStackTrace(output); + + const callsite = output.stack; + Error.prepareStackTrace = originalPrepareStacktrace; + return callsite; +} + +export function addBreadcrumb( + breadcrumb: string | BreadcrumbMessage, + type: Breadcrumb["type"] = "manual", +) { + const crumbs = getBreadcrumbs(); + + if (!crumbs) { + return; + } + + if (typeof breadcrumb === "string") { + const expandedBreadcrumb: BreadcrumbMessage = { + message: breadcrumb, + level: "info", + category: "", + }; + + breadcrumb = expandedBreadcrumb; + } + + const callsite = getCallsite(); + + const internalCrumb: Breadcrumb = { + ...(breadcrumb as BreadcrumbMessage), + category: breadcrumb.category || "", + message: breadcrumb.message || "", + level: breadcrumb.level || "info", + timestamp: Number(new Date()), + type, + className: callsite?.fileName, + methodName: callsite?.functionName, + + // TODO - do we need to do any source mapping? + lineNumber: callsite?.lineNumber || undefined, + }; + + debug(`[raygun.breadcrumbs.ts] recorded breadcrumb: ${internalCrumb}`); + + crumbs.push(internalCrumb); +} + +export function getBreadcrumbs(): Breadcrumb[] | null { + if (!asyncLocalStorage) { + return null; + } + + const store = asyncLocalStorage.getStore(); + + if (store) { + return store; + } + + const newStore: Breadcrumb[] = []; + + debug("[raygun.breadcrumbs.ts] enter with new store"); + asyncLocalStorage.enterWith(newStore); + + return newStore; +} + +export function runWithBreadcrumbs(f: () => void, store: Breadcrumb[] = []) { + if (!asyncLocalStorage) { + f(); + return; + } + + debug("[raygun.breadcrumbs.ts] running function with breadcrumbs"); + asyncLocalStorage.run(store, f); +} + +export function clear() { + if (!asyncLocalStorage) { + return; + } + + debug( + "[raygun.breadcrumbs.ts] clearing stored breadcrumbs, entering with new store", + ); + asyncLocalStorage.enterWith([]); +} diff --git a/lib/raygun.messageBuilder.ts b/lib/raygun.messageBuilder.ts index 96ebf29..d4e6454 100644 --- a/lib/raygun.messageBuilder.ts +++ b/lib/raygun.messageBuilder.ts @@ -24,8 +24,11 @@ import { CustomData, Environment, BuiltError, + Breadcrumb, } from "./types"; +const debug = require("debug")("raygun"); + type UserMessageData = RawUserData | string | undefined; const humanString = require("object-to-human-string"); @@ -276,4 +279,14 @@ export class RaygunMessageBuilder { } return data; } + + setBreadcrumbs(breadcrumbs: Breadcrumb[] | null) { + debug( + `[raygun.messageBuilder.ts] Added breadcrumbs: ${breadcrumbs?.length || 0}`, + ); + if (breadcrumbs) { + this.message.details.breadcrumbs = [...breadcrumbs]; + } + return this; + } } diff --git a/lib/raygun.ts b/lib/raygun.ts index cf1c55f..871ea7d 100644 --- a/lib/raygun.ts +++ b/lib/raygun.ts @@ -9,6 +9,7 @@ "use strict"; import { + BreadcrumbMessage, callVariadicCallback, Callback, CustomData, @@ -24,8 +25,10 @@ import { Tag, Transport, } from "./types"; +import * as breadcrumbs from "./raygun.breadcrumbs"; +import * as breadcrumbsExpressJs from "./raygun.breadcrumbs.express"; import type { IncomingMessage } from "http"; -import type { Request, Response, NextFunction } from "express"; +import { Request, Response, NextFunction } from "express"; import { RaygunBatchTransport } from "./raygun.batch"; import { RaygunMessageBuilder } from "./raygun.messageBuilder"; import { OfflineStorage } from "./raygun.offline"; @@ -195,6 +198,21 @@ class Raygun { this._tags = tags; } + /** + * Adds breadcrumb to current context + * @param breadcrumb either a string message or a Breadcrumb object + */ + addBreadcrumb(breadcrumb: string | BreadcrumbMessage) { + breadcrumbs.addBreadcrumb(breadcrumb); + } + + /** + * Manually clear stored breadcrumbs for current context + */ + clearBreadcrumbs() { + breadcrumbs.clear(); + } + transport(): Transport { if (this._batch && this._batchTransport) { return this._batchTransport; @@ -335,6 +353,31 @@ class Raygun { } } + /** + * Attach as express middleware to create a breadcrumb store scope per request. + * e.g. `app.use(raygun.expressHandlerBreadcrumbs);` + * Then call to `raygun.addBreadcrumb(...)` to add breadcrumbs to the future Raygun `send` call. + * @param req + * @param res + * @param next + */ + expressHandlerBreadcrumbs(req: Request, res: Response, next: NextFunction) { + breadcrumbs.runWithBreadcrumbs(() => { + breadcrumbsExpressJs.addRequestBreadcrumb(req); + // Make the current breadcrumb store available to the express error handler + res.locals.breadcrumbs = breadcrumbs.getBreadcrumbs(); + next(); + }); + } + + /** + * Attach as express middleware to report application errors to Raygun automatically. + * e.g. `app.use(raygun.expressHandler);` + * @param err + * @param req + * @param res + * @param next + */ expressHandler(err: Error, req: Request, res: Response, next: NextFunction) { let customData; @@ -355,11 +398,30 @@ class Raygun { body: req.body, }; - this.send(err, customData || {}, requestParams, [ - "UnhandledException", - ]).catch((err) => { - console.error("[Raygun] Failed to send Express error", err); - }); + // If a local store of breadcrumbs exist in the response + // run in scoped breadcrumbs store + if (res.locals && res.locals.breadcrumbs) { + breadcrumbs.runWithBreadcrumbs(() => { + debug("sending express error with scoped breadcrumbs store"); + this.send(err, customData || {}, requestParams, [ + "UnhandledException", + ]).catch((err) => { + console.error("[Raygun] Failed to send Express error", err); + }); + }, res.locals.breadcrumbs); + } else { + debug("sending express error with global breadcrumbs store"); + // Otherwise, run with the global breadcrumbs store + this.send(err, customData || {}, requestParams, ["UnhandledException"]) + .then((response) => { + // Clear global breadcrumbs store after successful sent + breadcrumbs.clear(); + }) + .catch((err) => { + console.error("[Raygun] Failed to send Express error", err); + }); + } + next(err); } @@ -399,6 +461,7 @@ class Raygun { .setUserCustomData(customData) .setUser(this.user(request) || this._user) .setVersion(this._version) + .setBreadcrumbs(breadcrumbs.getBreadcrumbs()) .setTags(mergedTags); let message = builder.build(); diff --git a/lib/types.ts b/lib/types.ts index 7fc506a..58e2fa6 100644 --- a/lib/types.ts +++ b/lib/types.ts @@ -52,6 +52,7 @@ export type MessageDetails = { machineName: string; environment: Environment; correlationId: string | null; + breadcrumbs?: Breadcrumb[]; }; export type Environment = { @@ -194,3 +195,19 @@ export function callVariadicCallback( } export type Callback = CallbackNoError | CallbackWithError; + +export type Breadcrumb = { + timestamp: number; + level: "debug" | "info" | "warning" | "error"; + type: "manual" | "navigation" | "click-event" | "request" | "console"; + category: string; + message: string; + customData?: CustomData; + className?: string; + methodName?: string; + lineNumber?: number; +}; + +export type BreadcrumbMessage = Partial< + Pick +>; diff --git a/test/raygun_breadcrumbs_test.js b/test/raygun_breadcrumbs_test.js new file mode 100644 index 0000000..96bf247 --- /dev/null +++ b/test/raygun_breadcrumbs_test.js @@ -0,0 +1,240 @@ +const express = require("express"); +const deepEqual = require("assert").deepEqual; +const test = require("tap").test; +const { listen, makeClientWithMockServer, request } = require("./utils"); + +test("add breadcrumbs to payload details", {}, async function (t) { + const testEnv = await makeClientWithMockServer(); + const client = testEnv.client; + + // Add breadcrumbs in global scope + client.addBreadcrumb("BREADCRUMB-1"); + client.addBreadcrumb("BREADCRUMB-2"); + + client.onBeforeSend(function (payload) { + // Raygun payload should include breadcrumbs + t.equal(payload.details.breadcrumbs.length, 2); + t.equal(payload.details.breadcrumbs[0].message, "BREADCRUMB-1"); + t.equal(payload.details.breadcrumbs[1].message, "BREADCRUMB-2"); + return payload; + }); + + // Send Raygun error + await client.send(new Error()); + + testEnv.stop(); +}); + +test("capturing breadcrumbs in single scope", async function (t) { + const app = express(); + const testEnv = await makeClientWithMockServer(); + const raygun = testEnv.client; + + // Must be defined before any endpoint is configured + app.use(raygun.expressHandlerBreadcrumbs); + + function requestHandler(req, res) { + // Breadcrumbs are added in the scope of the request + raygun.addBreadcrumb("first!"); + setTimeout(() => { + raygun.addBreadcrumb("second!"); + + // Send custom Raygun error with scoped breadcrumbs + raygun.send(new Error("test end")); + res.send("done!"); + }, 1); + } + + app.get("/", requestHandler); + + const server = await listen(app); + + await request(`http://localhost:${server.address().port}`); + const message = await testEnv.nextRequest(); + + server.close(); + testEnv.stop(); + + // Error should include breadcrumbs from scope + deepEqual( + message.details.breadcrumbs.map((b) => b.message), + ["GET /", "first!", "second!"], + ); + + // Breadcrumbs include method names + deepEqual( + message.details.breadcrumbs.map((b) => b.methodName), + [undefined, requestHandler.name, ""], + ); + + // Breadcrumbs include class names + deepEqual( + message.details.breadcrumbs.map((b) => b.className), + [undefined, __filename, __filename], + ); + + // line numbers correspond to the calls to `addBreadcrumb` in this test + // update accordingly if they change + deepEqual( + message.details.breadcrumbs.map((b) => b.lineNumber), + [undefined, 38, 40], + ); + + t.end(); +}); + +test("capturing breadcrumbs in different contexts", async function (t) { + const app = express(); + const testEnv = await makeClientWithMockServer(); + const raygun = testEnv.client; + + // Must be defined before any endpoint is configured + app.use(raygun.expressHandlerBreadcrumbs); + + app.get("/endpoint1", (req, res) => { + // Breadcrumbs are added in the scope of the request for endpoint1 + raygun.addBreadcrumb("endpoint1: 1"); + setTimeout(() => { + raygun.addBreadcrumb("endpoint1: 2"); + + // Send custom Raygun error with scoped breadcrumbs + raygun.send(new Error("error1")); + res.send("done!"); + }, 1); + }); + + app.get("/endpoint2", (req, res) => { + // Breadcrumbs are added in the scope of the request for endpoint2 + raygun.addBreadcrumb("endpoint2: 1"); + setTimeout(() => { + raygun.addBreadcrumb("endpoint2: 2"); + + // Send custom Raygun error with scoped breadcrumbs + raygun.send(new Error("error2")); + res.send("done!"); + }, 1); + }); + + const server = await listen(app); + + await request(`http://localhost:${server.address().port}/endpoint1`); + const message1 = await testEnv.nextRequest(); + + await request(`http://localhost:${server.address().port}/endpoint2`); + const message2 = await testEnv.nextRequest(); + + server.close(); + testEnv.stop(); + + // First error should include breadcrumbs from scope + deepEqual( + message1.details.breadcrumbs.map((b) => b.message), + ["GET /endpoint1", "endpoint1: 1", "endpoint1: 2"], + ); + + // Second error should include breadcrumbs from scope + deepEqual( + message2.details.breadcrumbs.map((b) => b.message), + ["GET /endpoint2", "endpoint2: 1", "endpoint2: 2"], + ); + + t.end(); +}); + +test("expressHandler and breadcrumbs", async function (t) { + const app = express(); + + const testEnvironment = await makeClientWithMockServer(); + const raygunClient = testEnvironment.client; + + // Must be defined before any endpoint is configured + app.use(raygunClient.expressHandlerBreadcrumbs); + + // This breadcrumb is in the global app scope, so it won't be included + raygunClient.addBreadcrumb("breadcrumb in global scope"); + + // Define root endpoint which throws an error + app.get("/", (req, res) => { + // Add an extra breadcrumb before throwing + raygunClient.addBreadcrumb("breadcrumb-1"); + + // Throw error, should be captured by the expressHandler + throw new Error("surprise error!"); + }); + + // Add Raygun error express handler + app.use(raygunClient.expressHandler); + + // Start test server and request root + const server = await listen(app); + await request(`http://localhost:${server.address().port}`); + const message = await testEnvironment.nextRequest(); + + server.close(); + testEnvironment.stop(); + + // Error captured by expressHandler + t.ok(message.details.tags.includes("UnhandledException")); + + // Error should include breadcrumbs from the scoped store + t.equal(message.details.breadcrumbs.length, 2); + t.equal(message.details.breadcrumbs[0].message, "GET /"); + t.equal(message.details.breadcrumbs[1].message, "breadcrumb-1"); + t.end(); +}); + +test("custom breadcrumb objects", {}, async function (t) { + const testEnv = await makeClientWithMockServer(); + const client = testEnv.client; + + // Breadcrumb object + client.addBreadcrumb({ + level: "info", + category: "CATEGORY", + message: "MESSAGE", + customData: { + custom: "data", + }, + }); + + client.onBeforeSend(function (payload) { + // Raygun payload should include breadcrumbs + t.equal(payload.details.breadcrumbs.length, 1); + t.equal(payload.details.breadcrumbs[0].message, "MESSAGE"); + t.equal(payload.details.breadcrumbs[0].category, "CATEGORY"); + t.equal(payload.details.breadcrumbs[0].level, "info"); + t.equal(payload.details.breadcrumbs[0].customData["custom"], "data"); + return payload; + }); + + // Send Raygun error + await client.send(new Error()); + + testEnv.stop(); +}); + +test("clear breadcrumbs", {}, async function (t) { + const testEnv = await makeClientWithMockServer(); + const client = testEnv.client; + + // Add one Breadcrumb + client.addBreadcrumb("SHOULD BE CLEARED"); + + // Clear breadcrumbs + client.clearBreadcrumbs(); + + // Add one Breadcrumb + client.addBreadcrumb("BREADCRUMB"); + + client.onBeforeSend(function (payload) { + // Only BREADCRUMB should exist + t.equal(payload.details.breadcrumbs.length, 1); + t.equal(payload.details.breadcrumbs[0].message, "BREADCRUMB"); + return payload; + }); + + // Send Raygun error + await client.send(new Error()); + + testEnv.stop(); +});