Skip to content

Commit 2b2a5d9

Browse files
committed
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`
1 parent b3380c4 commit 2b2a5d9

7 files changed

Lines changed: 250 additions & 5 deletions

File tree

packages/effect/src/client/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ export type EffectClientLayerOptions = BrowserOptions;
1616
*
1717
* This layer provides Effect applications with full Sentry instrumentation including:
1818
* - Effect spans traced as Sentry spans
19+
* - Effect logs forwarded to Sentry (when `enableLogs` is set)
1920
*
2021
* @example
2122
* ```typescript

packages/effect/src/logger.ts

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
import { logger as sentryLogger } from '@sentry/core';
2+
import * as Logger from 'effect/Logger';
3+
4+
/**
5+
* Effect Logger that sends logs to Sentry.
6+
*/
7+
export const SentryEffectLogger = Logger.make(({ logLevel, message }) => {
8+
let msg: string;
9+
if (typeof message === 'string') {
10+
msg = message;
11+
} else if (Array.isArray(message) && message.length === 1) {
12+
const firstElement = message[0];
13+
msg = typeof firstElement === 'string' ? firstElement : JSON.stringify(firstElement);
14+
} else {
15+
msg = JSON.stringify(message);
16+
}
17+
18+
switch (logLevel._tag) {
19+
case 'Fatal':
20+
sentryLogger.fatal(msg);
21+
break;
22+
case 'Error':
23+
sentryLogger.error(msg);
24+
break;
25+
case 'Warning':
26+
sentryLogger.warn(msg);
27+
break;
28+
case 'Info':
29+
sentryLogger.info(msg);
30+
break;
31+
case 'Debug':
32+
sentryLogger.debug(msg);
33+
break;
34+
case 'Trace':
35+
sentryLogger.trace(msg);
36+
break;
37+
case 'All':
38+
case 'None':
39+
break;
40+
default:
41+
logLevel satisfies never;
42+
}
43+
});

