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)), ); });