Skip to content

Commit d12dae1

Browse files
committed
feat(effect): Add logging to Sentry.effectLayer
1 parent 0ecb7da commit d12dae1

7 files changed

Lines changed: 229 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: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
import { logger as sentryLogger } from '@sentry/core';
2+
import * as Logger from 'effect/Logger';
3+
import * as LogLevel from 'effect/LogLevel';
4+
5+
/**
6+
* Effect Logger that sends logs to Sentry.
7+
*/
8+
export const SentryEffectLogger = Logger.make(({ logLevel, message }) => {
9+
let msg: string;
10+
if (typeof message === 'string') {
11+
msg = message;
12+
} else if (Array.isArray(message) && message.length === 1) {
13+
const firstElement = message[0];
14+
msg = typeof firstElement === 'string' ? firstElement : JSON.stringify(firstElement);
15+
} else {
16+
msg = JSON.stringify(message);
17+
}
18+
19+
if (LogLevel.greaterThanEqual(logLevel, LogLevel.Error)) {
20+
sentryLogger.error(msg);
21+
} else if (LogLevel.greaterThanEqual(logLevel, LogLevel.Warning)) {
22+
sentryLogger.warn(msg);
23+
} else if (LogLevel.greaterThanEqual(logLevel, LogLevel.Info)) {
24+
sentryLogger.info(msg);
25+
} else if (LogLevel.greaterThanEqual(logLevel, LogLevel.Debug)) {
26+
sentryLogger.debug(msg);
27+
} else {
28+
sentryLogger.trace(msg);
29+
}
30+
});

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, logging, and metrics.
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: 96 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,96 @@
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+
},
19+
};
20+
});
21+
22+
describe('SentryEffectLogger', () => {
23+
afterEach(() => {
24+
vi.clearAllMocks();
25+
});
26+
27+
const loggerLayer = Layer.mergeAll(
28+
Logger.replace(Logger.defaultLogger, SentryEffectLogger),
29+
Logger.minimumLogLevel(LogLevel.All),
30+
);
31+
32+
it.effect('forwards error logs to Sentry', () =>
33+
Effect.gen(function* () {
34+
yield* Effect.logError('This is an error message');
35+
expect(sentryCore.logger.error).toHaveBeenCalledWith('This is an error message');
36+
}).pipe(Effect.provide(loggerLayer)),
37+
);
38+
39+
it.effect('forwards warning logs to Sentry', () =>
40+
Effect.gen(function* () {
41+
yield* Effect.logWarning('This is a warning message');
42+
expect(sentryCore.logger.warn).toHaveBeenCalledWith('This is a warning message');
43+
}).pipe(Effect.provide(loggerLayer)),
44+
);
45+
46+
it.effect('forwards info logs to Sentry', () =>
47+
Effect.gen(function* () {
48+
yield* Effect.logInfo('This is an info message');
49+
expect(sentryCore.logger.info).toHaveBeenCalledWith('This is an info message');
50+
}).pipe(Effect.provide(loggerLayer)),
51+
);
52+
53+
it.effect('forwards debug logs to Sentry', () =>
54+
Effect.gen(function* () {
55+
yield* Effect.logDebug('This is a debug message');
56+
expect(sentryCore.logger.debug).toHaveBeenCalledWith('This is a debug message');
57+
}).pipe(Effect.provide(loggerLayer)),
58+
);
59+
60+
it.effect('forwards trace logs to Sentry', () =>
61+
Effect.gen(function* () {
62+
yield* Effect.logTrace('This is a trace message');
63+
expect(sentryCore.logger.trace).toHaveBeenCalledWith('This is a trace message');
64+
}).pipe(Effect.provide(loggerLayer)),
65+
);
66+
67+
it.effect('handles object messages by stringifying', () =>
68+
Effect.gen(function* () {
69+
yield* Effect.logInfo({ key: 'value', nested: { foo: 'bar' } });
70+
expect(sentryCore.logger.info).toHaveBeenCalledWith('{"key":"value","nested":{"foo":"bar"}}');
71+
}).pipe(Effect.provide(loggerLayer)),
72+
);
73+
74+
it.effect('handles multiple log calls', () =>
75+
Effect.gen(function* () {
76+
yield* Effect.logInfo('First message');
77+
yield* Effect.logInfo('Second message');
78+
yield* Effect.logWarning('Third message');
79+
expect(sentryCore.logger.info).toHaveBeenCalledTimes(2);
80+
expect(sentryCore.logger.info).toHaveBeenNthCalledWith(1, 'First message');
81+
expect(sentryCore.logger.info).toHaveBeenNthCalledWith(2, 'Second message');
82+
expect(sentryCore.logger.warn).toHaveBeenCalledWith('Third message');
83+
}).pipe(Effect.provide(loggerLayer)),
84+
);
85+
86+
it.effect('works with Effect.tap for logging side effects', () =>
87+
Effect.gen(function* () {
88+
const result = yield* Effect.succeed('data').pipe(
89+
Effect.tap(data => Effect.logInfo(`Processing: ${data}`)),
90+
Effect.map(d => d.toUpperCase()),
91+
);
92+
expect(result).toBe('DATA');
93+
expect(sentryCore.logger.info).toHaveBeenCalledWith('Processing: data');
94+
}).pipe(Effect.provide(loggerLayer)),
95+
);
96+
});

0 commit comments

Comments
 (0)