From 426c8828f24136958ddd8f53496331efa2b4a42f Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Tue, 19 May 2026 09:08:23 +0000 Subject: [PATCH 1/3] fix(wallets): log WalletNotAvailableError at warn instead of error The WithLoggerContext decorator unconditionally logged all thrown errors at error level. For WalletNotAvailableError this is a normal business outcome (wallet not found) used in locator-iteration and existence-check flows, yet it generated thousands of error-level entries in Datadog. Add an expectedErrors option to WithLoggerContext so decorated methods can declare which error classes represent expected outcomes. Matching errors are now logged at warn level instead of error. Mark WalletNotAvailableError as expected in walletFactory.getWallet. Co-Authored-By: Agus --- packages/common/base/src/logger/decorators.ts | 29 +++++++++++++++++-- .../wallets/src/wallets/wallet-factory.ts | 1 + 2 files changed, 28 insertions(+), 2 deletions(-) diff --git a/packages/common/base/src/logger/decorators.ts b/packages/common/base/src/logger/decorators.ts index 7b890e649..e5d1eb007 100644 --- a/packages/common/base/src/logger/decorators.ts +++ b/packages/common/base/src/logger/decorators.ts @@ -24,6 +24,14 @@ export interface WithLoggerContextOptions { * Optional function to build additional context from the method's this and arguments */ buildContext?: ContextBuilder; + + /** + * Error classes that represent expected/business-logic outcomes (e.g. "not found"). + * Errors matching any of these classes are logged at `warn` instead of `error` + * so they do not pollute error-level monitoring. + */ + // eslint-disable-next-line @typescript-eslint/no-explicit-any + expectedErrors?: Array Error>; } /** @@ -72,14 +80,14 @@ export function WithLoggerContext(options: WithLoggerContextOpt try { result = original.apply(this, args); } catch (error) { - options.logger.error(`${options.methodName} threw an error`, { error }); + logThrownError(options, error); throw error; } // Handle async functions if (result instanceof Promise) { return result.catch((error) => { - options.logger.error(`${options.methodName} threw an error`, { error }); + logThrownError(options, error); throw error; }); } @@ -93,3 +101,20 @@ export function WithLoggerContext(options: WithLoggerContextOpt return descriptor; }; } + +/** + * Log an error thrown by a decorated method. Expected errors (those matching + * `options.expectedErrors`) are logged at `warn`; everything else at `error`. + */ +function logThrownError( + options: Pick, + error: unknown +): void { + const isExpected = options.expectedErrors?.some((ErrorClass) => error instanceof ErrorClass) ?? false; + const message = `${options.methodName} threw an error`; + if (isExpected) { + options.logger.warn(message, { error }); + } else { + options.logger.error(message, { error }); + } +} diff --git a/packages/wallets/src/wallets/wallet-factory.ts b/packages/wallets/src/wallets/wallet-factory.ts index a25617692..3f53dea94 100644 --- a/packages/wallets/src/wallets/wallet-factory.ts +++ b/packages/wallets/src/wallets/wallet-factory.ts @@ -45,6 +45,7 @@ export class WalletFactory { @WithLoggerContext({ logger: walletsLogger, methodName: "walletFactory.getWallet", + expectedErrors: [WalletNotAvailableError], buildContext(_thisArg: WalletFactory, args: unknown[]) { if (typeof args[0] === "string") { const walletArgs = args[1] as WalletArgsFor | undefined; From b089205fa3190f0a64f9206e146d454f9d2c530e Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Tue, 19 May 2026 09:08:47 +0000 Subject: [PATCH 2/3] chore: add changeset for expected-errors log level fix Co-Authored-By: Agus --- .changeset/fix-wallet-not-found-log-level.md | 6 ++++++ 1 file changed, 6 insertions(+) create mode 100644 .changeset/fix-wallet-not-found-log-level.md diff --git a/.changeset/fix-wallet-not-found-log-level.md b/.changeset/fix-wallet-not-found-log-level.md new file mode 100644 index 000000000..2815de253 --- /dev/null +++ b/.changeset/fix-wallet-not-found-log-level.md @@ -0,0 +1,6 @@ +--- +"@crossmint/common-sdk-base": patch +"@crossmint/wallets-sdk": patch +--- + +Log `WalletNotAvailableError` from `walletFactory.getWallet` at warn level instead of error. The `WithLoggerContext` decorator now supports an `expectedErrors` option so decorated methods can declare which error classes represent normal business outcomes (e.g. wallet not found) that should not pollute error-level monitoring. From 224b979342257ddd7251ef3124cd5e2ced0d6d84 Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Tue, 19 May 2026 09:13:22 +0000 Subject: [PATCH 3/3] test(logger): add unit tests for expectedErrors in WithLoggerContext decorator Co-Authored-By: Agus --- .../common/base/src/logger/decorators.test.ts | 143 ++++++++++++++++++ 1 file changed, 143 insertions(+) create mode 100644 packages/common/base/src/logger/decorators.test.ts diff --git a/packages/common/base/src/logger/decorators.test.ts b/packages/common/base/src/logger/decorators.test.ts new file mode 100644 index 000000000..28a774f4a --- /dev/null +++ b/packages/common/base/src/logger/decorators.test.ts @@ -0,0 +1,143 @@ +import { describe, expect, it, vi } from "vitest"; +import { WithLoggerContext } from "./decorators"; +import { SdkLogger } from "./SdkLogger"; + +class ExpectedError extends Error { + constructor(message: string) { + super(message); + this.name = "ExpectedError"; + } +} + +class UnexpectedError extends Error { + constructor(message: string) { + super(message); + this.name = "UnexpectedError"; + } +} + +function createMockLogger() { + const logger = new SdkLogger({ packageName: "test" }); + logger.warn = vi.fn(); + logger.error = vi.fn(); + logger.info = vi.fn(); + logger.debug = vi.fn(); + return logger; +} + +describe("WithLoggerContext", () => { + describe("expectedErrors", () => { + it("logs expected errors at warn level", async () => { + const logger = createMockLogger(); + + class Subject { + @WithLoggerContext({ + logger, + methodName: "subject.method", + expectedErrors: [ExpectedError], + }) + async doWork(): Promise { + throw new ExpectedError("not found"); + } + } + + const subject = new Subject(); + await expect(subject.doWork()).rejects.toThrow("not found"); + + expect(logger.warn).toHaveBeenCalledWith("subject.method threw an error", { + error: expect.any(ExpectedError), + }); + expect(logger.error).not.toHaveBeenCalledWith("subject.method threw an error", expect.anything()); + }); + + it("logs unexpected errors at error level", async () => { + const logger = createMockLogger(); + + class Subject { + @WithLoggerContext({ + logger, + methodName: "subject.method", + expectedErrors: [ExpectedError], + }) + async doWork(): Promise { + throw new UnexpectedError("boom"); + } + } + + const subject = new Subject(); + await expect(subject.doWork()).rejects.toThrow("boom"); + + expect(logger.error).toHaveBeenCalledWith("subject.method threw an error", { + error: expect.any(UnexpectedError), + }); + expect(logger.warn).not.toHaveBeenCalledWith("subject.method threw an error", expect.anything()); + }); + + it("logs all errors at error level when expectedErrors is omitted", async () => { + const logger = createMockLogger(); + + class Subject { + @WithLoggerContext({ + logger, + methodName: "subject.method", + }) + async doWork(): Promise { + throw new ExpectedError("not found"); + } + } + + const subject = new Subject(); + await expect(subject.doWork()).rejects.toThrow("not found"); + + expect(logger.error).toHaveBeenCalledWith("subject.method threw an error", { + error: expect.any(ExpectedError), + }); + expect(logger.warn).not.toHaveBeenCalledWith("subject.method threw an error", expect.anything()); + }); + + it("still rethrows the error in all cases", async () => { + const logger = createMockLogger(); + + class Subject { + @WithLoggerContext({ + logger, + methodName: "subject.method", + expectedErrors: [ExpectedError], + }) + async doWork(err: Error): Promise { + throw err; + } + } + + const subject = new Subject(); + const expected = new ExpectedError("expected"); + const unexpected = new UnexpectedError("unexpected"); + + await expect(subject.doWork(expected)).rejects.toBe(expected); + await expect(subject.doWork(unexpected)).rejects.toBe(unexpected); + }); + + it("handles sync methods that throw expected errors", () => { + const logger = createMockLogger(); + + class Subject { + @WithLoggerContext({ + logger, + methodName: "subject.syncMethod", + expectedErrors: [ExpectedError], + }) + doWorkSync(): void { + throw new ExpectedError("sync not found"); + } + } + + const subject = new Subject(); + expect(() => subject.doWorkSync()).toThrow("sync not found"); + + expect(logger.warn).toHaveBeenCalledWith("subject.syncMethod threw an error", { + error: expect.any(ExpectedError), + }); + expect(logger.error).not.toHaveBeenCalledWith("subject.syncMethod threw an error", expect.anything()); + }); + }); +});