From 7c5c21434b2cb746f8290ddae0cc9b8c91b362aa 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 01/10] 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 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..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 d3f9e90305085170f875060ddff188e734e5efe7 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 02/10] 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 | 60 +++++++++++++----- 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 | 13 ++++ 11 files changed, 179 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..43a54b85e159 100644 --- a/packages/effect/package.json +++ b/packages/effect/package.json @@ -1,6 +1,6 @@ { "name": "@sentry/effect", - "version": "10.42.0", + "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", @@ -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.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", @@ -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 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" From 7a6f3035df38d6e4b6e3d4718643ebe5ae64c8c6 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 03/10] 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 940b84db1606c8b1e9e3aa844cf7048342feb9d1 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 04/10] 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)), + ); +}); From 0c987d5ffcf7a4bdb6bd44badd97f8fae878d2af Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jan=20Peer=20St=C3=B6cklmair?= Date: Wed, 11 Mar 2026 11:47:48 +0100 Subject: [PATCH 05/10] feat(effect): Add metrics to Sentry.effectLayer (#19709) This adds metrics to the `Sentry.effectLayer`. It is enabled when `enableMetrics: true` is added as option --- packages/effect/src/client/index.ts | 1 + packages/effect/src/metrics.ts | 135 ++++++++ packages/effect/src/server/index.ts | 1 + packages/effect/src/utils/buildEffectLayer.ts | 10 +- packages/effect/test/buildEffectLayer.test.ts | 35 +- packages/effect/test/layer.test.ts | 1 + packages/effect/test/metrics.test.ts | 321 ++++++++++++++++++ 7 files changed, 491 insertions(+), 13 deletions(-) create mode 100644 packages/effect/src/metrics.ts create mode 100644 packages/effect/test/metrics.test.ts diff --git a/packages/effect/src/client/index.ts b/packages/effect/src/client/index.ts index 34dfae6cb7c8..58b21991a8f4 100644 --- a/packages/effect/src/client/index.ts +++ b/packages/effect/src/client/index.ts @@ -17,6 +17,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) + * - Effect metrics sent to Sentry (when `enableMetrics` is set) * * @example * ```typescript 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 index 2dcca1f7a4e2..10d2a7651bf1 100644 --- a/packages/effect/src/server/index.ts +++ b/packages/effect/src/server/index.ts @@ -16,6 +16,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) + * - Effect metrics sent to Sentry (when `enableMetrics` is set) * * @example * ```typescript diff --git a/packages/effect/src/utils/buildEffectLayer.ts b/packages/effect/src/utils/buildEffectLayer.ts index 475d2d2a70c3..44393fe25731 100644 --- a/packages/effect/src/utils/buildEffectLayer.ts +++ b/packages/effect/src/utils/buildEffectLayer.ts @@ -2,14 +2,16 @@ import type * as EffectLayer from 'effect/Layer'; import { empty as emptyLayer, provideMerge } from 'effect/Layer'; import { defaultLogger, replace as replaceLogger } from 'effect/Logger'; import { SentryEffectLogger } from '../logger'; +import { SentryEffectMetricsLayer } from '../metrics'; import { SentryEffectTracerLayer } from '../tracer'; export interface EffectLayerBaseOptions { enableLogs?: boolean; + enableMetrics?: boolean; } /** - * Builds an Effect layer that integrates Sentry tracing and logging. + * Builds an Effect layer that integrates Sentry tracing, logging, and metrics. * * 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 @@ -23,7 +25,7 @@ export function buildEffectLayer( return emptyLayer; } - const { enableLogs = false } = options; + const { enableLogs = false, enableMetrics = true } = options; let layer: EffectLayer.Layer = SentryEffectTracerLayer; if (enableLogs) { @@ -31,5 +33,9 @@ export function buildEffectLayer( layer = layer.pipe(provideMerge(effectLogger)); } + if (enableMetrics) { + layer = layer.pipe(provideMerge(SentryEffectMetricsLayer)); + } + return layer; } diff --git a/packages/effect/test/buildEffectLayer.test.ts b/packages/effect/test/buildEffectLayer.test.ts index 9875cfe5b14b..a42aa7e82e26 100644 --- a/packages/effect/test/buildEffectLayer.test.ts +++ b/packages/effect/test/buildEffectLayer.test.ts @@ -48,8 +48,22 @@ describe('buildEffectLayer', () => { expect(Layer.isLayer(layer)).toBe(true); }); + it('returns a valid layer with enableMetrics: false', () => { + const layer = buildEffectLayer({ enableMetrics: false }, mockClient); + + expect(layer).toBeDefined(); + expect(Layer.isLayer(layer)).toBe(true); + }); + + it('returns a valid layer with enableMetrics: true', () => { + const layer = buildEffectLayer({ enableMetrics: 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); + const layer = buildEffectLayer({ enableLogs: true, enableMetrics: true }, mockClient); expect(layer).toBeDefined(); expect(Layer.isLayer(layer)).toBe(true); @@ -71,20 +85,18 @@ describe('buildEffectLayer', () => { }).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('returns different layer when enableMetrics is true vs false', () => { + const layerWithMetrics = buildEffectLayer({ enableMetrics: true }, mockClient); + const layerWithoutMetrics = buildEffectLayer({ enableMetrics: false }, mockClient); + + expect(layerWithMetrics).not.toBe(layerWithoutMetrics); + }); 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))), + }).pipe(Effect.provide(buildEffectLayer({ enableLogs: true, enableMetrics: true }, mockClient))), ); it.effect('layer enables tracing for Effect spans via Sentry tracer', () => @@ -109,9 +121,10 @@ describe('buildEffectLayer', () => { const layer = buildEffectLayer( { enableLogs: true, + enableMetrics: true, dsn: 'https://test@sentry.io/123', debug: true, - } as { enableLogs?: boolean; dsn?: string; debug?: boolean }, + } as { enableLogs?: boolean; enableMetrics?: boolean; dsn?: string; debug?: boolean }, mockClient, ); diff --git a/packages/effect/test/layer.test.ts b/packages/effect/test/layer.test.ts index 072d8becb601..8d96f039062b 100644 --- a/packages/effect/test/layer.test.ts +++ b/packages/effect/test/layer.test.ts @@ -74,6 +74,7 @@ describe.each([ dsn: TEST_DSN, transport: getMockTransport(), enableLogs: true, + enableMetrics: true, }); expect(layer).toBeDefined(); 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); + }); +}); From 3f3f921833d49f9eb1622880a2247d094bffdee9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jan=20Peer=20St=C3=B6cklmair?= Date: Wed, 11 Mar 2026 14:41:36 +0100 Subject: [PATCH 06/10] feat(effect): Add extra opt-in for logs and metrics (#19755) This PR is now adding a different naming schema for enabling logs and metrics based on: https://develop.sentry.dev/sdk/telemetry/metrics/#auto-emitted-metrics For the logs I also added them, which might not make the most sense, as `enableLogs` is now `false` by default, which means that there is a double opt-in needed to make logs work via `Effect.log`. The naming is TBD, but this is the best I came up with: `enableEffectLogs` & `enableEffectMetrics` --- packages/core/src/client.ts | 4 +- packages/effect/README.md | 7 +- packages/effect/src/client/index.ts | 7 +- packages/effect/src/server/index.ts | 11 +-- packages/effect/src/utils/buildEffectLayer.ts | 14 ++-- packages/effect/test/buildEffectLayer.test.ts | 76 +++++++++++++------ packages/effect/test/layer.test.ts | 6 +- 7 files changed, 78 insertions(+), 47 deletions(-) diff --git a/packages/core/src/client.ts b/packages/core/src/client.ts index 8d69411aacfd..4854aeb8af5a 100644 --- a/packages/core/src/client.ts +++ b/packages/core/src/client.ts @@ -256,10 +256,10 @@ export abstract class Client { // todo(v11): Remove the experimental flag // eslint-disable-next-line deprecation/deprecation - const enableMetrics = this._options.enableMetrics ?? this._options._experiments?.enableMetrics ?? true; + this._options.enableMetrics = this._options.enableMetrics ?? this._options._experiments?.enableMetrics ?? true; // Setup metric flushing with weight and timeout tracking - if (enableMetrics) { + if (this._options.enableMetrics) { setupWeightBasedFlushing( this, 'afterCaptureMetric', diff --git a/packages/effect/README.md b/packages/effect/README.md index a209c930b659..c04ff97446e4 100644 --- a/packages/effect/README.md +++ b/packages/effect/README.md @@ -23,7 +23,8 @@ const MainLive = HttpLive.pipe( Sentry.effectLayer({ dsn: '__DSN__', enableLogs: true, - enableMetrics: true, + enableEffectLogs: true, + enableEffectMetrics: true, }), ), ); @@ -35,8 +36,8 @@ The `effectLayer` function initializes Sentry and returns an Effect Layer that p - 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) +- Effect logs forwarded to Sentry (when `enableEffectLogs` is set) +- Effect metrics sent to Sentry (when `enableEffectMetrics` is set) ## Links diff --git a/packages/effect/src/client/index.ts b/packages/effect/src/client/index.ts index 58b21991a8f4..d26255f38e56 100644 --- a/packages/effect/src/client/index.ts +++ b/packages/effect/src/client/index.ts @@ -1,6 +1,7 @@ import type { BrowserOptions } from '@sentry/browser'; import type * as EffectLayer from 'effect/Layer'; import { suspend as suspendLayer } from 'effect/Layer'; +import type { EffectLayerBaseOptions } from '../utils/buildEffectLayer'; import { buildEffectLayer } from '../utils/buildEffectLayer'; import { init } from './sdk'; @@ -9,15 +10,15 @@ export { init } from './sdk'; /** * Options for the Sentry Effect client layer. */ -export type EffectClientLayerOptions = BrowserOptions; +export type EffectClientLayerOptions = BrowserOptions & EffectLayerBaseOptions; /** * 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 - * - Effect logs forwarded to Sentry (when `enableLogs` is set) - * - Effect metrics sent to Sentry (when `enableMetrics` is set) + * - Effect logs forwarded to Sentry (when `enableEffectLogs` is set) + * - Effect metrics sent to Sentry (when `enableEffectMetrics` is set) * * @example * ```typescript diff --git a/packages/effect/src/server/index.ts b/packages/effect/src/server/index.ts index 10d2a7651bf1..1ef0bc542877 100644 --- a/packages/effect/src/server/index.ts +++ b/packages/effect/src/server/index.ts @@ -1,5 +1,6 @@ import type { NodeOptions } from '@sentry/node-core/light'; import type * as EffectLayer from 'effect/Layer'; +import type { EffectLayerBaseOptions } from '../utils/buildEffectLayer'; import { buildEffectLayer } from '../utils/buildEffectLayer'; import { init } from './sdk'; @@ -8,15 +9,15 @@ export { init } from './sdk'; /** * Options for the Sentry Effect server layer. */ -export type EffectServerLayerOptions = NodeOptions; +export type EffectServerLayerOptions = NodeOptions & EffectLayerBaseOptions; /** * 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 - * - Effect logs forwarded to Sentry (when `enableLogs` is set) - * - Effect metrics sent to Sentry (when `enableMetrics` is set) + * - Effect logs forwarded to Sentry (when `enableEffectLogs` is set) + * - Effect metrics sent to Sentry (when `enableEffectMetrics` is set) * * @example * ```typescript @@ -28,8 +29,8 @@ export type EffectServerLayerOptions = NodeOptions; * const MainLive = HttpLive.pipe( * Layer.provide(Sentry.effectLayer({ * dsn: '__DSN__', - * enableLogs: true, - * enableMetrics: true, + * enableEffectLogs: true, + * enableEffectMetrics: true, * })), * ); * diff --git a/packages/effect/src/utils/buildEffectLayer.ts b/packages/effect/src/utils/buildEffectLayer.ts index 44393fe25731..36879e0b956d 100644 --- a/packages/effect/src/utils/buildEffectLayer.ts +++ b/packages/effect/src/utils/buildEffectLayer.ts @@ -1,3 +1,4 @@ +import type { Client } from '@sentry/core'; import type * as EffectLayer from 'effect/Layer'; import { empty as emptyLayer, provideMerge } from 'effect/Layer'; import { defaultLogger, replace as replaceLogger } from 'effect/Logger'; @@ -6,8 +7,8 @@ import { SentryEffectMetricsLayer } from '../metrics'; import { SentryEffectTracerLayer } from '../tracer'; export interface EffectLayerBaseOptions { - enableLogs?: boolean; - enableMetrics?: boolean; + enableEffectLogs?: boolean; + enableEffectMetrics?: boolean; } /** @@ -19,21 +20,22 @@ export interface EffectLayerBaseOptions { */ export function buildEffectLayer( options: T, - client: unknown, + client: Client | undefined, ): EffectLayer.Layer { if (!client) { return emptyLayer; } - const { enableLogs = false, enableMetrics = true } = options; + const clientOptions = client.getOptions(); + const { enableEffectLogs = false, enableEffectMetrics = false } = options; let layer: EffectLayer.Layer = SentryEffectTracerLayer; - if (enableLogs) { + if (enableEffectLogs && clientOptions.enableLogs) { const effectLogger = replaceLogger(defaultLogger, SentryEffectLogger); layer = layer.pipe(provideMerge(effectLogger)); } - if (enableMetrics) { + if (enableEffectMetrics && clientOptions.enableMetrics) { layer = layer.pipe(provideMerge(SentryEffectMetricsLayer)); } diff --git a/packages/effect/test/buildEffectLayer.test.ts b/packages/effect/test/buildEffectLayer.test.ts index a42aa7e82e26..d74045d5cb99 100644 --- a/packages/effect/test/buildEffectLayer.test.ts +++ b/packages/effect/test/buildEffectLayer.test.ts @@ -1,14 +1,31 @@ import { describe, expect, it, vi } from '@effect/vitest'; import * as sentryCore from '@sentry/core'; import { logger as sentryLogger } from '@sentry/core'; +import type { NodeOptions } from '@sentry/node-core'; import { Effect, Layer } from 'effect'; import { empty as emptyLayer } from 'effect/Layer'; +import { init } from '../src/index.server'; import { buildEffectLayer } from '../src/utils/buildEffectLayer'; +function getMockTransport() { + return () => ({ + send: vi.fn().mockResolvedValue({}), + flush: vi.fn().mockResolvedValue(true), + }); +} + +function createClient(options: NodeOptions = {}) { + return init({ + dsn: 'https://username@domain/123', + transport: getMockTransport(), + ...options, + }); +} + describe('buildEffectLayer', () => { describe('when client is falsy', () => { it('returns empty layer when client is null', () => { - const layer = buildEffectLayer({}, null); + const layer = buildEffectLayer({}, undefined); expect(layer).toBeDefined(); expect(Layer.isLayer(layer)).toBe(true); @@ -25,45 +42,49 @@ describe('buildEffectLayer', () => { }); describe('when client is truthy', () => { - const mockClient = { mock: true }; - it('returns a valid layer with default options', () => { - const layer = buildEffectLayer({}, mockClient); + const client = createClient(); + const layer = buildEffectLayer({}, client); expect(layer).toBeDefined(); expect(Layer.isLayer(layer)).toBe(true); }); - it('returns a valid layer with enableLogs: false', () => { - const layer = buildEffectLayer({ enableLogs: false }, mockClient); + it('returns a valid layer with enableEffectLogs: false', () => { + const client = createClient(); + const layer = buildEffectLayer({ enableEffectLogs: false }, client); expect(layer).toBeDefined(); expect(Layer.isLayer(layer)).toBe(true); }); - it('returns a valid layer with enableLogs: true', () => { - const layer = buildEffectLayer({ enableLogs: true }, mockClient); + it('returns a valid layer with enableEffectLogs: true', () => { + const client = createClient(); + const layer = buildEffectLayer({ enableEffectLogs: true }, client); expect(layer).toBeDefined(); expect(Layer.isLayer(layer)).toBe(true); }); - it('returns a valid layer with enableMetrics: false', () => { - const layer = buildEffectLayer({ enableMetrics: false }, mockClient); + it('returns a valid layer with enableEffectMetrics: false', () => { + const client = createClient(); + const layer = buildEffectLayer({ enableEffectMetrics: false }, client); expect(layer).toBeDefined(); expect(Layer.isLayer(layer)).toBe(true); }); - it('returns a valid layer with enableMetrics: true', () => { - const layer = buildEffectLayer({ enableMetrics: true }, mockClient); + it('returns a valid layer with enableEffectMetrics: true', () => { + const client = createClient(); + const layer = buildEffectLayer({ enableEffectMetrics: true }, client); expect(layer).toBeDefined(); expect(Layer.isLayer(layer)).toBe(true); }); it('returns a valid layer with all features enabled', () => { - const layer = buildEffectLayer({ enableLogs: true, enableMetrics: true }, mockClient); + const client = createClient(); + const layer = buildEffectLayer({ enableEffectLogs: true, enableEffectMetrics: true }, client); expect(layer).toBeDefined(); expect(Layer.isLayer(layer)).toBe(true); @@ -73,7 +94,7 @@ describe('buildEffectLayer', () => { Effect.gen(function* () { const result = yield* Effect.succeed('test-result'); expect(result).toBe('test-result'); - }).pipe(Effect.provide(buildEffectLayer({}, mockClient))), + }).pipe(Effect.provide(buildEffectLayer({}, createClient()))), ); it.effect('layer with logs enabled routes Effect logs to Sentry logger', () => @@ -82,12 +103,13 @@ describe('buildEffectLayer', () => { yield* Effect.log('test log message'); expect(infoSpy).toHaveBeenCalledWith('test log message'); infoSpy.mockRestore(); - }).pipe(Effect.provide(buildEffectLayer({ enableLogs: true }, mockClient))), + }).pipe(Effect.provide(buildEffectLayer({ enableEffectLogs: true }, createClient({ enableLogs: true })))), ); - it('returns different layer when enableMetrics is true vs false', () => { - const layerWithMetrics = buildEffectLayer({ enableMetrics: true }, mockClient); - const layerWithoutMetrics = buildEffectLayer({ enableMetrics: false }, mockClient); + it('returns different layer when enableEffectMetrics is true vs false', () => { + const client = createClient(); + const layerWithMetrics = buildEffectLayer({ enableEffectMetrics: true }, client); + const layerWithoutMetrics = buildEffectLayer({ enableEffectMetrics: false }, client); expect(layerWithMetrics).not.toBe(layerWithoutMetrics); }); @@ -96,7 +118,11 @@ describe('buildEffectLayer', () => { Effect.gen(function* () { const result = yield* Effect.succeed('all-features'); expect(result).toBe('all-features'); - }).pipe(Effect.provide(buildEffectLayer({ enableLogs: true, enableMetrics: true }, mockClient))), + }).pipe( + Effect.provide( + buildEffectLayer({ enableEffectLogs: true, enableEffectMetrics: true }, createClient({ enableLogs: true })), + ), + ), ); it.effect('layer enables tracing for Effect spans via Sentry tracer', () => @@ -110,22 +136,22 @@ describe('buildEffectLayer', () => { }), ); startInactiveSpanSpy.mockRestore(); - }).pipe(Effect.provide(buildEffectLayer({}, mockClient))), + }).pipe(Effect.provide(buildEffectLayer({}, createClient()))), ); }); describe('with additional options', () => { - const mockClient = { mock: true }; + const client = createClient({ enableLogs: true }); it('accepts options with additional properties', () => { const layer = buildEffectLayer( { - enableLogs: true, - enableMetrics: true, + enableEffectLogs: true, + enableEffectMetrics: true, dsn: 'https://test@sentry.io/123', debug: true, - } as { enableLogs?: boolean; enableMetrics?: boolean; dsn?: string; debug?: boolean }, - mockClient, + } as { enableEffectLogs?: boolean; enableEffectMetrics?: boolean; dsn?: string; debug?: boolean }, + client, ); expect(layer).toBeDefined(); diff --git a/packages/effect/test/layer.test.ts b/packages/effect/test/layer.test.ts index 8d96f039062b..420e12a8ed5c 100644 --- a/packages/effect/test/layer.test.ts +++ b/packages/effect/test/layer.test.ts @@ -63,7 +63,7 @@ describe.each([ const layer = effectLayer({ dsn: TEST_DSN, transport: getMockTransport(), - enableLogs: true, + enableEffectLogs: true, }); expect(layer).toBeDefined(); @@ -73,8 +73,8 @@ describe.each([ const layer = effectLayer({ dsn: TEST_DSN, transport: getMockTransport(), - enableLogs: true, - enableMetrics: true, + enableEffectLogs: true, + enableEffectMetrics: true, }); expect(layer).toBeDefined(); From 6d004ecfe9627512c8747b2200ca4b8906fad7b7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jan=20Peer=20St=C3=B6cklmair?= Date: Thu, 12 Mar 2026 14:21:47 +0100 Subject: [PATCH 07/10] feat(effect): Add E2E tests for the @sentry/effect SDK (#19763) This adds Node and Browser tests for the `@sentry/effect` SDK. I am not sure what to do with the browser part, as there is I guess no tree-shaking available right now. The basic usage for node and browser are the exact same, only the `effectLayer` has to be added into the runtime layer. --- .../effect-browser/.gitignore | 28 ++++ .../test-applications/effect-browser/.npmrc | 2 + .../effect-browser/build.mjs | 52 ++++++++ .../effect-browser/package.json | 42 ++++++ .../effect-browser/playwright.config.mjs | 7 + .../effect-browser/public/index.html | 48 +++++++ .../effect-browser/src/index.js | 87 +++++++++++++ .../effect-browser/start-event-proxy.mjs | 6 + .../effect-browser/tests/errors.test.ts | 56 ++++++++ .../effect-browser/tests/logs.test.ts | 116 +++++++++++++++++ .../effect-browser/tests/transactions.test.ts | 123 ++++++++++++++++++ .../effect-browser/tsconfig.json | 19 +++ .../test-applications/effect-node/.gitignore | 2 + .../test-applications/effect-node/.npmrc | 2 + .../effect-node/package.json | 29 +++++ .../effect-node/playwright.config.mjs | 7 + .../test-applications/effect-node/src/app.ts | 101 ++++++++++++++ .../effect-node/start-event-proxy.mjs | 6 + .../effect-node/tests/errors.test.ts | 56 ++++++++ .../effect-node/tests/logs.test.ts | 96 ++++++++++++++ .../effect-node/tests/transactions.test.ts | 91 +++++++++++++ .../effect-node/tsconfig.json | 14 ++ 22 files changed, 990 insertions(+) create mode 100644 dev-packages/e2e-tests/test-applications/effect-browser/.gitignore create mode 100644 dev-packages/e2e-tests/test-applications/effect-browser/.npmrc create mode 100644 dev-packages/e2e-tests/test-applications/effect-browser/build.mjs create mode 100644 dev-packages/e2e-tests/test-applications/effect-browser/package.json create mode 100644 dev-packages/e2e-tests/test-applications/effect-browser/playwright.config.mjs create mode 100644 dev-packages/e2e-tests/test-applications/effect-browser/public/index.html create mode 100644 dev-packages/e2e-tests/test-applications/effect-browser/src/index.js create mode 100644 dev-packages/e2e-tests/test-applications/effect-browser/start-event-proxy.mjs create mode 100644 dev-packages/e2e-tests/test-applications/effect-browser/tests/errors.test.ts create mode 100644 dev-packages/e2e-tests/test-applications/effect-browser/tests/logs.test.ts create mode 100644 dev-packages/e2e-tests/test-applications/effect-browser/tests/transactions.test.ts create mode 100644 dev-packages/e2e-tests/test-applications/effect-browser/tsconfig.json create mode 100644 dev-packages/e2e-tests/test-applications/effect-node/.gitignore create mode 100644 dev-packages/e2e-tests/test-applications/effect-node/.npmrc create mode 100644 dev-packages/e2e-tests/test-applications/effect-node/package.json create mode 100644 dev-packages/e2e-tests/test-applications/effect-node/playwright.config.mjs create mode 100644 dev-packages/e2e-tests/test-applications/effect-node/src/app.ts create mode 100644 dev-packages/e2e-tests/test-applications/effect-node/start-event-proxy.mjs create mode 100644 dev-packages/e2e-tests/test-applications/effect-node/tests/errors.test.ts create mode 100644 dev-packages/e2e-tests/test-applications/effect-node/tests/logs.test.ts create mode 100644 dev-packages/e2e-tests/test-applications/effect-node/tests/transactions.test.ts create mode 100644 dev-packages/e2e-tests/test-applications/effect-node/tsconfig.json 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

+ +
+
+

Error Tests

+ +
+ +
+

Effect Span Tests

+ + +
+ +
+

Effect Failure Tests

+ + +
+ + +
+ +
+

Log Tests

+ + +
+ + +
+ + +
+ + 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..c5d4645814ea --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/effect-browser/src/index.js @@ -0,0 +1,87 @@ +// @ts-check +import * as Sentry from '@sentry/effect'; +import { Cause, Effect, Layer, Logger, LogLevel, Runtime } from '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, + enableEffectLogs: true, + }), + 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..dbbb8fa7ddf3 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/effect-browser/tests/transactions.test.ts @@ -0,0 +1,123 @@ +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', + data: expect.objectContaining({ + 'sentry.op': 'internal', + }), + }), + ); + + 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..9e4a40e585c3 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/effect-node/src/app.ts @@ -0,0 +1,101 @@ +import * as Sentry from '@sentry/effect'; +import { HttpRouter, HttpServer, HttpServerResponse } from '@effect/platform'; +import { NodeHttpServer, NodeRuntime } from '@effect/platform-node'; +import { Cause, Effect, Layer, Logger, LogLevel } from 'effect'; +import { createServer } from 'http'; + +const SentryLive = Sentry.effectLayer({ + dsn: process.env.E2E_TEST_DSN, + environment: 'qa', + debug: !!process.env.DEBUG, + tunnel: 'http://localhost:3031/', + tracesSampleRate: 1, + enableLogs: true, + enableEffectLogs: true, +}); + +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..1f510f7d074c --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/effect-node/tests/transactions.test.ts @@ -0,0 +1,91 @@ +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?.contexts?.trace?.op === 'http.server' && + transactionEvent?.transaction?.includes('/test-success') + ); + }); + + await fetch(`${baseURL}/test-success`); + + const transactionEvent = await transactionEventPromise; + + expect(transactionEvent.contexts?.trace?.op).toBe('http.server'); + expect(transactionEvent.transaction).toContain('/test-success'); +}); + +test('Sends transaction with manual Effect span', async ({ baseURL }) => { + const transactionEventPromise = waitForTransaction('effect-node', transactionEvent => { + return ( + transactionEvent?.contexts?.trace?.op === 'http.server' && + transactionEvent?.transaction?.includes('/test-transaction') + ); + }); + + await fetch(`${baseURL}/test-transaction`); + + const transactionEvent = await transactionEventPromise; + + expect(transactionEvent.contexts?.trace?.op).toBe('http.server'); + expect(transactionEvent.transaction).toContain('/test-transaction'); + + const spans = transactionEvent.spans || []; + expect(spans).toContainEqual( + expect.objectContaining({ + description: 'test-span', + }), + ); +}); + +test('Sends Effect spans with correct parent-child structure', async ({ baseURL }) => { + const transactionEventPromise = waitForTransaction('effect-node', transactionEvent => { + return ( + transactionEvent?.contexts?.trace?.op === 'http.server' && + transactionEvent?.transaction?.includes('/test-effect-span') + ); + }); + + await fetch(`${baseURL}/test-effect-span`); + + const transactionEvent = await transactionEventPromise; + + expect(transactionEvent.contexts?.trace?.op).toBe('http.server'); + expect(transactionEvent.transaction).toContain('/test-effect-span'); + + const spans = transactionEvent.spans || []; + + expect(spans).toContainEqual( + expect.objectContaining({ + description: 'custom-effect-span', + op: 'internal', + }), + ); + + 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); +}); + +test('Sends transaction for error route', async ({ baseURL }) => { + const transactionEventPromise = waitForTransaction('effect-node', transactionEvent => { + return ( + transactionEvent?.contexts?.trace?.op === 'http.server' && transactionEvent?.transaction?.includes('/test-error') + ); + }); + + await fetch(`${baseURL}/test-error`); + + const transactionEvent = await transactionEventPromise; + + expect(transactionEvent.contexts?.trace?.op).toBe('http.server'); + expect(transactionEvent.transaction).toContain('/test-error'); +}); 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"] +} From d5efd68fadca92abe2ff4700ddcafea6cf201efb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jan=20Peer=20St=C3=B6cklmair?= Date: Fri, 13 Mar 2026 10:08:39 +0100 Subject: [PATCH 08/10] feat(effect): Use options in buildEffectLayer without overriding (#19794) To not mess with `@sentry/core` while adding the new SDK this, the options are directly moved into the Effect SDK. The main reason why this is now moved is that mutating the options could lead to other issues, and this is why I want to keep this in a separate and smaller PR. --- packages/core/src/client.ts | 4 ++-- packages/effect/src/utils/buildEffectLayer.ts | 6 ++++-- 2 files changed, 6 insertions(+), 4 deletions(-) diff --git a/packages/core/src/client.ts b/packages/core/src/client.ts index 4854aeb8af5a..8d69411aacfd 100644 --- a/packages/core/src/client.ts +++ b/packages/core/src/client.ts @@ -256,10 +256,10 @@ export abstract class Client { // todo(v11): Remove the experimental flag // eslint-disable-next-line deprecation/deprecation - this._options.enableMetrics = this._options.enableMetrics ?? this._options._experiments?.enableMetrics ?? true; + const enableMetrics = this._options.enableMetrics ?? this._options._experiments?.enableMetrics ?? true; // Setup metric flushing with weight and timeout tracking - if (this._options.enableMetrics) { + if (enableMetrics) { setupWeightBasedFlushing( this, 'afterCaptureMetric', diff --git a/packages/effect/src/utils/buildEffectLayer.ts b/packages/effect/src/utils/buildEffectLayer.ts index 36879e0b956d..6516b99b497a 100644 --- a/packages/effect/src/utils/buildEffectLayer.ts +++ b/packages/effect/src/utils/buildEffectLayer.ts @@ -27,15 +27,17 @@ export function buildEffectLayer( } const clientOptions = client.getOptions(); + const enableMetrics = clientOptions.enableMetrics ?? clientOptions._experiments?.enableMetrics ?? true; + const enableLogs = clientOptions.enableLogs ?? clientOptions._experiments?.enableLogs ?? false; const { enableEffectLogs = false, enableEffectMetrics = false } = options; let layer: EffectLayer.Layer = SentryEffectTracerLayer; - if (enableEffectLogs && clientOptions.enableLogs) { + if (enableEffectLogs && enableLogs) { const effectLogger = replaceLogger(defaultLogger, SentryEffectLogger); layer = layer.pipe(provideMerge(effectLogger)); } - if (enableEffectMetrics && clientOptions.enableMetrics) { + if (enableEffectMetrics && enableMetrics) { layer = layer.pipe(provideMerge(SentryEffectMetricsLayer)); } From 7ed77216411beebff1551b9fa76061952700d58a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jan=20Peer=20St=C3=B6cklmair?= Date: Tue, 17 Mar 2026 11:30:59 +0100 Subject: [PATCH 09/10] feat(effect): Export layers for browser | setTracer in buildEffectTracer (#19816) This changes three things in one go: - Export `SentryEffectTracer`, `SentryEffectLogger` and `SentryEffectMetricsLayer` for bundle sizes in the browser (and node if they want to go that route) - Respect `tracesSampleRate` when using `Sentry.effectLayer`, so tracing is only added when needed - Moving `setTracer` outside of `SentryEffectTracerLayer` to make it Effect native, where users could call `setTracer` on their own if they want to --- packages/effect/README.md | 1 + packages/effect/src/index.client.ts | 4 ++ packages/effect/src/index.server.ts | 4 ++ packages/effect/src/tracer.ts | 4 +- packages/effect/src/utils/buildEffectLayer.ts | 13 +++++-- packages/effect/test/buildEffectLayer.test.ts | 2 +- packages/effect/test/tracer.test.ts | 39 ++++++++++--------- 7 files changed, 41 insertions(+), 26 deletions(-) diff --git a/packages/effect/README.md b/packages/effect/README.md index c04ff97446e4..e98bf76d8df0 100644 --- a/packages/effect/README.md +++ b/packages/effect/README.md @@ -22,6 +22,7 @@ const MainLive = HttpLive.pipe( Layer.provide( Sentry.effectLayer({ dsn: '__DSN__', + tracesSampleRate: 1.0, enableLogs: true, enableEffectLogs: true, enableEffectMetrics: true, diff --git a/packages/effect/src/index.client.ts b/packages/effect/src/index.client.ts index e13f1ddea09e..2df8a2548fb9 100644 --- a/packages/effect/src/index.client.ts +++ b/packages/effect/src/index.client.ts @@ -5,3 +5,7 @@ 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 index a3f8e4f3766f..c66abbf43413 100644 --- a/packages/effect/src/index.server.ts +++ b/packages/effect/src/index.server.ts @@ -2,3 +2,7 @@ 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/tracer.ts b/packages/effect/src/tracer.ts index 116b7970a6ae..6a9c52b38ee6 100644 --- a/packages/effect/src/tracer.ts +++ b/packages/effect/src/tracer.ts @@ -8,8 +8,6 @@ import { } 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'; @@ -198,4 +196,4 @@ const makeSentryTracer = (): EffectTracer.Tracer => /** * Effect Layer that sets up the Sentry tracer for Effect spans. */ -export const SentryEffectTracerLayer: Layer.Layer = setTracer(makeSentryTracer()); +export const SentryEffectTracer = makeSentryTracer(); diff --git a/packages/effect/src/utils/buildEffectLayer.ts b/packages/effect/src/utils/buildEffectLayer.ts index 6516b99b497a..42d46a91d305 100644 --- a/packages/effect/src/utils/buildEffectLayer.ts +++ b/packages/effect/src/utils/buildEffectLayer.ts @@ -1,10 +1,10 @@ -import type { Client } from '@sentry/core'; +import { hasSpansEnabled, type Client } from '@sentry/core'; import type * as EffectLayer from 'effect/Layer'; -import { empty as emptyLayer, provideMerge } from 'effect/Layer'; +import { empty as emptyLayer, provideMerge, setTracer } from 'effect/Layer'; import { defaultLogger, replace as replaceLogger } from 'effect/Logger'; import { SentryEffectLogger } from '../logger'; import { SentryEffectMetricsLayer } from '../metrics'; -import { SentryEffectTracerLayer } from '../tracer'; +import { SentryEffectTracer } from '../tracer'; export interface EffectLayerBaseOptions { enableEffectLogs?: boolean; @@ -27,10 +27,15 @@ export function buildEffectLayer( } const clientOptions = client.getOptions(); + const hasSpans = hasSpansEnabled(clientOptions); const enableMetrics = clientOptions.enableMetrics ?? clientOptions._experiments?.enableMetrics ?? true; const enableLogs = clientOptions.enableLogs ?? clientOptions._experiments?.enableLogs ?? false; const { enableEffectLogs = false, enableEffectMetrics = false } = options; - let layer: EffectLayer.Layer = SentryEffectTracerLayer; + let layer = emptyLayer; + + if (hasSpans) { + layer = layer.pipe(provideMerge(setTracer(SentryEffectTracer))); + } if (enableEffectLogs && enableLogs) { const effectLogger = replaceLogger(defaultLogger, SentryEffectLogger); diff --git a/packages/effect/test/buildEffectLayer.test.ts b/packages/effect/test/buildEffectLayer.test.ts index d74045d5cb99..e6f4e4c77819 100644 --- a/packages/effect/test/buildEffectLayer.test.ts +++ b/packages/effect/test/buildEffectLayer.test.ts @@ -136,7 +136,7 @@ describe('buildEffectLayer', () => { }), ); startInactiveSpanSpy.mockRestore(); - }).pipe(Effect.provide(buildEffectLayer({}, createClient()))), + }).pipe(Effect.provide(buildEffectLayer({}, createClient({ tracesSampleRate: 1.0 })))), ); }); diff --git a/packages/effect/test/tracer.test.ts b/packages/effect/test/tracer.test.ts index 8955200695fa..b8313ce6d421 100644 --- a/packages/effect/test/tracer.test.ts +++ b/packages/effect/test/tracer.test.ts @@ -2,10 +2,13 @@ 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 { setTracer } from 'effect/Layer'; import { afterEach, vi } from 'vitest'; -import { SentryEffectTracerLayer } from '../src/tracer'; +import { SentryEffectTracer } from '../src/tracer'; -describe('SentryEffectTracerLayer', () => { +const SentryTracerLayer = setTracer(SentryEffectTracer); + +describe('SentryEffectTracer', () => { afterEach(() => { vi.restoreAllMocks(); }); @@ -22,7 +25,7 @@ describe('SentryEffectTracerLayer', () => { ); expect(capturedSpanName).toBe('effect-span-executed'); - }).pipe(Effect.provide(SentryEffectTracerLayer)), + }).pipe(Effect.provide(SentryTracerLayer)), ); it.effect('creates spans with correct attributes', () => @@ -30,7 +33,7 @@ describe('SentryEffectTracerLayer', () => { const result = yield* Effect.withSpan('my-operation')(Effect.succeed('success')); expect(result).toBe('success'); - }).pipe(Effect.provide(SentryEffectTracerLayer)), + }).pipe(Effect.provide(SentryTracerLayer)), ); it.effect('handles nested spans', () => @@ -43,7 +46,7 @@ describe('SentryEffectTracerLayer', () => { ); expect(result).toBe('outer-inner-result'); - }).pipe(Effect.provide(SentryEffectTracerLayer)), + }).pipe(Effect.provide(SentryTracerLayer)), ); it.effect('propagates span context through Effect fibers', () => @@ -60,7 +63,7 @@ describe('SentryEffectTracerLayer', () => { ); expect(results).toEqual(['parent-start', 'child-1', 'child-2', 'parent-end']); - }).pipe(Effect.provide(SentryEffectTracerLayer)), + }).pipe(Effect.provide(SentryTracerLayer)), ); it.effect('handles span failures correctly', () => @@ -70,7 +73,7 @@ describe('SentryEffectTracerLayer', () => { ); expect(result).toBe('caught: expected-error'); - }).pipe(Effect.provide(SentryEffectTracerLayer)), + }).pipe(Effect.provide(SentryTracerLayer)), ); it.effect('handles span with defects (die)', () => @@ -80,7 +83,7 @@ describe('SentryEffectTracerLayer', () => { ); expect(result).toBe('caught-defect: defect-value'); - }).pipe(Effect.provide(SentryEffectTracerLayer)), + }).pipe(Effect.provide(SentryTracerLayer)), ); it.effect('works with Effect.all for parallel operations', () => @@ -94,7 +97,7 @@ describe('SentryEffectTracerLayer', () => { ); expect(results).toEqual([1, 2, 3]); - }).pipe(Effect.provide(SentryEffectTracerLayer)), + }).pipe(Effect.provide(SentryTracerLayer)), ); it.effect('supports span annotations', () => @@ -105,7 +108,7 @@ describe('SentryEffectTracerLayer', () => { ); expect(result).toBe('annotated'); - }).pipe(Effect.provide(SentryEffectTracerLayer)), + }).pipe(Effect.provide(SentryTracerLayer)), ); it.effect('sets span status to ok on success', () => @@ -128,7 +131,7 @@ describe('SentryEffectTracerLayer', () => { expect(setStatusCalls).toContainEqual({ code: 1 }); mockStartInactiveSpan.mockRestore(); - }).pipe(Effect.provide(SentryEffectTracerLayer)), + }).pipe(Effect.provide(SentryTracerLayer)), ); it.effect('sets span status to error on failure', () => @@ -151,7 +154,7 @@ describe('SentryEffectTracerLayer', () => { expect(setStatusCalls).toContainEqual({ code: 2, message: 'test-error' }); mockStartInactiveSpan.mockRestore(); - }).pipe(Effect.provide(SentryEffectTracerLayer)), + }).pipe(Effect.provide(SentryTracerLayer)), ); it.effect('sets span status to error on defect', () => @@ -174,7 +177,7 @@ describe('SentryEffectTracerLayer', () => { expect(setStatusCalls).toContainEqual({ code: 2, message: 'fatal-defect' }); mockStartInactiveSpan.mockRestore(); - }).pipe(Effect.provide(SentryEffectTracerLayer)), + }).pipe(Effect.provide(SentryTracerLayer)), ); it.effect('propagates Sentry span context via withActiveSpan', () => @@ -195,7 +198,7 @@ describe('SentryEffectTracerLayer', () => { expect(withActiveSpanCalls.length).toBeGreaterThan(0); mockWithActiveSpan.mockRestore(); - }).pipe(Effect.provide(SentryEffectTracerLayer)), + }).pipe(Effect.provide(SentryTracerLayer)), ); it.effect('sets origin to auto.function.effect for regular spans', () => @@ -220,7 +223,7 @@ describe('SentryEffectTracerLayer', () => { expect(capturedAttributes?.[SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]).toBe('auto.function.effect'); mockStartInactiveSpan.mockRestore(); - }).pipe(Effect.provide(SentryEffectTracerLayer)), + }).pipe(Effect.provide(SentryTracerLayer)), ); it.effect('sets origin to auto.http.effect for http.server spans', () => @@ -245,7 +248,7 @@ describe('SentryEffectTracerLayer', () => { expect(capturedAttributes?.[SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]).toBe('auto.http.effect'); mockStartInactiveSpan.mockRestore(); - }).pipe(Effect.provide(SentryEffectTracerLayer)), + }).pipe(Effect.provide(SentryTracerLayer)), ); it.effect('sets origin to auto.http.effect for http.client spans', () => @@ -270,7 +273,7 @@ describe('SentryEffectTracerLayer', () => { expect(capturedAttributes?.[SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]).toBe('auto.http.effect'); mockStartInactiveSpan.mockRestore(); - }).pipe(Effect.provide(SentryEffectTracerLayer)), + }).pipe(Effect.provide(SentryTracerLayer)), ); it.effect('uses transaction name from isolation scope for http.server spans', () => @@ -301,6 +304,6 @@ describe('SentryEffectTracerLayer', () => { mockStartInactiveSpan.mockRestore(); mockGetIsolationScope.mockRestore(); - }).pipe(Effect.provide(SentryEffectTracerLayer)), + }).pipe(Effect.provide(SentryTracerLayer)), ); }); From 7eef9d9211b07ebf808a0fe5abe12a7c349bd047 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jan=20Peer=20St=C3=B6cklmair?= Date: Tue, 17 Mar 2026 14:23:18 +0100 Subject: [PATCH 10/10] feat(effect): Remove effectLayer auto composition (#19823) There are 2 changes in this PR: 1. No auto-injection anymore 2. Removal of the OP and the span names. We fully rely now on Effect as much as possible (related: https://github.com/getsentry/sentry-javascript/pull/19644#discussion_r2930298904) About the removal of auto injection of traces, logs or metrics from the `Sentry.effectLayer`: this means that `Sentry.effectLayer` is **only** initializing the browser/node client. So the usages in comparison for logs and traces: before: ```js Layer.provide(Sentry.effectLayer({ dsn: '', tracesSampleRate: 1.0, enableLogs: true, enableEffectLogs: true, })); ``` after: ```js import * as Layer from "effect/Layer"; import * as Logger from "effect/Logger" Layer.mergeAll( Sentry.effectLayer({ dsn: '', tracesSampleRate: 1.0, enableLogs: true, }, Layer.setTracer(Sentry.SentryEffectTracer), Logger.replace(Logger.defaultLogger, Sentry.SentryEffectLogger) )); ``` The benefit of this is to have a unified usage between browser and node, which also follows the usage of Effect, so users have to actively set the tracer with `setTracer` or replace the logger with `Logger.replace`. There is no extra opt-in via `enableEffectLogs` or `enableEffectMetrics`, this would also remove the confusion of not having the `enableEffectTraces` option (which didn't exist before, nor would have existed in any future versions) This was discussed offline with @Lms24 to have this. In the first alpha we can always shift if there is the need, but this is now way cleaner. I ask myself now if `Sentry.effectLayer` is now the best function name. --- .../effect-browser/src/index.js | 9 +- .../effect-browser/tests/transactions.test.ts | 3 - .../test-applications/effect-node/src/app.ts | 27 +-- .../effect-node/tests/transactions.test.ts | 76 +++++---- packages/effect/README.md | 31 ++-- packages/effect/src/client/index.ts | 32 ++-- packages/effect/src/server/index.ts | 31 ++-- packages/effect/src/tracer.ts | 44 +---- packages/effect/src/utils/buildEffectLayer.ts | 50 ------ packages/effect/test/buildEffectLayer.test.ts | 161 ------------------ packages/effect/test/layer.test.ts | 117 +++++++++---- packages/effect/test/tracer.test.ts | 67 +++----- 12 files changed, 222 insertions(+), 426 deletions(-) delete mode 100644 packages/effect/src/utils/buildEffectLayer.ts delete mode 100644 packages/effect/test/buildEffectLayer.test.ts 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 index c5d4645814ea..4e9cb70d6e44 100644 --- a/dev-packages/e2e-tests/test-applications/effect-browser/src/index.js +++ b/dev-packages/e2e-tests/test-applications/effect-browser/src/index.js @@ -1,6 +1,10 @@ // @ts-check import * as Sentry from '@sentry/effect'; -import { Cause, Effect, Layer, Logger, LogLevel, Runtime } from '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( @@ -16,8 +20,9 @@ const AppLayer = Layer.mergeAll( environment: 'qa', tunnel: 'http://localhost:3031', enableLogs: true, - enableEffectLogs: true, }), + Layer.setTracer(Sentry.SentryEffectTracer), + Logger.replace(Logger.defaultLogger, Sentry.SentryEffectLogger), LogLevelLive, ); 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 index dbbb8fa7ddf3..b7c60b488403 100644 --- 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 @@ -105,9 +105,6 @@ test('captures Effect spans with correct parent-child structure', async ({ page expect(spans).toContainEqual( expect.objectContaining({ description: 'custom-effect-span', - data: expect.objectContaining({ - 'sentry.op': 'internal', - }), }), ); 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 index 9e4a40e585c3..899adfb4aa98 100644 --- a/dev-packages/e2e-tests/test-applications/effect-node/src/app.ts +++ b/dev-packages/e2e-tests/test-applications/effect-node/src/app.ts @@ -1,18 +1,25 @@ import * as Sentry from '@sentry/effect'; import { HttpRouter, HttpServer, HttpServerResponse } from '@effect/platform'; import { NodeHttpServer, NodeRuntime } from '@effect/platform-node'; -import { Cause, Effect, Layer, Logger, LogLevel } from 'effect'; +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 = Sentry.effectLayer({ - dsn: process.env.E2E_TEST_DSN, - environment: 'qa', - debug: !!process.env.DEBUG, - tunnel: 'http://localhost:3031/', - tracesSampleRate: 1, - enableLogs: true, - enableEffectLogs: true, -}); +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' })), 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 index 1f510f7d074c..ed7a58fa28df 100644 --- 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 @@ -3,25 +3,21 @@ import { waitForTransaction } from '@sentry-internal/test-utils'; test('Sends an HTTP transaction', async ({ baseURL }) => { const transactionEventPromise = waitForTransaction('effect-node', transactionEvent => { - return ( - transactionEvent?.contexts?.trace?.op === 'http.server' && - transactionEvent?.transaction?.includes('/test-success') - ); + return transactionEvent?.transaction === 'http.server GET'; }); await fetch(`${baseURL}/test-success`); const transactionEvent = await transactionEventPromise; - expect(transactionEvent.contexts?.trace?.op).toBe('http.server'); - expect(transactionEvent.transaction).toContain('/test-success'); + expect(transactionEvent.transaction).toBe('http.server GET'); }); test('Sends transaction with manual Effect span', async ({ baseURL }) => { const transactionEventPromise = waitForTransaction('effect-node', transactionEvent => { return ( - transactionEvent?.contexts?.trace?.op === 'http.server' && - transactionEvent?.transaction?.includes('/test-transaction') + transactionEvent?.transaction === 'http.server GET' && + transactionEvent?.spans?.some(span => span.description === 'test-span') ); }); @@ -29,22 +25,21 @@ test('Sends transaction with manual Effect span', async ({ baseURL }) => { const transactionEvent = await transactionEventPromise; - expect(transactionEvent.contexts?.trace?.op).toBe('http.server'); - expect(transactionEvent.transaction).toContain('/test-transaction'); + expect(transactionEvent.transaction).toBe('http.server GET'); const spans = transactionEvent.spans || []; - expect(spans).toContainEqual( + 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?.contexts?.trace?.op === 'http.server' && - transactionEvent?.transaction?.includes('/test-effect-span') + transactionEvent?.transaction === 'http.server GET' && + transactionEvent?.spans?.some(span => span.description === 'custom-effect-span') ); }); @@ -52,40 +47,53 @@ test('Sends Effect spans with correct parent-child structure', async ({ baseURL const transactionEvent = await transactionEventPromise; - expect(transactionEvent.contexts?.trace?.op).toBe('http.server'); - expect(transactionEvent.transaction).toContain('/test-effect-span'); - - const spans = transactionEvent.spans || []; + expect(transactionEvent.transaction).toBe('http.server GET'); - expect(spans).toContainEqual( + expect(transactionEvent).toEqual( expect.objectContaining({ - description: 'custom-effect-span', - op: 'internal', + 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', + }), + ], + }), }), ); - expect(spans).toContainEqual( - expect.objectContaining({ - description: 'nested-span', - }), - ); + const parentSpan = transactionEvent.spans?.[0]?.span_id; + const nestedSpan = transactionEvent.spans?.[1]?.parent_span_id; - 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); + expect(nestedSpan).toBe(parentSpan); }); test('Sends transaction for error route', async ({ baseURL }) => { const transactionEventPromise = waitForTransaction('effect-node', transactionEvent => { - return ( - transactionEvent?.contexts?.trace?.op === 'http.server' && transactionEvent?.transaction?.includes('/test-error') - ); + return transactionEvent?.transaction === 'http.server GET'; }); await fetch(`${baseURL}/test-error`); const transactionEvent = await transactionEventPromise; - expect(transactionEvent.contexts?.trace?.op).toBe('http.server'); - expect(transactionEvent.transaction).toContain('/test-error'); + expect(transactionEvent.transaction).toBe('http.server GET'); }); diff --git a/packages/effect/README.md b/packages/effect/README.md index e98bf76d8df0..78b2f6471dc0 100644 --- a/packages/effect/README.md +++ b/packages/effect/README.md @@ -15,30 +15,29 @@ This SDK does not have docs yet. Stay tuned. ```typescript import * as Sentry from '@sentry/effect/server'; import { NodeRuntime } from '@effect/platform-node'; -import { Layer } from 'effect'; +import { Layer, Logger } from 'effect'; import { HttpLive } from './Http.js'; -const MainLive = HttpLive.pipe( - Layer.provide( - Sentry.effectLayer({ - dsn: '__DSN__', - tracesSampleRate: 1.0, - enableLogs: true, - enableEffectLogs: true, - enableEffectMetrics: true, - }), - ), +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 and returns an Effect Layer that provides: +The `effectLayer` function initializes Sentry. To enable Effect instrumentation, compose with: -- Distributed tracing with automatic HTTP header extraction/injection -- Effect spans traced as Sentry spans -- Effect logs forwarded to Sentry (when `enableEffectLogs` is set) -- Effect metrics sent to Sentry (when `enableEffectMetrics` is set) +- `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 diff --git a/packages/effect/src/client/index.ts b/packages/effect/src/client/index.ts index d26255f38e56..e60843bc1e3e 100644 --- a/packages/effect/src/client/index.ts +++ b/packages/effect/src/client/index.ts @@ -1,8 +1,6 @@ import type { BrowserOptions } from '@sentry/browser'; import type * as EffectLayer from 'effect/Layer'; -import { suspend as suspendLayer } from 'effect/Layer'; -import type { EffectLayerBaseOptions } from '../utils/buildEffectLayer'; -import { buildEffectLayer } from '../utils/buildEffectLayer'; +import { empty as emptyLayer, suspend as suspendLayer } from 'effect/Layer'; import { init } from './sdk'; export { init } from './sdk'; @@ -10,32 +8,36 @@ export { init } from './sdk'; /** * Options for the Sentry Effect client layer. */ -export type EffectClientLayerOptions = BrowserOptions & EffectLayerBaseOptions; +export type EffectClientLayerOptions = BrowserOptions; /** * 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 - * - Effect logs forwarded to Sentry (when `enableEffectLogs` is set) - * - Effect metrics sent to Sentry (when `enableEffectMetrics` is set) + * 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, Effect } from 'effect'; + * import { Layer, Logger, LogLevel } from 'effect'; * - * const ApiClientWithSentry = ApiClientLive.pipe( - * Layer.provide(Sentry.effectLayer({ + * const SentryLive = Layer.mergeAll( + * Sentry.effectLayer({ * dsn: '__DSN__', * integrations: [Sentry.browserTracingIntegration()], * tracesSampleRate: 1.0, - * })), + * }), + * Layer.setTracer(Sentry.SentryEffectTracer), + * Logger.replace(Logger.defaultLogger, Sentry.SentryEffectLogger), * ); - * - * Effect.runPromise(Effect.provide(myEffect, ApiClientWithSentry)); * ``` */ export function effectLayer(options: EffectClientLayerOptions): EffectLayer.Layer { - return suspendLayer(() => buildEffectLayer(options, init(options))); + return suspendLayer(() => { + init(options); + + return emptyLayer; + }); } diff --git a/packages/effect/src/server/index.ts b/packages/effect/src/server/index.ts index 1ef0bc542877..76c078544af1 100644 --- a/packages/effect/src/server/index.ts +++ b/packages/effect/src/server/index.ts @@ -1,7 +1,6 @@ import type { NodeOptions } from '@sentry/node-core/light'; import type * as EffectLayer from 'effect/Layer'; -import type { EffectLayerBaseOptions } from '../utils/buildEffectLayer'; -import { buildEffectLayer } from '../utils/buildEffectLayer'; +import { empty as emptyLayer, suspend as suspendLayer } from 'effect/Layer'; import { init } from './sdk'; export { init } from './sdk'; @@ -9,34 +8,36 @@ export { init } from './sdk'; /** * Options for the Sentry Effect server layer. */ -export type EffectServerLayerOptions = NodeOptions & EffectLayerBaseOptions; +export type EffectServerLayerOptions = NodeOptions; /** * 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 - * - Effect logs forwarded to Sentry (when `enableEffectLogs` is set) - * - Effect metrics sent to Sentry (when `enableEffectMetrics` is set) + * 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 } from 'effect'; + * import { Layer, Logger } from 'effect'; * import { HttpLive } from './Http.js'; * - * const MainLive = HttpLive.pipe( - * Layer.provide(Sentry.effectLayer({ - * dsn: '__DSN__', - * enableEffectLogs: true, - * enableEffectMetrics: true, - * })), + * 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 buildEffectLayer(options, init(options)); + return suspendLayer(() => { + init(options); + return emptyLayer; + }); } diff --git a/packages/effect/src/tracer.ts b/packages/effect/src/tracer.ts index 6a9c52b38ee6..f755101e4417 100644 --- a/packages/effect/src/tracer.ts +++ b/packages/effect/src/tracer.ts @@ -1,36 +1,10 @@ import type { Span } from '@sentry/core'; -import { - getActiveSpan, - getIsolationScope, - SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN, - startInactiveSpan, - withActiveSpan, -} 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'; -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'; @@ -39,17 +13,6 @@ function deriveOrigin(name: string): string { 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'); @@ -164,11 +127,8 @@ function createSentrySpan( 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), + name, startTime: nanosToHrTime(startTime), attributes: { [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: deriveOrigin(name), diff --git a/packages/effect/src/utils/buildEffectLayer.ts b/packages/effect/src/utils/buildEffectLayer.ts deleted file mode 100644 index 42d46a91d305..000000000000 --- a/packages/effect/src/utils/buildEffectLayer.ts +++ /dev/null @@ -1,50 +0,0 @@ -import { hasSpansEnabled, type Client } from '@sentry/core'; -import type * as EffectLayer from 'effect/Layer'; -import { empty as emptyLayer, provideMerge, setTracer } from 'effect/Layer'; -import { defaultLogger, replace as replaceLogger } from 'effect/Logger'; -import { SentryEffectLogger } from '../logger'; -import { SentryEffectMetricsLayer } from '../metrics'; -import { SentryEffectTracer } from '../tracer'; - -export interface EffectLayerBaseOptions { - enableEffectLogs?: boolean; - enableEffectMetrics?: boolean; -} - -/** - * Builds an Effect layer that integrates Sentry tracing, logging, and metrics. - * - * 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: Client | undefined, -): EffectLayer.Layer { - if (!client) { - return emptyLayer; - } - - const clientOptions = client.getOptions(); - const hasSpans = hasSpansEnabled(clientOptions); - const enableMetrics = clientOptions.enableMetrics ?? clientOptions._experiments?.enableMetrics ?? true; - const enableLogs = clientOptions.enableLogs ?? clientOptions._experiments?.enableLogs ?? false; - const { enableEffectLogs = false, enableEffectMetrics = false } = options; - let layer = emptyLayer; - - if (hasSpans) { - layer = layer.pipe(provideMerge(setTracer(SentryEffectTracer))); - } - - if (enableEffectLogs && enableLogs) { - const effectLogger = replaceLogger(defaultLogger, SentryEffectLogger); - layer = layer.pipe(provideMerge(effectLogger)); - } - - if (enableEffectMetrics && enableMetrics) { - layer = layer.pipe(provideMerge(SentryEffectMetricsLayer)); - } - - return layer; -} diff --git a/packages/effect/test/buildEffectLayer.test.ts b/packages/effect/test/buildEffectLayer.test.ts deleted file mode 100644 index e6f4e4c77819..000000000000 --- a/packages/effect/test/buildEffectLayer.test.ts +++ /dev/null @@ -1,161 +0,0 @@ -import { describe, expect, it, vi } from '@effect/vitest'; -import * as sentryCore from '@sentry/core'; -import { logger as sentryLogger } from '@sentry/core'; -import type { NodeOptions } from '@sentry/node-core'; -import { Effect, Layer } from 'effect'; -import { empty as emptyLayer } from 'effect/Layer'; -import { init } from '../src/index.server'; -import { buildEffectLayer } from '../src/utils/buildEffectLayer'; - -function getMockTransport() { - return () => ({ - send: vi.fn().mockResolvedValue({}), - flush: vi.fn().mockResolvedValue(true), - }); -} - -function createClient(options: NodeOptions = {}) { - return init({ - dsn: 'https://username@domain/123', - transport: getMockTransport(), - ...options, - }); -} - -describe('buildEffectLayer', () => { - describe('when client is falsy', () => { - it('returns empty layer when client is null', () => { - const layer = buildEffectLayer({}, undefined); - - 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', () => { - it('returns a valid layer with default options', () => { - const client = createClient(); - const layer = buildEffectLayer({}, client); - - expect(layer).toBeDefined(); - expect(Layer.isLayer(layer)).toBe(true); - }); - - it('returns a valid layer with enableEffectLogs: false', () => { - const client = createClient(); - const layer = buildEffectLayer({ enableEffectLogs: false }, client); - - expect(layer).toBeDefined(); - expect(Layer.isLayer(layer)).toBe(true); - }); - - it('returns a valid layer with enableEffectLogs: true', () => { - const client = createClient(); - const layer = buildEffectLayer({ enableEffectLogs: true }, client); - - expect(layer).toBeDefined(); - expect(Layer.isLayer(layer)).toBe(true); - }); - - it('returns a valid layer with enableEffectMetrics: false', () => { - const client = createClient(); - const layer = buildEffectLayer({ enableEffectMetrics: false }, client); - - expect(layer).toBeDefined(); - expect(Layer.isLayer(layer)).toBe(true); - }); - - it('returns a valid layer with enableEffectMetrics: true', () => { - const client = createClient(); - const layer = buildEffectLayer({ enableEffectMetrics: true }, client); - - expect(layer).toBeDefined(); - expect(Layer.isLayer(layer)).toBe(true); - }); - - it('returns a valid layer with all features enabled', () => { - const client = createClient(); - const layer = buildEffectLayer({ enableEffectLogs: true, enableEffectMetrics: true }, client); - - 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({}, createClient()))), - ); - - 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({ enableEffectLogs: true }, createClient({ enableLogs: true })))), - ); - - it('returns different layer when enableEffectMetrics is true vs false', () => { - const client = createClient(); - const layerWithMetrics = buildEffectLayer({ enableEffectMetrics: true }, client); - const layerWithoutMetrics = buildEffectLayer({ enableEffectMetrics: false }, client); - - expect(layerWithMetrics).not.toBe(layerWithoutMetrics); - }); - - 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({ enableEffectLogs: true, enableEffectMetrics: true }, createClient({ enableLogs: true })), - ), - ), - ); - - 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({}, createClient({ tracesSampleRate: 1.0 })))), - ); - }); - - describe('with additional options', () => { - const client = createClient({ enableLogs: true }); - - it('accepts options with additional properties', () => { - const layer = buildEffectLayer( - { - enableEffectLogs: true, - enableEffectMetrics: true, - dsn: 'https://test@sentry.io/123', - debug: true, - } as { enableEffectLogs?: boolean; enableEffectMetrics?: boolean; dsn?: string; debug?: boolean }, - client, - ); - - 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 420e12a8ed5c..590502fb657e 100644 --- a/packages/effect/test/layer.test.ts +++ b/packages/effect/test/layer.test.ts @@ -1,6 +1,7 @@ import { describe, expect, it } from '@effect/vitest'; -import { getClient, getCurrentScope, getIsolationScope, SDK_VERSION } from '@sentry/core'; -import { Effect, Layer } from 'effect'; +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'; @@ -15,9 +16,25 @@ function getMockTransport() { } describe.each([ - [{ subSdkName: 'browser', effectLayer: sentryClient.effectLayer }], - [{ subSdkName: 'node-light', effectLayer: sentryServer.effectLayer }], -])('effectLayer ($subSdkName)', ({ subSdkName, effectLayer }) => { + [ + { + 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(); @@ -25,6 +42,7 @@ describe.each([ afterEach(() => { getCurrentScope().setClient(undefined); + vi.restoreAllMocks(); }); it('creates a valid Effect layer', () => { @@ -59,27 +77,6 @@ describe.each([ ), ); - it('creates layer with logs enabled', () => { - const layer = effectLayer({ - dsn: TEST_DSN, - transport: getMockTransport(), - enableEffectLogs: true, - }); - - expect(layer).toBeDefined(); - }); - - it('creates layer with all features enabled', () => { - const layer = effectLayer({ - dsn: TEST_DSN, - transport: getMockTransport(), - enableEffectLogs: true, - enableEffectMetrics: true, - }); - - expect(layer).toBeDefined(); - }); - it.effect('layer can be provided to an Effect program', () => Effect.gen(function* () { const result = yield* Effect.succeed('test-result'); @@ -94,11 +91,15 @@ describe.each([ ), ); - it.effect('layer enables tracing for Effect spans', () => + 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, @@ -108,19 +109,71 @@ describe.each([ ), ); - it.effect('layer can be composed with other layers', () => + 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( - effectLayer({ - dsn: TEST_DSN, - transport: getMockTransport(), - }), + 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/tracer.test.ts b/packages/effect/test/tracer.test.ts index b8313ce6d421..9583e7d12c5b 100644 --- a/packages/effect/test/tracer.test.ts +++ b/packages/effect/test/tracer.test.ts @@ -1,12 +1,11 @@ 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 { setTracer } from 'effect/Layer'; +import { Effect, Layer } from 'effect'; import { afterEach, vi } from 'vitest'; import { SentryEffectTracer } from '../src/tracer'; -const SentryTracerLayer = setTracer(SentryEffectTracer); +const TracerLayer = Layer.setTracer(SentryEffectTracer); describe('SentryEffectTracer', () => { afterEach(() => { @@ -25,7 +24,7 @@ describe('SentryEffectTracer', () => { ); expect(capturedSpanName).toBe('effect-span-executed'); - }).pipe(Effect.provide(SentryTracerLayer)), + }).pipe(Effect.provide(TracerLayer)), ); it.effect('creates spans with correct attributes', () => @@ -33,7 +32,7 @@ describe('SentryEffectTracer', () => { const result = yield* Effect.withSpan('my-operation')(Effect.succeed('success')); expect(result).toBe('success'); - }).pipe(Effect.provide(SentryTracerLayer)), + }).pipe(Effect.provide(TracerLayer)), ); it.effect('handles nested spans', () => @@ -46,7 +45,7 @@ describe('SentryEffectTracer', () => { ); expect(result).toBe('outer-inner-result'); - }).pipe(Effect.provide(SentryTracerLayer)), + }).pipe(Effect.provide(TracerLayer)), ); it.effect('propagates span context through Effect fibers', () => @@ -63,7 +62,7 @@ describe('SentryEffectTracer', () => { ); expect(results).toEqual(['parent-start', 'child-1', 'child-2', 'parent-end']); - }).pipe(Effect.provide(SentryTracerLayer)), + }).pipe(Effect.provide(TracerLayer)), ); it.effect('handles span failures correctly', () => @@ -73,7 +72,7 @@ describe('SentryEffectTracer', () => { ); expect(result).toBe('caught: expected-error'); - }).pipe(Effect.provide(SentryTracerLayer)), + }).pipe(Effect.provide(TracerLayer)), ); it.effect('handles span with defects (die)', () => @@ -83,7 +82,7 @@ describe('SentryEffectTracer', () => { ); expect(result).toBe('caught-defect: defect-value'); - }).pipe(Effect.provide(SentryTracerLayer)), + }).pipe(Effect.provide(TracerLayer)), ); it.effect('works with Effect.all for parallel operations', () => @@ -97,7 +96,7 @@ describe('SentryEffectTracer', () => { ); expect(results).toEqual([1, 2, 3]); - }).pipe(Effect.provide(SentryTracerLayer)), + }).pipe(Effect.provide(TracerLayer)), ); it.effect('supports span annotations', () => @@ -108,7 +107,7 @@ describe('SentryEffectTracer', () => { ); expect(result).toBe('annotated'); - }).pipe(Effect.provide(SentryTracerLayer)), + }).pipe(Effect.provide(TracerLayer)), ); it.effect('sets span status to ok on success', () => @@ -131,7 +130,7 @@ describe('SentryEffectTracer', () => { expect(setStatusCalls).toContainEqual({ code: 1 }); mockStartInactiveSpan.mockRestore(); - }).pipe(Effect.provide(SentryTracerLayer)), + }).pipe(Effect.provide(TracerLayer)), ); it.effect('sets span status to error on failure', () => @@ -154,7 +153,7 @@ describe('SentryEffectTracer', () => { expect(setStatusCalls).toContainEqual({ code: 2, message: 'test-error' }); mockStartInactiveSpan.mockRestore(); - }).pipe(Effect.provide(SentryTracerLayer)), + }).pipe(Effect.provide(TracerLayer)), ); it.effect('sets span status to error on defect', () => @@ -177,7 +176,7 @@ describe('SentryEffectTracer', () => { expect(setStatusCalls).toContainEqual({ code: 2, message: 'fatal-defect' }); mockStartInactiveSpan.mockRestore(); - }).pipe(Effect.provide(SentryTracerLayer)), + }).pipe(Effect.provide(TracerLayer)), ); it.effect('propagates Sentry span context via withActiveSpan', () => @@ -198,7 +197,7 @@ describe('SentryEffectTracer', () => { expect(withActiveSpanCalls.length).toBeGreaterThan(0); mockWithActiveSpan.mockRestore(); - }).pipe(Effect.provide(SentryTracerLayer)), + }).pipe(Effect.provide(TracerLayer)), ); it.effect('sets origin to auto.function.effect for regular spans', () => @@ -223,7 +222,7 @@ describe('SentryEffectTracer', () => { expect(capturedAttributes?.[SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]).toBe('auto.function.effect'); mockStartInactiveSpan.mockRestore(); - }).pipe(Effect.provide(SentryTracerLayer)), + }).pipe(Effect.provide(TracerLayer)), ); it.effect('sets origin to auto.http.effect for http.server spans', () => @@ -248,7 +247,7 @@ describe('SentryEffectTracer', () => { expect(capturedAttributes?.[SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]).toBe('auto.http.effect'); mockStartInactiveSpan.mockRestore(); - }).pipe(Effect.provide(SentryTracerLayer)), + }).pipe(Effect.provide(TracerLayer)), ); it.effect('sets origin to auto.http.effect for http.client spans', () => @@ -273,37 +272,13 @@ describe('SentryEffectTracer', () => { expect(capturedAttributes?.[SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]).toBe('auto.http.effect'); mockStartInactiveSpan.mockRestore(); - }).pipe(Effect.provide(SentryTracerLayer)), + }).pipe(Effect.provide(TracerLayer)), ); - it.effect('uses transaction name from isolation scope for http.server spans', () => + it.effect('can be used with Effect.withTracer', () => 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(SentryTracerLayer)), + const result = yield* Effect.withSpan('inline-tracer-span')(Effect.succeed('with-tracer')); + expect(result).toBe('with-tracer'); + }).pipe(Effect.withTracer(SentryEffectTracer)), ); });