diff --git a/dev-packages/e2e-tests/test-applications/effect-browser/.gitignore b/dev-packages/e2e-tests/test-applications/effect-browser/.gitignore
new file mode 100644
index 000000000000..bd66327c3b4a
--- /dev/null
+++ b/dev-packages/e2e-tests/test-applications/effect-browser/.gitignore
@@ -0,0 +1,28 @@
+# dependencies
+/node_modules
+/.pnp
+.pnp.js
+
+# testing
+/coverage
+
+# production
+/build
+/dist
+
+# misc
+.DS_Store
+.env.local
+.env.development.local
+.env.test.local
+.env.production.local
+
+npm-debug.log*
+yarn-debug.log*
+yarn-error.log*
+
+/test-results/
+/playwright-report/
+/playwright/.cache/
+
+!*.d.ts
diff --git a/dev-packages/e2e-tests/test-applications/effect-browser/.npmrc b/dev-packages/e2e-tests/test-applications/effect-browser/.npmrc
new file mode 100644
index 000000000000..070f80f05092
--- /dev/null
+++ b/dev-packages/e2e-tests/test-applications/effect-browser/.npmrc
@@ -0,0 +1,2 @@
+@sentry:registry=http://127.0.0.1:4873
+@sentry-internal:registry=http://127.0.0.1:4873
diff --git a/dev-packages/e2e-tests/test-applications/effect-browser/build.mjs b/dev-packages/e2e-tests/test-applications/effect-browser/build.mjs
new file mode 100644
index 000000000000..63c63597d4fe
--- /dev/null
+++ b/dev-packages/e2e-tests/test-applications/effect-browser/build.mjs
@@ -0,0 +1,52 @@
+import * as path from 'path';
+import * as url from 'url';
+import HtmlWebpackPlugin from 'html-webpack-plugin';
+import TerserPlugin from 'terser-webpack-plugin';
+import webpack from 'webpack';
+
+const __dirname = path.dirname(url.fileURLToPath(import.meta.url));
+
+webpack(
+ {
+ entry: path.join(__dirname, 'src/index.js'),
+ output: {
+ path: path.join(__dirname, 'build'),
+ filename: 'app.js',
+ },
+ optimization: {
+ minimize: true,
+ minimizer: [new TerserPlugin()],
+ },
+ plugins: [
+ new webpack.EnvironmentPlugin(['E2E_TEST_DSN']),
+ new HtmlWebpackPlugin({
+ template: path.join(__dirname, 'public/index.html'),
+ }),
+ ],
+ performance: {
+ hints: false,
+ },
+ mode: 'production',
+ },
+ (err, stats) => {
+ if (err) {
+ console.error(err.stack || err);
+ if (err.details) {
+ console.error(err.details);
+ }
+ return;
+ }
+
+ const info = stats.toJson();
+
+ if (stats.hasErrors()) {
+ console.error(info.errors);
+ process.exit(1);
+ }
+
+ if (stats.hasWarnings()) {
+ console.warn(info.warnings);
+ process.exit(1);
+ }
+ },
+);
diff --git a/dev-packages/e2e-tests/test-applications/effect-browser/package.json b/dev-packages/e2e-tests/test-applications/effect-browser/package.json
new file mode 100644
index 000000000000..6c2e7e63ced8
--- /dev/null
+++ b/dev-packages/e2e-tests/test-applications/effect-browser/package.json
@@ -0,0 +1,42 @@
+{
+ "name": "effect-browser-test-app",
+ "version": "1.0.0",
+ "private": true,
+ "scripts": {
+ "start": "serve -s build",
+ "build": "node build.mjs",
+ "test": "playwright test",
+ "clean": "npx rimraf node_modules pnpm-lock.yaml",
+ "test:build": "pnpm install && pnpm build",
+ "test:assert": "pnpm test"
+ },
+ "dependencies": {
+ "@sentry/effect": "latest || *",
+ "@types/node": "^18.19.1",
+ "effect": "^3.19.19",
+ "typescript": "~5.0.0"
+ },
+ "devDependencies": {
+ "@playwright/test": "~1.56.0",
+ "@sentry-internal/test-utils": "link:../../../test-utils",
+ "webpack": "^5.91.0",
+ "serve": "14.0.1",
+ "terser-webpack-plugin": "^5.3.10",
+ "html-webpack-plugin": "^5.6.0"
+ },
+ "browserslist": {
+ "production": [
+ ">0.2%",
+ "not dead",
+ "not op_mini all"
+ ],
+ "development": [
+ "last 1 chrome version",
+ "last 1 firefox version",
+ "last 1 safari version"
+ ]
+ },
+ "volta": {
+ "extends": "../../package.json"
+ }
+}
diff --git a/dev-packages/e2e-tests/test-applications/effect-browser/playwright.config.mjs b/dev-packages/e2e-tests/test-applications/effect-browser/playwright.config.mjs
new file mode 100644
index 000000000000..31f2b913b58b
--- /dev/null
+++ b/dev-packages/e2e-tests/test-applications/effect-browser/playwright.config.mjs
@@ -0,0 +1,7 @@
+import { getPlaywrightConfig } from '@sentry-internal/test-utils';
+
+const config = getPlaywrightConfig({
+ startCommand: `pnpm start`,
+});
+
+export default config;
diff --git a/dev-packages/e2e-tests/test-applications/effect-browser/public/index.html b/dev-packages/e2e-tests/test-applications/effect-browser/public/index.html
new file mode 100644
index 000000000000..19d5c3d99a2f
--- /dev/null
+++ b/dev-packages/e2e-tests/test-applications/effect-browser/public/index.html
@@ -0,0 +1,48 @@
+
+
+
+
+
+ Effect Browser App
+
+
+ Effect Browser E2E Test
+
+
+
+
diff --git a/dev-packages/e2e-tests/test-applications/effect-browser/src/index.js b/dev-packages/e2e-tests/test-applications/effect-browser/src/index.js
new file mode 100644
index 000000000000..4e9cb70d6e44
--- /dev/null
+++ b/dev-packages/e2e-tests/test-applications/effect-browser/src/index.js
@@ -0,0 +1,92 @@
+// @ts-check
+import * as Sentry from '@sentry/effect';
+import * as Logger from 'effect/Logger';
+import * as Layer from 'effect/Layer';
+import * as Runtime from 'effect/Runtime';
+import * as LogLevel from 'effect/LogLevel';
+import * as Effect from 'effect/Effect';
+
+const LogLevelLive = Logger.minimumLogLevel(LogLevel.Debug);
+const AppLayer = Layer.mergeAll(
+ Sentry.effectLayer({
+ dsn: process.env.E2E_TEST_DSN,
+ integrations: [
+ Sentry.browserTracingIntegration({
+ _experiments: { enableInteractions: true },
+ }),
+ ],
+ tracesSampleRate: 1.0,
+ release: 'e2e-test',
+ environment: 'qa',
+ tunnel: 'http://localhost:3031',
+ enableLogs: true,
+ }),
+ Layer.setTracer(Sentry.SentryEffectTracer),
+ Logger.replace(Logger.defaultLogger, Sentry.SentryEffectLogger),
+ LogLevelLive,
+);
+
+const runtime = Layer.toRuntime(AppLayer).pipe(Effect.scoped, Effect.runSync);
+
+const runEffect = fn => Runtime.runPromise(runtime)(fn());
+
+document.getElementById('exception-button')?.addEventListener('click', () => {
+ throw new Error('I am an error!');
+});
+
+document.getElementById('effect-span-button')?.addEventListener('click', async () => {
+ await runEffect(() =>
+ Effect.gen(function* () {
+ yield* Effect.sleep('50 millis');
+ yield* Effect.sleep('25 millis').pipe(Effect.withSpan('nested-span'));
+ }).pipe(Effect.withSpan('custom-effect-span', { kind: 'internal' })),
+ );
+ const el = document.getElementById('effect-span-result');
+ if (el) el.textContent = 'Span sent!';
+});
+
+document.getElementById('effect-fail-button')?.addEventListener('click', async () => {
+ try {
+ await runEffect(() => Effect.fail(new Error('Effect failure')));
+ } catch {
+ const el = document.getElementById('effect-fail-result');
+ if (el) el.textContent = 'Effect failed (expected)';
+ }
+});
+
+document.getElementById('effect-die-button')?.addEventListener('click', async () => {
+ try {
+ await runEffect(() => Effect.die('Effect defect'));
+ } catch {
+ const el = document.getElementById('effect-die-result');
+ if (el) el.textContent = 'Effect died (expected)';
+ }
+});
+
+document.getElementById('log-button')?.addEventListener('click', async () => {
+ await runEffect(() =>
+ Effect.gen(function* () {
+ yield* Effect.logDebug('Debug log from Effect');
+ yield* Effect.logInfo('Info log from Effect');
+ yield* Effect.logWarning('Warning log from Effect');
+ yield* Effect.logError('Error log from Effect');
+ }),
+ );
+ const el = document.getElementById('log-result');
+ if (el) el.textContent = 'Logs sent!';
+});
+
+document.getElementById('log-context-button')?.addEventListener('click', async () => {
+ await runEffect(() =>
+ Effect.logInfo('Log with context').pipe(
+ Effect.annotateLogs('userId', '12345'),
+ Effect.annotateLogs('action', 'test'),
+ ),
+ );
+ const el = document.getElementById('log-context-result');
+ if (el) el.textContent = 'Log with context sent!';
+});
+
+document.getElementById('navigation-link')?.addEventListener('click', () => {
+ document.getElementById('navigation-target')?.scrollIntoView({ behavior: 'smooth' });
+});
diff --git a/dev-packages/e2e-tests/test-applications/effect-browser/start-event-proxy.mjs b/dev-packages/e2e-tests/test-applications/effect-browser/start-event-proxy.mjs
new file mode 100644
index 000000000000..a86a1bd91404
--- /dev/null
+++ b/dev-packages/e2e-tests/test-applications/effect-browser/start-event-proxy.mjs
@@ -0,0 +1,6 @@
+import { startEventProxyServer } from '@sentry-internal/test-utils';
+
+startEventProxyServer({
+ port: 3031,
+ proxyServerName: 'effect-browser',
+});
diff --git a/dev-packages/e2e-tests/test-applications/effect-browser/tests/errors.test.ts b/dev-packages/e2e-tests/test-applications/effect-browser/tests/errors.test.ts
new file mode 100644
index 000000000000..80589f683c28
--- /dev/null
+++ b/dev-packages/e2e-tests/test-applications/effect-browser/tests/errors.test.ts
@@ -0,0 +1,56 @@
+import { expect, test } from '@playwright/test';
+import { waitForError, waitForTransaction } from '@sentry-internal/test-utils';
+
+test('captures an error', async ({ page }) => {
+ const errorEventPromise = waitForError('effect-browser', event => {
+ return !event.type && event.exception?.values?.[0]?.value === 'I am an error!';
+ });
+
+ await page.goto('/');
+
+ const exceptionButton = page.locator('id=exception-button');
+ await exceptionButton.click();
+
+ const errorEvent = await errorEventPromise;
+
+ expect(errorEvent.exception?.values).toHaveLength(1);
+ expect(errorEvent.exception?.values?.[0]?.value).toBe('I am an error!');
+ expect(errorEvent.transaction).toBe('/');
+
+ expect(errorEvent.request).toEqual({
+ url: 'http://localhost:3030/',
+ headers: expect.any(Object),
+ });
+
+ expect(errorEvent.contexts?.trace).toEqual({
+ trace_id: expect.stringMatching(/[a-f0-9]{32}/),
+ span_id: expect.stringMatching(/[a-f0-9]{16}/),
+ });
+});
+
+test('sets correct transactionName', async ({ page }) => {
+ const transactionPromise = waitForTransaction('effect-browser', async transactionEvent => {
+ return !!transactionEvent?.transaction && transactionEvent.contexts?.trace?.op === 'pageload';
+ });
+
+ const errorEventPromise = waitForError('effect-browser', event => {
+ return !event.type && event.exception?.values?.[0]?.value === 'I am an error!';
+ });
+
+ await page.goto('/');
+ const transactionEvent = await transactionPromise;
+
+ const exceptionButton = page.locator('id=exception-button');
+ await exceptionButton.click();
+
+ const errorEvent = await errorEventPromise;
+
+ expect(errorEvent.exception?.values).toHaveLength(1);
+ expect(errorEvent.exception?.values?.[0]?.value).toBe('I am an error!');
+ expect(errorEvent.transaction).toEqual('/');
+
+ expect(errorEvent.contexts?.trace).toEqual({
+ trace_id: transactionEvent.contexts?.trace?.trace_id,
+ span_id: expect.not.stringContaining(transactionEvent.contexts?.trace?.span_id || ''),
+ });
+});
diff --git a/dev-packages/e2e-tests/test-applications/effect-browser/tests/logs.test.ts b/dev-packages/e2e-tests/test-applications/effect-browser/tests/logs.test.ts
new file mode 100644
index 000000000000..f81bc249cbd8
--- /dev/null
+++ b/dev-packages/e2e-tests/test-applications/effect-browser/tests/logs.test.ts
@@ -0,0 +1,116 @@
+import { expect, test } from '@playwright/test';
+import { waitForEnvelopeItem } from '@sentry-internal/test-utils';
+import type { SerializedLogContainer } from '@sentry/core';
+
+test('should send Effect debug logs', async ({ page }) => {
+ const logEnvelopePromise = waitForEnvelopeItem('effect-browser', envelope => {
+ return (
+ envelope[0].type === 'log' &&
+ (envelope[1] as SerializedLogContainer).items.some(
+ item => item.level === 'debug' && item.body === 'Debug log from Effect',
+ )
+ );
+ });
+
+ await page.goto('/');
+ const logButton = page.locator('id=log-button');
+ await logButton.click();
+
+ await expect(page.locator('id=log-result')).toHaveText('Logs sent!');
+
+ const logEnvelope = await logEnvelopePromise;
+ const logs = (logEnvelope[1] as SerializedLogContainer).items;
+ const debugLog = logs.find(log => log.level === 'debug' && log.body === 'Debug log from Effect');
+ expect(debugLog).toBeDefined();
+ expect(debugLog?.level).toBe('debug');
+});
+
+test('should send Effect info logs', async ({ page }) => {
+ const logEnvelopePromise = waitForEnvelopeItem('effect-browser', envelope => {
+ return (
+ envelope[0].type === 'log' &&
+ (envelope[1] as SerializedLogContainer).items.some(
+ item => item.level === 'info' && item.body === 'Info log from Effect',
+ )
+ );
+ });
+
+ await page.goto('/');
+ const logButton = page.locator('id=log-button');
+ await logButton.click();
+
+ await expect(page.locator('id=log-result')).toHaveText('Logs sent!');
+
+ const logEnvelope = await logEnvelopePromise;
+ const logs = (logEnvelope[1] as SerializedLogContainer).items;
+ const infoLog = logs.find(log => log.level === 'info' && log.body === 'Info log from Effect');
+ expect(infoLog).toBeDefined();
+ expect(infoLog?.level).toBe('info');
+});
+
+test('should send Effect warning logs', async ({ page }) => {
+ const logEnvelopePromise = waitForEnvelopeItem('effect-browser', envelope => {
+ return (
+ envelope[0].type === 'log' &&
+ (envelope[1] as SerializedLogContainer).items.some(
+ item => item.level === 'warn' && item.body === 'Warning log from Effect',
+ )
+ );
+ });
+
+ await page.goto('/');
+ const logButton = page.locator('id=log-button');
+ await logButton.click();
+
+ await expect(page.locator('id=log-result')).toHaveText('Logs sent!');
+
+ const logEnvelope = await logEnvelopePromise;
+ const logs = (logEnvelope[1] as SerializedLogContainer).items;
+ const warnLog = logs.find(log => log.level === 'warn' && log.body === 'Warning log from Effect');
+ expect(warnLog).toBeDefined();
+ expect(warnLog?.level).toBe('warn');
+});
+
+test('should send Effect error logs', async ({ page }) => {
+ const logEnvelopePromise = waitForEnvelopeItem('effect-browser', envelope => {
+ return (
+ envelope[0].type === 'log' &&
+ (envelope[1] as SerializedLogContainer).items.some(
+ item => item.level === 'error' && item.body === 'Error log from Effect',
+ )
+ );
+ });
+
+ await page.goto('/');
+ const logButton = page.locator('id=log-button');
+ await logButton.click();
+
+ await expect(page.locator('id=log-result')).toHaveText('Logs sent!');
+
+ const logEnvelope = await logEnvelopePromise;
+ const logs = (logEnvelope[1] as SerializedLogContainer).items;
+ const errorLog = logs.find(log => log.level === 'error' && log.body === 'Error log from Effect');
+ expect(errorLog).toBeDefined();
+ expect(errorLog?.level).toBe('error');
+});
+
+test('should send Effect logs with context attributes', async ({ page }) => {
+ const logEnvelopePromise = waitForEnvelopeItem('effect-browser', envelope => {
+ return (
+ envelope[0].type === 'log' &&
+ (envelope[1] as SerializedLogContainer).items.some(item => item.body === 'Log with context')
+ );
+ });
+
+ await page.goto('/');
+ const logContextButton = page.locator('id=log-context-button');
+ await logContextButton.click();
+
+ await expect(page.locator('id=log-context-result')).toHaveText('Log with context sent!');
+
+ const logEnvelope = await logEnvelopePromise;
+ const logs = (logEnvelope[1] as SerializedLogContainer).items;
+ const contextLog = logs.find(log => log.body === 'Log with context');
+ expect(contextLog).toBeDefined();
+ expect(contextLog?.level).toBe('info');
+});
diff --git a/dev-packages/e2e-tests/test-applications/effect-browser/tests/transactions.test.ts b/dev-packages/e2e-tests/test-applications/effect-browser/tests/transactions.test.ts
new file mode 100644
index 000000000000..b7c60b488403
--- /dev/null
+++ b/dev-packages/e2e-tests/test-applications/effect-browser/tests/transactions.test.ts
@@ -0,0 +1,120 @@
+import { expect, test } from '@playwright/test';
+import { waitForTransaction } from '@sentry-internal/test-utils';
+
+test('captures a pageload transaction', async ({ page }) => {
+ const transactionPromise = waitForTransaction('effect-browser', async transactionEvent => {
+ return !!transactionEvent?.transaction && transactionEvent.contexts?.trace?.op === 'pageload';
+ });
+
+ await page.goto('/');
+
+ const pageLoadTransaction = await transactionPromise;
+
+ expect(pageLoadTransaction).toMatchObject({
+ contexts: {
+ trace: {
+ data: expect.objectContaining({
+ 'sentry.idle_span_finish_reason': 'idleTimeout',
+ 'sentry.op': 'pageload',
+ 'sentry.origin': 'auto.pageload.browser',
+ 'sentry.sample_rate': 1,
+ 'sentry.source': 'url',
+ }),
+ op: 'pageload',
+ origin: 'auto.pageload.browser',
+ span_id: expect.stringMatching(/[a-f0-9]{16}/),
+ trace_id: expect.stringMatching(/[a-f0-9]{32}/),
+ },
+ },
+ environment: 'qa',
+ event_id: expect.stringMatching(/[a-f0-9]{32}/),
+ measurements: expect.any(Object),
+ platform: 'javascript',
+ release: 'e2e-test',
+ request: {
+ headers: {
+ 'User-Agent': expect.any(String),
+ },
+ url: 'http://localhost:3030/',
+ },
+ spans: expect.any(Array),
+ start_timestamp: expect.any(Number),
+ timestamp: expect.any(Number),
+ transaction: '/',
+ transaction_info: {
+ source: 'url',
+ },
+ type: 'transaction',
+ });
+});
+
+test('captures a navigation transaction', async ({ page }) => {
+ const pageLoadTransactionPromise = waitForTransaction('effect-browser', async transactionEvent => {
+ return !!transactionEvent?.transaction && transactionEvent.contexts?.trace?.op === 'pageload';
+ });
+
+ const navigationTransactionPromise = waitForTransaction('effect-browser', async transactionEvent => {
+ return !!transactionEvent?.transaction && transactionEvent.contexts?.trace?.op === 'navigation';
+ });
+
+ await page.goto('/');
+ await pageLoadTransactionPromise;
+
+ const linkElement = page.locator('id=navigation-link');
+ await linkElement.click();
+
+ const navigationTransaction = await navigationTransactionPromise;
+
+ expect(navigationTransaction).toMatchObject({
+ contexts: {
+ trace: {
+ op: 'navigation',
+ origin: 'auto.navigation.browser',
+ },
+ },
+ transaction: '/',
+ transaction_info: {
+ source: 'url',
+ },
+ });
+});
+
+test('captures Effect spans with correct parent-child structure', async ({ page }) => {
+ const pageloadPromise = waitForTransaction('effect-browser', transactionEvent => {
+ return transactionEvent?.contexts?.trace?.op === 'pageload';
+ });
+
+ const transactionPromise = waitForTransaction('effect-browser', transactionEvent => {
+ return (
+ transactionEvent?.contexts?.trace?.op === 'ui.action.click' &&
+ transactionEvent.spans?.some(span => span.description === 'custom-effect-span')
+ );
+ });
+
+ await page.goto('/');
+ await pageloadPromise;
+
+ const effectSpanButton = page.locator('id=effect-span-button');
+ await effectSpanButton.click();
+
+ await expect(page.locator('id=effect-span-result')).toHaveText('Span sent!');
+
+ const transactionEvent = await transactionPromise;
+ const spans = transactionEvent.spans || [];
+
+ expect(spans).toContainEqual(
+ expect.objectContaining({
+ description: 'custom-effect-span',
+ }),
+ );
+
+ expect(spans).toContainEqual(
+ expect.objectContaining({
+ description: 'nested-span',
+ }),
+ );
+
+ const parentSpan = spans.find(s => s.description === 'custom-effect-span');
+ const nestedSpan = spans.find(s => s.description === 'nested-span');
+ expect(nestedSpan?.parent_span_id).toBe(parentSpan?.span_id);
+});
diff --git a/dev-packages/e2e-tests/test-applications/effect-browser/tsconfig.json b/dev-packages/e2e-tests/test-applications/effect-browser/tsconfig.json
new file mode 100644
index 000000000000..cb69f25b8d50
--- /dev/null
+++ b/dev-packages/e2e-tests/test-applications/effect-browser/tsconfig.json
@@ -0,0 +1,19 @@
+{
+ "compilerOptions": {
+ "target": "es2018",
+ "lib": ["dom", "dom.iterable", "esnext"],
+ "allowJs": true,
+ "skipLibCheck": true,
+ "esModuleInterop": true,
+ "allowSyntheticDefaultImports": true,
+ "strict": true,
+ "forceConsistentCasingInFileNames": true,
+ "noFallthroughCasesInSwitch": true,
+ "module": "esnext",
+ "moduleResolution": "node",
+ "resolveJsonModule": true,
+ "isolatedModules": true,
+ "noEmit": true
+ },
+ "include": ["src", "tests"]
+}
diff --git a/dev-packages/e2e-tests/test-applications/effect-node/.gitignore b/dev-packages/e2e-tests/test-applications/effect-node/.gitignore
new file mode 100644
index 000000000000..f06235c460c2
--- /dev/null
+++ b/dev-packages/e2e-tests/test-applications/effect-node/.gitignore
@@ -0,0 +1,2 @@
+node_modules
+dist
diff --git a/dev-packages/e2e-tests/test-applications/effect-node/.npmrc b/dev-packages/e2e-tests/test-applications/effect-node/.npmrc
new file mode 100644
index 000000000000..070f80f05092
--- /dev/null
+++ b/dev-packages/e2e-tests/test-applications/effect-node/.npmrc
@@ -0,0 +1,2 @@
+@sentry:registry=http://127.0.0.1:4873
+@sentry-internal:registry=http://127.0.0.1:4873
diff --git a/dev-packages/e2e-tests/test-applications/effect-node/package.json b/dev-packages/e2e-tests/test-applications/effect-node/package.json
new file mode 100644
index 000000000000..621a017d3020
--- /dev/null
+++ b/dev-packages/e2e-tests/test-applications/effect-node/package.json
@@ -0,0 +1,29 @@
+{
+ "name": "effect-node-app",
+ "version": "1.0.0",
+ "private": true,
+ "type": "module",
+ "scripts": {
+ "build": "tsc",
+ "start": "node dist/app.js",
+ "test": "playwright test",
+ "clean": "npx rimraf node_modules pnpm-lock.yaml",
+ "test:build": "pnpm install && pnpm build",
+ "test:assert": "pnpm test"
+ },
+ "dependencies": {
+ "@effect/platform": "^0.94.5",
+ "@effect/platform-node": "^0.104.1",
+ "@sentry/effect": "latest || *",
+ "@types/node": "^18.19.1",
+ "effect": "^3.19.19",
+ "typescript": "~5.0.0"
+ },
+ "devDependencies": {
+ "@playwright/test": "~1.56.0",
+ "@sentry-internal/test-utils": "link:../../../test-utils"
+ },
+ "volta": {
+ "extends": "../../package.json"
+ }
+}
diff --git a/dev-packages/e2e-tests/test-applications/effect-node/playwright.config.mjs b/dev-packages/e2e-tests/test-applications/effect-node/playwright.config.mjs
new file mode 100644
index 000000000000..31f2b913b58b
--- /dev/null
+++ b/dev-packages/e2e-tests/test-applications/effect-node/playwright.config.mjs
@@ -0,0 +1,7 @@
+import { getPlaywrightConfig } from '@sentry-internal/test-utils';
+
+const config = getPlaywrightConfig({
+ startCommand: `pnpm start`,
+});
+
+export default config;
diff --git a/dev-packages/e2e-tests/test-applications/effect-node/src/app.ts b/dev-packages/e2e-tests/test-applications/effect-node/src/app.ts
new file mode 100644
index 000000000000..899adfb4aa98
--- /dev/null
+++ b/dev-packages/e2e-tests/test-applications/effect-node/src/app.ts
@@ -0,0 +1,108 @@
+import * as Sentry from '@sentry/effect';
+import { HttpRouter, HttpServer, HttpServerResponse } from '@effect/platform';
+import { NodeHttpServer, NodeRuntime } from '@effect/platform-node';
+import * as Effect from 'effect/Effect';
+import * as Cause from 'effect/Cause';
+import * as Layer from 'effect/Layer';
+import * as Logger from 'effect/Logger';
+import * as LogLevel from 'effect/LogLevel';
+import { createServer } from 'http';
+
+const SentryLive = Layer.mergeAll(
+ Sentry.effectLayer({
+ dsn: process.env.E2E_TEST_DSN,
+ environment: 'qa',
+ debug: !!process.env.DEBUG,
+ tunnel: 'http://localhost:3031/',
+ tracesSampleRate: 1,
+ enableLogs: true,
+ }),
+ Layer.setTracer(Sentry.SentryEffectTracer),
+ Logger.replace(Logger.defaultLogger, Sentry.SentryEffectLogger),
+);
+
+const router = HttpRouter.empty.pipe(
+ HttpRouter.get('/test-success', HttpServerResponse.json({ version: 'v1' })),
+
+ HttpRouter.get(
+ '/test-transaction',
+ Effect.gen(function* () {
+ yield* Effect.void.pipe(Effect.withSpan('test-span'));
+ return yield* HttpServerResponse.json({ status: 'ok' });
+ }),
+ ),
+
+ HttpRouter.get(
+ '/test-effect-span',
+ Effect.gen(function* () {
+ yield* Effect.gen(function* () {
+ yield* Effect.sleep('50 millis');
+ yield* Effect.sleep('25 millis').pipe(Effect.withSpan('nested-span'));
+ }).pipe(Effect.withSpan('custom-effect-span', { kind: 'internal' }));
+ return yield* HttpServerResponse.json({ status: 'ok' });
+ }),
+ ),
+
+ HttpRouter.get(
+ '/test-error',
+ Effect.gen(function* () {
+ const exceptionId = Sentry.captureException(new Error('This is an error'));
+ yield* Effect.promise(() => Sentry.flush(2000));
+ return yield* HttpServerResponse.json({ exceptionId });
+ }),
+ ),
+
+ HttpRouter.get(
+ '/test-exception/:id',
+ Effect.sync(() => {
+ throw new Error('This is an exception with id 123');
+ }),
+ ),
+
+ HttpRouter.get('/test-effect-fail', Effect.fail(new Error('Effect failure'))),
+
+ HttpRouter.get('/test-effect-die', Effect.die('Effect defect')),
+
+ HttpRouter.get(
+ '/test-log',
+ Effect.gen(function* () {
+ yield* Effect.logDebug('Debug log from Effect');
+ yield* Effect.logInfo('Info log from Effect');
+ yield* Effect.logWarning('Warning log from Effect');
+ yield* Effect.logError('Error log from Effect');
+ return yield* HttpServerResponse.json({ message: 'Logs sent' });
+ }),
+ ),
+
+ HttpRouter.get(
+ '/test-log-with-context',
+ Effect.gen(function* () {
+ yield* Effect.logInfo('Log with context').pipe(
+ Effect.annotateLogs('userId', '12345'),
+ Effect.annotateLogs('action', 'test'),
+ );
+ return yield* HttpServerResponse.json({ message: 'Log with context sent' });
+ }),
+ ),
+
+ HttpRouter.catchAllCause(cause => {
+ const error = Cause.squash(cause);
+ Sentry.captureException(error);
+ return Effect.gen(function* () {
+ yield* Effect.promise(() => Sentry.flush(2000));
+ return yield* HttpServerResponse.json({ error: String(error) }, { status: 500 });
+ });
+ }),
+);
+
+const LogLevelLive = Logger.minimumLogLevel(LogLevel.Debug);
+
+const ServerLive = router.pipe(
+ HttpServer.serve(),
+ HttpServer.withLogAddress,
+ Layer.provide(NodeHttpServer.layer(createServer, { port: 3030 })),
+ Layer.provide(SentryLive),
+ Layer.provide(LogLevelLive),
+);
+
+ServerLive.pipe(Layer.launch, NodeRuntime.runMain);
diff --git a/dev-packages/e2e-tests/test-applications/effect-node/start-event-proxy.mjs b/dev-packages/e2e-tests/test-applications/effect-node/start-event-proxy.mjs
new file mode 100644
index 000000000000..41eb647958b7
--- /dev/null
+++ b/dev-packages/e2e-tests/test-applications/effect-node/start-event-proxy.mjs
@@ -0,0 +1,6 @@
+import { startEventProxyServer } from '@sentry-internal/test-utils';
+
+startEventProxyServer({
+ port: 3031,
+ proxyServerName: 'effect-node',
+});
diff --git a/dev-packages/e2e-tests/test-applications/effect-node/tests/errors.test.ts b/dev-packages/e2e-tests/test-applications/effect-node/tests/errors.test.ts
new file mode 100644
index 000000000000..3b7da230c0e0
--- /dev/null
+++ b/dev-packages/e2e-tests/test-applications/effect-node/tests/errors.test.ts
@@ -0,0 +1,56 @@
+import { expect, test } from '@playwright/test';
+import { waitForError } from '@sentry-internal/test-utils';
+
+test('Captures manually reported error', async ({ baseURL }) => {
+ const errorEventPromise = waitForError('effect-node', event => {
+ return !event.type && event.exception?.values?.[0]?.value === 'This is an error';
+ });
+
+ const response = await fetch(`${baseURL}/test-error`);
+ const body = await response.json();
+
+ const errorEvent = await errorEventPromise;
+
+ expect(body.exceptionId).toBeDefined();
+ expect(errorEvent.exception?.values).toHaveLength(1);
+ expect(errorEvent.exception?.values?.[0]?.value).toBe('This is an error');
+});
+
+test('Captures thrown exception', async ({ baseURL }) => {
+ const errorEventPromise = waitForError('effect-node', event => {
+ return !event.type && event.exception?.values?.[0]?.value === 'This is an exception with id 123';
+ });
+
+ await fetch(`${baseURL}/test-exception/123`);
+
+ const errorEvent = await errorEventPromise;
+
+ expect(errorEvent.exception?.values).toHaveLength(1);
+ expect(errorEvent.exception?.values?.[0]?.value).toBe('This is an exception with id 123');
+});
+
+test('Captures Effect.fail as error', async ({ baseURL }) => {
+ const errorEventPromise = waitForError('effect-node', event => {
+ return !event.type && event.exception?.values?.[0]?.value === 'Effect failure';
+ });
+
+ await fetch(`${baseURL}/test-effect-fail`);
+
+ const errorEvent = await errorEventPromise;
+
+ expect(errorEvent.exception?.values).toHaveLength(1);
+ expect(errorEvent.exception?.values?.[0]?.value).toBe('Effect failure');
+});
+
+test('Captures Effect.die as error', async ({ baseURL }) => {
+ const errorEventPromise = waitForError('effect-node', event => {
+ return !event.type && event.exception?.values?.[0]?.value?.includes('Effect defect');
+ });
+
+ await fetch(`${baseURL}/test-effect-die`);
+
+ const errorEvent = await errorEventPromise;
+
+ expect(errorEvent.exception?.values).toHaveLength(1);
+ expect(errorEvent.exception?.values?.[0]?.value).toContain('Effect defect');
+});
diff --git a/dev-packages/e2e-tests/test-applications/effect-node/tests/logs.test.ts b/dev-packages/e2e-tests/test-applications/effect-node/tests/logs.test.ts
new file mode 100644
index 000000000000..85f5840e14a8
--- /dev/null
+++ b/dev-packages/e2e-tests/test-applications/effect-node/tests/logs.test.ts
@@ -0,0 +1,96 @@
+import { expect, test } from '@playwright/test';
+import { waitForEnvelopeItem } from '@sentry-internal/test-utils';
+import type { SerializedLogContainer } from '@sentry/core';
+
+test('should send Effect debug logs', async ({ baseURL }) => {
+ const logEnvelopePromise = waitForEnvelopeItem('effect-node', envelope => {
+ return (
+ envelope[0].type === 'log' &&
+ (envelope[1] as SerializedLogContainer).items.some(
+ item => item.level === 'debug' && item.body === 'Debug log from Effect',
+ )
+ );
+ });
+
+ await fetch(`${baseURL}/test-log`);
+
+ const logEnvelope = await logEnvelopePromise;
+ const logs = (logEnvelope[1] as SerializedLogContainer).items;
+ const debugLog = logs.find(log => log.level === 'debug' && log.body === 'Debug log from Effect');
+ expect(debugLog).toBeDefined();
+ expect(debugLog?.level).toBe('debug');
+});
+
+test('should send Effect info logs', async ({ baseURL }) => {
+ const logEnvelopePromise = waitForEnvelopeItem('effect-node', envelope => {
+ return (
+ envelope[0].type === 'log' &&
+ (envelope[1] as SerializedLogContainer).items.some(
+ item => item.level === 'info' && item.body === 'Info log from Effect',
+ )
+ );
+ });
+
+ await fetch(`${baseURL}/test-log`);
+
+ const logEnvelope = await logEnvelopePromise;
+ const logs = (logEnvelope[1] as SerializedLogContainer).items;
+ const infoLog = logs.find(log => log.level === 'info' && log.body === 'Info log from Effect');
+ expect(infoLog).toBeDefined();
+ expect(infoLog?.level).toBe('info');
+});
+
+test('should send Effect warning logs', async ({ baseURL }) => {
+ const logEnvelopePromise = waitForEnvelopeItem('effect-node', envelope => {
+ return (
+ envelope[0].type === 'log' &&
+ (envelope[1] as SerializedLogContainer).items.some(
+ item => item.level === 'warn' && item.body === 'Warning log from Effect',
+ )
+ );
+ });
+
+ await fetch(`${baseURL}/test-log`);
+
+ const logEnvelope = await logEnvelopePromise;
+ const logs = (logEnvelope[1] as SerializedLogContainer).items;
+ const warnLog = logs.find(log => log.level === 'warn' && log.body === 'Warning log from Effect');
+ expect(warnLog).toBeDefined();
+ expect(warnLog?.level).toBe('warn');
+});
+
+test('should send Effect error logs', async ({ baseURL }) => {
+ const logEnvelopePromise = waitForEnvelopeItem('effect-node', envelope => {
+ return (
+ envelope[0].type === 'log' &&
+ (envelope[1] as SerializedLogContainer).items.some(
+ item => item.level === 'error' && item.body === 'Error log from Effect',
+ )
+ );
+ });
+
+ await fetch(`${baseURL}/test-log`);
+
+ const logEnvelope = await logEnvelopePromise;
+ const logs = (logEnvelope[1] as SerializedLogContainer).items;
+ const errorLog = logs.find(log => log.level === 'error' && log.body === 'Error log from Effect');
+ expect(errorLog).toBeDefined();
+ expect(errorLog?.level).toBe('error');
+});
+
+test('should send Effect logs with context attributes', async ({ baseURL }) => {
+ const logEnvelopePromise = waitForEnvelopeItem('effect-node', envelope => {
+ return (
+ envelope[0].type === 'log' &&
+ (envelope[1] as SerializedLogContainer).items.some(item => item.body === 'Log with context')
+ );
+ });
+
+ await fetch(`${baseURL}/test-log-with-context`);
+
+ const logEnvelope = await logEnvelopePromise;
+ const logs = (logEnvelope[1] as SerializedLogContainer).items;
+ const contextLog = logs.find(log => log.body === 'Log with context');
+ expect(contextLog).toBeDefined();
+ expect(contextLog?.level).toBe('info');
+});
diff --git a/dev-packages/e2e-tests/test-applications/effect-node/tests/transactions.test.ts b/dev-packages/e2e-tests/test-applications/effect-node/tests/transactions.test.ts
new file mode 100644
index 000000000000..ed7a58fa28df
--- /dev/null
+++ b/dev-packages/e2e-tests/test-applications/effect-node/tests/transactions.test.ts
@@ -0,0 +1,99 @@
+import { expect, test } from '@playwright/test';
+import { waitForTransaction } from '@sentry-internal/test-utils';
+
+test('Sends an HTTP transaction', async ({ baseURL }) => {
+ const transactionEventPromise = waitForTransaction('effect-node', transactionEvent => {
+ return transactionEvent?.transaction === 'http.server GET';
+ });
+
+ await fetch(`${baseURL}/test-success`);
+
+ const transactionEvent = await transactionEventPromise;
+
+ expect(transactionEvent.transaction).toBe('http.server GET');
+});
+
+test('Sends transaction with manual Effect span', async ({ baseURL }) => {
+ const transactionEventPromise = waitForTransaction('effect-node', transactionEvent => {
+ return (
+ transactionEvent?.transaction === 'http.server GET' &&
+ transactionEvent?.spans?.some(span => span.description === 'test-span')
+ );
+ });
+
+ await fetch(`${baseURL}/test-transaction`);
+
+ const transactionEvent = await transactionEventPromise;
+
+ expect(transactionEvent.transaction).toBe('http.server GET');
+
+ const spans = transactionEvent.spans || [];
+ expect(spans).toEqual([
+ expect.objectContaining({
+ description: 'test-span',
+ }),
+ ]);
+});
+
+test('Sends Effect spans with correct parent-child structure', async ({ baseURL }) => {
+ const transactionEventPromise = waitForTransaction('effect-node', transactionEvent => {
+ return (
+ transactionEvent?.transaction === 'http.server GET' &&
+ transactionEvent?.spans?.some(span => span.description === 'custom-effect-span')
+ );
+ });
+
+ await fetch(`${baseURL}/test-effect-span`);
+
+ const transactionEvent = await transactionEventPromise;
+
+ expect(transactionEvent.transaction).toBe('http.server GET');
+
+ expect(transactionEvent).toEqual(
+ expect.objectContaining({
+ contexts: expect.objectContaining({
+ trace: expect.objectContaining({
+ origin: 'auto.http.effect',
+ }),
+ }),
+ spans: [
+ expect.objectContaining({
+ description: 'custom-effect-span',
+ origin: 'auto.function.effect',
+ }),
+ expect.objectContaining({
+ description: 'nested-span',
+ origin: 'auto.function.effect',
+ }),
+ ],
+ sdk: expect.objectContaining({
+ name: 'sentry.javascript.effect',
+ packages: [
+ expect.objectContaining({
+ name: 'npm:@sentry/effect',
+ }),
+ expect.objectContaining({
+ name: 'npm:@sentry/node-light',
+ }),
+ ],
+ }),
+ }),
+ );
+
+ const parentSpan = transactionEvent.spans?.[0]?.span_id;
+ const nestedSpan = transactionEvent.spans?.[1]?.parent_span_id;
+
+ expect(nestedSpan).toBe(parentSpan);
+});
+
+test('Sends transaction for error route', async ({ baseURL }) => {
+ const transactionEventPromise = waitForTransaction('effect-node', transactionEvent => {
+ return transactionEvent?.transaction === 'http.server GET';
+ });
+
+ await fetch(`${baseURL}/test-error`);
+
+ const transactionEvent = await transactionEventPromise;
+
+ expect(transactionEvent.transaction).toBe('http.server GET');
+});
diff --git a/dev-packages/e2e-tests/test-applications/effect-node/tsconfig.json b/dev-packages/e2e-tests/test-applications/effect-node/tsconfig.json
new file mode 100644
index 000000000000..2cc9aca23e0e
--- /dev/null
+++ b/dev-packages/e2e-tests/test-applications/effect-node/tsconfig.json
@@ -0,0 +1,14 @@
+{
+ "compilerOptions": {
+ "target": "ES2022",
+ "module": "NodeNext",
+ "moduleResolution": "NodeNext",
+ "outDir": "dist",
+ "rootDir": "src",
+ "strict": true,
+ "esModuleInterop": true,
+ "skipLibCheck": true,
+ "declaration": false
+ },
+ "include": ["src"]
+}
diff --git a/dev-packages/e2e-tests/verdaccio-config/config.yaml b/dev-packages/e2e-tests/verdaccio-config/config.yaml
index 6e57ee2ea812..beb758aca018 100644
--- a/dev-packages/e2e-tests/verdaccio-config/config.yaml
+++ b/dev-packages/e2e-tests/verdaccio-config/config.yaml
@@ -74,6 +74,12 @@ packages:
unpublish: $all
# proxy: npmjs # Don't proxy for E2E tests!
+ '@sentry/effect':
+ access: $all
+ publish: $all
+ unpublish: $all
+ # proxy: npmjs # Don't proxy for E2E tests!
+
'@sentry/ember':
access: $all
publish: $all
diff --git a/package.json b/package.json
index 718155e18636..42edbcaf8879 100644
--- a/package.json
+++ b/package.json
@@ -59,6 +59,7 @@
"packages/core",
"packages/cloudflare",
"packages/deno",
+ "packages/effect",
"packages/ember",
"packages/eslint-config-sdk",
"packages/eslint-plugin-sdk",
diff --git a/packages/effect/.eslintrc.js b/packages/effect/.eslintrc.js
new file mode 100644
index 000000000000..d37e458c151c
--- /dev/null
+++ b/packages/effect/.eslintrc.js
@@ -0,0 +1,15 @@
+module.exports = {
+ env: {
+ browser: true,
+ node: true,
+ },
+ overrides: [
+ {
+ files: ['vite.config.ts', 'vitest.config.ts'],
+ parserOptions: {
+ project: ['tsconfig.vite.json'],
+ },
+ },
+ ],
+ extends: ['../../.eslintrc.js'],
+};
diff --git a/packages/effect/LICENSE b/packages/effect/LICENSE
new file mode 100644
index 000000000000..fea6013e7dbf
--- /dev/null
+++ b/packages/effect/LICENSE
@@ -0,0 +1,21 @@
+MIT License
+
+Copyright (c) 2026 Functional Software Inc. dba Sentry
+
+Permission is hereby granted, free of charge, to any person obtaining a copy
+of this software and associated documentation files (the "Software"), to deal
+in the Software without restriction, including without limitation the rights
+to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+copies of the Software, and to permit persons to whom the Software is
+furnished to do so, subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included in all
+copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+SOFTWARE.
diff --git a/packages/effect/README.md b/packages/effect/README.md
new file mode 100644
index 000000000000..78b2f6471dc0
--- /dev/null
+++ b/packages/effect/README.md
@@ -0,0 +1,48 @@
+# Official Sentry SDK for Effect.ts (Alpha)
+
+[](https://www.npmjs.com/package/@sentry/effect)
+[](https://www.npmjs.com/package/@sentry/effect)
+[](https://www.npmjs.com/package/@sentry/effect)
+
+> NOTICE: This package is in alpha state and may be subject to breaking changes.
+
+## Getting Started
+
+This SDK does not have docs yet. Stay tuned.
+
+## Usage
+
+```typescript
+import * as Sentry from '@sentry/effect/server';
+import { NodeRuntime } from '@effect/platform-node';
+import { Layer, Logger } from 'effect';
+import { HttpLive } from './Http.js';
+
+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,
+);
+
+const MainLive = HttpLive.pipe(Layer.provide(SentryLive));
+MainLive.pipe(Layer.launch, NodeRuntime.runMain);
+```
+
+The `effectLayer` function initializes Sentry. To enable Effect instrumentation, compose with:
+
+- `Layer.setTracer(Sentry.SentryEffectTracer)` - Effect spans traced as Sentry spans
+- `Logger.replace(Logger.defaultLogger, Sentry.SentryEffectLogger)` - Effect logs forwarded to Sentry
+- `Sentry.SentryEffectMetricsLayer` - Effect metrics sent to Sentry
+
+## Links
+
+
+
+- [Sentry.io](https://sentry.io/?utm_source=github&utm_medium=npm_effect)
+- [Sentry Discord Server](https://discord.gg/Ww9hbqr)
+- [Stack Overflow](https://stackoverflow.com/questions/tagged/sentry)
diff --git a/packages/effect/package.json b/packages/effect/package.json
new file mode 100644
index 000000000000..43a54b85e159
--- /dev/null
+++ b/packages/effect/package.json
@@ -0,0 +1,99 @@
+{
+ "name": "@sentry/effect",
+ "version": "10.43.0",
+ "description": "Official Sentry SDK for Effect",
+ "repository": "git://github.com/getsentry/sentry-javascript.git",
+ "homepage": "https://github.com/getsentry/sentry-javascript/tree/master/packages/effect",
+ "author": "Sentry",
+ "license": "MIT",
+ "engines": {
+ "node": ">=18"
+ },
+ "files": [
+ "/build"
+ ],
+ "main": "build/cjs/index.server.js",
+ "module": "build/esm/index.server.js",
+ "browser": "build/esm/index.client.js",
+ "types": "build/types/index.types.d.ts",
+ "exports": {
+ "./package.json": "./package.json",
+ ".": {
+ "types": "./build/types/index.types.d.ts",
+ "browser": {
+ "import": "./build/esm/index.client.js",
+ "require": "./build/cjs/index.client.js"
+ },
+ "node": {
+ "import": "./build/esm/index.server.js",
+ "require": "./build/cjs/index.server.js"
+ }
+ },
+ "./server": {
+ "types": "./build/types/index.server.d.ts",
+ "import": "./build/esm/index.server.js",
+ "require": "./build/cjs/index.server.js"
+ },
+ "./client": {
+ "types": "./build/types/index.client.d.ts",
+ "import": "./build/esm/index.client.js",
+ "require": "./build/cjs/index.client.js"
+ }
+ },
+ "typesVersions": {
+ "<5.0": {
+ "build/types/index.types.d.ts": [
+ "build/types-ts3.8/index.types.d.ts"
+ ],
+ "build/types/index.server.d.ts": [
+ "build/types-ts3.8/index.server.d.ts"
+ ],
+ "build/types/index.client.d.ts": [
+ "build/types-ts3.8/index.client.d.ts"
+ ]
+ }
+ },
+ "publishConfig": {
+ "access": "public"
+ },
+ "dependencies": {
+ "@sentry/browser": "10.43.0",
+ "@sentry/core": "10.43.0",
+ "@sentry/node-core": "10.43.0"
+ },
+ "peerDependencies": {
+ "effect": "^3.0.0"
+ },
+ "peerDependenciesMeta": {
+ "effect": {
+ "optional": false
+ }
+ },
+ "devDependencies": {
+ "@effect/vitest": "^0.23.9",
+ "effect": "^3.19.19"
+ },
+ "scripts": {
+ "build": "run-p build:transpile build:types",
+ "build:dev": "yarn build",
+ "build:transpile": "rollup -c rollup.npm.config.mjs",
+ "build:types": "run-s build:types:core build:types:downlevel",
+ "build:types:core": "tsc -p tsconfig.types.json",
+ "build:types:downlevel": "yarn downlevel-dts build/types build/types-ts3.8 --to ts3.8",
+ "build:watch": "run-p build:transpile:watch",
+ "build:dev:watch": "yarn build:watch",
+ "build:transpile:watch": "rollup -c rollup.npm.config.mjs --watch",
+ "build:tarball": "npm pack",
+ "circularDepCheck": "madge --circular src/index.client.ts && madge --circular src/index.server.ts && madge --circular src/index.types.ts",
+ "clean": "rimraf build coverage sentry-effect-*.tgz",
+ "fix": "eslint . --format stylish --fix",
+ "lint": "eslint . --format stylish",
+ "test": "vitest run",
+ "test:watch": "vitest --watch",
+ "yalc:publish": "yalc publish --push --sig"
+ },
+ "volta": {
+ "extends": "../../package.json"
+ },
+ "sideEffects": false
+}
diff --git a/packages/effect/rollup.npm.config.mjs b/packages/effect/rollup.npm.config.mjs
new file mode 100644
index 000000000000..211157646473
--- /dev/null
+++ b/packages/effect/rollup.npm.config.mjs
@@ -0,0 +1,25 @@
+import { makeBaseNPMConfig, makeNPMConfigVariants } from '@sentry-internal/rollup-utils';
+
+const baseConfig = makeBaseNPMConfig({
+ entrypoints: ['src/index.server.ts', 'src/index.client.ts'],
+ packageSpecificConfig: {
+ output: {
+ preserveModulesRoot: 'src',
+ },
+ },
+});
+
+const defaultExternal = baseConfig.external || [];
+baseConfig.external = id => {
+ if (defaultExternal.includes(id)) {
+ return true;
+ }
+
+ if (id === 'effect' || id.startsWith('effect/') || id.startsWith('@sentry/')) {
+ return true;
+ }
+
+ return false;
+};
+
+export default makeNPMConfigVariants(baseConfig);
diff --git a/packages/effect/src/client/index.ts b/packages/effect/src/client/index.ts
new file mode 100644
index 000000000000..e60843bc1e3e
--- /dev/null
+++ b/packages/effect/src/client/index.ts
@@ -0,0 +1,43 @@
+import type { BrowserOptions } from '@sentry/browser';
+import type * as EffectLayer from 'effect/Layer';
+import { empty as emptyLayer, suspend as suspendLayer } from 'effect/Layer';
+import { init } from './sdk';
+
+export { init } from './sdk';
+
+/**
+ * Options for the Sentry Effect client layer.
+ */
+export type EffectClientLayerOptions = BrowserOptions;
+
+/**
+ * Creates an Effect Layer that initializes Sentry for browser clients.
+ *
+ * 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
+ * - `Sentry.SentryEffectMetricsLayer` for metrics
+ *
+ * @example
+ * ```typescript
+ * import * as Sentry from '@sentry/effect/client';
+ * import { Layer, Logger, LogLevel } from 'effect';
+ *
+ * const SentryLive = Layer.mergeAll(
+ * Sentry.effectLayer({
+ * dsn: '__DSN__',
+ * integrations: [Sentry.browserTracingIntegration()],
+ * tracesSampleRate: 1.0,
+ * }),
+ * Layer.setTracer(Sentry.SentryEffectTracer),
+ * Logger.replace(Logger.defaultLogger, Sentry.SentryEffectLogger),
+ * );
+ * ```
+ */
+export function effectLayer(options: EffectClientLayerOptions): EffectLayer.Layer {
+ return suspendLayer(() => {
+ init(options);
+
+ return emptyLayer;
+ });
+}
diff --git a/packages/effect/src/client/sdk.ts b/packages/effect/src/client/sdk.ts
new file mode 100644
index 000000000000..5f2210a92b3a
--- /dev/null
+++ b/packages/effect/src/client/sdk.ts
@@ -0,0 +1,20 @@
+import type { BrowserOptions } from '@sentry/browser';
+import { init as initBrowser } from '@sentry/browser';
+import type { Client } from '@sentry/core';
+import { applySdkMetadata } from '@sentry/core';
+
+/**
+ * Initializes the Sentry Effect SDK for browser clients.
+ *
+ * @param options - Configuration options for the SDK
+ * @returns The initialized Sentry client, or undefined if initialization failed
+ */
+export function init(options: BrowserOptions): Client | undefined {
+ const opts = {
+ ...options,
+ };
+
+ applySdkMetadata(opts, 'effect', ['effect', 'browser']);
+
+ return initBrowser(opts);
+}
diff --git a/packages/effect/src/index.client.ts b/packages/effect/src/index.client.ts
new file mode 100644
index 000000000000..2df8a2548fb9
--- /dev/null
+++ b/packages/effect/src/index.client.ts
@@ -0,0 +1,11 @@
+// import/export got a false positive, and affects most of our index barrel files
+// can be removed once following issue is fixed: https://github.com/import-js/eslint-plugin-import/issues/703
+/* eslint-disable import/export */
+export * from '@sentry/browser';
+
+export { effectLayer, init } from './client/index';
+export type { EffectClientLayerOptions } from './client/index';
+
+export { SentryEffectTracer } from './tracer';
+export { SentryEffectLogger } from './logger';
+export { SentryEffectMetricsLayer } from './metrics';
diff --git a/packages/effect/src/index.server.ts b/packages/effect/src/index.server.ts
new file mode 100644
index 000000000000..c66abbf43413
--- /dev/null
+++ b/packages/effect/src/index.server.ts
@@ -0,0 +1,8 @@
+export * from '@sentry/node-core/light';
+
+export { effectLayer, init } from './server/index';
+export type { EffectServerLayerOptions } from './server/index';
+
+export { SentryEffectTracer } from './tracer';
+export { SentryEffectLogger } from './logger';
+export { SentryEffectMetricsLayer } from './metrics';
diff --git a/packages/effect/src/index.types.ts b/packages/effect/src/index.types.ts
new file mode 100644
index 000000000000..e0a6e9512eeb
--- /dev/null
+++ b/packages/effect/src/index.types.ts
@@ -0,0 +1,26 @@
+/* eslint-disable import/export */
+
+// We export everything from both the client part of the SDK and from the server part.
+// Some of the exports collide, which is not allowed, unless we redefine the colliding
+// exports in this file - which we do below.
+import type { Client, Integration, Options, StackParser } from '@sentry/core';
+import type * as EffectLayer from 'effect/Layer';
+import type * as clientSdk from './index.client';
+import type * as serverSdk from './index.server';
+
+export * from './index.client';
+export * from './index.server';
+
+export type { EffectClientLayerOptions } from './index.client';
+export type { EffectServerLayerOptions } from './index.server';
+
+export declare function effectLayer(
+ options: clientSdk.EffectClientLayerOptions | serverSdk.EffectServerLayerOptions,
+): EffectLayer.Layer;
+
+export declare function init(options: Options | clientSdk.BrowserOptions | serverSdk.NodeOptions): Client | undefined;
+export declare const linkedErrorsIntegration: typeof clientSdk.linkedErrorsIntegration;
+export declare const contextLinesIntegration: typeof clientSdk.contextLinesIntegration;
+export declare const getDefaultIntegrations: (options: Options) => Integration[];
+export declare const defaultStackParser: StackParser;
+export declare const logger: typeof clientSdk.logger | typeof serverSdk.logger;
diff --git a/packages/effect/src/logger.ts b/packages/effect/src/logger.ts
new file mode 100644
index 000000000000..833f5b6b7e95
--- /dev/null
+++ b/packages/effect/src/logger.ts
@@ -0,0 +1,43 @@
+import { logger as sentryLogger } from '@sentry/core';
+import * as Logger from 'effect/Logger';
+
+/**
+ * Effect Logger that sends logs to Sentry.
+ */
+export const SentryEffectLogger = Logger.make(({ logLevel, message }) => {
+ let msg: string;
+ if (typeof message === 'string') {
+ msg = message;
+ } else if (Array.isArray(message) && message.length === 1) {
+ const firstElement = message[0];
+ msg = typeof firstElement === 'string' ? firstElement : JSON.stringify(firstElement);
+ } else {
+ msg = JSON.stringify(message);
+ }
+
+ switch (logLevel._tag) {
+ case 'Fatal':
+ sentryLogger.fatal(msg);
+ break;
+ case 'Error':
+ sentryLogger.error(msg);
+ break;
+ case 'Warning':
+ sentryLogger.warn(msg);
+ break;
+ case 'Info':
+ sentryLogger.info(msg);
+ break;
+ case 'Debug':
+ sentryLogger.debug(msg);
+ break;
+ case 'Trace':
+ sentryLogger.trace(msg);
+ break;
+ case 'All':
+ case 'None':
+ break;
+ default:
+ logLevel satisfies never;
+ }
+});
diff --git a/packages/effect/src/metrics.ts b/packages/effect/src/metrics.ts
new file mode 100644
index 000000000000..82daf5e67a5d
--- /dev/null
+++ b/packages/effect/src/metrics.ts
@@ -0,0 +1,135 @@
+import { metrics as sentryMetrics } from '@sentry/core';
+import * as Effect from 'effect/Effect';
+import type * as Layer from 'effect/Layer';
+import { scopedDiscard } from 'effect/Layer';
+import * as Metric from 'effect/Metric';
+import * as MetricKeyType from 'effect/MetricKeyType';
+import type * as MetricPair from 'effect/MetricPair';
+import * as MetricState from 'effect/MetricState';
+import * as Schedule from 'effect/Schedule';
+
+type MetricAttributes = Record;
+
+function labelsToAttributes(labels: ReadonlyArray<{ key: string; value: string }>): MetricAttributes {
+ return labels.reduce((acc, label) => ({ ...acc, [label.key]: label.value }), {});
+}
+
+function sendMetricToSentry(pair: MetricPair.MetricPair.Untyped): void {
+ const { metricKey, metricState } = pair;
+ const name = metricKey.name;
+ const attributes = labelsToAttributes(metricKey.tags);
+
+ if (MetricState.isCounterState(metricState)) {
+ const value = Number(metricState.count);
+ sentryMetrics.count(name, value, { attributes });
+ } else if (MetricState.isGaugeState(metricState)) {
+ const value = Number(metricState.value);
+ sentryMetrics.gauge(name, value, { attributes });
+ } else if (MetricState.isHistogramState(metricState)) {
+ sentryMetrics.gauge(`${name}.sum`, metricState.sum, { attributes });
+ sentryMetrics.gauge(`${name}.count`, metricState.count, { attributes });
+ sentryMetrics.gauge(`${name}.min`, metricState.min, { attributes });
+ sentryMetrics.gauge(`${name}.max`, metricState.max, { attributes });
+ } else if (MetricState.isSummaryState(metricState)) {
+ sentryMetrics.gauge(`${name}.sum`, metricState.sum, { attributes });
+ sentryMetrics.gauge(`${name}.count`, metricState.count, { attributes });
+ sentryMetrics.gauge(`${name}.min`, metricState.min, { attributes });
+ sentryMetrics.gauge(`${name}.max`, metricState.max, { attributes });
+ } else if (MetricState.isFrequencyState(metricState)) {
+ for (const [word, count] of metricState.occurrences) {
+ sentryMetrics.count(name, count, {
+ attributes: { ...attributes, word },
+ });
+ }
+ }
+}
+
+function getMetricId(pair: MetricPair.MetricPair.Untyped): string {
+ const tags = pair.metricKey.tags.map(t => `${t.key}=${t.value}`).join(',');
+ return `${pair.metricKey.name}:${tags}`;
+}
+
+function sendDeltaMetricToSentry(
+ pair: MetricPair.MetricPair.Untyped,
+ previousCounterValues: Map,
+): void {
+ const { metricKey, metricState } = pair;
+ const name = metricKey.name;
+ const attributes = labelsToAttributes(metricKey.tags);
+ const metricId = getMetricId(pair);
+
+ if (MetricState.isCounterState(metricState)) {
+ const currentValue = Number(metricState.count);
+
+ const previousValue = previousCounterValues.get(metricId) ?? 0;
+ const delta = currentValue - previousValue;
+
+ if (delta > 0) {
+ sentryMetrics.count(name, delta, { attributes });
+ }
+
+ previousCounterValues.set(metricId, currentValue);
+ } else {
+ sendMetricToSentry(pair);
+ }
+}
+
+/**
+ * Flushes all Effect metrics to Sentry.
+ * @param previousCounterValues - Map tracking previous counter values for delta calculation
+ */
+function flushMetricsToSentry(previousCounterValues: Map): void {
+ const snapshot = Metric.unsafeSnapshot();
+
+ snapshot.forEach((pair: MetricPair.MetricPair.Untyped) => {
+ if (MetricKeyType.isCounterKey(pair.metricKey.keyType)) {
+ sendDeltaMetricToSentry(pair, previousCounterValues);
+ } else {
+ sendMetricToSentry(pair);
+ }
+ });
+}
+
+/**
+ * Creates a metrics flusher with its own isolated state for delta tracking.
+ * Useful for testing scenarios where you need to control the lifecycle.
+ * @internal
+ */
+export function createMetricsFlusher(): {
+ flush: () => void;
+ clear: () => void;
+} {
+ const previousCounterValues = new Map();
+ return {
+ flush: () => flushMetricsToSentry(previousCounterValues),
+ clear: () => previousCounterValues.clear(),
+ };
+}
+
+function createMetricsReporterEffect(previousCounterValues: Map): Effect.Effect {
+ const schedule = Schedule.spaced('10 seconds');
+
+ return Effect.repeat(
+ Effect.sync(() => flushMetricsToSentry(previousCounterValues)),
+ schedule,
+ ).pipe(Effect.asVoid, Effect.interruptible);
+}
+
+/**
+ * Effect Layer that periodically flushes metrics to Sentry.
+ * The layer manages its own state for delta counter calculations,
+ * which is automatically cleaned up when the layer is finalized.
+ */
+export const SentryEffectMetricsLayer: Layer.Layer = scopedDiscard(
+ Effect.gen(function* () {
+ const previousCounterValues = new Map();
+
+ yield* Effect.acquireRelease(Effect.void, () =>
+ Effect.sync(() => {
+ previousCounterValues.clear();
+ }),
+ );
+
+ yield* Effect.forkScoped(createMetricsReporterEffect(previousCounterValues));
+ }),
+);
diff --git a/packages/effect/src/server/index.ts b/packages/effect/src/server/index.ts
new file mode 100644
index 000000000000..76c078544af1
--- /dev/null
+++ b/packages/effect/src/server/index.ts
@@ -0,0 +1,43 @@
+import type { NodeOptions } from '@sentry/node-core/light';
+import type * as EffectLayer from 'effect/Layer';
+import { empty as emptyLayer, suspend as suspendLayer } from 'effect/Layer';
+import { init } from './sdk';
+
+export { init } from './sdk';
+
+/**
+ * Options for the Sentry Effect server layer.
+ */
+export type EffectServerLayerOptions = NodeOptions;
+
+/**
+ * Creates an Effect Layer that initializes Sentry for Node.js servers.
+ *
+ * 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
+ * - `Sentry.SentryEffectMetricsLayer` for metrics
+ *
+ * @example
+ * ```typescript
+ * import * as Sentry from '@sentry/effect/server';
+ * import { NodeRuntime } from '@effect/platform-node';
+ * import { Layer, Logger } from 'effect';
+ * import { HttpLive } from './Http.js';
+ *
+ * const SentryLive = Layer.mergeAll(
+ * Sentry.effectLayer({ dsn: '__DSN__' }),
+ * Layer.setTracer(Sentry.SentryEffectTracer),
+ * Logger.replace(Logger.defaultLogger, Sentry.SentryEffectLogger),
+ * );
+ *
+ * const MainLive = HttpLive.pipe(Layer.provide(SentryLive));
+ * MainLive.pipe(Layer.launch, NodeRuntime.runMain);
+ * ```
+ */
+export function effectLayer(options: EffectServerLayerOptions): EffectLayer.Layer {
+ return suspendLayer(() => {
+ init(options);
+ return emptyLayer;
+ });
+}
diff --git a/packages/effect/src/server/sdk.ts b/packages/effect/src/server/sdk.ts
new file mode 100644
index 000000000000..ee910be13487
--- /dev/null
+++ b/packages/effect/src/server/sdk.ts
@@ -0,0 +1,20 @@
+import type { Client } from '@sentry/core';
+import { applySdkMetadata } from '@sentry/core';
+import type { NodeOptions } from '@sentry/node-core/light';
+import { init as initNode } from '@sentry/node-core/light';
+
+/**
+ * Initializes the Sentry Effect SDK for Node.js servers.
+ *
+ * @param options - Configuration options for the SDK
+ * @returns The initialized Sentry client, or undefined if initialization failed
+ */
+export function init(options: NodeOptions): Client | undefined {
+ const opts = {
+ ...options,
+ };
+
+ applySdkMetadata(opts, 'effect', ['effect', 'node-light']);
+
+ return initNode(opts);
+}
diff --git a/packages/effect/src/tracer.ts b/packages/effect/src/tracer.ts
new file mode 100644
index 000000000000..f755101e4417
--- /dev/null
+++ b/packages/effect/src/tracer.ts
@@ -0,0 +1,159 @@
+import type { Span } from '@sentry/core';
+import { getActiveSpan, SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN, startInactiveSpan, withActiveSpan } from '@sentry/core';
+import type * as Context from 'effect/Context';
+import * as Exit from 'effect/Exit';
+import * as Option from 'effect/Option';
+import * as EffectTracer from 'effect/Tracer';
+
+function deriveOrigin(name: string): string {
+ if (name.startsWith('http.server') || name.startsWith('http.client')) {
+ return 'auto.http.effect';
+ }
+
+ return 'auto.function.effect';
+}
+
+type HrTime = [number, number];
+
+const SENTRY_SPAN_SYMBOL = Symbol.for('@sentry/effect.SentrySpan');
+
+function nanosToHrTime(nanos: bigint): HrTime {
+ const seconds = Number(nanos / BigInt(1_000_000_000));
+ const remainingNanos = Number(nanos % BigInt(1_000_000_000));
+ return [seconds, remainingNanos];
+}
+
+interface SentrySpanLike extends EffectTracer.Span {
+ readonly [SENTRY_SPAN_SYMBOL]: true;
+ readonly sentrySpan: Span;
+}
+
+function isSentrySpan(span: EffectTracer.AnySpan): span is SentrySpanLike {
+ return SENTRY_SPAN_SYMBOL in span;
+}
+
+class SentrySpanWrapper implements SentrySpanLike {
+ public readonly [SENTRY_SPAN_SYMBOL]: true;
+ public readonly _tag: 'Span';
+ public readonly spanId: string;
+ public readonly traceId: string;
+ public readonly attributes: Map;
+ public readonly sampled: boolean;
+ public readonly parent: Option.Option;
+ public readonly links: Array;
+ public status: EffectTracer.SpanStatus;
+ public readonly sentrySpan: Span;
+
+ public constructor(
+ public readonly name: string,
+ parent: Option.Option,
+ public readonly context: Context.Context,
+ links: ReadonlyArray,
+ startTime: bigint,
+ public readonly kind: EffectTracer.SpanKind,
+ existingSpan: Span,
+ ) {
+ this[SENTRY_SPAN_SYMBOL] = true as const;
+ this._tag = 'Span' as const;
+ this.attributes = new Map();
+ this.parent = parent;
+ this.links = [...links];
+ this.sentrySpan = existingSpan;
+
+ const spanContext = this.sentrySpan.spanContext();
+ this.spanId = spanContext.spanId;
+ this.traceId = spanContext.traceId;
+ this.sampled = this.sentrySpan.isRecording();
+ this.status = {
+ _tag: 'Started',
+ startTime,
+ };
+ }
+
+ public attribute(key: string, value: unknown): void {
+ if (!this.sentrySpan.isRecording()) {
+ return;
+ }
+
+ this.sentrySpan.setAttribute(key, value as Parameters[1]);
+ this.attributes.set(key, value);
+ }
+
+ public addLinks(links: ReadonlyArray): void {
+ this.links.push(...links);
+ }
+
+ public end(endTime: bigint, exit: Exit.Exit): void {
+ this.status = {
+ _tag: 'Ended',
+ endTime,
+ exit,
+ startTime: this.status.startTime,
+ };
+
+ if (!this.sentrySpan.isRecording()) {
+ return;
+ }
+
+ if (Exit.isFailure(exit)) {
+ const cause = exit.cause;
+ const message =
+ cause._tag === 'Fail' ? String(cause.error) : cause._tag === 'Die' ? String(cause.defect) : 'internal_error';
+ this.sentrySpan.setStatus({ code: 2, message });
+ } else {
+ this.sentrySpan.setStatus({ code: 1 });
+ }
+
+ this.sentrySpan.end(nanosToHrTime(endTime));
+ }
+
+ public event(name: string, startTime: bigint, attributes?: Record): void {
+ if (!this.sentrySpan.isRecording()) {
+ return;
+ }
+
+ this.sentrySpan.addEvent(name, attributes as Parameters[1], nanosToHrTime(startTime));
+ }
+}
+
+function createSentrySpan(
+ name: string,
+ parent: Option.Option,
+ context: Context.Context,
+ links: ReadonlyArray,
+ startTime: bigint,
+ kind: EffectTracer.SpanKind,
+): SentrySpanLike {
+ const parentSentrySpan =
+ Option.isSome(parent) && isSentrySpan(parent.value) ? parent.value.sentrySpan : (getActiveSpan() ?? null);
+
+ const newSpan = startInactiveSpan({
+ name,
+ startTime: nanosToHrTime(startTime),
+ attributes: {
+ [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: deriveOrigin(name),
+ },
+ ...(parentSentrySpan ? { parentSpan: parentSentrySpan } : {}),
+ });
+
+ return new SentrySpanWrapper(name, parent, context, links, startTime, kind, newSpan);
+}
+
+const makeSentryTracer = (): EffectTracer.Tracer =>
+ EffectTracer.make({
+ span(name, parent, context, links, startTime, kind) {
+ return createSentrySpan(name, parent, context, links, startTime, kind);
+ },
+ context(execution, fiber) {
+ const currentSpan = fiber.currentSpan;
+ if (currentSpan === undefined || !isSentrySpan(currentSpan)) {
+ return execution();
+ }
+ return withActiveSpan(currentSpan.sentrySpan, execution);
+ },
+ });
+
+/**
+ * Effect Layer that sets up the Sentry tracer for Effect spans.
+ */
+export const SentryEffectTracer = makeSentryTracer();
diff --git a/packages/effect/test/index.test.ts b/packages/effect/test/index.test.ts
new file mode 100644
index 000000000000..950ec06fb670
--- /dev/null
+++ b/packages/effect/test/index.test.ts
@@ -0,0 +1,8 @@
+import { describe, expect, it } from 'vitest';
+import * as index from '../src/index.client';
+
+describe('effect index export', () => {
+ it('has correct exports', () => {
+ expect(index.captureException).toBeDefined();
+ });
+});
diff --git a/packages/effect/test/layer.test.ts b/packages/effect/test/layer.test.ts
new file mode 100644
index 000000000000..590502fb657e
--- /dev/null
+++ b/packages/effect/test/layer.test.ts
@@ -0,0 +1,180 @@
+import { describe, expect, it } from '@effect/vitest';
+import * as sentryCore from '@sentry/core';
+import { getClient, getCurrentScope, getIsolationScope, SDK_VERSION, SentrySpan } from '@sentry/core';
+import { Effect, Layer, Logger, LogLevel } 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';
+
+function getMockTransport() {
+ return () => ({
+ send: vi.fn().mockResolvedValue({}),
+ flush: vi.fn().mockResolvedValue(true),
+ });
+}
+
+describe.each([
+ [
+ {
+ subSdkName: 'browser',
+ effectLayer: sentryClient.effectLayer,
+ SentryEffectTracer: sentryClient.SentryEffectTracer,
+ SentryEffectLogger: sentryClient.SentryEffectLogger,
+ SentryEffectMetricsLayer: sentryClient.SentryEffectMetricsLayer,
+ },
+ ],
+ [
+ {
+ subSdkName: 'node-light',
+ effectLayer: sentryServer.effectLayer,
+ SentryEffectTracer: sentryServer.SentryEffectTracer,
+ SentryEffectLogger: sentryServer.SentryEffectLogger,
+ SentryEffectMetricsLayer: sentryServer.SentryEffectMetricsLayer,
+ },
+ ],
+])('effectLayer ($subSdkName)', ({ subSdkName, effectLayer, SentryEffectTracer, SentryEffectLogger }) => {
+ beforeEach(() => {
+ getCurrentScope().clear();
+ getIsolationScope().clear();
+ });
+
+ afterEach(() => {
+ getCurrentScope().setClient(undefined);
+ vi.restoreAllMocks();
+ });
+
+ it('creates a valid Effect layer', () => {
+ const layer = effectLayer({
+ dsn: TEST_DSN,
+ transport: getMockTransport(),
+ });
+
+ expect(layer).toBeDefined();
+ expect(Layer.isLayer(layer)).toBe(true);
+ });
+
+ it.effect('applies SDK metadata', () =>
+ Effect.gen(function* () {
+ yield* Effect.void;
+
+ const client = getClient();
+ const metadata = client?.getOptions()._metadata?.sdk;
+
+ expect(metadata?.name).toBe('sentry.javascript.effect');
+ expect(metadata?.packages).toEqual([
+ { name: 'npm:@sentry/effect', version: SDK_VERSION },
+ { name: `npm:@sentry/${subSdkName}`, version: SDK_VERSION },
+ ]);
+ }).pipe(
+ Effect.provide(
+ effectLayer({
+ dsn: TEST_DSN,
+ transport: getMockTransport(),
+ }),
+ ),
+ ),
+ );
+
+ it.effect('layer can be provided to an Effect program', () =>
+ Effect.gen(function* () {
+ const result = yield* Effect.succeed('test-result');
+ expect(result).toBe('test-result');
+ }).pipe(
+ Effect.provide(
+ effectLayer({
+ dsn: TEST_DSN,
+ transport: getMockTransport(),
+ }),
+ ),
+ ),
+ );
+
+ it.effect('layer enables tracing when tracer is set', () =>
+ Effect.gen(function* () {
+ const startInactiveSpanMock = vi.spyOn(sentryCore, 'startInactiveSpan');
+
+ const result = yield* Effect.withSpan('test-span')(Effect.succeed('traced'));
+ expect(result).toBe('traced');
+ expect(startInactiveSpanMock).toHaveBeenCalledWith(expect.objectContaining({ name: 'test-span' }));
+ }).pipe(
+ Effect.withTracer(SentryEffectTracer),
+ Effect.provide(
+ effectLayer({
+ dsn: TEST_DSN,
+ transport: getMockTransport(),
+ }),
+ ),
+ ),
+ );
+
+ it.effect('layer can be composed with tracer layer', () =>
+ Effect.gen(function* () {
+ const startInactiveSpanMock = vi.spyOn(sentryCore, 'startInactiveSpan');
+
+ const result = yield* Effect.succeed(42).pipe(
+ Effect.map(n => n * 2),
+ Effect.withSpan('computation'),
+ );
+ expect(result).toBe(84);
+ expect(startInactiveSpanMock).toHaveBeenCalledWith(expect.objectContaining({ name: 'computation' }));
+ }).pipe(
+ Effect.provide(
+ Layer.mergeAll(
+ effectLayer({
+ dsn: TEST_DSN,
+ transport: getMockTransport(),
+ }),
+ Layer.setTracer(SentryEffectTracer),
+ ),
+ ),
+ ),
+ );
+
+ it.effect('layer can be composed with logger layer', () =>
+ Effect.gen(function* () {
+ yield* Effect.logInfo('test log');
+ const result = yield* Effect.succeed('logged');
+ expect(result).toBe('logged');
+ }).pipe(
+ Effect.provide(
+ Layer.mergeAll(
+ effectLayer({
+ dsn: TEST_DSN,
+ transport: getMockTransport(),
+ }),
+ Logger.replace(Logger.defaultLogger, SentryEffectLogger),
+ Logger.minimumLogLevel(LogLevel.All),
+ ),
+ ),
+ ),
+ );
+
+ it.effect('layer can be composed with all Effect features', () =>
+ Effect.gen(function* () {
+ const startInactiveSpanMock = vi.spyOn(sentryCore, 'startInactiveSpan');
+
+ yield* Effect.logInfo('starting computation');
+ const result = yield* Effect.succeed(42).pipe(
+ Effect.map(n => n * 2),
+ Effect.withSpan('computation'),
+ );
+ yield* Effect.logInfo('computation complete');
+ expect(result).toBe(84);
+ expect(startInactiveSpanMock).toHaveBeenCalledWith(expect.objectContaining({ name: 'computation' }));
+ }).pipe(
+ Effect.provide(
+ Layer.mergeAll(
+ effectLayer({
+ dsn: TEST_DSN,
+ transport: getMockTransport(),
+ }),
+ Layer.setTracer(SentryEffectTracer),
+ Logger.replace(Logger.defaultLogger, SentryEffectLogger),
+ Logger.minimumLogLevel(LogLevel.All),
+ ),
+ ),
+ ),
+ );
+});
diff --git a/packages/effect/test/logger.test.ts b/packages/effect/test/logger.test.ts
new file mode 100644
index 000000000000..c372784b483f
--- /dev/null
+++ b/packages/effect/test/logger.test.ts
@@ -0,0 +1,104 @@
+import { describe, expect, it } from '@effect/vitest';
+import * as sentryCore from '@sentry/core';
+import { Effect, Layer, Logger, LogLevel } from 'effect';
+import { afterEach, vi } from 'vitest';
+import { SentryEffectLogger } from '../src/logger';
+
+vi.mock('@sentry/core', async importOriginal => {
+ const original = await importOriginal();
+ return {
+ ...original,
+ logger: {
+ ...original.logger,
+ error: vi.fn(),
+ warn: vi.fn(),
+ info: vi.fn(),
+ debug: vi.fn(),
+ trace: vi.fn(),
+ fatal: vi.fn(),
+ },
+ };
+});
+
+describe('SentryEffectLogger', () => {
+ afterEach(() => {
+ vi.clearAllMocks();
+ });
+
+ const loggerLayer = Layer.mergeAll(
+ Logger.replace(Logger.defaultLogger, SentryEffectLogger),
+ Logger.minimumLogLevel(LogLevel.All),
+ );
+
+ it.effect('forwards fatal logs to Sentry', () =>
+ Effect.gen(function* () {
+ yield* Effect.logFatal('This is a fatal message');
+ expect(sentryCore.logger.fatal).toHaveBeenCalledWith('This is a fatal message');
+ }).pipe(Effect.provide(loggerLayer)),
+ );
+
+ it.effect('forwards error logs to Sentry', () =>
+ Effect.gen(function* () {
+ yield* Effect.logError('This is an error message');
+ expect(sentryCore.logger.error).toHaveBeenCalledWith('This is an error message');
+ }).pipe(Effect.provide(loggerLayer)),
+ );
+
+ it.effect('forwards warning logs to Sentry', () =>
+ Effect.gen(function* () {
+ yield* Effect.logWarning('This is a warning message');
+ expect(sentryCore.logger.warn).toHaveBeenCalledWith('This is a warning message');
+ }).pipe(Effect.provide(loggerLayer)),
+ );
+
+ it.effect('forwards info logs to Sentry', () =>
+ Effect.gen(function* () {
+ yield* Effect.logInfo('This is an info message');
+ expect(sentryCore.logger.info).toHaveBeenCalledWith('This is an info message');
+ }).pipe(Effect.provide(loggerLayer)),
+ );
+
+ it.effect('forwards debug logs to Sentry', () =>
+ Effect.gen(function* () {
+ yield* Effect.logDebug('This is a debug message');
+ expect(sentryCore.logger.debug).toHaveBeenCalledWith('This is a debug message');
+ }).pipe(Effect.provide(loggerLayer)),
+ );
+
+ it.effect('forwards trace logs to Sentry', () =>
+ Effect.gen(function* () {
+ yield* Effect.logTrace('This is a trace message');
+ expect(sentryCore.logger.trace).toHaveBeenCalledWith('This is a trace message');
+ }).pipe(Effect.provide(loggerLayer)),
+ );
+
+ it.effect('handles object messages by stringifying', () =>
+ Effect.gen(function* () {
+ yield* Effect.logInfo({ key: 'value', nested: { foo: 'bar' } });
+ expect(sentryCore.logger.info).toHaveBeenCalledWith('{"key":"value","nested":{"foo":"bar"}}');
+ }).pipe(Effect.provide(loggerLayer)),
+ );
+
+ it.effect('handles multiple log calls', () =>
+ Effect.gen(function* () {
+ yield* Effect.logInfo('First message');
+ yield* Effect.logInfo('Second message');
+ yield* Effect.logWarning('Third message');
+ expect(sentryCore.logger.info).toHaveBeenCalledTimes(2);
+ expect(sentryCore.logger.info).toHaveBeenNthCalledWith(1, 'First message');
+ expect(sentryCore.logger.info).toHaveBeenNthCalledWith(2, 'Second message');
+ expect(sentryCore.logger.warn).toHaveBeenCalledWith('Third message');
+ }).pipe(Effect.provide(loggerLayer)),
+ );
+
+ it.effect('works with Effect.tap for logging side effects', () =>
+ Effect.gen(function* () {
+ const result = yield* Effect.succeed('data').pipe(
+ Effect.tap(data => Effect.logInfo(`Processing: ${data}`)),
+ Effect.map(d => d.toUpperCase()),
+ );
+ expect(result).toBe('DATA');
+ expect(sentryCore.logger.info).toHaveBeenCalledWith('Processing: data');
+ }).pipe(Effect.provide(loggerLayer)),
+ );
+});
diff --git a/packages/effect/test/metrics.test.ts b/packages/effect/test/metrics.test.ts
new file mode 100644
index 000000000000..8c2b092b967f
--- /dev/null
+++ b/packages/effect/test/metrics.test.ts
@@ -0,0 +1,321 @@
+import { describe, expect, it } from '@effect/vitest';
+import * as sentryCore from '@sentry/core';
+import { Duration, Effect, Metric, MetricBoundaries, MetricLabel } from 'effect';
+import { afterEach, beforeEach, vi } from 'vitest';
+import { createMetricsFlusher } from '../src/metrics';
+
+describe('SentryEffectMetricsLayer', () => {
+ const mockCount = vi.fn();
+ const mockGauge = vi.fn();
+ const mockDistribution = vi.fn();
+
+ beforeEach(() => {
+ vi.spyOn(sentryCore.metrics, 'count').mockImplementation(mockCount);
+ vi.spyOn(sentryCore.metrics, 'gauge').mockImplementation(mockGauge);
+ vi.spyOn(sentryCore.metrics, 'distribution').mockImplementation(mockDistribution);
+ });
+
+ afterEach(() => {
+ vi.clearAllMocks();
+ vi.restoreAllMocks();
+ });
+
+ it.effect('creates counter metrics', () =>
+ Effect.gen(function* () {
+ const counter = Metric.counter('test_counter');
+
+ yield* Metric.increment(counter);
+ yield* Metric.increment(counter);
+ yield* Metric.incrementBy(counter, 5);
+
+ const snapshot = Metric.unsafeSnapshot();
+ const counterMetric = snapshot.find(p => p.metricKey.name === 'test_counter');
+
+ expect(counterMetric).toBeDefined();
+ }),
+ );
+
+ it.effect('creates gauge metrics', () =>
+ Effect.gen(function* () {
+ const gauge = Metric.gauge('test_gauge');
+
+ yield* Metric.set(gauge, 42);
+
+ const snapshot = Metric.unsafeSnapshot();
+ const gaugeMetric = snapshot.find(p => p.metricKey.name === 'test_gauge');
+
+ expect(gaugeMetric).toBeDefined();
+ }),
+ );
+
+ it.effect('creates histogram metrics', () =>
+ Effect.gen(function* () {
+ const histogram = Metric.histogram('test_histogram', MetricBoundaries.linear({ start: 0, width: 10, count: 10 }));
+
+ yield* Metric.update(histogram, 5);
+ yield* Metric.update(histogram, 15);
+ yield* Metric.update(histogram, 25);
+
+ const snapshot = Metric.unsafeSnapshot();
+ const histogramMetric = snapshot.find(p => p.metricKey.name === 'test_histogram');
+
+ expect(histogramMetric).toBeDefined();
+ }),
+ );
+
+ it.effect('creates summary metrics', () =>
+ Effect.gen(function* () {
+ const summary = Metric.summary({
+ name: 'test_summary',
+ maxAge: '1 minute',
+ maxSize: 100,
+ error: 0.01,
+ quantiles: [0.5, 0.9, 0.99],
+ });
+
+ yield* Metric.update(summary, 10);
+ yield* Metric.update(summary, 20);
+ yield* Metric.update(summary, 30);
+
+ const snapshot = Metric.unsafeSnapshot();
+ const summaryMetric = snapshot.find(p => p.metricKey.name === 'test_summary');
+
+ expect(summaryMetric).toBeDefined();
+ }),
+ );
+
+ it.effect('creates frequency metrics', () =>
+ Effect.gen(function* () {
+ const frequency = Metric.frequency('test_frequency');
+
+ yield* Metric.update(frequency, 'foo');
+ yield* Metric.update(frequency, 'bar');
+ yield* Metric.update(frequency, 'foo');
+
+ const snapshot = Metric.unsafeSnapshot();
+ const frequencyMetric = snapshot.find(p => p.metricKey.name === 'test_frequency');
+
+ expect(frequencyMetric).toBeDefined();
+ }),
+ );
+
+ it.effect('supports metrics with labels', () =>
+ Effect.gen(function* () {
+ const counter = Metric.counter('labeled_counter').pipe(
+ Metric.taggedWithLabels([MetricLabel.make('env', 'test'), MetricLabel.make('service', 'my-service')]),
+ );
+
+ yield* Metric.increment(counter);
+
+ const snapshot = Metric.unsafeSnapshot();
+ const labeledMetric = snapshot.find(p => p.metricKey.name === 'labeled_counter');
+
+ expect(labeledMetric).toBeDefined();
+ const tags = labeledMetric?.metricKey.tags ?? [];
+ expect(tags.some(t => t.key === 'env' && t.value === 'test')).toBe(true);
+ expect(tags.some(t => t.key === 'service' && t.value === 'my-service')).toBe(true);
+ }),
+ );
+
+ it.effect('tracks Effect durations with timer metric', () =>
+ Effect.gen(function* () {
+ const timer = Metric.timerWithBoundaries('operation_duration', [10, 50, 100, 500, 1000]);
+
+ yield* Effect.succeed('done').pipe(Metric.trackDuration(timer));
+
+ const snapshot = Metric.unsafeSnapshot();
+ const timerMetric = snapshot.find(p => p.metricKey.name === 'operation_duration');
+
+ expect(timerMetric).toBeDefined();
+ }),
+ );
+
+ it.effect('integrates with Effect.timed', () =>
+ Effect.gen(function* () {
+ const [duration, result] = yield* Effect.timed(Effect.succeed('completed'));
+
+ expect(result).toBe('completed');
+ expect(Duration.toMillis(duration)).toBeGreaterThanOrEqual(0);
+ }),
+ );
+});
+
+describe('createMetricsFlusher', () => {
+ const mockCount = vi.fn();
+ const mockGauge = vi.fn();
+ const mockDistribution = vi.fn();
+
+ beforeEach(() => {
+ vi.spyOn(sentryCore.metrics, 'count').mockImplementation(mockCount);
+ vi.spyOn(sentryCore.metrics, 'gauge').mockImplementation(mockGauge);
+ vi.spyOn(sentryCore.metrics, 'distribution').mockImplementation(mockDistribution);
+ });
+
+ afterEach(() => {
+ vi.clearAllMocks();
+ vi.restoreAllMocks();
+ });
+
+ it.effect('sends counter metrics to Sentry', () =>
+ Effect.gen(function* () {
+ const flusher = createMetricsFlusher();
+ const counter = Metric.counter('flush_test_counter');
+
+ yield* Metric.increment(counter);
+ yield* Metric.incrementBy(counter, 4);
+
+ flusher.flush();
+
+ expect(mockCount).toHaveBeenCalledWith('flush_test_counter', 5, { attributes: {} });
+ }),
+ );
+
+ it.effect('sends gauge metrics to Sentry', () =>
+ Effect.gen(function* () {
+ const flusher = createMetricsFlusher();
+ const gauge = Metric.gauge('flush_test_gauge');
+
+ yield* Metric.set(gauge, 42);
+
+ flusher.flush();
+
+ expect(mockGauge).toHaveBeenCalledWith('flush_test_gauge', 42, { attributes: {} });
+ }),
+ );
+
+ it.effect('sends histogram metrics to Sentry', () =>
+ Effect.gen(function* () {
+ const flusher = createMetricsFlusher();
+ const histogram = Metric.histogram(
+ 'flush_test_histogram',
+ MetricBoundaries.linear({ start: 0, width: 10, count: 5 }),
+ );
+
+ yield* Metric.update(histogram, 5);
+ yield* Metric.update(histogram, 15);
+
+ flusher.flush();
+
+ expect(mockGauge).toHaveBeenCalledWith('flush_test_histogram.sum', expect.any(Number), { attributes: {} });
+ expect(mockGauge).toHaveBeenCalledWith('flush_test_histogram.count', expect.any(Number), { attributes: {} });
+ expect(mockGauge).toHaveBeenCalledWith('flush_test_histogram.min', expect.any(Number), { attributes: {} });
+ expect(mockGauge).toHaveBeenCalledWith('flush_test_histogram.max', expect.any(Number), { attributes: {} });
+ }),
+ );
+
+ it.effect('sends summary metrics to Sentry', () =>
+ Effect.gen(function* () {
+ const flusher = createMetricsFlusher();
+ const summary = Metric.summary({
+ name: 'flush_test_summary',
+ maxAge: '1 minute',
+ maxSize: 100,
+ error: 0.01,
+ quantiles: [0.5, 0.9, 0.99],
+ });
+
+ yield* Metric.update(summary, 10);
+ yield* Metric.update(summary, 20);
+ yield* Metric.update(summary, 30);
+
+ flusher.flush();
+
+ expect(mockGauge).toHaveBeenCalledWith('flush_test_summary.sum', 60, { attributes: {} });
+ expect(mockGauge).toHaveBeenCalledWith('flush_test_summary.count', 3, { attributes: {} });
+ expect(mockGauge).toHaveBeenCalledWith('flush_test_summary.min', 10, { attributes: {} });
+ expect(mockGauge).toHaveBeenCalledWith('flush_test_summary.max', 30, { attributes: {} });
+ }),
+ );
+
+ it.effect('sends frequency metrics to Sentry', () =>
+ Effect.gen(function* () {
+ const flusher = createMetricsFlusher();
+ const frequency = Metric.frequency('flush_test_frequency');
+
+ yield* Metric.update(frequency, 'apple');
+ yield* Metric.update(frequency, 'banana');
+ yield* Metric.update(frequency, 'apple');
+
+ flusher.flush();
+
+ expect(mockCount).toHaveBeenCalledWith('flush_test_frequency', 2, { attributes: { word: 'apple' } });
+ expect(mockCount).toHaveBeenCalledWith('flush_test_frequency', 1, { attributes: { word: 'banana' } });
+ }),
+ );
+
+ it.effect('sends metrics with labels as attributes to Sentry', () =>
+ Effect.gen(function* () {
+ const flusher = createMetricsFlusher();
+ const gauge = Metric.gauge('flush_test_labeled_gauge').pipe(
+ Metric.taggedWithLabels([MetricLabel.make('env', 'production'), MetricLabel.make('region', 'us-east')]),
+ );
+
+ yield* Metric.set(gauge, 100);
+
+ flusher.flush();
+
+ expect(mockGauge).toHaveBeenCalledWith('flush_test_labeled_gauge', 100, {
+ attributes: { env: 'production', region: 'us-east' },
+ });
+ }),
+ );
+
+ it.effect('sends counter delta values on subsequent flushes', () =>
+ Effect.gen(function* () {
+ const flusher = createMetricsFlusher();
+ const counter = Metric.counter('flush_test_delta_counter');
+
+ yield* Metric.incrementBy(counter, 10);
+ flusher.flush();
+
+ mockCount.mockClear();
+
+ yield* Metric.incrementBy(counter, 5);
+ flusher.flush();
+
+ expect(mockCount).toHaveBeenCalledWith('flush_test_delta_counter', 5, { attributes: {} });
+ }),
+ );
+
+ it.effect('does not send counter when delta is zero', () =>
+ Effect.gen(function* () {
+ const flusher = createMetricsFlusher();
+ const counter = Metric.counter('flush_test_zero_delta');
+
+ yield* Metric.incrementBy(counter, 10);
+ flusher.flush();
+
+ mockCount.mockClear();
+
+ flusher.flush();
+
+ expect(mockCount).not.toHaveBeenCalledWith('flush_test_zero_delta', 0, { attributes: {} });
+ }),
+ );
+
+ it.effect('clear() resets delta tracking state', () =>
+ Effect.gen(function* () {
+ const flusher = createMetricsFlusher();
+ const counter = Metric.counter('flush_test_clear_counter');
+
+ yield* Metric.incrementBy(counter, 10);
+ flusher.flush();
+
+ mockCount.mockClear();
+ flusher.clear();
+
+ flusher.flush();
+
+ expect(mockCount).toHaveBeenCalledWith('flush_test_clear_counter', 10, { attributes: {} });
+ }),
+ );
+
+ it('each flusher has isolated state', () => {
+ const flusher1 = createMetricsFlusher();
+ const flusher2 = createMetricsFlusher();
+
+ expect(flusher1).not.toBe(flusher2);
+ expect(flusher1.flush).not.toBe(flusher2.flush);
+ expect(flusher1.clear).not.toBe(flusher2.clear);
+ });
+});
diff --git a/packages/effect/test/tracer.test.ts b/packages/effect/test/tracer.test.ts
new file mode 100644
index 000000000000..9583e7d12c5b
--- /dev/null
+++ b/packages/effect/test/tracer.test.ts
@@ -0,0 +1,284 @@
+import { describe, expect, it } from '@effect/vitest';
+import * as sentryCore from '@sentry/core';
+import { SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN } from '@sentry/core';
+import { Effect, Layer } from 'effect';
+import { afterEach, vi } from 'vitest';
+import { SentryEffectTracer } from '../src/tracer';
+
+const TracerLayer = Layer.setTracer(SentryEffectTracer);
+
+describe('SentryEffectTracer', () => {
+ afterEach(() => {
+ vi.restoreAllMocks();
+ });
+
+ it.effect('traces Effect spans to Sentry', () =>
+ Effect.gen(function* () {
+ let capturedSpanName: string | undefined;
+
+ yield* Effect.withSpan('test-parent-span')(
+ Effect.gen(function* () {
+ yield* Effect.annotateCurrentSpan('test-attribute', 'test-value');
+ capturedSpanName = 'effect-span-executed';
+ }),
+ );
+
+ expect(capturedSpanName).toBe('effect-span-executed');
+ }).pipe(Effect.provide(TracerLayer)),
+ );
+
+ it.effect('creates spans with correct attributes', () =>
+ Effect.gen(function* () {
+ const result = yield* Effect.withSpan('my-operation')(Effect.succeed('success'));
+
+ expect(result).toBe('success');
+ }).pipe(Effect.provide(TracerLayer)),
+ );
+
+ it.effect('handles nested spans', () =>
+ Effect.gen(function* () {
+ const result = yield* Effect.withSpan('outer')(
+ Effect.gen(function* () {
+ const inner = yield* Effect.withSpan('inner')(Effect.succeed('inner-result'));
+ return `outer-${inner}`;
+ }),
+ );
+
+ expect(result).toBe('outer-inner-result');
+ }).pipe(Effect.provide(TracerLayer)),
+ );
+
+ it.effect('propagates span context through Effect fibers', () =>
+ Effect.gen(function* () {
+ const results: string[] = [];
+
+ yield* Effect.withSpan('parent')(
+ Effect.gen(function* () {
+ results.push('parent-start');
+ yield* Effect.withSpan('child-1')(Effect.sync(() => results.push('child-1')));
+ yield* Effect.withSpan('child-2')(Effect.sync(() => results.push('child-2')));
+ results.push('parent-end');
+ }),
+ );
+
+ expect(results).toEqual(['parent-start', 'child-1', 'child-2', 'parent-end']);
+ }).pipe(Effect.provide(TracerLayer)),
+ );
+
+ it.effect('handles span failures correctly', () =>
+ Effect.gen(function* () {
+ const result = yield* Effect.withSpan('failing-span')(Effect.fail('expected-error')).pipe(
+ Effect.catchAll(e => Effect.succeed(`caught: ${e}`)),
+ );
+
+ expect(result).toBe('caught: expected-error');
+ }).pipe(Effect.provide(TracerLayer)),
+ );
+
+ it.effect('handles span with defects (die)', () =>
+ Effect.gen(function* () {
+ const result = yield* Effect.withSpan('defect-span')(Effect.die('defect-value')).pipe(
+ Effect.catchAllDefect(d => Effect.succeed(`caught-defect: ${d}`)),
+ );
+
+ expect(result).toBe('caught-defect: defect-value');
+ }).pipe(Effect.provide(TracerLayer)),
+ );
+
+ it.effect('works with Effect.all for parallel operations', () =>
+ Effect.gen(function* () {
+ const results = yield* Effect.withSpan('parallel-parent')(
+ Effect.all([
+ Effect.withSpan('task-1')(Effect.succeed(1)),
+ Effect.withSpan('task-2')(Effect.succeed(2)),
+ Effect.withSpan('task-3')(Effect.succeed(3)),
+ ]),
+ );
+
+ expect(results).toEqual([1, 2, 3]);
+ }).pipe(Effect.provide(TracerLayer)),
+ );
+
+ it.effect('supports span annotations', () =>
+ Effect.gen(function* () {
+ const result = yield* Effect.succeed('annotated').pipe(
+ Effect.withSpan('annotated-span'),
+ Effect.tap(() => Effect.annotateCurrentSpan('custom-key', 'custom-value')),
+ );
+
+ expect(result).toBe('annotated');
+ }).pipe(Effect.provide(TracerLayer)),
+ );
+
+ it.effect('sets span status to ok on success', () =>
+ Effect.gen(function* () {
+ const setStatusCalls: Array<{ code: number; message?: string }> = [];
+
+ const mockStartInactiveSpan = vi.spyOn(sentryCore, 'startInactiveSpan').mockImplementation(_options => {
+ return {
+ spanContext: () => ({ spanId: 'test-span-id', traceId: 'test-trace-id' }),
+ isRecording: () => true,
+ setAttribute: vi.fn(),
+ setStatus: (status: { code: number; message?: string }) => setStatusCalls.push(status),
+ addEvent: vi.fn(),
+ end: vi.fn(),
+ } as unknown as sentryCore.Span;
+ });
+
+ yield* Effect.withSpan('success-span')(Effect.succeed('ok'));
+
+ expect(setStatusCalls).toContainEqual({ code: 1 });
+
+ mockStartInactiveSpan.mockRestore();
+ }).pipe(Effect.provide(TracerLayer)),
+ );
+
+ it.effect('sets span status to error on failure', () =>
+ Effect.gen(function* () {
+ const setStatusCalls: Array<{ code: number; message?: string }> = [];
+
+ const mockStartInactiveSpan = vi.spyOn(sentryCore, 'startInactiveSpan').mockImplementation(_options => {
+ return {
+ spanContext: () => ({ spanId: 'test-span-id', traceId: 'test-trace-id' }),
+ isRecording: () => true,
+ setAttribute: vi.fn(),
+ setStatus: (status: { code: number; message?: string }) => setStatusCalls.push(status),
+ addEvent: vi.fn(),
+ end: vi.fn(),
+ } as unknown as sentryCore.Span;
+ });
+
+ yield* Effect.withSpan('error-span')(Effect.fail('test-error')).pipe(Effect.catchAll(() => Effect.void));
+
+ expect(setStatusCalls).toContainEqual({ code: 2, message: 'test-error' });
+
+ mockStartInactiveSpan.mockRestore();
+ }).pipe(Effect.provide(TracerLayer)),
+ );
+
+ it.effect('sets span status to error on defect', () =>
+ Effect.gen(function* () {
+ const setStatusCalls: Array<{ code: number; message?: string }> = [];
+
+ const mockStartInactiveSpan = vi.spyOn(sentryCore, 'startInactiveSpan').mockImplementation(_options => {
+ return {
+ spanContext: () => ({ spanId: 'test-span-id', traceId: 'test-trace-id' }),
+ isRecording: () => true,
+ setAttribute: vi.fn(),
+ setStatus: (status: { code: number; message?: string }) => setStatusCalls.push(status),
+ addEvent: vi.fn(),
+ end: vi.fn(),
+ } as unknown as sentryCore.Span;
+ });
+
+ yield* Effect.withSpan('defect-span')(Effect.die('fatal-defect')).pipe(Effect.catchAllDefect(() => Effect.void));
+
+ expect(setStatusCalls).toContainEqual({ code: 2, message: 'fatal-defect' });
+
+ mockStartInactiveSpan.mockRestore();
+ }).pipe(Effect.provide(TracerLayer)),
+ );
+
+ it.effect('propagates Sentry span context via withActiveSpan', () =>
+ Effect.gen(function* () {
+ const withActiveSpanCalls: sentryCore.Span[] = [];
+
+ const mockWithActiveSpan = vi
+ .spyOn(sentryCore, 'withActiveSpan')
+ .mockImplementation((span: sentryCore.Span | null, callback: (scope: sentryCore.Scope) => T): T => {
+ if (span) {
+ withActiveSpanCalls.push(span);
+ }
+ return callback({} as sentryCore.Scope);
+ });
+
+ yield* Effect.withSpan('context-span')(Effect.succeed('done'));
+
+ expect(withActiveSpanCalls.length).toBeGreaterThan(0);
+
+ mockWithActiveSpan.mockRestore();
+ }).pipe(Effect.provide(TracerLayer)),
+ );
+
+ it.effect('sets origin to auto.function.effect for regular spans', () =>
+ Effect.gen(function* () {
+ let capturedAttributes: Record | undefined;
+
+ const mockStartInactiveSpan = vi.spyOn(sentryCore, 'startInactiveSpan').mockImplementation(options => {
+ capturedAttributes = options.attributes;
+ return {
+ spanContext: () => ({ spanId: 'test-span-id', traceId: 'test-trace-id' }),
+ isRecording: () => true,
+ setAttribute: vi.fn(),
+ setStatus: vi.fn(),
+ addEvent: vi.fn(),
+ end: vi.fn(),
+ } as unknown as sentryCore.Span;
+ });
+
+ yield* Effect.withSpan('my-operation')(Effect.succeed('ok'));
+
+ expect(capturedAttributes).toBeDefined();
+ expect(capturedAttributes?.[SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]).toBe('auto.function.effect');
+
+ mockStartInactiveSpan.mockRestore();
+ }).pipe(Effect.provide(TracerLayer)),
+ );
+
+ it.effect('sets origin to auto.http.effect for http.server spans', () =>
+ Effect.gen(function* () {
+ let capturedAttributes: Record | undefined;
+
+ const mockStartInactiveSpan = vi.spyOn(sentryCore, 'startInactiveSpan').mockImplementation(options => {
+ capturedAttributes = options.attributes;
+ return {
+ spanContext: () => ({ spanId: 'test-span-id', traceId: 'test-trace-id' }),
+ isRecording: () => true,
+ setAttribute: vi.fn(),
+ setStatus: vi.fn(),
+ addEvent: vi.fn(),
+ end: vi.fn(),
+ } as unknown as sentryCore.Span;
+ });
+
+ yield* Effect.withSpan('http.server GET /api/users')(Effect.succeed('ok'));
+
+ expect(capturedAttributes).toBeDefined();
+ expect(capturedAttributes?.[SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]).toBe('auto.http.effect');
+
+ mockStartInactiveSpan.mockRestore();
+ }).pipe(Effect.provide(TracerLayer)),
+ );
+
+ it.effect('sets origin to auto.http.effect for http.client spans', () =>
+ Effect.gen(function* () {
+ let capturedAttributes: Record | undefined;
+
+ const mockStartInactiveSpan = vi.spyOn(sentryCore, 'startInactiveSpan').mockImplementation(options => {
+ capturedAttributes = options.attributes;
+ return {
+ spanContext: () => ({ spanId: 'test-span-id', traceId: 'test-trace-id' }),
+ isRecording: () => true,
+ setAttribute: vi.fn(),
+ setStatus: vi.fn(),
+ addEvent: vi.fn(),
+ end: vi.fn(),
+ } as unknown as sentryCore.Span;
+ });
+
+ yield* Effect.withSpan('http.client GET https://api.example.com')(Effect.succeed('ok'));
+
+ expect(capturedAttributes).toBeDefined();
+ expect(capturedAttributes?.[SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]).toBe('auto.http.effect');
+
+ mockStartInactiveSpan.mockRestore();
+ }).pipe(Effect.provide(TracerLayer)),
+ );
+
+ it.effect('can be used with Effect.withTracer', () =>
+ Effect.gen(function* () {
+ const result = yield* Effect.withSpan('inline-tracer-span')(Effect.succeed('with-tracer'));
+ expect(result).toBe('with-tracer');
+ }).pipe(Effect.withTracer(SentryEffectTracer)),
+ );
+});
diff --git a/packages/effect/tsconfig.json b/packages/effect/tsconfig.json
new file mode 100644
index 000000000000..d49b053b37f8
--- /dev/null
+++ b/packages/effect/tsconfig.json
@@ -0,0 +1,9 @@
+{
+ "extends": "../../tsconfig.json",
+ "compilerOptions": {
+ "module": "esnext",
+ "moduleResolution": "bundler",
+ "outDir": "build"
+ },
+ "include": ["src/**/*"]
+}
diff --git a/packages/effect/tsconfig.test.json b/packages/effect/tsconfig.test.json
new file mode 100644
index 000000000000..9dd90014ef37
--- /dev/null
+++ b/packages/effect/tsconfig.test.json
@@ -0,0 +1,9 @@
+{
+ "extends": "./tsconfig.json",
+
+ "include": ["test/**/*", "vitest.config.ts"],
+
+ "compilerOptions": {
+ "types": ["node"]
+ }
+}
diff --git a/packages/effect/tsconfig.types.json b/packages/effect/tsconfig.types.json
new file mode 100644
index 000000000000..76eb1a9bb7c3
--- /dev/null
+++ b/packages/effect/tsconfig.types.json
@@ -0,0 +1,10 @@
+{
+ "extends": "./tsconfig.json",
+ "compilerOptions": {
+ "declaration": true,
+ "declarationMap": true,
+ "emitDeclarationOnly": true,
+ "outDir": "build/types"
+ },
+ "include": ["src/**/*"]
+}
diff --git a/packages/effect/tsconfig.vite.json b/packages/effect/tsconfig.vite.json
new file mode 100644
index 000000000000..4f2b7371b076
--- /dev/null
+++ b/packages/effect/tsconfig.vite.json
@@ -0,0 +1,9 @@
+{
+ "extends": "./tsconfig.json",
+
+ "include": ["vite.config.ts", "vitest.config.ts"],
+
+ "compilerOptions": {
+ "types": ["node"]
+ }
+}
diff --git a/packages/effect/vitest.config.ts b/packages/effect/vitest.config.ts
new file mode 100644
index 000000000000..ed62557713c1
--- /dev/null
+++ b/packages/effect/vitest.config.ts
@@ -0,0 +1,10 @@
+import { defineConfig } from 'vitest/config';
+import baseConfig from '../../vite/vite.config';
+
+export default defineConfig({
+ ...baseConfig,
+ test: {
+ ...baseConfig.test,
+ include: ['test/**/*.test.ts'],
+ },
+});
diff --git a/yarn.lock b/yarn.lock
index db0b58a9a828..eb2ddb451775 100644
--- a/yarn.lock
+++ b/yarn.lock
@@ -3274,6 +3274,11 @@
dependencies:
"@edge-runtime/primitives" "6.0.0"
+"@effect/vitest@^0.23.9":
+ version "0.23.13"
+ resolved "https://registry.yarnpkg.com/@effect/vitest/-/vitest-0.23.13.tgz#17edf9d8e3443f080ff8fe93bd37b023612a07a4"
+ integrity sha512-F3x2phMXuVzqWexdcYp8v0z1qQHkKxp2UaHNbqZaEjPEp8FBz/iMwbi6iS/oIWzLfGF8XqdP8BGJptvGIJONNw==
+
"@ember-data/rfc395-data@^0.0.4":
version "0.0.4"
resolved "https://registry.yarnpkg.com/@ember-data/rfc395-data/-/rfc395-data-0.0.4.tgz#ecb86efdf5d7733a76ff14ea651a1b0ed1f8a843"
@@ -15034,6 +15039,14 @@ effect@3.16.12:
"@standard-schema/spec" "^1.0.0"
fast-check "^3.23.1"
+effect@^3.19.19:
+ version "3.19.19"
+ resolved "https://registry.yarnpkg.com/effect/-/effect-3.19.19.tgz#643a5a4b7445cc924a28270bc6cd1a5c8facd27e"
+ integrity sha512-Yc8U/SVXo2dHnaP7zNBlAo83h/nzSJpi7vph6Hzyl4ulgMBIgPmz3UzOjb9sBgpFE00gC0iETR244sfXDNLHRg==
+ dependencies:
+ "@standard-schema/spec" "^1.0.0"
+ fast-check "^3.23.1"
+
ejs@^3.1.7:
version "3.1.8"
resolved "https://registry.yarnpkg.com/ejs/-/ejs-3.1.8.tgz#758d32910c78047585c7ef1f92f9ee041c1c190b"