Skip to content
Draft
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
27 changes: 27 additions & 0 deletions .github/workflows/build.yml
Original file line number Diff line number Diff line change
Expand Up @@ -164,6 +164,9 @@ jobs:
changed_browser_integration:
${{ needs.job_get_metadata.outputs.changed_ci == 'true' || contains(steps.checkForAffected.outputs.affected,
'@sentry-internal/browser-integration-tests') }}
changed_effect_v3_compatibility:
${{ needs.job_get_metadata.outputs.changed_ci == 'true' || contains(steps.checkForAffected.outputs.affected,
'@sentry-internal/effect-3-compatibility-tests') }}

job_check_branches:
name: Check PR branches
Expand Down Expand Up @@ -749,6 +752,29 @@ jobs:
working-directory: dev-packages/node-core-integration-tests
run: yarn test

job_effect_v3_compatibility_tests:
name: Effect v3 Compatibility Tests
needs: [job_get_metadata, job_build]
if: needs.job_build.outputs.changed_effect_v3_compatibility == 'true' || github.event_name != 'pull_request'
runs-on: ubuntu-24.04
timeout-minutes: 10
steps:
- name: Check out current commit (${{ needs.job_get_metadata.outputs.commit_label }})
uses: actions/checkout@v6
with:
ref: ${{ env.HEAD_COMMIT }}
- name: Set up Node
uses: actions/setup-node@v6
with:
node-version-file: 'package.json'
- name: Restore caches
uses: ./.github/actions/restore-cache
with:
dependency_cache_key: ${{ needs.job_build.outputs.dependency_cache_key }}
- name: Run tests
working-directory: dev-packages/effect-3-compatibility-tests
run: yarn test

job_cloudflare_integration_tests:
name: Cloudflare Integration Tests
needs: [job_get_metadata, job_build]
Expand Down Expand Up @@ -1123,6 +1149,7 @@ jobs:
job_node_unit_tests,
job_node_integration_tests,
job_node_core_integration_tests,
job_effect_v3_compatibility_tests,
job_cloudflare_integration_tests,
job_bun_integration_tests,
job_browser_playwright_tests,
Expand Down
28 changes: 28 additions & 0 deletions dev-packages/effect-3-compatibility-tests/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
{
"name": "@sentry-internal/effect-3-compatibility-tests",
"version": "10.49.0",
"license": "MIT",
"engines": {
"node": ">=18"
},
"private": true,
"scripts": {
"lint": "oxlint . --type-aware",
"lint:fix": "oxlint . --fix --type-aware",
"type-check": "tsc",
"test": "vitest run",
"test:watch": "vitest --watch"
},
"dependencies": {
"@sentry/core": "link:../../packages/core",
"@sentry/effect": "link:../../packages/effect"
},
"devDependencies": {
"@effect/vitest": "^0.29.0",
"effect": "^3.21.1",
"vitest": "^3.2.4"
},
"volta": {
"extends": "../../package.json"
}
}
8 changes: 8 additions & 0 deletions dev-packages/effect-3-compatibility-tests/test/index.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
import { describe, expect, it } from 'vitest';
import * as index from '@sentry/effect/client';

describe('effect index export', () => {
it('has correct exports', () => {
expect(index.captureException).toBeDefined();
});
});
180 changes: 180 additions & 0 deletions dev-packages/effect-3-compatibility-tests/test/layer.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,180 @@
import { describe, expect, it } from '@effect/vitest';
import * as sentryCore from '@sentry/core';
import { getClient, getCurrentScope, getIsolationScope, SDK_VERSION } from '@sentry/core';
import { Effect, Layer, Logger, LogLevel } from 'effect';
import { afterEach, beforeEach, vi } from 'vitest';
import * as sentryClient from '@sentry/effect/client';
import * as sentryServer from '@sentry/effect/server';

const TEST_DSN = 'https://username@domain/123';

function getMockTransport() {
return () => ({
send: vi.fn().mockResolvedValue({}),
flush: vi.fn().mockResolvedValue(true),
});
}

describe.each([
[
{
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();
});

afterEach(() => {
getCurrentScope().setClient(undefined);
vi.restoreAllMocks();
});

it('creates a valid Effect layer', () => {
const layer = effectLayer({
dsn: TEST_DSN,
transport: getMockTransport(),
});

expect(layer).toBeDefined();
expect(Layer.isLayer(layer)).toBe(true);
});

it.effect('applies SDK metadata', () =>
Effect.gen(function* () {
yield* Effect.void;

const client = getClient();
const metadata = client?.getOptions()._metadata?.sdk;

expect(metadata?.name).toBe('sentry.javascript.effect');
expect(metadata?.packages).toEqual([
{ name: 'npm:@sentry/effect', version: SDK_VERSION },
{ name: `npm:@sentry/${subSdkName}`, version: SDK_VERSION },
]);
}).pipe(
Effect.provide(
effectLayer({
dsn: TEST_DSN,
transport: getMockTransport(),
}),
),
),
);

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(
effectLayer({
dsn: TEST_DSN,
transport: getMockTransport(),
}),
),
),
);

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,
transport: getMockTransport(),
}),
),
),
);

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(
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),
),
),
),
);
});
104 changes: 104 additions & 0 deletions dev-packages/effect-3-compatibility-tests/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 '@sentry/effect';

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