Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions packages/effect/src/client/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
43 changes: 43 additions & 0 deletions packages/effect/src/logger.ts
Original file line number Diff line number Diff line change
@@ -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;
}
});
1 change: 1 addition & 0 deletions packages/effect/src/server/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
21 changes: 16 additions & 5 deletions packages/effect/src/utils/buildEffectLayer.ts
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -20,5 +23,13 @@ export function buildEffectLayer<T extends EffectLayerBaseOptions>(
return emptyLayer;
}

return SentryEffectTracerLayer;
const { enableLogs = false } = options;
let layer: EffectLayer.Layer<never, never, never> = SentryEffectTracerLayer;

if (enableLogs) {
const effectLogger = replaceLogger(defaultLogger, SentryEffectLogger);
layer = layer.pipe(provideMerge(effectLogger));
}

return layer;
}
65 changes: 65 additions & 0 deletions packages/effect/test/buildEffectLayer.test.ts
Original file line number Diff line number Diff line change
@@ -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';
Expand Down Expand Up @@ -33,13 +34,59 @@ 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');
expect(result).toBe('test-result');
}).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');
Expand All @@ -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);
});
});
});
20 changes: 20 additions & 0 deletions packages/effect/test/layer.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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');
Expand Down
104 changes: 104 additions & 0 deletions packages/effect/test/logger.test.ts
Original file line number Diff line number Diff line change
@@ -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<typeof sentryCore>();
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)),
);
});
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Feat PR missing integration or E2E test

Low Severity

This is a feat PR that adds Effect log forwarding to Sentry, but it only includes vitest unit tests. Per the project review rules, feat PRs are expected to include at least one integration or E2E test. The existing tests validate the logger and layer composition well at the unit level, but an integration or E2E test (e.g., in dev-packages/e2e-tests/ or dev-packages/node-integration-tests/) verifying that Effect logs actually arrive as Sentry log entries end-to-end would increase confidence.

Additional Locations (1)

Fix in Cursor Fix in Web

Triggered by project rule: PR Review Guidelines for Cursor Bot

Loading