From 65cced946664af8a65dc62207a736ef629140076 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jan=20Peer=20St=C3=B6cklmair?= Date: Thu, 5 Mar 2026 10:48:22 +0100 Subject: [PATCH 1/4] feat(effect): Add base skaffolding for Effect.ts (#19622) This is one of many PRs to create the effect SDK. Once this has been merged I will open the draft PR for the effect sdk and create the plan in there. (the almost final SDK can be viewed here: https://github.com/getsentry/sentry-javascript/tree/jp/effect-sdk. It might be that some specifics change, especially when having browser + server split, and with tracing) --- This PR focuses on the base skaffolding of `@sentry/effect`. This on its own is not really doing anything except setting up the skaffold. The README already reflects the actual usage, while the export doesn't exist yet, this will come in another PR (also `init` is exposed here, just for the sake of completeness) --------- Co-authored-by: Claude --- .../e2e-tests/verdaccio-config/config.yaml | 6 ++ package.json | 1 + packages/effect/.eslintrc.js | 15 ++++ packages/effect/LICENSE | 21 +++++ packages/effect/README.md | 47 ++++++++++ packages/effect/package.json | 67 ++++++++++++++ packages/effect/rollup.npm.config.mjs | 11 +++ packages/effect/src/index.ts | 87 +++++++++++++++++++ packages/effect/test/index.test.ts | 8 ++ packages/effect/tsconfig.json | 8 ++ packages/effect/tsconfig.test.json | 9 ++ packages/effect/tsconfig.types.json | 10 +++ packages/effect/tsconfig.vite.json | 9 ++ packages/effect/vitest.config.ts | 10 +++ 14 files changed, 309 insertions(+) create mode 100644 packages/effect/.eslintrc.js create mode 100644 packages/effect/LICENSE create mode 100644 packages/effect/README.md create mode 100644 packages/effect/package.json create mode 100644 packages/effect/rollup.npm.config.mjs create mode 100644 packages/effect/src/index.ts create mode 100644 packages/effect/test/index.test.ts create mode 100644 packages/effect/tsconfig.json create mode 100644 packages/effect/tsconfig.test.json create mode 100644 packages/effect/tsconfig.types.json create mode 100644 packages/effect/tsconfig.vite.json create mode 100644 packages/effect/vitest.config.ts 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 91bc549e4527..59d0415142c1 100644 --- a/package.json +++ b/package.json @@ -60,6 +60,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..a209c930b659 --- /dev/null +++ b/packages/effect/README.md @@ -0,0 +1,47 @@ +# Official Sentry SDK for Effect.ts (Alpha) + +[![npm version](https://img.shields.io/npm/v/@sentry/effect.svg)](https://www.npmjs.com/package/@sentry/effect) +[![npm dm](https://img.shields.io/npm/dm/@sentry/effect.svg)](https://www.npmjs.com/package/@sentry/effect) +[![npm dt](https://img.shields.io/npm/dt/@sentry/effect.svg)](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 } from 'effect'; +import { HttpLive } from './Http.js'; + +const MainLive = HttpLive.pipe( + Layer.provide( + Sentry.effectLayer({ + dsn: '__DSN__', + enableLogs: true, + enableMetrics: true, + }), + ), +); + +MainLive.pipe(Layer.launch, NodeRuntime.runMain); +``` + +The `effectLayer` function initializes Sentry and returns an Effect Layer that provides: + +- Distributed tracing with automatic HTTP header extraction/injection +- Effect spans traced as Sentry spans +- Effect logs forwarded to Sentry (when `enableLogs` is set) +- Effect metrics sent to Sentry (when `enableMetrics` is set) + +## 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..dbee14b478a8 --- /dev/null +++ b/packages/effect/package.json @@ -0,0 +1,67 @@ +{ + "name": "@sentry/effect", + "version": "10.42.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.js", + "module": "build/esm/index.js", + "types": "build/types/index.d.ts", + "exports": { + "./package.json": "./package.json", + ".": { + "import": { + "types": "./build/types/index.d.ts", + "default": "./build/esm/index.js" + }, + "require": { + "types": "./build/types/index.d.ts", + "default": "./build/cjs/index.js" + } + } + }, + "typesVersions": { + "<5.0": { + "build/types/index.d.ts": [ + "build/types-ts3.8/index.d.ts" + ] + } + }, + "publishConfig": { + "access": "public" + }, + "dependencies": { + "@sentry/core": "10.42.0" + }, + "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.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..ca36da81392f --- /dev/null +++ b/packages/effect/rollup.npm.config.mjs @@ -0,0 +1,11 @@ +import { makeBaseNPMConfig, makeNPMConfigVariants } from '@sentry-internal/rollup-utils'; + +export default makeNPMConfigVariants( + makeBaseNPMConfig({ + packageSpecificConfig: { + output: { + preserveModulesRoot: 'src', + }, + }, + }), +); diff --git a/packages/effect/src/index.ts b/packages/effect/src/index.ts new file mode 100644 index 000000000000..88781f88051a --- /dev/null +++ b/packages/effect/src/index.ts @@ -0,0 +1,87 @@ +export type { + Breadcrumb, + BreadcrumbHint, + Context, + Contexts, + RequestEventData, + SdkInfo, + Event, + EventHint, + ErrorEvent, + Exception, + SeverityLevel, + StackFrame, + Stacktrace, + Thread, + User, + Session, + CaptureContext, + ExclusiveEventHintOrCaptureContext, + Log, + LogSeverityLevel, + Span, +} from '@sentry/core'; + +export { + addEventProcessor, + addBreadcrumb, + addIntegration, + captureException, + captureEvent, + captureFeedback, + captureMessage, + close, + createTransport, + lastEventId, + flush, + getClient, + isInitialized, + isEnabled, + getCurrentScope, + getIsolationScope, + getGlobalScope, + setCurrentClient, + Scope, + continueTrace, + getTraceData, + suppressTracing, + SDK_VERSION, + setContext, + setExtra, + setExtras, + setTag, + setTags, + setUser, + withScope, + withIsolationScope, + functionToStringIntegration, + eventFiltersIntegration, + dedupeIntegration, + parameterize, + startSession, + captureSession, + endSession, + spanToJSON, + spanToTraceHeader, + spanToBaggageHeader, + updateSpanName, + metrics, + getActiveSpan, + getRootSpan, + startSpan, + startInactiveSpan, + startSpanManual, + withActiveSpan, + startNewTrace, + getSpanDescendants, + setMeasurement, + getSpanStatusFromHttpCode, + setHttpStatus, +} from '@sentry/core'; + +export { + SEMANTIC_ATTRIBUTE_SENTRY_OP, + SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN, + SEMANTIC_ATTRIBUTE_SENTRY_SOURCE, + SEMANTIC_ATTRIBUTE_SENTRY_SAMPLE_RATE, +} from '@sentry/core'; diff --git a/packages/effect/test/index.test.ts b/packages/effect/test/index.test.ts new file mode 100644 index 000000000000..f774de6eaf24 --- /dev/null +++ b/packages/effect/test/index.test.ts @@ -0,0 +1,8 @@ +import { describe, expect, it } from 'vitest'; +import * as index from '../src'; + +describe('effect index export', () => { + it('has correct exports', () => { + expect(index.captureException).toBeDefined(); + }); +}); diff --git a/packages/effect/tsconfig.json b/packages/effect/tsconfig.json new file mode 100644 index 000000000000..ee81c1a20817 --- /dev/null +++ b/packages/effect/tsconfig.json @@ -0,0 +1,8 @@ +{ + "extends": "../../tsconfig.json", + "compilerOptions": { + "module": "esnext", + "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'], + }, +}); From 15170d03ccc35ac01a1284d4c337d1eb8b9952b2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jan=20Peer=20St=C3=B6cklmair?= Date: Thu, 5 Mar 2026 14:35:36 +0100 Subject: [PATCH 2/4] feat(effect): Add client/server entrypoints without functionality (#19649) That adds now the functionality to use the `Sentry.effectLayer` properly. **But** it doesn't do anything, which means right now, to keep the PRs small, it returns an empty layer. Following can be used without any Sentry functionality: ```js const MainLive = HttpLive.pipe(Layer.provide(Sentry.effectLayer({ dsn: "", tracesSampleRate: 1.0, debug: true, }))) MainLive.pipe(Layer.launch, NodeRuntime.runMain) ``` --- packages/effect/package.json | 58 ++++++++++++++---- packages/effect/rollup.npm.config.mjs | 30 ++++++--- packages/effect/src/client/index.ts | 30 +++++++++ packages/effect/src/index.client.ts | 4 ++ packages/effect/src/index.server.ts | 4 ++ packages/effect/src/index.ts | 87 --------------------------- packages/effect/src/index.types.ts | 26 ++++++++ packages/effect/src/server/index.ts | 32 ++++++++++ packages/effect/test/index.test.ts | 2 +- packages/effect/tsconfig.json | 1 + yarn.lock | 14 ++++- 11 files changed, 178 insertions(+), 110 deletions(-) create mode 100644 packages/effect/src/client/index.ts create mode 100644 packages/effect/src/index.client.ts create mode 100644 packages/effect/src/index.server.ts delete mode 100644 packages/effect/src/index.ts create mode 100644 packages/effect/src/index.types.ts create mode 100644 packages/effect/src/server/index.ts diff --git a/packages/effect/package.json b/packages/effect/package.json index dbee14b478a8..c088def04907 100644 --- a/packages/effect/package.json +++ b/packages/effect/package.json @@ -12,26 +12,44 @@ "files": [ "/build" ], - "main": "build/cjs/index.js", - "module": "build/esm/index.js", - "types": "build/types/index.d.ts", + "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", ".": { - "import": { - "types": "./build/types/index.d.ts", - "default": "./build/esm/index.js" + "types": "./build/types/index.types.d.ts", + "browser": { + "import": "./build/esm/index.client.js", + "require": "./build/cjs/index.client.js" }, - "require": { - "types": "./build/types/index.d.ts", - "default": "./build/cjs/index.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.d.ts": [ - "build/types-ts3.8/index.d.ts" + "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" ] } }, @@ -39,7 +57,21 @@ "access": "public" }, "dependencies": { - "@sentry/core": "10.42.0" + "@sentry/browser": "10.42.0", + "@sentry/core": "10.42.0", + "@sentry/node-core": "10.42.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", @@ -52,7 +84,7 @@ "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.ts", + "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", diff --git a/packages/effect/rollup.npm.config.mjs b/packages/effect/rollup.npm.config.mjs index ca36da81392f..211157646473 100644 --- a/packages/effect/rollup.npm.config.mjs +++ b/packages/effect/rollup.npm.config.mjs @@ -1,11 +1,25 @@ import { makeBaseNPMConfig, makeNPMConfigVariants } from '@sentry-internal/rollup-utils'; -export default makeNPMConfigVariants( - makeBaseNPMConfig({ - packageSpecificConfig: { - output: { - preserveModulesRoot: 'src', - }, +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..f2a4ce7bec9b --- /dev/null +++ b/packages/effect/src/client/index.ts @@ -0,0 +1,30 @@ +import type { BrowserOptions } from '@sentry/browser'; +import * as EffectLayer from 'effect/Layer'; + +/** + * Options for the Sentry Effect client layer. + */ +export type EffectClientLayerOptions = BrowserOptions; + +/** + * Creates an empty Effect Layer + * + * @example + * ```typescript + * import * as Sentry from '@sentry/effect/client'; + * import { Layer, Effect } from 'effect'; + * + * const ApiClientWithSentry = ApiClientLive.pipe( + * Layer.provide(Sentry.effectLayer({ + * dsn: '__DSN__', + * integrations: [Sentry.browserTracingIntegration()], + * tracesSampleRate: 1.0, + * })), + * ); + * + * Effect.runPromise(Effect.provide(myEffect, ApiClientWithSentry)); + * ``` + */ +export function effectLayer(_: EffectClientLayerOptions): EffectLayer.Layer { + return EffectLayer.empty; +} diff --git a/packages/effect/src/index.client.ts b/packages/effect/src/index.client.ts new file mode 100644 index 000000000000..b5b4833026df --- /dev/null +++ b/packages/effect/src/index.client.ts @@ -0,0 +1,4 @@ +export * from '@sentry/browser'; + +export { effectLayer } from './client/index'; +export type { EffectClientLayerOptions } from './client/index'; diff --git a/packages/effect/src/index.server.ts b/packages/effect/src/index.server.ts new file mode 100644 index 000000000000..f9aa4d562c6f --- /dev/null +++ b/packages/effect/src/index.server.ts @@ -0,0 +1,4 @@ +export * from '@sentry/node-core/light'; + +export { effectLayer } from './server/index'; +export type { EffectServerLayerOptions } from './server/index'; diff --git a/packages/effect/src/index.ts b/packages/effect/src/index.ts deleted file mode 100644 index 88781f88051a..000000000000 --- a/packages/effect/src/index.ts +++ /dev/null @@ -1,87 +0,0 @@ -export type { - Breadcrumb, - BreadcrumbHint, - Context, - Contexts, - RequestEventData, - SdkInfo, - Event, - EventHint, - ErrorEvent, - Exception, - SeverityLevel, - StackFrame, - Stacktrace, - Thread, - User, - Session, - CaptureContext, - ExclusiveEventHintOrCaptureContext, - Log, - LogSeverityLevel, - Span, -} from '@sentry/core'; - -export { - addEventProcessor, - addBreadcrumb, - addIntegration, - captureException, - captureEvent, - captureFeedback, - captureMessage, - close, - createTransport, - lastEventId, - flush, - getClient, - isInitialized, - isEnabled, - getCurrentScope, - getIsolationScope, - getGlobalScope, - setCurrentClient, - Scope, - continueTrace, - getTraceData, - suppressTracing, - SDK_VERSION, - setContext, - setExtra, - setExtras, - setTag, - setTags, - setUser, - withScope, - withIsolationScope, - functionToStringIntegration, - eventFiltersIntegration, - dedupeIntegration, - parameterize, - startSession, - captureSession, - endSession, - spanToJSON, - spanToTraceHeader, - spanToBaggageHeader, - updateSpanName, - metrics, - getActiveSpan, - getRootSpan, - startSpan, - startInactiveSpan, - startSpanManual, - withActiveSpan, - startNewTrace, - getSpanDescendants, - setMeasurement, - getSpanStatusFromHttpCode, - setHttpStatus, -} from '@sentry/core'; - -export { - SEMANTIC_ATTRIBUTE_SENTRY_OP, - SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN, - SEMANTIC_ATTRIBUTE_SENTRY_SOURCE, - SEMANTIC_ATTRIBUTE_SENTRY_SAMPLE_RATE, -} from '@sentry/core'; 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/server/index.ts b/packages/effect/src/server/index.ts new file mode 100644 index 000000000000..91281ea96486 --- /dev/null +++ b/packages/effect/src/server/index.ts @@ -0,0 +1,32 @@ +import type { NodeOptions } from '@sentry/node-core'; +import * as EffectLayer from 'effect/Layer'; + +/** + * Options for the Sentry Effect server layer. + */ +export type EffectServerLayerOptions = NodeOptions; + +/** + * Creates an empty Effect Layer + * + * @example + * ```typescript + * import * as Sentry from '@sentry/effect/server'; + * import { NodeRuntime } from '@effect/platform-node'; + * import { Layer } from 'effect'; + * import { HttpLive } from './Http.js'; + * + * const MainLive = HttpLive.pipe( + * Layer.provide(Sentry.effectLayer({ + * dsn: '__DSN__', + * enableLogs: true, + * enableMetrics: true, + * })), + * ); + * + * MainLive.pipe(Layer.launch, NodeRuntime.runMain); + * ``` + */ +export function effectLayer(_: EffectServerLayerOptions): EffectLayer.Layer { + return EffectLayer.empty; +} diff --git a/packages/effect/test/index.test.ts b/packages/effect/test/index.test.ts index f774de6eaf24..950ec06fb670 100644 --- a/packages/effect/test/index.test.ts +++ b/packages/effect/test/index.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from 'vitest'; -import * as index from '../src'; +import * as index from '../src/index.client'; describe('effect index export', () => { it('has correct exports', () => { diff --git a/packages/effect/tsconfig.json b/packages/effect/tsconfig.json index ee81c1a20817..d49b053b37f8 100644 --- a/packages/effect/tsconfig.json +++ b/packages/effect/tsconfig.json @@ -2,6 +2,7 @@ "extends": "../../tsconfig.json", "compilerOptions": { "module": "esnext", + "moduleResolution": "bundler", "outDir": "build" }, "include": ["src/**/*"] diff --git a/yarn.lock b/yarn.lock index ac89a4468d6a..af2910e4f916 100644 --- a/yarn.lock +++ b/yarn.lock @@ -3269,6 +3269,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" @@ -14877,6 +14882,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" @@ -28096,7 +28109,6 @@ stylus@0.59.0, stylus@^0.59.0: sucrase@^3.27.0, sucrase@^3.35.0, sucrase@getsentry/sucrase#es2020-polyfills: version "3.36.0" - uid fd682f6129e507c00bb4e6319cc5d6b767e36061 resolved "https://codeload.github.com/getsentry/sucrase/tar.gz/fd682f6129e507c00bb4e6319cc5d6b767e36061" dependencies: "@jridgewell/gen-mapping" "^0.3.2" From 0ecb7daf326f33d8edbaab3d8a7b3ae2c34dc794 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jan=20Peer=20St=C3=B6cklmair?= Date: Fri, 6 Mar 2026 16:17:49 +0100 Subject: [PATCH 3/4] feat(effect): Add tracing to the effectLayer (#19655) This adds tracing to the `Sentry.effectLayer`. By setting `tracesSampleRate: 1.0` in the options tracing is enabled and spans can be send to Sentry --- packages/effect/src/client/index.ts | 16 +- packages/effect/src/client/sdk.ts | 20 ++ packages/effect/src/index.client.ts | 5 +- packages/effect/src/index.server.ts | 2 +- packages/effect/src/server/index.ts | 17 +- packages/effect/src/server/sdk.ts | 20 ++ packages/effect/src/tracer.ts | 201 ++++++++++++ packages/effect/src/utils/buildEffectLayer.ts | 24 ++ packages/effect/test/buildEffectLayer.test.ts | 57 ++++ packages/effect/test/layer.test.ts | 106 ++++++ packages/effect/test/tracer.test.ts | 306 ++++++++++++++++++ 11 files changed, 763 insertions(+), 11 deletions(-) create mode 100644 packages/effect/src/client/sdk.ts create mode 100644 packages/effect/src/server/sdk.ts create mode 100644 packages/effect/src/tracer.ts create mode 100644 packages/effect/src/utils/buildEffectLayer.ts create mode 100644 packages/effect/test/buildEffectLayer.test.ts create mode 100644 packages/effect/test/layer.test.ts create mode 100644 packages/effect/test/tracer.test.ts diff --git a/packages/effect/src/client/index.ts b/packages/effect/src/client/index.ts index f2a4ce7bec9b..e8b37b10b28a 100644 --- a/packages/effect/src/client/index.ts +++ b/packages/effect/src/client/index.ts @@ -1,5 +1,10 @@ import type { BrowserOptions } from '@sentry/browser'; -import * as EffectLayer from 'effect/Layer'; +import type * as EffectLayer from 'effect/Layer'; +import { suspend as suspendLayer } from 'effect/Layer'; +import { buildEffectLayer } from '../utils/buildEffectLayer'; +import { init } from './sdk'; + +export { init } from './sdk'; /** * Options for the Sentry Effect client layer. @@ -7,7 +12,10 @@ import * as EffectLayer from 'effect/Layer'; export type EffectClientLayerOptions = BrowserOptions; /** - * Creates an empty Effect Layer + * Creates an Effect Layer that initializes Sentry for browser clients. + * + * This layer provides Effect applications with full Sentry instrumentation including: + * - Effect spans traced as Sentry spans * * @example * ```typescript @@ -25,6 +33,6 @@ export type EffectClientLayerOptions = BrowserOptions; * Effect.runPromise(Effect.provide(myEffect, ApiClientWithSentry)); * ``` */ -export function effectLayer(_: EffectClientLayerOptions): EffectLayer.Layer { - return EffectLayer.empty; +export function effectLayer(options: EffectClientLayerOptions): EffectLayer.Layer { + return suspendLayer(() => buildEffectLayer(options, init(options))); } 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 index b5b4833026df..e13f1ddea09e 100644 --- a/packages/effect/src/index.client.ts +++ b/packages/effect/src/index.client.ts @@ -1,4 +1,7 @@ +// 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 } from './client/index'; +export { effectLayer, init } from './client/index'; export type { EffectClientLayerOptions } from './client/index'; diff --git a/packages/effect/src/index.server.ts b/packages/effect/src/index.server.ts index f9aa4d562c6f..a3f8e4f3766f 100644 --- a/packages/effect/src/index.server.ts +++ b/packages/effect/src/index.server.ts @@ -1,4 +1,4 @@ export * from '@sentry/node-core/light'; -export { effectLayer } from './server/index'; +export { effectLayer, init } from './server/index'; export type { EffectServerLayerOptions } from './server/index'; diff --git a/packages/effect/src/server/index.ts b/packages/effect/src/server/index.ts index 91281ea96486..ad8ddd7192bc 100644 --- a/packages/effect/src/server/index.ts +++ b/packages/effect/src/server/index.ts @@ -1,5 +1,9 @@ -import type { NodeOptions } from '@sentry/node-core'; -import * as EffectLayer from 'effect/Layer'; +import type { NodeOptions } from '@sentry/node-core/light'; +import type * as EffectLayer from 'effect/Layer'; +import { buildEffectLayer } from '../utils/buildEffectLayer'; +import { init } from './sdk'; + +export { init } from './sdk'; /** * Options for the Sentry Effect server layer. @@ -7,7 +11,10 @@ import * as EffectLayer from 'effect/Layer'; export type EffectServerLayerOptions = NodeOptions; /** - * Creates an empty Effect Layer + * Creates an Effect Layer that initializes Sentry for Node.js servers. + * + * This layer provides Effect applications with full Sentry instrumentation including: + * - Effect spans traced as Sentry spans * * @example * ```typescript @@ -27,6 +34,6 @@ export type EffectServerLayerOptions = NodeOptions; * MainLive.pipe(Layer.launch, NodeRuntime.runMain); * ``` */ -export function effectLayer(_: EffectServerLayerOptions): EffectLayer.Layer { - return EffectLayer.empty; +export function effectLayer(options: EffectServerLayerOptions): EffectLayer.Layer { + return buildEffectLayer(options, init(options)); } 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..116b7970a6ae --- /dev/null +++ b/packages/effect/src/tracer.ts @@ -0,0 +1,201 @@ +import type { Span } from '@sentry/core'; +import { + getActiveSpan, + getIsolationScope, + SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN, + startInactiveSpan, + withActiveSpan, +} from '@sentry/core'; +import type * as Context from 'effect/Context'; +import * as Exit from 'effect/Exit'; +import type * as Layer from 'effect/Layer'; +import { setTracer } from 'effect/Layer'; +import * as Option from 'effect/Option'; +import * as EffectTracer from 'effect/Tracer'; + +const KIND_MAP: Record = { + internal: 'internal', + client: 'client', + server: 'server', + producer: 'producer', + consumer: 'consumer', +}; + +function deriveOp(name: string, kind: EffectTracer.SpanKind): string { + if (name.startsWith('http.server')) { + return 'http.server'; + } + + if (name.startsWith('http.client')) { + return 'http.client'; + } + + return KIND_MAP[kind]; +} + +function deriveOrigin(name: string): string { + if (name.startsWith('http.server') || name.startsWith('http.client')) { + return 'auto.http.effect'; + } + + return 'auto.function.effect'; +} + +function deriveSpanName(name: string, kind: EffectTracer.SpanKind): string { + if (name.startsWith('http.server') && kind === 'server') { + const isolationScope = getIsolationScope(); + const transactionName = isolationScope.getScopeData().transactionName; + if (transactionName) { + return transactionName; + } + } + return name; +} + +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 spanName = deriveSpanName(name, kind); + + const newSpan = startInactiveSpan({ + name: spanName, + op: deriveOp(name, kind), + 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 SentryEffectTracerLayer: Layer.Layer = setTracer(makeSentryTracer()); diff --git a/packages/effect/src/utils/buildEffectLayer.ts b/packages/effect/src/utils/buildEffectLayer.ts new file mode 100644 index 000000000000..93c1ac6b42e8 --- /dev/null +++ b/packages/effect/src/utils/buildEffectLayer.ts @@ -0,0 +1,24 @@ +import type * as EffectLayer from 'effect/Layer'; +import { empty as emptyLayer } from 'effect/Layer'; +import { SentryEffectTracerLayer } from '../tracer'; + +// eslint-disable-next-line @typescript-eslint/no-empty-interface +export interface EffectLayerBaseOptions {} + +/** + * Builds an Effect layer that integrates Sentry tracing. + * + * Returns an empty layer if no Sentry client is available. Otherwise, starts with + * the Sentry tracer layer and optionally merges logging and metrics layers based + * on the provided options. + */ +export function buildEffectLayer( + options: T, + client: unknown, +): EffectLayer.Layer { + if (!client) { + return emptyLayer; + } + + return SentryEffectTracerLayer; +} diff --git a/packages/effect/test/buildEffectLayer.test.ts b/packages/effect/test/buildEffectLayer.test.ts new file mode 100644 index 000000000000..4213b1448311 --- /dev/null +++ b/packages/effect/test/buildEffectLayer.test.ts @@ -0,0 +1,57 @@ +import { describe, expect, it, vi } from '@effect/vitest'; +import * as sentryCore from '@sentry/core'; +import { Effect, Layer } from 'effect'; +import { empty as emptyLayer } from 'effect/Layer'; +import { buildEffectLayer } from '../src/utils/buildEffectLayer'; + +describe('buildEffectLayer', () => { + describe('when client is falsy', () => { + it('returns empty layer when client is null', () => { + const layer = buildEffectLayer({}, null); + + expect(layer).toBeDefined(); + expect(Layer.isLayer(layer)).toBe(true); + expect(layer).toBe(emptyLayer); + }); + + it('returns empty layer when client is undefined', () => { + const layer = buildEffectLayer({}, undefined); + + expect(layer).toBeDefined(); + expect(Layer.isLayer(layer)).toBe(true); + expect(layer).toBe(emptyLayer); + }); + }); + + describe('when client is truthy', () => { + const mockClient = { mock: true }; + + it('returns a valid layer with default options', () => { + const layer = buildEffectLayer({}, mockClient); + + expect(layer).toBeDefined(); + expect(Layer.isLayer(layer)).toBe(true); + }); + + 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(buildEffectLayer({}, mockClient))), + ); + + it.effect('layer enables tracing for Effect spans via Sentry tracer', () => + Effect.gen(function* () { + const startInactiveSpanSpy = vi.spyOn(sentryCore, 'startInactiveSpan'); + const result = yield* Effect.withSpan('test-sentry-span')(Effect.succeed('traced')); + expect(result).toBe('traced'); + expect(startInactiveSpanSpy).toHaveBeenCalledWith( + expect.objectContaining({ + name: 'test-sentry-span', + }), + ); + startInactiveSpanSpy.mockRestore(); + }).pipe(Effect.provide(buildEffectLayer({}, mockClient))), + ); + }); +}); diff --git a/packages/effect/test/layer.test.ts b/packages/effect/test/layer.test.ts new file mode 100644 index 000000000000..ee5315e55409 --- /dev/null +++ b/packages/effect/test/layer.test.ts @@ -0,0 +1,106 @@ +import { describe, expect, it } from '@effect/vitest'; +import { getClient, getCurrentScope, getIsolationScope, SDK_VERSION } from '@sentry/core'; +import { Effect, Layer } from 'effect'; +import { afterEach, beforeEach, vi } from 'vitest'; +import * as sentryClient from '../src/index.client'; +import * as sentryServer from '../src/index.server'; + +const TEST_DSN = 'https://username@domain/123'; + +function getMockTransport() { + return () => ({ + send: vi.fn().mockResolvedValue({}), + flush: vi.fn().mockResolvedValue(true), + }); +} + +describe.each([ + [{ subSdkName: 'browser', effectLayer: sentryClient.effectLayer }], + [{ subSdkName: 'node-light', effectLayer: sentryServer.effectLayer }], +])('effectLayer ($subSdkName)', ({ subSdkName, effectLayer }) => { + beforeEach(() => { + getCurrentScope().clear(); + getIsolationScope().clear(); + }); + + afterEach(() => { + getCurrentScope().setClient(undefined); + }); + + 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 for Effect spans', () => + Effect.gen(function* () { + const result = yield* Effect.withSpan('test-span')(Effect.succeed('traced')); + expect(result).toBe('traced'); + }).pipe( + Effect.provide( + effectLayer({ + dsn: TEST_DSN, + transport: getMockTransport(), + }), + ), + ), + ); + + it.effect('layer can be composed with other layers', () => + Effect.gen(function* () { + const result = yield* Effect.succeed(42).pipe( + Effect.map(n => n * 2), + Effect.withSpan('computation'), + ); + expect(result).toBe(84); + }).pipe( + Effect.provide( + effectLayer({ + dsn: TEST_DSN, + transport: getMockTransport(), + }), + ), + ), + ); +}); diff --git a/packages/effect/test/tracer.test.ts b/packages/effect/test/tracer.test.ts new file mode 100644 index 000000000000..8955200695fa --- /dev/null +++ b/packages/effect/test/tracer.test.ts @@ -0,0 +1,306 @@ +import { describe, expect, it } from '@effect/vitest'; +import * as sentryCore from '@sentry/core'; +import { SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN } from '@sentry/core'; +import { Effect } from 'effect'; +import { afterEach, vi } from 'vitest'; +import { SentryEffectTracerLayer } from '../src/tracer'; + +describe('SentryEffectTracerLayer', () => { + 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(SentryEffectTracerLayer)), + ); + + 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(SentryEffectTracerLayer)), + ); + + 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(SentryEffectTracerLayer)), + ); + + 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(SentryEffectTracerLayer)), + ); + + 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(SentryEffectTracerLayer)), + ); + + 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(SentryEffectTracerLayer)), + ); + + 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(SentryEffectTracerLayer)), + ); + + 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(SentryEffectTracerLayer)), + ); + + 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(SentryEffectTracerLayer)), + ); + + 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(SentryEffectTracerLayer)), + ); + + 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(SentryEffectTracerLayer)), + ); + + 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(SentryEffectTracerLayer)), + ); + + 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(SentryEffectTracerLayer)), + ); + + 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(SentryEffectTracerLayer)), + ); + + 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(SentryEffectTracerLayer)), + ); + + it.effect('uses transaction name from isolation scope for http.server spans', () => + Effect.gen(function* () { + let capturedSpanName: string | undefined; + + const mockGetIsolationScope = vi.spyOn(sentryCore, 'getIsolationScope').mockReturnValue({ + getScopeData: () => ({ + transactionName: 'GET /users/:id', + }), + } as unknown as sentryCore.Scope); + + const mockStartInactiveSpan = vi.spyOn(sentryCore, 'startInactiveSpan').mockImplementation(options => { + capturedSpanName = options.name; + 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 /users/123', { kind: 'server' })(Effect.succeed('ok')); + + expect(capturedSpanName).toBe('GET /users/:id'); + + mockStartInactiveSpan.mockRestore(); + mockGetIsolationScope.mockRestore(); + }).pipe(Effect.provide(SentryEffectTracerLayer)), + ); +}); From 06546f8b3550d0a2e515d7d9ab4a1d98e0336b1b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jan=20Peer=20St=C3=B6cklmair?= Date: Mon, 9 Mar 2026 08:34:31 +0100 Subject: [PATCH 4/4] feat(effect): Add logging to Sentry.effectLayer (#19656) This adds the functionality to send logs to Sentry by setting `enableLogs: true` in the `Sentry.effectLayer` --- packages/effect/src/client/index.ts | 1 + packages/effect/src/logger.ts | 43 ++++++++ packages/effect/src/server/index.ts | 1 + packages/effect/src/utils/buildEffectLayer.ts | 21 +++- packages/effect/test/buildEffectLayer.test.ts | 65 +++++++++++ packages/effect/test/layer.test.ts | 20 ++++ packages/effect/test/logger.test.ts | 104 ++++++++++++++++++ 7 files changed, 250 insertions(+), 5 deletions(-) create mode 100644 packages/effect/src/logger.ts create mode 100644 packages/effect/test/logger.test.ts diff --git a/packages/effect/src/client/index.ts b/packages/effect/src/client/index.ts index e8b37b10b28a..34dfae6cb7c8 100644 --- a/packages/effect/src/client/index.ts +++ b/packages/effect/src/client/index.ts @@ -16,6 +16,7 @@ export type EffectClientLayerOptions = BrowserOptions; * * This layer provides Effect applications with full Sentry instrumentation including: * - Effect spans traced as Sentry spans + * - Effect logs forwarded to Sentry (when `enableLogs` is set) * * @example * ```typescript 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/server/index.ts b/packages/effect/src/server/index.ts index ad8ddd7192bc..2dcca1f7a4e2 100644 --- a/packages/effect/src/server/index.ts +++ b/packages/effect/src/server/index.ts @@ -15,6 +15,7 @@ export type EffectServerLayerOptions = NodeOptions; * * This layer provides Effect applications with full Sentry instrumentation including: * - Effect spans traced as Sentry spans + * - Effect logs forwarded to Sentry (when `enableLogs` is set) * * @example * ```typescript diff --git a/packages/effect/src/utils/buildEffectLayer.ts b/packages/effect/src/utils/buildEffectLayer.ts index 93c1ac6b42e8..475d2d2a70c3 100644 --- a/packages/effect/src/utils/buildEffectLayer.ts +++ b/packages/effect/src/utils/buildEffectLayer.ts @@ -1,12 +1,15 @@ import type * as EffectLayer from 'effect/Layer'; -import { empty as emptyLayer } from 'effect/Layer'; +import { empty as emptyLayer, provideMerge } from 'effect/Layer'; +import { defaultLogger, replace as replaceLogger } from 'effect/Logger'; +import { SentryEffectLogger } from '../logger'; import { SentryEffectTracerLayer } from '../tracer'; -// eslint-disable-next-line @typescript-eslint/no-empty-interface -export interface EffectLayerBaseOptions {} +export interface EffectLayerBaseOptions { + enableLogs?: boolean; +} /** - * Builds an Effect layer that integrates Sentry tracing. + * Builds an Effect layer that integrates Sentry tracing and logging. * * Returns an empty layer if no Sentry client is available. Otherwise, starts with * the Sentry tracer layer and optionally merges logging and metrics layers based @@ -20,5 +23,13 @@ export function buildEffectLayer( return emptyLayer; } - return SentryEffectTracerLayer; + const { enableLogs = false } = options; + let layer: EffectLayer.Layer = SentryEffectTracerLayer; + + if (enableLogs) { + const effectLogger = replaceLogger(defaultLogger, SentryEffectLogger); + layer = layer.pipe(provideMerge(effectLogger)); + } + + return layer; } diff --git a/packages/effect/test/buildEffectLayer.test.ts b/packages/effect/test/buildEffectLayer.test.ts index 4213b1448311..9875cfe5b14b 100644 --- a/packages/effect/test/buildEffectLayer.test.ts +++ b/packages/effect/test/buildEffectLayer.test.ts @@ -1,5 +1,6 @@ import { describe, expect, it, vi } from '@effect/vitest'; import * as sentryCore from '@sentry/core'; +import { logger as sentryLogger } from '@sentry/core'; import { Effect, Layer } from 'effect'; import { empty as emptyLayer } from 'effect/Layer'; import { buildEffectLayer } from '../src/utils/buildEffectLayer'; @@ -33,6 +34,27 @@ describe('buildEffectLayer', () => { expect(Layer.isLayer(layer)).toBe(true); }); + it('returns a valid layer with enableLogs: false', () => { + const layer = buildEffectLayer({ enableLogs: false }, mockClient); + + expect(layer).toBeDefined(); + expect(Layer.isLayer(layer)).toBe(true); + }); + + it('returns a valid layer with enableLogs: true', () => { + const layer = buildEffectLayer({ enableLogs: true }, mockClient); + + expect(layer).toBeDefined(); + expect(Layer.isLayer(layer)).toBe(true); + }); + + it('returns a valid layer with all features enabled', () => { + const layer = buildEffectLayer({ enableLogs: true }, mockClient); + + expect(layer).toBeDefined(); + expect(Layer.isLayer(layer)).toBe(true); + }); + it.effect('layer can be provided to an Effect program', () => Effect.gen(function* () { const result = yield* Effect.succeed('test-result'); @@ -40,6 +62,31 @@ describe('buildEffectLayer', () => { }).pipe(Effect.provide(buildEffectLayer({}, mockClient))), ); + it.effect('layer with logs enabled routes Effect logs to Sentry logger', () => + Effect.gen(function* () { + const infoSpy = vi.spyOn(sentryLogger, 'info'); + yield* Effect.log('test log message'); + expect(infoSpy).toHaveBeenCalledWith('test log message'); + infoSpy.mockRestore(); + }).pipe(Effect.provide(buildEffectLayer({ enableLogs: true }, mockClient))), + ); + + it.effect('layer with logs disabled routes Effect does not log to Sentry logger', () => + Effect.gen(function* () { + const infoSpy = vi.spyOn(sentryLogger, 'info'); + yield* Effect.log('test log message'); + expect(infoSpy).not.toHaveBeenCalled(); + infoSpy.mockRestore(); + }).pipe(Effect.provide(buildEffectLayer({ enableLogs: false }, mockClient))), + ); + + it.effect('layer with all features enabled can be provided to an Effect program', () => + Effect.gen(function* () { + const result = yield* Effect.succeed('all-features'); + expect(result).toBe('all-features'); + }).pipe(Effect.provide(buildEffectLayer({ enableLogs: true }, mockClient))), + ); + it.effect('layer enables tracing for Effect spans via Sentry tracer', () => Effect.gen(function* () { const startInactiveSpanSpy = vi.spyOn(sentryCore, 'startInactiveSpan'); @@ -54,4 +101,22 @@ describe('buildEffectLayer', () => { }).pipe(Effect.provide(buildEffectLayer({}, mockClient))), ); }); + + describe('with additional options', () => { + const mockClient = { mock: true }; + + it('accepts options with additional properties', () => { + const layer = buildEffectLayer( + { + enableLogs: true, + dsn: 'https://test@sentry.io/123', + debug: true, + } as { enableLogs?: boolean; dsn?: string; debug?: boolean }, + mockClient, + ); + + expect(layer).toBeDefined(); + expect(Layer.isLayer(layer)).toBe(true); + }); + }); }); diff --git a/packages/effect/test/layer.test.ts b/packages/effect/test/layer.test.ts index ee5315e55409..072d8becb601 100644 --- a/packages/effect/test/layer.test.ts +++ b/packages/effect/test/layer.test.ts @@ -59,6 +59,26 @@ describe.each([ ), ); + it('creates layer with logs enabled', () => { + const layer = effectLayer({ + dsn: TEST_DSN, + transport: getMockTransport(), + enableLogs: true, + }); + + expect(layer).toBeDefined(); + }); + + it('creates layer with all features enabled', () => { + const layer = effectLayer({ + dsn: TEST_DSN, + transport: getMockTransport(), + enableLogs: true, + }); + + expect(layer).toBeDefined(); + }); + it.effect('layer can be provided to an Effect program', () => Effect.gen(function* () { const result = yield* Effect.succeed('test-result'); 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)), + ); +});