Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
55 changes: 55 additions & 0 deletions dev-packages/e2e-tests/test-applications/effect-4-node/src/app.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -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<never, Error> {
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',
Expand Down
Original file line number Diff line number Diff line change
@@ -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 });
});
28 changes: 28 additions & 0 deletions packages/effect/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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/)
Expand Down
17 changes: 12 additions & 5 deletions packages/effect/src/client/index.ts
Original file line number Diff line number Diff line change
@@ -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';
Expand All @@ -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
Expand All @@ -35,9 +39,12 @@ export type EffectClientLayerOptions = BrowserOptions;
* ```
*/
export function effectLayer(options: EffectClientLayerOptions): EffectLayer.Layer<never, never, never> {
return suspendLayer(() => {
init(options);
return mergeLayer(
suspendLayer(() => {
init(options);

return emptyLayer;
});
return emptyLayer;
}),
makeSentryErrorReporterLayer(),
);
}
89 changes: 89 additions & 0 deletions packages/effect/src/errorReporter.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,89 @@
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. 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<string, unknown>;
const ErrorReporter = effectExports.ErrorReporter as typeof EffectErrorReporter | undefined;

const SEVERITY_TO_LEVEL: Record<LogLevel.Severity, SeverityLevel> = {
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<object>();

return {
[errorReporter.TypeId]: errorReporter.TypeId,
report({ cause }: { readonly cause: Cause.Cause<unknown> }): 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<never, never, never> {
if (!ErrorReporter) {
return emptyLayer;
}

return ErrorReporter.layer([makeSentryErrorReporter(ErrorReporter)], { mergeWithExisting: true });
}
17 changes: 12 additions & 5 deletions packages/effect/src/server/index.ts
Original file line number Diff line number Diff line change
@@ -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';
Expand All @@ -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
Expand All @@ -36,8 +40,11 @@ export type EffectServerLayerOptions = NodeOptions;
* ```
*/
export function effectLayer(options: EffectServerLayerOptions): EffectLayer.Layer<never, never, never> {
return suspendLayer(() => {
init(options);
return emptyLayer;
});
return mergeLayer(
suspendLayer(() => {
init(options);
return emptyLayer;
}),
makeSentryErrorReporterLayer(),
);
Comment thread
JPeer264 marked this conversation as resolved.
}
16 changes: 11 additions & 5 deletions packages/effect/src/server/sdk.ts
Original file line number Diff line number Diff line change
@@ -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.
Expand All @@ -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']);
Expand Down
Loading
Loading