From 0a93706614ea3baf558720bda4de40bfb9318e41 Mon Sep 17 00:00:00 2001 From: JPeer264 Date: Mon, 7 Sep 2026 11:36:47 +0200 Subject: [PATCH 1/2] feat(effect): Capture errors through the Effect v4 ErrorReporter API On Effect v4, `Sentry.effectLayer` now registers a Sentry `ErrorReporter`. Failures that pass through `Effect.withErrorReporting`, `ErrorReporter.report` or the built-in HTTP and RPC reporting boundaries are captured automatically. The `ErrorReporter.ignore`, `severity` and `attributes` annotations are respected. Effect v3 has no `ErrorReporter` module, so the layer stays empty there. The server SDK now enables the `contextLines` and `linkedErrors` integrations by default, so captured errors carry source context and their `cause` chain. No other Node default integration is enabled. Co-Authored-By: Claude Fable 5.1 --- CHANGELOG.md | 6 + .../effect-4-node/src/app.ts | 55 +++++ .../tests/error-reporter.test.ts | 80 +++++++ packages/effect/README.md | 28 +++ packages/effect/src/client/index.ts | 17 +- packages/effect/src/errorReporter.ts | 87 +++++++ packages/effect/src/server/index.ts | 17 +- packages/effect/src/server/sdk.ts | 16 +- packages/effect/test/errorReporter-v3.test.ts | 40 ++++ packages/effect/test/errorReporter.test.ts | 219 ++++++++++++++++++ 10 files changed, 550 insertions(+), 15 deletions(-) create mode 100644 dev-packages/e2e-tests/test-applications/effect-4-node/tests/error-reporter.test.ts create mode 100644 packages/effect/src/errorReporter.ts create mode 100644 packages/effect/test/errorReporter-v3.test.ts create mode 100644 packages/effect/test/errorReporter.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 43662f8399e5..f1895f1421cb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -34,6 +34,12 @@ Work in this release was contributed by @psh4607, @thijsw, @trinitiwowka, @nehap ### Important Changes +- **feat(effect): Capture errors through the Effect v4 `ErrorReporter` API** + + On Effect v4, `Sentry.effectLayer` now registers a Sentry `ErrorReporter`. Failures that pass through `Effect.withErrorReporting`, `ErrorReporter.report` or the built-in HTTP and RPC reporting boundaries are captured automatically, with `ErrorReporter.ignore`, `ErrorReporter.severity` and `ErrorReporter.attributes` annotations respected. Nothing changes on Effect v3. + + The server SDK now enables the `contextLines` and `linkedErrors` integrations by default, so captured errors carry source context and their `cause` chain. No other Node default integration is enabled. + - **feat(sveltekit): Add support for SvelteKit 3 ([#22264](https://github.com/getsentry/sentry-javascript/pull/22264))** The SvelteKit SDK now supports the pre-release of SvelteKit 3, including client-side pageload and navigation tracing and server-side native tracing, alongside continued SvelteKit 2 support. No Sentry-specific setup changes are required. The SDK detects your SvelteKit version and picks the right implementation automatically. diff --git a/dev-packages/e2e-tests/test-applications/effect-4-node/src/app.ts b/dev-packages/e2e-tests/test-applications/effect-4-node/src/app.ts index 7ccc484ea40f..48d9c254fc6e 100644 --- a/dev-packages/e2e-tests/test-applications/effect-4-node/src/app.ts +++ b/dev-packages/e2e-tests/test-applications/effect-4-node/src/app.ts @@ -2,6 +2,8 @@ import * as Sentry from '@sentry/effect'; import { NodeHttpServer, NodeRuntime } from '@effect/platform-node'; import * as Effect from 'effect/Effect'; import * as Cause from 'effect/Cause'; +import * as Data from 'effect/Data'; +import * as ErrorReporter from 'effect/ErrorReporter'; import * as Layer from 'effect/Layer'; import * as Logger from 'effect/Logger'; import * as Tracer from 'effect/Tracer'; @@ -23,9 +25,62 @@ const SentryLive = Layer.mergeAll( Layer.succeed(References.MinimumLogLevel, 'Debug'), ); +class NotFoundError extends Data.TaggedError('NotFoundError')<{ readonly id: string }> { + readonly [ErrorReporter.ignore] = true; +} + +class RateLimitError extends Data.TaggedError('RateLimitError')<{ readonly retryAfter: number }> { + readonly [ErrorReporter.severity] = 'Warn' as const; + readonly [ErrorReporter.attributes] = { retryAfter: this.retryAfter }; +} + +function loadUser(id: string): Effect.Effect { + return Effect.fail(new Error(`User ${id} could not be loaded`)); +} + const Routes = Layer.mergeAll( HttpRouter.add('GET', '/test-success', HttpServerResponse.json({ version: 'v1' })), + HttpRouter.add( + 'GET', + '/test-error-reporter/unhandled/:id', + Effect.gen(function* () { + const params = yield* HttpRouter.params; + yield* loadUser(params.id ?? 'unknown'); + return HttpServerResponse.empty(); + }), + ), + + HttpRouter.add( + 'GET', + '/test-error-reporter/handled', + Effect.gen(function* () { + yield* Effect.fail(new Error('Handled after reporting')); + return HttpServerResponse.empty(); + }).pipe( + Effect.withErrorReporting, + Effect.catch(() => HttpServerResponse.json({ recovered: true })), + ), + ), + + HttpRouter.add( + 'GET', + '/test-error-reporter/ignored', + Effect.gen(function* () { + yield* Effect.fail(new NotFoundError({ id: 'missing' })); + return HttpServerResponse.empty(); + }), + ), + + HttpRouter.add( + 'GET', + '/test-error-reporter/annotated', + Effect.gen(function* () { + yield* Effect.fail(new RateLimitError({ retryAfter: 60 })); + return HttpServerResponse.empty(); + }), + ), + HttpRouter.add( 'GET', '/test-transaction', diff --git a/dev-packages/e2e-tests/test-applications/effect-4-node/tests/error-reporter.test.ts b/dev-packages/e2e-tests/test-applications/effect-4-node/tests/error-reporter.test.ts new file mode 100644 index 000000000000..859cb7b4c3ff --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/effect-4-node/tests/error-reporter.test.ts @@ -0,0 +1,80 @@ +import { expect, test } from '@playwright/test'; +import { waitForError } from '@sentry-internal/test-utils'; + +test('Captures an unhandled route failure through the HTTP reporting boundary', async ({ baseURL }) => { + const errorEventPromise = waitForError('effect-4-node', event => { + return !event.type && event.exception?.values?.[0]?.value === 'User 42 could not be loaded'; + }); + + const response = await fetch(`${baseURL}/test-error-reporter/unhandled/42`); + expect(response.status).toBe(500); + + const errorEvent = await errorEventPromise; + const exception = errorEvent.exception?.values?.[0]; + + expect(errorEvent.level).toBe('error'); + expect(exception).toMatchObject({ + type: 'Error', + value: 'User 42 could not be loaded', + mechanism: { type: 'auto.function.effect.error_reporter', handled: false }, + }); + + const frames = exception?.stacktrace?.frames ?? []; + const throwingFrame = frames[frames.length - 1]; + expect(throwingFrame).toMatchObject({ + function: 'loadUser', + filename: expect.stringMatching(/app\.js$/), + context_line: expect.stringContaining('could not be loaded'), + pre_context: expect.any(Array), + post_context: expect.any(Array), + }); +}); + +test('Captures a failure reported with withErrorReporting before it is handled', async ({ baseURL }) => { + const errorEventPromise = waitForError('effect-4-node', event => { + return !event.type && event.exception?.values?.[0]?.value === 'Handled after reporting'; + }); + + const response = await fetch(`${baseURL}/test-error-reporter/handled`); + const body = await response.json(); + + const errorEvent = await errorEventPromise; + + expect(response.status).toBe(200); + expect(body).toEqual({ recovered: true }); + expect(errorEvent.exception?.values?.[0]?.value).toBe('Handled after reporting'); +}); + +test('Skips errors annotated with ErrorReporter.ignore', async ({ baseURL }) => { + const ignoredEventPromise = waitForError('effect-4-node', event => { + return !event.type && event.exception?.values?.[0]?.type === 'NotFoundError'; + }).then(() => 'ignored error received'); + + const sentinelEventPromise = waitForError('effect-4-node', event => { + return !event.type && event.exception?.values?.[0]?.value === 'User sentinel could not be loaded'; + }); + + const ignoredResponse = await fetch(`${baseURL}/test-error-reporter/ignored`); + expect(ignoredResponse.status).toBe(500); + + await fetch(`${baseURL}/test-error-reporter/unhandled/sentinel`); + await sentinelEventPromise; + + // Events arrive in order, so once the sentinel is here an ignored event would already have resolved. + await expect(Promise.race([ignoredEventPromise, Promise.resolve('no ignored error')])).resolves.toBe( + 'no ignored error', + ); +}); + +test('Applies the severity and attributes annotations', async ({ baseURL }) => { + const errorEventPromise = waitForError('effect-4-node', event => { + return !event.type && event.exception?.values?.[0]?.type === 'RateLimitError'; + }); + + await fetch(`${baseURL}/test-error-reporter/annotated`); + + const errorEvent = await errorEventPromise; + + expect(errorEvent.level).toBe('warning'); + expect(errorEvent.extra).toEqual({ retryAfter: 60 }); +}); diff --git a/packages/effect/README.md b/packages/effect/README.md index aa733a4e528e..1a2f3abd2f0c 100644 --- a/packages/effect/README.md +++ b/packages/effect/README.md @@ -72,6 +72,34 @@ const HttpLive = HttpRouter.serve(Routes).pipe( NodeRuntime.runMain(Layer.launch(HttpLive)); ``` +### Error reporting + +On Effect v4, `Sentry.effectLayer` registers a Sentry `ErrorReporter`. Failures +that pass through `Effect.withErrorReporting`, `ErrorReporter.report` or the +built-in HTTP and RPC reporting boundaries are captured automatically. The +`ErrorReporter.ignore`, `ErrorReporter.severity` and `ErrorReporter.attributes` +annotations are respected: ignored errors are skipped, the severity becomes the +event level and the attributes are attached as extra data. + +```typescript +import { Data, Effect, ErrorReporter } from 'effect'; + +class RateLimitError extends Data.TaggedError('RateLimitError')<{ readonly retryAfter: number }> { + readonly [ErrorReporter.severity] = 'Warn' as const; + readonly [ErrorReporter.attributes] = { retryAfter: this.retryAfter }; +} + +const program = Effect.fail(new RateLimitError({ retryAfter: 60 })).pipe(Effect.withErrorReporting); +``` + +Reporters registered with `ErrorReporter.layer` replace the current set, so add +your own reporters with `mergeWithExisting: true` or provide them below the +Sentry layer to keep the Sentry reporter: + +```typescript +const SentryLive = Sentry.effectLayer({ dsn: '__DSN__' }).pipe(Layer.provide(ErrorReporter.layer([consoleReporter]))); +``` + ## Links - [Official SDK Docs](https://docs.sentry.io/platforms/javascript/guides/effect/) diff --git a/packages/effect/src/client/index.ts b/packages/effect/src/client/index.ts index e60843bc1e3e..f71634cb0b54 100644 --- a/packages/effect/src/client/index.ts +++ b/packages/effect/src/client/index.ts @@ -1,6 +1,7 @@ import type { BrowserOptions } from '@sentry/browser'; import type * as EffectLayer from 'effect/Layer'; -import { empty as emptyLayer, suspend as suspendLayer } from 'effect/Layer'; +import { empty as emptyLayer, merge as mergeLayer, suspend as suspendLayer } from 'effect/Layer'; +import { makeSentryErrorReporterLayer } from '../errorReporter'; import { init } from './sdk'; export { init } from './sdk'; @@ -13,6 +14,9 @@ export type EffectClientLayerOptions = BrowserOptions; /** * Creates an Effect Layer that initializes Sentry for browser clients. * + * On Effect v4 the layer also registers a Sentry `ErrorReporter`, so failures passing through + * `Effect.withErrorReporting`, `ErrorReporter.report` or the built-in HTTP and RPC boundaries are captured. + * * To enable Effect tracing, logs, or metrics, compose with the respective layers: * - `Layer.setTracer(Sentry.SentryEffectTracer)` for tracing * - `Logger.replace(Logger.defaultLogger, Sentry.SentryEffectLogger)` for logs @@ -35,9 +39,12 @@ export type EffectClientLayerOptions = BrowserOptions; * ``` */ export function effectLayer(options: EffectClientLayerOptions): EffectLayer.Layer { - return suspendLayer(() => { - init(options); + return mergeLayer( + suspendLayer(() => { + init(options); - return emptyLayer; - }); + return emptyLayer; + }), + makeSentryErrorReporterLayer(), + ); } diff --git a/packages/effect/src/errorReporter.ts b/packages/effect/src/errorReporter.ts new file mode 100644 index 000000000000..d0c3d09b7040 --- /dev/null +++ b/packages/effect/src/errorReporter.ts @@ -0,0 +1,87 @@ +import type { SeverityLevel } from '@sentry/core'; +import { captureException, isObjectLike } from '@sentry/core'; +import * as Effect from 'effect'; +import type * as Cause from 'effect/Cause'; +import type * as EffectErrorReporter from 'effect/ErrorReporter'; +import type * as EffectLayer from 'effect/Layer'; +import { empty as emptyLayer } from 'effect/Layer'; +import type * as LogLevel from 'effect/LogLevel'; + +// `effect/ErrorReporter` only exists in Effect v4, so it is read off the main entry instead of being imported +// as a subpath. On Effect v3 the lookup yields `undefined` and no reporter is registered. +const ErrorReporter = (Effect as Partial).ErrorReporter; + +const SEVERITY_TO_LEVEL: Record = { + Fatal: 'fatal', + Error: 'error', + Warn: 'warning', + Info: 'info', + Debug: 'debug', + Trace: 'debug', +}; + +function getLevel(errorReporter: typeof EffectErrorReporter, error: unknown): SeverityLevel { + // Effect's `getSeverity` falls back to `Info` for unannotated errors, which would file plain failures as + // informational in Sentry. Only an explicit annotation changes the level. + if (isObjectLike(error) && errorReporter.severity in error) { + return SEVERITY_TO_LEVEL[errorReporter.getSeverity(error)]; + } + return 'error'; +} + +function makeSentryErrorReporter(errorReporter: typeof EffectErrorReporter): EffectErrorReporter.ErrorReporter { + const reported = new WeakSet(); + + return { + [errorReporter.TypeId]: errorReporter.TypeId, + report({ cause }: { readonly cause: Cause.Cause }): void { + if (reported.has(cause)) { + return; + } + reported.add(cause); + + for (const reason of cause.reasons) { + if (reason._tag === 'Interrupt') { + continue; + } + + // The raw error is captured rather than Effect's pretty-printed copy so Sentry sees the original stack, + // `cause` chain and error class. + const error = reason._tag === 'Fail' ? reason.error : reason.defect; + + if (isObjectLike(error)) { + if (reported.has(error)) { + continue; + } + reported.add(error); + } + + if (errorReporter.isIgnored(error)) { + continue; + } + + captureException(error, { + mechanism: { type: 'auto.function.effect.error_reporter', handled: false }, + captureContext: { + level: getLevel(errorReporter, error), + extra: isObjectLike(error) ? { ...errorReporter.getAttributes(error) } : undefined, + }, + }); + } + }, + }; +} + +/** + * Registers a Sentry `ErrorReporter` for `Effect.withErrorReporting`, `ErrorReporter.report` and the + * built-in HTTP and RPC reporting boundaries. Existing reporters are kept. + * + * Effect v3 has no `ErrorReporter` API, so the returned layer is empty there. + */ +export function makeSentryErrorReporterLayer(): EffectLayer.Layer { + if (!ErrorReporter) { + return emptyLayer; + } + + return ErrorReporter.layer([makeSentryErrorReporter(ErrorReporter)], { mergeWithExisting: true }); +} diff --git a/packages/effect/src/server/index.ts b/packages/effect/src/server/index.ts index b0a64b9f5f9d..06a670b3f2b6 100644 --- a/packages/effect/src/server/index.ts +++ b/packages/effect/src/server/index.ts @@ -1,6 +1,7 @@ import type { NodeOptions } from '@sentry/node'; import type * as EffectLayer from 'effect/Layer'; -import { empty as emptyLayer, suspend as suspendLayer } from 'effect/Layer'; +import { empty as emptyLayer, merge as mergeLayer, suspend as suspendLayer } from 'effect/Layer'; +import { makeSentryErrorReporterLayer } from '../errorReporter'; import { init } from './sdk'; export { init } from './sdk'; @@ -13,6 +14,9 @@ export type EffectServerLayerOptions = NodeOptions; /** * Creates an Effect Layer that initializes Sentry for Node.js servers. * + * On Effect v4 the layer also registers a Sentry `ErrorReporter`, so failures passing through + * `Effect.withErrorReporting`, `ErrorReporter.report` or the built-in HTTP and RPC boundaries are captured. + * * To enable Effect tracing, logs, or metrics, compose with the respective layers: * - `Layer.setTracer(Sentry.SentryEffectTracer)` for tracing * - `Logger.replace(Logger.defaultLogger, Sentry.SentryEffectLogger)` for logs @@ -36,8 +40,11 @@ export type EffectServerLayerOptions = NodeOptions; * ``` */ export function effectLayer(options: EffectServerLayerOptions): EffectLayer.Layer { - return suspendLayer(() => { - init(options); - return emptyLayer; - }); + return mergeLayer( + suspendLayer(() => { + init(options); + return emptyLayer; + }), + makeSentryErrorReporterLayer(), + ); } diff --git a/packages/effect/src/server/sdk.ts b/packages/effect/src/server/sdk.ts index a344ddc08203..447ae538daa0 100644 --- a/packages/effect/src/server/sdk.ts +++ b/packages/effect/src/server/sdk.ts @@ -1,7 +1,15 @@ -import type { Client } from '@sentry/core'; +import type { Client, Integration } from '@sentry/core'; import { applySdkMetadata } from '@sentry/core'; import type { NodeOptions } from '@sentry/node'; -import { init as initNode } from '@sentry/node'; +import { contextLinesIntegration, init as initNode, linkedErrorsIntegration } from '@sentry/node'; + +/** + * Only the integrations needed to enrich captured errors with source context and `cause` chains. + * Node's auto-instrumentation defaults are left out because `SentryEffectTracer` records Effect's own spans. + */ +export function getDefaultIntegrations(): Integration[] { + return [contextLinesIntegration(), linkedErrorsIntegration()]; +} /** * Initializes the Sentry Effect SDK for Node.js servers. @@ -12,9 +20,7 @@ import { init as initNode } from '@sentry/node'; export function init(options: NodeOptions): Client | undefined { const opts = { ...options, - // The Effect SDK provides its own tracing (`SentryEffectTracer`), logging and error capture, so - // node's auto-instrumentation default integrations should not additionally create spans. - defaultIntegrations: options.defaultIntegrations ?? false, + defaultIntegrations: options.defaultIntegrations ?? getDefaultIntegrations(), }; applySdkMetadata(opts, 'effect', ['effect', 'node']); diff --git a/packages/effect/test/errorReporter-v3.test.ts b/packages/effect/test/errorReporter-v3.test.ts new file mode 100644 index 000000000000..03e18ce7e445 --- /dev/null +++ b/packages/effect/test/errorReporter-v3.test.ts @@ -0,0 +1,40 @@ +import { getMainCarrier } from '@sentry/core'; +import type * as EffectModule from 'effect'; +import { Effect, Layer } from 'effect'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { makeSentryErrorReporterLayer } from '../src/errorReporter'; +import * as sentryServer from '../src/index.server'; + +// Effect v3 ships no `ErrorReporter` module, so its main entry has no such namespace. +vi.mock('effect', async importOriginal => { + const actual = await importOriginal(); + return { ...actual, ErrorReporter: undefined }; +}); + +describe('Sentry ErrorReporter without the Effect v4 ErrorReporter API', () => { + beforeEach(() => { + getMainCarrier().__SENTRY__ = undefined; + }); + + it('returns an empty layer', () => { + expect(makeSentryErrorReporterLayer()).toBe(Layer.empty); + }); + + it('still builds the effectLayer', async () => { + const result = await Effect.runPromise( + Effect.succeed('ok').pipe( + Effect.provide( + sentryServer.effectLayer({ + dsn: 'https://username@domain/123', + transport: () => ({ + send: vi.fn().mockResolvedValue({}), + flush: vi.fn().mockResolvedValue(true), + }), + }), + ), + ), + ); + + expect(result).toBe('ok'); + }); +}); diff --git a/packages/effect/test/errorReporter.test.ts b/packages/effect/test/errorReporter.test.ts new file mode 100644 index 000000000000..ca5c6b5e97e3 --- /dev/null +++ b/packages/effect/test/errorReporter.test.ts @@ -0,0 +1,219 @@ +import { describe, expect, it } from '@effect/vitest'; +import type { Event } from '@sentry/core'; +import { getClient, getMainCarrier } from '@sentry/core'; +import { Cause, Data, Effect, ErrorReporter, Layer } from 'effect'; +import { afterEach, beforeEach, vi } from 'vitest'; +import * as sentryClient from '../src/index.client'; +import * as sentryServer from '../src/index.server'; + +const TEST_DSN = 'https://username@domain/123'; + +class IgnoredError extends Data.TaggedError('IgnoredError')<{ readonly reason: string }> { + readonly [ErrorReporter.ignore] = true; +} + +class RateLimitError extends Data.TaggedError('RateLimitError')<{ readonly retryAfter: number }> { + readonly [ErrorReporter.severity] = 'Warn' as const; + readonly [ErrorReporter.attributes] = { retryAfter: this.retryAfter }; +} + +class FatalError extends Data.TaggedError('FatalError')<{ readonly message: string }> { + readonly [ErrorReporter.severity] = 'Fatal' as const; +} + +describe.each([ + [{ subSdkName: 'browser', effectLayer: sentryClient.effectLayer }], + [{ subSdkName: 'node', effectLayer: sentryServer.effectLayer }], +])('Sentry ErrorReporter ($subSdkName)', ({ effectLayer }) => { + let events: Event[]; + + function makeLayer(): Layer.Layer { + return effectLayer({ + dsn: TEST_DSN, + transport: () => ({ + send: vi.fn().mockResolvedValue({}), + flush: vi.fn().mockResolvedValue(true), + }), + beforeSend: event => { + events.push(event); + return event; + }, + }); + } + + const flush = Effect.promise(() => getClient()!.flush(1000)); + + beforeEach(() => { + events = []; + getMainCarrier().__SENTRY__ = undefined; + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + it.effect('captures Effect.fail errors reported through withErrorReporting', () => + Effect.gen(function* () { + yield* Effect.fail(new Error('Effect failure')).pipe(Effect.withErrorReporting, Effect.exit); + yield* flush; + + expect(events).toHaveLength(1); + expect(events[0]).toEqual( + expect.objectContaining({ + level: 'error', + exception: { + values: [ + expect.objectContaining({ + type: 'Error', + value: 'Effect failure', + mechanism: { type: 'auto.function.effect.error_reporter', handled: false }, + stacktrace: expect.objectContaining({ + frames: expect.arrayContaining([expect.objectContaining({ filename: expect.any(String) })]), + }), + }), + ], + }, + }), + ); + }).pipe(Effect.provide(makeLayer())), + ); + + it.effect('captures Effect.die defects', () => + Effect.gen(function* () { + yield* Effect.die(new TypeError('Effect defect')).pipe(Effect.withErrorReporting, Effect.exit); + yield* flush; + + expect(events).toHaveLength(1); + expect(events[0]?.exception?.values?.[0]).toEqual( + expect.objectContaining({ type: 'TypeError', value: 'Effect defect' }), + ); + }).pipe(Effect.provide(makeLayer())), + ); + + it.effect('captures non-error values', () => + Effect.gen(function* () { + yield* Effect.die('plain defect').pipe(Effect.withErrorReporting, Effect.exit); + yield* flush; + + expect(events).toHaveLength(1); + expect(events[0]?.exception?.values?.[0]?.value).toBe('plain defect'); + expect(events[0]?.level).toBe('error'); + }).pipe(Effect.provide(makeLayer())), + ); + + it.effect('captures causes reported with ErrorReporter.report', () => + Effect.gen(function* () { + yield* ErrorReporter.report(Cause.fail(new Error('Manually reported'))); + yield* flush; + + expect(events).toHaveLength(1); + expect(events[0]?.exception?.values?.[0]?.value).toBe('Manually reported'); + }).pipe(Effect.provide(makeLayer())), + ); + + it.effect('skips errors annotated with ErrorReporter.ignore', () => + Effect.gen(function* () { + yield* Effect.fail(new IgnoredError({ reason: 'expected' })).pipe(Effect.withErrorReporting, Effect.exit); + yield* Effect.fail(new Error('Sentinel')).pipe(Effect.withErrorReporting, Effect.exit); + yield* flush; + + expect(events).toHaveLength(1); + expect(events[0]?.exception?.values?.[0]?.value).toBe('Sentinel'); + }).pipe(Effect.provide(makeLayer())), + ); + + it.effect('skips interruptions', () => + Effect.gen(function* () { + yield* Effect.interrupt.pipe(Effect.withErrorReporting, Effect.exit); + yield* Effect.fail(new Error('Sentinel')).pipe(Effect.withErrorReporting, Effect.exit); + yield* flush; + + expect(events).toHaveLength(1); + expect(events[0]?.exception?.values?.[0]?.value).toBe('Sentinel'); + }).pipe(Effect.provide(makeLayer())), + ); + + it.effect('maps the severity annotation to the event level', () => + Effect.gen(function* () { + yield* Effect.fail(new RateLimitError({ retryAfter: 60 })).pipe(Effect.withErrorReporting, Effect.exit); + yield* Effect.fail(new FatalError({ message: 'disk gone' })).pipe(Effect.withErrorReporting, Effect.exit); + yield* flush; + + expect(events).toHaveLength(2); + expect(events[0]?.level).toBe('warning'); + expect(events[0]?.exception?.values?.[0]?.type).toBe('RateLimitError'); + expect(events[1]?.level).toBe('fatal'); + expect(events[1]?.exception?.values?.[0]?.type).toBe('FatalError'); + }).pipe(Effect.provide(makeLayer())), + ); + + it.effect('forwards the attributes annotation as extra data', () => + Effect.gen(function* () { + yield* Effect.fail(new RateLimitError({ retryAfter: 60 })).pipe(Effect.withErrorReporting, Effect.exit); + yield* flush; + + expect(events).toHaveLength(1); + expect(events[0]?.extra).toEqual({ retryAfter: 60 }); + }).pipe(Effect.provide(makeLayer())), + ); + + it.effect('reports the same error only once', () => + Effect.gen(function* () { + const error = new Error('Reported twice'); + yield* Effect.fail(error).pipe(Effect.withErrorReporting, Effect.withErrorReporting, Effect.exit); + yield* Effect.fail(error).pipe(Effect.withErrorReporting, Effect.exit); + yield* flush; + + expect(events).toHaveLength(1); + }).pipe(Effect.provide(makeLayer())), + ); + + it.effect('keeps reporters registered before the Sentry layer', () => + Effect.gen(function* () { + const userReporter = vi.fn(); + + yield* Effect.fail(new Error('Shared failure')).pipe( + Effect.withErrorReporting, + Effect.exit, + Effect.provide(makeLayer().pipe(Layer.provide(ErrorReporter.layer([ErrorReporter.make(userReporter)])))), + ); + yield* flush; + + expect(userReporter).toHaveBeenCalledWith( + expect.objectContaining({ error: expect.objectContaining({ message: 'Shared failure' }) }), + ); + expect(events).toHaveLength(1); + expect(events[0]?.exception?.values?.[0]?.value).toBe('Shared failure'); + }), + ); + + it.effect('attaches the error to the active span', () => + Effect.gen(function* () { + yield* Effect.fail(new Error('Failure in span')).pipe( + Effect.withErrorReporting, + Effect.exit, + Effect.withSpan('failing-operation'), + ); + yield* flush; + + expect(events).toHaveLength(1); + expect(events[0]?.contexts?.trace?.trace_id).toEqual(expect.any(String)); + }).pipe( + Effect.withTracer(sentryServer.SentryEffectTracer), + Effect.provide( + effectLayer({ + dsn: TEST_DSN, + tracesSampleRate: 1, + transport: () => ({ + send: vi.fn().mockResolvedValue({}), + flush: vi.fn().mockResolvedValue(true), + }), + beforeSend: event => { + events.push(event); + return event; + }, + }), + ), + ), + ); +}); From 1da906c66edcf83a794215c7a81c89f0211ea575 Mon Sep 17 00:00:00 2001 From: JPeer264 Date: Mon, 7 Sep 2026 15:34:32 +0200 Subject: [PATCH 2/2] fixup! feat(effect): Capture errors through the Effect v4 ErrorReporter API --- packages/effect/src/errorReporter.ts | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/packages/effect/src/errorReporter.ts b/packages/effect/src/errorReporter.ts index d0c3d09b7040..8accd462557c 100644 --- a/packages/effect/src/errorReporter.ts +++ b/packages/effect/src/errorReporter.ts @@ -8,8 +8,10 @@ import { empty as emptyLayer } from 'effect/Layer'; import type * as LogLevel from 'effect/LogLevel'; // `effect/ErrorReporter` only exists in Effect v4, so it is read off the main entry instead of being imported -// as a subpath. On Effect v3 the lookup yields `undefined` and no reporter is registered. -const ErrorReporter = (Effect as Partial).ErrorReporter; +// as a subpath. On Effect v3 the lookup yields `undefined` and no reporter is registered. The property is read +// through a separate binding because bundlers fail the build on `Effect.ErrorReporter` when the export is absent. +const effectExports = Effect as Record; +const ErrorReporter = effectExports.ErrorReporter as typeof EffectErrorReporter | undefined; const SEVERITY_TO_LEVEL: Record = { Fatal: 'fatal',