From bf747de7ded13bb7e85e3e7dc508dc8afbff085c Mon Sep 17 00:00:00 2001 From: "Eric J. Smith" Date: Wed, 4 Nov 2026 13:36:09 -0600 Subject: [PATCH 1/4] Fix Vue error handler serializing the component instance (#203) Record the component name and Vue's info string instead of the whole instance, and accept Vue's errorHandler signature so the documented app.config.errorHandler assignment compiles under strict TypeScript. --- .../references/client-vue.md | 3 + packages/vue/README.md | 3 + packages/vue/src/index.ts | 36 ++++++-- .../test/ExceptionlessErrorHandler.test.ts | 82 +++++++++++++++++++ vitest.config.ts | 19 +++++ 5 files changed, 138 insertions(+), 5 deletions(-) create mode 100644 packages/vue/test/ExceptionlessErrorHandler.test.ts diff --git a/.agents/skills/exceptionless-javascript/references/client-vue.md b/.agents/skills/exceptionless-javascript/references/client-vue.md index 096e0cb8..f3dec4e4 100644 --- a/.agents/skills/exceptionless-javascript/references/client-vue.md +++ b/.agents/skills/exceptionless-javascript/references/client-vue.md @@ -28,6 +28,9 @@ app.config.errorHandler = ExceptionlessErrorHandler; app.mount("#app"); ``` +`ExceptionlessErrorHandler` adds the component name (`component`) and Vue's error source (`info`) to each +error. It does not include the component instance. + ## Send ```js diff --git a/packages/vue/README.md b/packages/vue/README.md index 6c46c371..8ee8bb31 100644 --- a/packages/vue/README.md +++ b/packages/vue/README.md @@ -38,6 +38,9 @@ app.config.errorHandler = ExceptionlessErrorHandler; app.mount("#app"); ``` +`ExceptionlessErrorHandler` adds the component name (`component`) and Vue's error source (`info`) to each +error. It does not include the component instance. + ## Handling Events While errors within the components themselves are automatically sent to diff --git a/packages/vue/src/index.ts b/packages/vue/src/index.ts index fd18a215..50fdfc5a 100644 --- a/packages/vue/src/index.ts +++ b/packages/vue/src/index.ts @@ -1,13 +1,39 @@ export * from "@exceptionless/browser"; -import { Exceptionless } from "@exceptionless/browser"; +import { Exceptionless, toError } from "@exceptionless/browser"; + +interface VueComponentOptions { + name?: unknown; + __name?: unknown; + _componentTag?: unknown; +} /** - * https://vuejs.org/v2/api/#errorHandler - * https://v3.vuejs.org/api/application-config.html#errorhandler + * Gets the component name without walking the component instance, which references the whole app (and the DOM). + */ +function getComponentName(vm: unknown): string | undefined { + if (typeof vm !== "object" || vm === null) { + return undefined; + } + + const options = (vm as { $options?: VueComponentOptions }).$options; + const name = options?.name ?? options?.__name ?? options?._componentTag; + return typeof name === "string" && name ? name : undefined; +} + +/** + * https://vuejs.org/api/application.html#app-config-errorhandler + * https://v2.vuejs.org/v2/api/#errorHandler * @param err * @param vm * @param info */ -export const ExceptionlessErrorHandler = async (err: Error, vm: unknown, info: unknown): Promise => { - await Exceptionless.createException(err).setProperty("vm", vm).setProperty("info", info).submit(); +export const ExceptionlessErrorHandler = async (err: unknown, vm: unknown, info: unknown): Promise => { + const builder = Exceptionless.createException(toError(err)); + + const component = getComponentName(vm); + if (component) { + builder.setProperty("component", component); + } + + await builder.setProperty("info", info).submit(); }; diff --git a/packages/vue/test/ExceptionlessErrorHandler.test.ts b/packages/vue/test/ExceptionlessErrorHandler.test.ts new file mode 100644 index 00000000..5794a580 --- /dev/null +++ b/packages/vue/test/ExceptionlessErrorHandler.test.ts @@ -0,0 +1,82 @@ +import { beforeEach, describe, expect, test, vi } from "vitest"; + +import { ExceptionlessErrorHandler } from "../src/index.js"; + +const mocks = vi.hoisted(() => ({ + createException: vi.fn(), + setProperty: vi.fn(), + submit: vi.fn().mockResolvedValue(undefined) +})); + +vi.mock("@exceptionless/browser", () => { + const builder = { + setProperty: mocks.setProperty.mockImplementation(() => builder), + submit: mocks.submit + }; + + return { + Exceptionless: { + createException: mocks.createException.mockImplementation(() => builder) + }, + toError: (value: unknown) => (value instanceof Error ? value : new Error(String(value))) + }; +}); + +describe("ExceptionlessErrorHandler", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + test("should record the component name and info instead of the component instance", async () => { + const root: Record = { + $options: { + name: "App" + } + }; + const vm = { + $options: { + __name: "Broken" + }, + $root: root, + $parent: root + }; + root["$children"] = [vm]; + const error = new Error("Boom"); + + await ExceptionlessErrorHandler(error, vm, "render function"); + + expect(mocks.createException).toHaveBeenCalledWith(error); + expect(mocks.setProperty.mock.calls).toEqual([ + ["component", "Broken"], + ["info", "render function"] + ]); + expect(mocks.submit).toHaveBeenCalled(); + }); + + test("should prefer the explicit component name", async () => { + await ExceptionlessErrorHandler( + new Error("Boom"), + { + $options: { + name: "Named", + __name: "File" + } + }, + "setup function" + ); + + expect(mocks.setProperty).toHaveBeenCalledWith("component", "Named"); + }); + + test("should handle a missing instance and a non-error value", async () => { + await ExceptionlessErrorHandler("Boom", null, "watcher callback"); + + expect(mocks.createException).toHaveBeenCalledWith( + expect.objectContaining({ + message: "Boom" + }) + ); + expect(mocks.setProperty.mock.calls).toEqual([["info", "watcher callback"]]); + expect(mocks.submit).toHaveBeenCalled(); + }); +}); diff --git a/vitest.config.ts b/vitest.config.ts index 09130b02..6f4cc2f9 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -79,6 +79,25 @@ export default defineConfig({ } } }, + { + test: { + name: "vue", + root: "packages/vue", + environment: "jsdom", + environmentOptions: { + jsdom: { + url: "http://localhost/" + } + } + }, + resolve: { + conditions: ["source"], + alias: { + "@exceptionless/core": path.resolve(__dirname, "packages/core/src"), + "@exceptionless/browser": path.resolve(__dirname, "packages/browser/src") + } + } + }, { test: { name: "react-native", From c23ed132a08147beaf6f8c97f7c45437daf5401a Mon Sep 17 00:00:00 2001 From: "Eric J. Smith" Date: Wed, 4 Nov 2026 13:36:58 -0600 Subject: [PATCH 2/4] Fix Node request info using route params as the query string (#204) --- .../node/src/plugins/NodeRequestInfoPlugin.ts | 4 +- .../plugins/NodeRequestInfoPlugin.test.ts | 65 +++++++++++++++++++ 2 files changed, 67 insertions(+), 2 deletions(-) create mode 100644 packages/node/test/plugins/NodeRequestInfoPlugin.test.ts diff --git a/packages/node/src/plugins/NodeRequestInfoPlugin.ts b/packages/node/src/plugins/NodeRequestInfoPlugin.ts index 251bab54..6cc2e9e5 100644 --- a/packages/node/src/plugins/NodeRequestInfoPlugin.ts +++ b/packages/node/src/plugins/NodeRequestInfoPlugin.ts @@ -37,7 +37,7 @@ export class NodeRequestInfoPlugin implements IEventPlugin { hostname: string; path: string; headers: Record; - params: Record; + query?: Record; body?: object; }; @@ -81,7 +81,7 @@ export class NodeRequestInfoPlugin implements IEventPlugin { } if (config.includeQueryString) { - const json = stringify(request.params, exclusions); + const json = stringify(request.query, exclusions); if (!isEmpty(json)) { requestInfo.query_string = JSON.parse(json) as Record; } diff --git a/packages/node/test/plugins/NodeRequestInfoPlugin.test.ts b/packages/node/test/plugins/NodeRequestInfoPlugin.test.ts new file mode 100644 index 00000000..0ecd662a --- /dev/null +++ b/packages/node/test/plugins/NodeRequestInfoPlugin.test.ts @@ -0,0 +1,65 @@ +import { describe, expect, test } from "vitest"; + +import { EventContext, EventPluginContext, ExceptionlessClient, KnownEventDataKeys, RequestInfo } from "@exceptionless/core"; + +import { NodeRequestInfoPlugin } from "../../src/plugins/NodeRequestInfoPlugin.js"; + +describe("NodeRequestInfoPlugin", () => { + const run = async (request: Record): Promise => { + const client = new ExceptionlessClient(); + const eventContext = new EventContext(); + eventContext[KnownEventDataKeys.RequestInfo] = request; + + const context = new EventPluginContext( + client, + { + data: {} + }, + eventContext + ); + await new NodeRequestInfoPlugin().run(context); + return context.event.data?.[KnownEventDataKeys.RequestInfo]; + }; + + test("should set query_string from the request query instead of route params", async () => { + const requestInfo = await run({ + method: "GET", + secure: false, + ip: "127.0.0.1", + hostname: "localhost", + path: "/users/42", + headers: { + host: "localhost:3000", + "user-agent": "test" + }, + params: { + id: "42" + }, + query: { + search: "abc", + page: "2" + } + }); + + expect(requestInfo?.path).toBe("/users/42"); + expect(requestInfo?.query_string).toEqual({ + search: "abc", + page: "2" + }); + }); + + test("should omit query_string when the request has no query", async () => { + const requestInfo = await run({ + method: "GET", + path: "/users/42", + headers: { + "user-agent": "test" + }, + params: { + id: "42" + } + }); + + expect(requestInfo?.query_string).toBeUndefined(); + }); +}); From ad5cd6290f009880b3515218f7ab5cc3ecb82b76 Mon Sep 17 00:00:00 2001 From: "Eric J. Smith" Date: Wed, 4 Nov 2026 13:37:11 -0600 Subject: [PATCH 3/4] Remove the unreachable React Native in-memory storage fallback (#205) Nothing in the try block could throw, and a missing AsyncStorage native module fails when the package is imported, so the fallback and its log message never ran. AsyncStorage remains a required peer dependency. --- .../src/ReactNativeExceptionlessClient.ts | 13 ++----------- 1 file changed, 2 insertions(+), 11 deletions(-) diff --git a/packages/react-native/src/ReactNativeExceptionlessClient.ts b/packages/react-native/src/ReactNativeExceptionlessClient.ts index 9cee8a61..d6c24ea2 100644 --- a/packages/react-native/src/ReactNativeExceptionlessClient.ts +++ b/packages/react-native/src/ReactNativeExceptionlessClient.ts @@ -13,7 +13,8 @@ export class ReactNativeExceptionlessClient extends ExceptionlessClient { const config = this.config; if (configurationOrApiKey && !this._initialized) { - this.configureStorage(config); + config.services.storage = new AsyncStorageProvider(); + config.usePersistedQueueStorage = true; if (Platform.OS === "ios") { config.addPlugin(new NativeCrashPlugin()); @@ -28,14 +29,4 @@ export class ReactNativeExceptionlessClient extends ExceptionlessClient { await super.startup(configurationOrApiKey); } - - private configureStorage(config: Configuration): void { - try { - const storage = new AsyncStorageProvider(); - config.services.storage = storage; - config.usePersistedQueueStorage = true; - } catch { - config.services.log.info("AsyncStorage provider failed to initialize. Using in-memory storage. Events will not persist across app restarts."); - } - } } From c71997ca3e84c5af4e93fc6f32f2b681d6633ee9 Mon Sep 17 00:00:00 2001 From: "Eric J. Smith" Date: Wed, 4 Nov 2026 13:37:24 -0600 Subject: [PATCH 4/4] Fix docs that describe the no-op Expo config plugin as required (#206) --- .../references/client-react-native.md | 4 ++-- packages/react-native/README.md | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/.agents/skills/exceptionless-javascript/references/client-react-native.md b/.agents/skills/exceptionless-javascript/references/client-react-native.md index a87ddf5a..d2ae4da8 100644 --- a/.agents/skills/exceptionless-javascript/references/client-react-native.md +++ b/.agents/skills/exceptionless-javascript/references/client-react-native.md @@ -17,7 +17,7 @@ npm install @exceptionless/react-native @react-native-async-storage/async-storag cd ios && pod install ``` -For Expo development or standalone builds, add the config plugin: +No config plugin is required: the native crash reporter pod is linked by autolinking. The optional config plugin currently makes no native changes: ```json { @@ -104,7 +104,7 @@ await Exceptionless.createException(new Error("Checkout failed")) ## Troubleshooting -- If native crashes do not appear in Expo, verify the app is not running in Expo Go and that the config plugin is present before rebuilding the native app. +- If native crashes do not appear in Expo, verify the app is not running in Expo Go, rebuild the native app after installing the package, and check that `ios/Podfile.lock` includes `exceptionless-react-native` (autolinking). - If simulator submissions cannot reach a local Exceptionless server, use `http://localhost:` for iOS Simulator and make sure Metro is not running in Expo's default LAN mode. Physical devices need a reachable LAN host. - For malformed or unexpected stacks, verify behavior in `ReactNativeErrorPlugin` tests before changing parser logic. - For native crash report loss concerns, verify `NativeCrashPlugin` only clears pending reports after at least one report is retrieved and submitted. diff --git a/packages/react-native/README.md b/packages/react-native/README.md index 5751f0ed..2a1b8e2b 100644 --- a/packages/react-native/README.md +++ b/packages/react-native/README.md @@ -38,7 +38,7 @@ That's it. Unhandled errors, promise rejections, and native iOS crashes are now npx expo install @exceptionless/react-native @react-native-async-storage/async-storage ``` -Add the config plugin to `app.json` when using development or standalone builds: +The native crash reporter is linked by autolinking, so no config plugin is required. The package ships an optional config plugin that currently makes no native changes; you can add it to `app.json` so future native configuration is applied automatically: ```json {