From 751cac1628374f9fb0e58ccdd451a48a51163320 Mon Sep 17 00:00:00 2001 From: JPeer264 Date: Thu, 5 Mar 2026 14:53:14 +0100 Subject: [PATCH 1/2] feat(effect): Add logging to Sentry.effectLayer --- packages/effect/src/client/index.ts | 1 + packages/effect/src/logger.ts | 30 ++++++ 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 | 96 +++++++++++++++++++ 7 files changed, 229 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..c73c0c0cc30f --- /dev/null +++ b/packages/effect/src/logger.ts @@ -0,0 +1,30 @@ +import { logger as sentryLogger } from '@sentry/core'; +import * as Logger from 'effect/Logger'; +import * as LogLevel from 'effect/LogLevel'; + +/** + * 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); + } + + if (LogLevel.greaterThanEqual(logLevel, LogLevel.Error)) { + sentryLogger.error(msg); + } else if (LogLevel.greaterThanEqual(logLevel, LogLevel.Warning)) { + sentryLogger.warn(msg); + } else if (LogLevel.greaterThanEqual(logLevel, LogLevel.Info)) { + sentryLogger.info(msg); + } else if (LogLevel.greaterThanEqual(logLevel, LogLevel.Debug)) { + sentryLogger.debug(msg); + } else { + sentryLogger.trace(msg); + } +}); 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..53bb31577a30 --- /dev/null +++ b/packages/effect/test/logger.test.ts @@ -0,0 +1,96 @@ +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(), + }, + }; +}); + +describe('SentryEffectLogger', () => { + afterEach(() => { + vi.clearAllMocks(); + }); + + const loggerLayer = Layer.mergeAll( + Logger.replace(Logger.defaultLogger, SentryEffectLogger), + Logger.minimumLogLevel(LogLevel.All), + ); + + 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 141467658326b28ad6941c7517a4aa295aab512f Mon Sep 17 00:00:00 2001 From: JPeer264 Date: Fri, 6 Mar 2026 16:44:17 +0100 Subject: [PATCH 2/2] fixup! feat(effect): Add logging to Sentry.effectLayer --- packages/effect/src/logger.ts | 35 ++++++++++++++++++++--------- packages/effect/test/logger.test.ts | 8 +++++++ 2 files changed, 32 insertions(+), 11 deletions(-) diff --git a/packages/effect/src/logger.ts b/packages/effect/src/logger.ts index c73c0c0cc30f..833f5b6b7e95 100644 --- a/packages/effect/src/logger.ts +++ b/packages/effect/src/logger.ts @@ -1,6 +1,5 @@ import { logger as sentryLogger } from '@sentry/core'; import * as Logger from 'effect/Logger'; -import * as LogLevel from 'effect/LogLevel'; /** * Effect Logger that sends logs to Sentry. @@ -16,15 +15,29 @@ export const SentryEffectLogger = Logger.make(({ logLevel, message }) => { msg = JSON.stringify(message); } - if (LogLevel.greaterThanEqual(logLevel, LogLevel.Error)) { - sentryLogger.error(msg); - } else if (LogLevel.greaterThanEqual(logLevel, LogLevel.Warning)) { - sentryLogger.warn(msg); - } else if (LogLevel.greaterThanEqual(logLevel, LogLevel.Info)) { - sentryLogger.info(msg); - } else if (LogLevel.greaterThanEqual(logLevel, LogLevel.Debug)) { - sentryLogger.debug(msg); - } else { - sentryLogger.trace(msg); + 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/test/logger.test.ts b/packages/effect/test/logger.test.ts index 53bb31577a30..c372784b483f 100644 --- a/packages/effect/test/logger.test.ts +++ b/packages/effect/test/logger.test.ts @@ -15,6 +15,7 @@ vi.mock('@sentry/core', async importOriginal => { info: vi.fn(), debug: vi.fn(), trace: vi.fn(), + fatal: vi.fn(), }, }; }); @@ -29,6 +30,13 @@ describe('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');