Skip to content

Commit 2a84a99

Browse files
committed
feat(effect): Support v4 beta (#20394)
This adds support to Effect v4, but also keeps the compatibility for v3. There is no way that we can unit test against v3, as the `devDependencies` need to use `effect@4` and an updated `@effect/vitest` version, which is not compatible with Effect v3 (this is added in The API for Effect v4 has changed a little, so there are safeguards to detect if it is v3 or v4 and uses the correct API. The good part is that for users nothing changed, so they still can use the same methods in their app as before (ofc, respecting the new Effect v4 API). Before (Effect v3): ```ts const SentryLive = Layer.mergeAll( Sentry.effectLayer({ dsn: '__DSN__', tracesSampleRate: 1.0, enableLogs: true, }), Layer.setTracer(Sentry.SentryEffectTracer), Logger.replace(Logger.defaultLogger, Sentry.SentryEffectLogger), Sentry.SentryEffectMetricsLayer, ); ``` After (Effect v4): ```js const SentryLive = Layer.mergeAll( Sentry.effectLayer({ dsn: '__DSN__', tracesSampleRate: 1.0, enableLogs: true, }), Layer.succeed(Tracer.Tracer, Sentry.SentryEffectTracer), Logger.layer([Sentry.SentryEffectLogger]), Sentry.SentryEffectMetricsLayer, ); ``` Both usages still work and are represented in the E2E tests.
1 parent 79be1ef commit 2a84a99

32 files changed

+1586
-252
lines changed
Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
# dependencies
2+
/node_modules
3+
/.pnp
4+
.pnp.js
5+
6+
# testing
7+
/coverage
8+
9+
# production
10+
/build
11+
/dist
12+
13+
# misc
14+
.DS_Store
15+
.env.local
16+
.env.development.local
17+
.env.test.local
18+
.env.production.local
19+
20+
npm-debug.log*
21+
yarn-debug.log*
22+
yarn-error.log*
23+
24+
/test-results/
25+
/playwright-report/
26+
/playwright/.cache/
27+
28+
!*.d.ts
Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
@sentry:registry=http://127.0.0.1:4873
2+
@sentry-internal:registry=http://127.0.0.1:4873
Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,52 @@
1+
import * as path from 'path';
2+
import * as url from 'url';
3+
import HtmlWebpackPlugin from 'html-webpack-plugin';
4+
import TerserPlugin from 'terser-webpack-plugin';
5+
import webpack from 'webpack';
6+
7+
const __dirname = path.dirname(url.fileURLToPath(import.meta.url));
8+
9+
webpack(
10+
{
11+
entry: path.join(__dirname, 'src/index.js'),
12+
output: {
13+
path: path.join(__dirname, 'build'),
14+
filename: 'app.js',
15+
},
16+
optimization: {
17+
minimize: true,
18+
minimizer: [new TerserPlugin()],
19+
},
20+
plugins: [
21+
new webpack.EnvironmentPlugin(['E2E_TEST_DSN']),
22+
new HtmlWebpackPlugin({
23+
template: path.join(__dirname, 'public/index.html'),
24+
}),
25+
],
26+
performance: {
27+
hints: false,
28+
},
29+
mode: 'production',
30+
},
31+
(err, stats) => {
32+
if (err) {
33+
console.error(err.stack || err);
34+
if (err.details) {
35+
console.error(err.details);
36+
}
37+
return;
38+
}
39+
40+
const info = stats.toJson();
41+
42+
if (stats.hasErrors()) {
43+
console.error(info.errors);
44+
process.exit(1);
45+
}
46+
47+
if (stats.hasWarnings()) {
48+
console.warn(info.warnings);
49+
process.exit(1);
50+
}
51+
},
52+
);
Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
{
2+
"name": "effect-4-browser-test-app",
3+
"version": "1.0.0",
4+
"private": true,
5+
"scripts": {
6+
"start": "serve -s build",
7+
"build": "node build.mjs",
8+
"test": "playwright test",
9+
"clean": "npx rimraf node_modules pnpm-lock.yaml",
10+
"test:build": "pnpm install && pnpm build",
11+
"test:assert": "pnpm test"
12+
},
13+
"dependencies": {
14+
"@sentry/effect": "latest || *",
15+
"@types/node": "^18.19.1",
16+
"effect": "^4.0.0-beta.50",
17+
"typescript": "~5.0.0"
18+
},
19+
"devDependencies": {
20+
"@playwright/test": "~1.56.0",
21+
"@sentry-internal/test-utils": "link:../../../test-utils",
22+
"webpack": "^5.91.0",
23+
"serve": "14.0.1",
24+
"terser-webpack-plugin": "^5.3.10",
25+
"html-webpack-plugin": "^5.6.0"
26+
},
27+
"browserslist": {
28+
"production": [
29+
">0.2%",
30+
"not dead",
31+
"not op_mini all"
32+
],
33+
"development": [
34+
"last 1 chrome version",
35+
"last 1 firefox version",
36+
"last 1 safari version"
37+
]
38+
},
39+
"volta": {
40+
"node": "22.15.0",
41+
"extends": "../../package.json"
42+
}
43+
}
Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
import { getPlaywrightConfig } from '@sentry-internal/test-utils';
2+
3+
const config = getPlaywrightConfig({
4+
startCommand: `pnpm start`,
5+
});
6+
7+
export default config;
Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
1+
<!doctype html>
2+
<html lang="en">
3+
<head>
4+
<meta charset="UTF-8" />
5+
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
6+
<title>Effect Browser App</title>
7+
</head>
8+
<body>
9+
<h1>Effect Browser E2E Test</h1>
10+
11+
<div id="app">
12+
<section>
13+
<h2>Error Tests</h2>
14+
<input type="button" value="Capture Exception" id="exception-button" />
15+
</section>
16+
17+
<section>
18+
<h2>Effect Span Tests</h2>
19+
<input type="button" value="Create Effect Span" id="effect-span-button" />
20+
<span id="effect-span-result"></span>
21+
</section>
22+
23+
<section>
24+
<h2>Effect Failure Tests</h2>
25+
<input type="button" value="Effect.fail()" id="effect-fail-button" />
26+
<span id="effect-fail-result"></span>
27+
<br />
28+
<input type="button" value="Effect.die()" id="effect-die-button" />
29+
<span id="effect-die-result"></span>
30+
</section>
31+
32+
<section>
33+
<h2>Log Tests</h2>
34+
<input type="button" value="Send Logs" id="log-button" />
35+
<span id="log-result"></span>
36+
<br />
37+
<input type="button" value="Send Log with Context" id="log-context-button" />
38+
<span id="log-context-result"></span>
39+
</section>
40+
41+
<section id="navigation">
42+
<h2>Navigation Test</h2>
43+
<a id="navigation-link" href="#navigation-target">Navigation Link</a>
44+
<div id="navigation-target" style="margin-top: 50px">Navigated Element</div>
45+
</section>
46+
</div>
47+
</body>
48+
</html>
Lines changed: 96 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,96 @@
1+
// @ts-check
2+
import * as Sentry from '@sentry/effect';
3+
import * as Logger from 'effect/Logger';
4+
import * as Layer from 'effect/Layer';
5+
import * as ManagedRuntime from 'effect/ManagedRuntime';
6+
import * as Tracer from 'effect/Tracer';
7+
import * as References from 'effect/References';
8+
import * as Effect from 'effect/Effect';
9+
10+
const AppLayer = Layer.mergeAll(
11+
Sentry.effectLayer({
12+
dsn: process.env.E2E_TEST_DSN,
13+
integrations: [
14+
Sentry.browserTracingIntegration({
15+
_experiments: { enableInteractions: true },
16+
}),
17+
],
18+
tracesSampleRate: 1.0,
19+
release: 'e2e-test',
20+
environment: 'qa',
21+
tunnel: 'http://localhost:3031',
22+
enableLogs: true,
23+
}),
24+
Logger.layer([Sentry.SentryEffectLogger]),
25+
Layer.succeed(Tracer.Tracer, Sentry.SentryEffectTracer),
26+
Layer.succeed(References.MinimumLogLevel, 'Debug'),
27+
);
28+
29+
// v4 pattern: ManagedRuntime creates a long-lived runtime from the layer
30+
const runtime = ManagedRuntime.make(AppLayer);
31+
32+
// Force layer to build immediately (synchronously) so Sentry initializes at page load
33+
Effect.runSync(runtime.contextEffect);
34+
35+
const runEffect = fn => runtime.runPromise(fn());
36+
37+
document.getElementById('exception-button')?.addEventListener('click', () => {
38+
throw new Error('I am an error!');
39+
});
40+
41+
document.getElementById('effect-span-button')?.addEventListener('click', async () => {
42+
await runEffect(() =>
43+
Effect.gen(function* () {
44+
yield* Effect.sleep('50 millis');
45+
yield* Effect.sleep('25 millis').pipe(Effect.withSpan('nested-span'));
46+
}).pipe(Effect.withSpan('custom-effect-span', { kind: 'internal' })),
47+
);
48+
const el = document.getElementById('effect-span-result');
49+
if (el) el.textContent = 'Span sent!';
50+
});
51+
52+
document.getElementById('effect-fail-button')?.addEventListener('click', async () => {
53+
try {
54+
await runEffect(() => Effect.fail(new Error('Effect failure')));
55+
} catch {
56+
const el = document.getElementById('effect-fail-result');
57+
if (el) el.textContent = 'Effect failed (expected)';
58+
}
59+
});
60+
61+
document.getElementById('effect-die-button')?.addEventListener('click', async () => {
62+
try {
63+
await runEffect(() => Effect.die('Effect defect'));
64+
} catch {
65+
const el = document.getElementById('effect-die-result');
66+
if (el) el.textContent = 'Effect died (expected)';
67+
}
68+
});
69+
70+
document.getElementById('log-button')?.addEventListener('click', async () => {
71+
await runEffect(() =>
72+
Effect.gen(function* () {
73+
yield* Effect.logDebug('Debug log from Effect');
74+
yield* Effect.logInfo('Info log from Effect');
75+
yield* Effect.logWarning('Warning log from Effect');
76+
yield* Effect.logError('Error log from Effect');
77+
}),
78+
);
79+
const el = document.getElementById('log-result');
80+
if (el) el.textContent = 'Logs sent!';
81+
});
82+
83+
document.getElementById('log-context-button')?.addEventListener('click', async () => {
84+
await runEffect(() =>
85+
Effect.logInfo('Log with context').pipe(
86+
Effect.annotateLogs('userId', '12345'),
87+
Effect.annotateLogs('action', 'test'),
88+
),
89+
);
90+
const el = document.getElementById('log-context-result');
91+
if (el) el.textContent = 'Log with context sent!';
92+
});
93+
94+
document.getElementById('navigation-link')?.addEventListener('click', () => {
95+
document.getElementById('navigation-target')?.scrollIntoView({ behavior: 'smooth' });
96+
});
Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
import { startEventProxyServer } from '@sentry-internal/test-utils';
2+
3+
startEventProxyServer({
4+
port: 3031,
5+
proxyServerName: 'effect-4-browser',
6+
});
Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,56 @@
1+
import { expect, test } from '@playwright/test';
2+
import { waitForError, waitForTransaction } from '@sentry-internal/test-utils';
3+
4+
test('captures an error', async ({ page }) => {
5+
const errorEventPromise = waitForError('effect-4-browser', event => {
6+
return !event.type && event.exception?.values?.[0]?.value === 'I am an error!';
7+
});
8+
9+
await page.goto('/');
10+
11+
const exceptionButton = page.locator('id=exception-button');
12+
await exceptionButton.click();
13+
14+
const errorEvent = await errorEventPromise;
15+
16+
expect(errorEvent.exception?.values).toHaveLength(1);
17+
expect(errorEvent.exception?.values?.[0]?.value).toBe('I am an error!');
18+
expect(errorEvent.transaction).toBe('/');
19+
20+
expect(errorEvent.request).toEqual({
21+
url: 'http://localhost:3030/',
22+
headers: expect.any(Object),
23+
});
24+
25+
expect(errorEvent.contexts?.trace).toEqual({
26+
trace_id: expect.stringMatching(/[a-f0-9]{32}/),
27+
span_id: expect.stringMatching(/[a-f0-9]{16}/),
28+
});
29+
});
30+
31+
test('sets correct transactionName', async ({ page }) => {
32+
const transactionPromise = waitForTransaction('effect-4-browser', async transactionEvent => {
33+
return !!transactionEvent?.transaction && transactionEvent.contexts?.trace?.op === 'pageload';
34+
});
35+
36+
const errorEventPromise = waitForError('effect-4-browser', event => {
37+
return !event.type && event.exception?.values?.[0]?.value === 'I am an error!';
38+
});
39+
40+
await page.goto('/');
41+
const transactionEvent = await transactionPromise;
42+
43+
const exceptionButton = page.locator('id=exception-button');
44+
await exceptionButton.click();
45+
46+
const errorEvent = await errorEventPromise;
47+
48+
expect(errorEvent.exception?.values).toHaveLength(1);
49+
expect(errorEvent.exception?.values?.[0]?.value).toBe('I am an error!');
50+
expect(errorEvent.transaction).toEqual('/');
51+
52+
expect(errorEvent.contexts?.trace).toEqual({
53+
trace_id: transactionEvent.contexts?.trace?.trace_id,
54+
span_id: expect.not.stringContaining(transactionEvent.contexts?.trace?.span_id || ''),
55+
});
56+
});

0 commit comments

Comments
 (0)