packages/effect/src/server/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ export type EffectServerLayerOptions = NodeOptions;
1515
*
1616
* This layer provides Effect applications with full Sentry instrumentation including:
1717
* - Effect spans traced as Sentry spans
18+
* - Effect logs forwarded to Sentry (when `enableLogs` is set)
1819
*
1920
* @example
2021
* ```typescript

packages/effect/src/utils/buildEffectLayer.ts

Lines changed: 16 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,15 @@
11
import type * as EffectLayer from 'effect/Layer';
2-
import { empty as emptyLayer } from 'effect/Layer';
2+
import { empty as emptyLayer, provideMerge } from 'effect/Layer';
3+
import { defaultLogger, replace as replaceLogger } from 'effect/Logger';
4+
import { SentryEffectLogger } from '../logger';
35
import { SentryEffectTracerLayer } from '../tracer';
46

5-
// eslint-disable-next-line @typescript-eslint/no-empty-interface
6-
export interface EffectLayerBaseOptions {}
7+
export interface EffectLayerBaseOptions {
8+
enableLogs?: boolean;
9+
}
710

811
/**
9-
* Builds an Effect layer that integrates Sentry tracing.
12+
* Builds an Effect layer that integrates Sentry tracing and logging.
1013
*
1114
* Returns an empty layer if no Sentry client is available. Otherwise, starts with
1215
* the Sentry tracer layer and optionally merges logging and metrics layers based
@@ -20,5 +23,13 @@ export function buildEffectLayer<T extends EffectLayerBaseOptions>(
2023
return emptyLayer;
2124
}
2225

23-
return SentryEffectTracerLayer;
26+
const { enableLogs = false } = options;
27+
let layer: EffectLayer.Layer<never, never, never> = SentryEffectTracerLayer;
28+
29+
if (enableLogs) {
30+
const effectLogger = replaceLogger(defaultLogger, SentryEffectLogger);
31+
layer = layer.pipe(provideMerge(effectLogger));
32+
}
33+
34+
return layer;
2435
}

packages/effect/test/buildEffectLayer.test.ts

Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import { describe, expect, it, vi } from '@effect/vitest';
22
import * as sentryCore from '@sentry/core';
3+
import { logger as sentryLogger } from '@sentry/core';
34
import { Effect, Layer } from 'effect';
45
import { empty as emptyLayer } from 'effect/Layer';
56
import { buildEffectLayer } from '../src/utils/buildEffectLayer';
@@ -33,13 +34,59 @@ describe('buildEffectLayer', () => {
3334
expect(Layer.isLayer(layer)).toBe(true);
3435
});
3536

37+
it('returns a valid layer with enableLogs: false', () => {
38+
const layer = buildEffectLayer({ enableLogs: false }, mockClient);
39+
40+
expect(layer).toBeDefined();
41+
expect(Layer.isLayer(layer)).toBe(true);
42+
});
43+
44+
it('returns a valid layer with enableLogs: true', () => {
45+
const layer = buildEffectLayer({ enableLogs: true }, mockClient);
46+
47+
expect(layer).toBeDefined();
48+
expect(Layer.isLayer(layer)).toBe(true);
49+
});
50+
51+
it('returns a valid layer with all features enabled', () => {
52+
const layer = buildEffectLayer({ enableLogs: true }, mockClient);
53+
54+
expect(layer).toBeDefined();
55+
expect(Layer.isLayer(layer)).toBe(true);
56+
});
57+
3658
it.effect('layer can be provided to an Effect program', () =>
3759
Effect.gen(function* () {
3860
const result = yield* Effect.succeed('test-result');
3961
expect(result).toBe('test-result');
4062
}).pipe(Effect.provide(buildEffectLayer({}, mockClient))),
4163
);
4264

65+
it.effect('layer with logs enabled routes Effect logs to Sentry logger', () =>
66+
Effect.gen(function* () {
67+
const infoSpy = vi.spyOn(sentryLogger, 'info');
68+
yield* Effect.log('test log message');
69+
expect(infoSpy).toHaveBeenCalledWith('test log message');
70+
infoSpy.mockRestore();
71+
}).pipe(Effect.provide(buildEffectLayer({ enableLogs: true }, mockClient))),
72+
);
73+
74+
it.effect('layer with logs disabled routes Effect does not log to Sentry logger', () =>
75+
Effect.gen(function* () {
76+
const infoSpy = vi.spyOn(sentryLogger, 'info');
77+
yield* Effect.log('test log message');
78+
expect(infoSpy).not.toHaveBeenCalled();
79+
infoSpy.mockRestore();
80+
}).pipe(Effect.provide(buildEffectLayer({ enableLogs: false }, mockClient))),
81+
);
82+
83+
it.effect('layer with all features enabled can be provided to an Effect program', () =>
84+
Effect.gen(function* () {
85+
const result = yield* Effect.succeed('all-features');
86+
expect(result).toBe('all-features');
87+
}).pipe(Effect.provide(buildEffectLayer({ enableLogs: true }, mockClient))),
88+
);
89+
4390
it.effect('layer enables tracing for Effect spans via Sentry tracer', () =>
4491
Effect.gen(function* () {
4592
const startInactiveSpanSpy = vi.spyOn(sentryCore, 'startInactiveSpan');
@@ -54,4 +101,22 @@ describe('buildEffectLayer', () => {
54101
}).pipe(Effect.provide(buildEffectLayer({}, mockClient))),
55102
);
56103
});
104+
105+
describe('with additional options', () => {
106+
const mockClient = { mock: true };
107+
108+
it('accepts options with additional properties', () => {
109+
const layer = buildEffectLayer(
110+
{
111+
enableLogs: true,
112+
dsn: 'https://test@sentry.io/123',
113+
debug: true,
114+
} as { enableLogs?: boolean; dsn?: string; debug?: boolean },
115+
mockClient,
116+
);
117+
118+
expect(layer).toBeDefined();
119+
expect(Layer.isLayer(layer)).toBe(true);
120+
});
121+
});
57122
});

packages/effect/test/layer.test.ts

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -59,6 +59,26 @@ describe.each([
5959
),
6060
);
6161

62+
it('creates layer with logs enabled', () => {
63+
const layer = effectLayer({
64+
dsn: TEST_DSN,
65+
transport: getMockTransport(),
66+
enableLogs: true,
67+
});
68+
69+
expect(layer).toBeDefined();
70+
});
71+
72+
it('creates layer with all features enabled', () => {
73+
const layer = effectLayer({
74+
dsn: TEST_DSN,
75+
transport: getMockTransport(),
76+
enableLogs: true,
77+
});
78+
79+
expect(layer).toBeDefined();
80+
});
81+
6282
it.effect('layer can be provided to an Effect program', () =>
6383
Effect.gen(function* () {
6484
const result = yield* Effect.succeed('test-result');
Lines changed: 104 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,104 @@
1+
import { describe, expect, it } from '@effect/vitest';
2+
import * as sentryCore from '@sentry/core';
3+
import { Effect, Layer, Logger, LogLevel } from 'effect';
4+
import { afterEach, vi } from 'vitest';
5+
import { SentryEffectLogger } from '../src/logger';
6+
7+
vi.mock('@sentry/core', async importOriginal => {
8+
const original = await importOriginal<typeof sentryCore>();
9+
return {
10+
...original,
11+
logger: {
12+
...original.logger,
13+
error: vi.fn(),
14+
warn: vi.fn(),
15+
info: vi.fn(),
16+
debug: vi.fn(),
17+
trace: vi.fn(),
18+
fatal: vi.fn(),
19+
},
20+
};
21+
});
22+
23+
describe('SentryEffectLogger', () => {
24+
afterEach(() => {
25+
vi.clearAllMocks();
26+
});
27+
28+
const loggerLayer = Layer.mergeAll(
29+
Logger.replace(Logger.defaultLogger, SentryEffectLogger),
30+
Logger.minimumLogLevel(LogLevel.All),
31+
);
32+
33+
it.effect('forwards fatal logs to Sentry', () =>
34+
Effect.gen(function* () {
35+
yield* Effect.logFatal('This is a fatal message');
36+
expect(sentryCore.logger.fatal).toHaveBeenCalledWith('This is a fatal message');
37+
}).pipe(Effect.provide(loggerLayer)),
38+
);
39+
40+
it.effect('forwards error logs to Sentry', () =>
41+
Effect.gen(function* () {
42+
yield* Effect.logError('This is an error message');
43+
expect(sentryCore.logger.error).toHaveBeenCalledWith('This is an error message');
44+
}).pipe(Effect.provide(loggerLayer)),
45+
);
46+
47+
it.effect('forwards warning logs to Sentry', () =>
48+
Effect.gen(function* () {
49+
yield* Effect.logWarning('This is a warning message');
50+
expect(sentryCore.logger.warn).toHaveBeenCalledWith('This is a warning message');
51+
}).pipe(Effect.provide(loggerLayer)),
52+
);
53+
54+
it.effect('forwards info logs to Sentry', () =>
55+
Effect.gen(function* () {
56+
yield* Effect.logInfo('This is an info message');
57+
expect(sentryCore.logger.info).toHaveBeenCalledWith('This is an info message');
58+
}).pipe(Effect.provide(loggerLayer)),
59+
);
60+
61+
it.effect('forwards debug logs to Sentry', () =>
62+
Effect.gen(function* () {
63+
yield* Effect.logDebug('This is a debug message');
64+
expect(sentryCore.logger.debug).toHaveBeenCalledWith('This is a debug message');
65+
}).pipe(Effect.provide(loggerLayer)),
66+
);
67+
68+
it.effect('forwards trace logs to Sentry', () =>
69+
Effect.gen(function* () {
70+
yield* Effect.logTrace('This is a trace message');
71+
expect(sentryCore.logger.trace).toHaveBeenCalledWith('This is a trace message');
72+
}).pipe(Effect.provide(loggerLayer)),
73+
);
74+
75+
it.effect('handles object messages by stringifying', () =>
76+
Effect.gen(function* () {
77+
yield* Effect.logInfo({ key: 'value', nested: { foo: 'bar' } });
78+
expect(sentryCore.logger.info).toHaveBeenCalledWith('{"key":"value","nested":{"foo":"bar"}}');
79+
}).pipe(Effect.provide(loggerLayer)),
80+
);
81+
82+
it.effect('handles multiple log calls', () =>
83+
Effect.gen(function* () {
84+
yield* Effect.logInfo('First message');
85+
yield* Effect.logInfo('Second message');
86+
yield* Effect.logWarning('Third message');
87+
expect(sentryCore.logger.info).toHaveBeenCalledTimes(2);
88+
expect(sentryCore.logger.info).toHaveBeenNthCalledWith(1, 'First message');
89+
expect(sentryCore.logger.info).toHaveBeenNthCalledWith(2, 'Second message');
90+
expect(sentryCore.logger.warn).toHaveBeenCalledWith('Third message');
91+
}).pipe(Effect.provide(loggerLayer)),
92+
);
93+
94+
it.effect('works with Effect.tap for logging side effects', () =>
95+
Effect.gen(function* () {
96+
const result = yield* Effect.succeed('data').pipe(
97+
Effect.tap(data => Effect.logInfo(`Processing: ${data}`)),
98+
Effect.map(d => d.toUpperCase()),
99+
);
100+
expect(result).toBe('DATA');
101+
expect(sentryCore.logger.info).toHaveBeenCalledWith('Processing: data');
102+
}).pipe(Effect.provide(loggerLayer)),
103+
);
104+
});

0 commit comments

Comments
 (0)