Getting Proper Stack Traces from Effect.ts in Sentry
How to preserve meaningful error information when integrating Effect with Sentry error trackingWhen building production applications with Effect, proper error tracking is crucial. However, if you’re using Sentry for error monitoring, you might have noticed that Effect’s error handling can obscure the original stack traces, making debugging significantly harder.
The problem: lost stack traces
When using Effect.runPromise()
with Sentry, errors get wrapped in Effect’s internal error handling, leading to unhelpful error reports that look like this:
(FiberFailure) UnknownException
An unknown error occurred in Effect.tryPromise
/app/node_modules/.pnpm/effect@3.14.16/node_modules/effect/dist/esm/internal/core-effect.js in fail at line 563:75
// This is internal Effect code… not very useful!
let catcher = undefined;
if (typeof arg === "function") {
evaluate = arg;
} else {
evaluate = arg.try;
catcher = arg.catch;
}
const fail = e => catcher ? core.failSync(() => catcher(e)) : core.fail(new core.UnknownException(e, "An unknown error occurred in Effect. {snip}
if (evaluate.length >= 1) {
return core.async((resolve, signal) => {
try {
evaluate(signal).then(a => resolve(core.exitSucceed(a)), e => resolve(fail(e)));
} catch (e) {
resolve(fail(e));
}
This tells us nothing about where the actual error occurred in our application code. The stack trace points to Effect’s internals rather than our business logic, making debugging a frustrating experience.
The root cause
The root cause lies in Effect’s defensive error handling. When an operation fails, Effect wraps the error in its own types like FiberFailure
. This is intentional, as an Effect can fail for many reasons, such as interruptions and defects. While this design makes Effect robust, it prevents Sentry from seeing the original error, breaking its ability to capture meaningful stack traces for the known errors you want to monitor.
The solution: runPromiseExit
Instead of runPromise()
, we can use runPromiseExit()
to access the raw exit information and selectively re-throw the original errors for Sentry:
// https://effect.website/play/#139986d1e1cd
import { Effect, Cause, Data, Exit } from "effect";
export class CustomError extends Data.TaggedError("CustomError")<{
message: string;
cause: unknown;
}> {
constructor(cause: unknown) {
super({
message: `Custom error: ${cause}`,
cause,
});
}
}
const myLogic = () => {
console.log("executing business logic");
throw new Error("Database connection failed");
};
// This returns UnknownException when wrapped by Effect
const engine = Effect.try(myLogic);
// This returns our CustomError from the catch callback
const engine2 = Effect.try({
try: myLogic,
catch: (err) => new CustomError(err),
});
// Handle the exit and re-throw original errors for Sentry
Effect.runPromiseExit(engine2).then((exit) => {
if (Exit.isFailure(exit)) {
const error = Cause.squash(exit.cause);
// Re-throw the original error for proper stack traces
if (Cause.isUnknownException(error) || error instanceof CustomError) {
throw error.cause; // This preserves the original stack trace
}
}
});
With this approach, Sentry now captures the original stack trace:
Error: Database connection failed
at myLogic (/app/src/services/database.js:42:11)
at processUserRequest (/app/src/controllers/user.js:18:5)
at handleRequest (/app/src/middleware/auth.js:33:12)
at Server.app (/app/src/server.js:67:3)
at Server.emit (node:events:514:28)
at parserOnIncoming (node:_http_server:1068:12)
We can now see the actual path through our application code that led to the error.
Caveat
Only re-throw errors that represent genuine application failures and gracefully handle the rest. The whole point of the Result type is avoiding unnecessary throwing in the first place.
Note that the idiomatic way to handle unrecoverable errors is via Defects:
// https://effect.website/play/#8802fc08b38a
import { Cause, Data, Effect, Exit, Random } from "effect";
// A recoverable validation error
class ValidationError extends Data.TaggedError("ValidationError")<{
readonly message: string;
}> {}
// A potentially critical error
class DatabaseConnectionError extends Data.TaggedError(
"DatabaseConnectionError"
)<{}> {}
// A business logic function that can fail
function performDatabaseOperation(): Effect.Effect<
string,
DatabaseConnectionError
> {
// Simulating a failure
return Effect.fail(new DatabaseConnectionError());
}
// In this part of our application, a DB failure is a critical, unrecoverable defect.
// We use `orDie` to escalate the `DatabaseConnectionError` from a failure to a defect.
const criticalOperation = Effect.orDie(performDatabaseOperation());
// In another part, a validation error is a recoverable failure.
const validationOperation = Effect.fail(
new ValidationError({ message: "Invalid user input" })
);
// A program that might result in a defect or a recoverable error
const myEffect = Random.nextBoolean.pipe(
Effect.flatMap((bool) => (bool ? criticalOperation : validationOperation))
);
Effect.runPromiseExit(myEffect).then((exit) => {
if (Exit.isFailure(exit)) {
const { cause } = exit;
const e = Cause.squash(exit.cause);
// Check for defects first. These are our unrecoverable errors.
if (Cause.isDie(cause)) {
// Re-throw the original defect. Sentry will capture this.
// This should crash the application.
throw e;
}
// Now, handle recoverable failures.
if (Cause.isFailure(cause)) {
// We can pattern match on our recoverable errors
if (e instanceof ValidationError) {
console.error(`Handled validation error: ${cause}`);
// Here we would NOT re-throw, but handle it gracefully
// (e.g., return a 400 response to the user).
}
}
}
});
Alternative approaches
You could also implement a custom Effect runtime that integrates directly with Sentry.captureException()
, but the approach shown here has the advantage of leveraging Sentry’s native error capturing mechanisms, which often provide richer context and better integration with Sentry’s features.
Conclusion
While Effect.ts provides excellent error handling within your application logic, integrating with external monitoring tools like Sentry requires thoughtful consideration of how errors are exposed. By using runPromiseExit()
and selectively re-throwing original errors, you maintain both Effect’s structured error handling and Sentry’s powerful debugging capabilities.
This pattern ensures that when things go wrong in production, you have the information you need to fix them quickly.