diff --git a/packages/effect/src/client/index.ts b/packages/effect/src/client/index.ts index f2a4ce7bec9b..e8b37b10b28a 100644 --- a/packages/effect/src/client/index.ts +++ b/packages/effect/src/client/index.ts @@ -1,5 +1,10 @@ import type { BrowserOptions } from '@sentry/browser'; -import * as EffectLayer from 'effect/Layer'; +import type * as EffectLayer from 'effect/Layer'; +import { suspend as suspendLayer } from 'effect/Layer'; +import { buildEffectLayer } from '../utils/buildEffectLayer'; +import { init } from './sdk'; + +export { init } from './sdk'; /** * Options for the Sentry Effect client layer. @@ -7,7 +12,10 @@ import * as EffectLayer from 'effect/Layer'; export type EffectClientLayerOptions = BrowserOptions; /** - * Creates an empty Effect Layer + * Creates an Effect Layer that initializes Sentry for browser clients. + * + * This layer provides Effect applications with full Sentry instrumentation including: + * - Effect spans traced as Sentry spans * * @example * ```typescript @@ -25,6 +33,6 @@ export type EffectClientLayerOptions = BrowserOptions; * Effect.runPromise(Effect.provide(myEffect, ApiClientWithSentry)); * ``` */ -export function effectLayer(_: EffectClientLayerOptions): EffectLayer.Layer { - return EffectLayer.empty; +export function effectLayer(options: EffectClientLayerOptions): EffectLayer.Layer { + return suspendLayer(() => buildEffectLayer(options, init(options))); } diff --git a/packages/effect/src/client/sdk.ts b/packages/effect/src/client/sdk.ts new file mode 100644 index 000000000000..5f2210a92b3a --- /dev/null +++ b/packages/effect/src/client/sdk.ts @@ -0,0 +1,20 @@ +import type { BrowserOptions } from '@sentry/browser'; +import { init as initBrowser } from '@sentry/browser'; +import type { Client } from '@sentry/core'; +import { applySdkMetadata } from '@sentry/core'; + +/** + * Initializes the Sentry Effect SDK for browser clients. + * + * @param options - Configuration options for the SDK + * @returns The initialized Sentry client, or undefined if initialization failed + */ +export function init(options: BrowserOptions): Client | undefined { + const opts = { + ...options, + }; + + applySdkMetadata(opts, 'effect', ['effect', 'browser']); + + return initBrowser(opts); +} diff --git a/packages/effect/src/index.client.ts b/packages/effect/src/index.client.ts index b5b4833026df..e13f1ddea09e 100644 --- a/packages/effect/src/index.client.ts +++ b/packages/effect/src/index.client.ts @@ -1,4 +1,7 @@ +// import/export got a false positive, and affects most of our index barrel files +// can be removed once following issue is fixed: https://github.com/import-js/eslint-plugin-import/issues/703 +/* eslint-disable import/export */ export * from '@sentry/browser'; -export { effectLayer } from './client/index'; +export { effectLayer, init } from './client/index'; export type { EffectClientLayerOptions } from './client/index'; diff --git a/packages/effect/src/index.server.ts b/packages/effect/src/index.server.ts index f9aa4d562c6f..a3f8e4f3766f 100644 --- a/packages/effect/src/index.server.ts +++ b/packages/effect/src/index.server.ts @@ -1,4 +1,4 @@ export * from '@sentry/node-core/light'; -export { effectLayer } from './server/index'; +export { effectLayer, init } from './server/index'; export type { EffectServerLayerOptions } from './server/index'; diff --git a/packages/effect/src/server/index.ts b/packages/effect/src/server/index.ts index 91281ea96486..ad8ddd7192bc 100644 --- a/packages/effect/src/server/index.ts +++ b/packages/effect/src/server/index.ts @@ -1,5 +1,9 @@ -import type { NodeOptions } from '@sentry/node-core'; -import * as EffectLayer from 'effect/Layer'; +import type { NodeOptions } from '@sentry/node-core/light'; +import type * as EffectLayer from 'effect/Layer'; +import { buildEffectLayer } from '../utils/buildEffectLayer'; +import { init } from './sdk'; + +export { init } from './sdk'; /** * Options for the Sentry Effect server layer. @@ -7,7 +11,10 @@ import * as EffectLayer from 'effect/Layer'; export type EffectServerLayerOptions = NodeOptions; /** - * Creates an empty Effect Layer + * Creates an Effect Layer that initializes Sentry for Node.js servers. + * + * This layer provides Effect applications with full Sentry instrumentation including: + * - Effect spans traced as Sentry spans * * @example * ```typescript @@ -27,6 +34,6 @@ export type EffectServerLayerOptions = NodeOptions; * MainLive.pipe(Layer.launch, NodeRuntime.runMain); * ``` */ -export function effectLayer(_: EffectServerLayerOptions): EffectLayer.Layer { - return EffectLayer.empty; +export function effectLayer(options: EffectServerLayerOptions): EffectLayer.Layer { + return buildEffectLayer(options, init(options)); } diff --git a/packages/effect/src/server/sdk.ts b/packages/effect/src/server/sdk.ts new file mode 100644 index 000000000000..ee910be13487 --- /dev/null +++ b/packages/effect/src/server/sdk.ts @@ -0,0 +1,20 @@ +import type { Client } from '@sentry/core'; +import { applySdkMetadata } from '@sentry/core'; +import type { NodeOptions } from '@sentry/node-core/light'; +import { init as initNode } from '@sentry/node-core/light'; + +/** + * Initializes the Sentry Effect SDK for Node.js servers. + * + * @param options - Configuration options for the SDK + * @returns The initialized Sentry client, or undefined if initialization failed + */ +export function init(options: NodeOptions): Client | undefined { + const opts = { + ...options, + }; + + applySdkMetadata(opts, 'effect', ['effect', 'node-light']); + + return initNode(opts); +} diff --git a/packages/effect/src/tracer.ts b/packages/effect/src/tracer.ts new file mode 100644 index 000000000000..116b7970a6ae --- /dev/null +++ b/packages/effect/src/tracer.ts @@ -0,0 +1,201 @@ +import type { Span } from '@sentry/core'; +import { + getActiveSpan, + getIsolationScope, + SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN, + startInactiveSpan, + withActiveSpan, +} from '@sentry/core'; +import type * as Context from 'effect/Context'; +import * as Exit from 'effect/Exit'; +import type * as Layer from 'effect/Layer'; +import { setTracer } from 'effect/Layer'; +import * as Option from 'effect/Option'; +import * as EffectTracer from 'effect/Tracer'; + +const KIND_MAP: Record = { + internal: 'internal', + client: 'client', + server: 'server', + producer: 'producer', + consumer: 'consumer', +}; + +function deriveOp(name: string, kind: EffectTracer.SpanKind): string { + if (name.startsWith('http.server')) { + return 'http.server'; + } + + if (name.startsWith('http.client')) { + return 'http.client'; + } + + return KIND_MAP[kind]; +} + +function deriveOrigin(name: string): string { + if (name.startsWith('http.server') || name.startsWith('http.client')) { + return 'auto.http.effect'; + } + + return 'auto.function.effect'; +} + +function deriveSpanName(name: string, kind: EffectTracer.SpanKind): string { + if (name.startsWith('http.server') && kind === 'server') { + const isolationScope = getIsolationScope(); + const transactionName = isolationScope.getScopeData().transactionName; + if (transactionName) { + return transactionName; + } + } + return name; +} + +type HrTime = [number, number]; + +const SENTRY_SPAN_SYMBOL = Symbol.for('@sentry/effect.SentrySpan'); + +function nanosToHrTime(nanos: bigint): HrTime { + const seconds = Number(nanos / BigInt(1_000_000_000)); + const remainingNanos = Number(nanos % BigInt(1_000_000_000)); + return [seconds, remainingNanos]; +} + +interface SentrySpanLike extends EffectTracer.Span { + readonly [SENTRY_SPAN_SYMBOL]: true; + readonly sentrySpan: Span; +} + +function isSentrySpan(span: EffectTracer.AnySpan): span is SentrySpanLike { + return SENTRY_SPAN_SYMBOL in span; +} + +class SentrySpanWrapper implements SentrySpanLike { + public readonly [SENTRY_SPAN_SYMBOL]: true; + public readonly _tag: 'Span'; + public readonly spanId: string; + public readonly traceId: string; + public readonly attributes: Map; + public readonly sampled: boolean; + public readonly parent: Option.Option; + public readonly links: Array; + public status: EffectTracer.SpanStatus; + public readonly sentrySpan: Span; + + public constructor( + public readonly name: string, + parent: Option.Option, + public readonly context: Context.Context, + links: ReadonlyArray, + startTime: bigint, + public readonly kind: EffectTracer.SpanKind, + existingSpan: Span, + ) { + this[SENTRY_SPAN_SYMBOL] = true as const; + this._tag = 'Span' as const; + this.attributes = new Map(); + this.parent = parent; + this.links = [...links]; + this.sentrySpan = existingSpan; + + const spanContext = this.sentrySpan.spanContext(); + this.spanId = spanContext.spanId; + this.traceId = spanContext.traceId; + this.sampled = this.sentrySpan.isRecording(); + this.status = { + _tag: 'Started', + startTime, + }; + } + + public attribute(key: string, value: unknown): void { + if (!this.sentrySpan.isRecording()) { + return; + } + + this.sentrySpan.setAttribute(key, value as Parameters[1]); + this.attributes.set(key, value); + } + + public addLinks(links: ReadonlyArray): void { + this.links.push(...links); + } + + public end(endTime: bigint, exit: Exit.Exit): void { + this.status = { + _tag: 'Ended', + endTime, + exit, + startTime: this.status.startTime, + }; + + if (!this.sentrySpan.isRecording()) { + return; + } + + if (Exit.isFailure(exit)) { + const cause = exit.cause; + const message = + cause._tag === 'Fail' ? String(cause.error) : cause._tag === 'Die' ? String(cause.defect) : 'internal_error'; + this.sentrySpan.setStatus({ code: 2, message }); + } else { + this.sentrySpan.setStatus({ code: 1 }); + } + + this.sentrySpan.end(nanosToHrTime(endTime)); + } + + public event(name: string, startTime: bigint, attributes?: Record): void { + if (!this.sentrySpan.isRecording()) { + return; + } + + this.sentrySpan.addEvent(name, attributes as Parameters[1], nanosToHrTime(startTime)); + } +} + +function createSentrySpan( + name: string, + parent: Option.Option, + context: Context.Context, + links: ReadonlyArray, + startTime: bigint, + kind: EffectTracer.SpanKind, +): SentrySpanLike { + const parentSentrySpan = + Option.isSome(parent) && isSentrySpan(parent.value) ? parent.value.sentrySpan : (getActiveSpan() ?? null); + + const spanName = deriveSpanName(name, kind); + + const newSpan = startInactiveSpan({ + name: spanName, + op: deriveOp(name, kind), + startTime: nanosToHrTime(startTime), + attributes: { + [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: deriveOrigin(name), + }, + ...(parentSentrySpan ? { parentSpan: parentSentrySpan } : {}), + }); + + return new SentrySpanWrapper(name, parent, context, links, startTime, kind, newSpan); +} + +const makeSentryTracer = (): EffectTracer.Tracer => + EffectTracer.make({ + span(name, parent, context, links, startTime, kind) { + return createSentrySpan(name, parent, context, links, startTime, kind); + }, + context(execution, fiber) { + const currentSpan = fiber.currentSpan; + if (currentSpan === undefined || !isSentrySpan(currentSpan)) { + return execution(); + } + return withActiveSpan(currentSpan.sentrySpan, execution); + }, + }); + +/** + * Effect Layer that sets up the Sentry tracer for Effect spans. + */ +export const SentryEffectTracerLayer: Layer.Layer = setTracer(makeSentryTracer()); diff --git a/packages/effect/src/utils/buildEffectLayer.ts b/packages/effect/src/utils/buildEffectLayer.ts new file mode 100644 index 000000000000..93c1ac6b42e8 --- /dev/null +++ b/packages/effect/src/utils/buildEffectLayer.ts @@ -0,0 +1,24 @@ +import type * as EffectLayer from 'effect/Layer'; +import { empty as emptyLayer } from 'effect/Layer'; +import { SentryEffectTracerLayer } from '../tracer'; + +// eslint-disable-next-line @typescript-eslint/no-empty-interface +export interface EffectLayerBaseOptions {} + +/** + * Builds an Effect layer that integrates Sentry tracing. + * + * 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 + * on the provided options. + */ +export function buildEffectLayer( + options: T, + client: unknown, +): EffectLayer.Layer { + if (!client) { + return emptyLayer; + } + + return SentryEffectTracerLayer; +} diff --git a/packages/effect/test/buildEffectLayer.test.ts b/packages/effect/test/buildEffectLayer.test.ts new file mode 100644 index 000000000000..4213b1448311 --- /dev/null +++ b/packages/effect/test/buildEffectLayer.test.ts @@ -0,0 +1,57 @@ +import { describe, expect, it, vi } from '@effect/vitest'; +import * as sentryCore from '@sentry/core'; +import { Effect, Layer } from 'effect'; +import { empty as emptyLayer } from 'effect/Layer'; +import { buildEffectLayer } from '../src/utils/buildEffectLayer'; + +describe('buildEffectLayer', () => { + describe('when client is falsy', () => { + it('returns empty layer when client is null', () => { + const layer = buildEffectLayer({}, null); + + expect(layer).toBeDefined(); + expect(Layer.isLayer(layer)).toBe(true); + expect(layer).toBe(emptyLayer); + }); + + it('returns empty layer when client is undefined', () => { + const layer = buildEffectLayer({}, undefined); + + expect(layer).toBeDefined(); + expect(Layer.isLayer(layer)).toBe(true); + expect(layer).toBe(emptyLayer); + }); + }); + + describe('when client is truthy', () => { + const mockClient = { mock: true }; + + it('returns a valid layer with default options', () => { + const layer = buildEffectLayer({}, 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 enables tracing for Effect spans via Sentry tracer', () => + Effect.gen(function* () { + const startInactiveSpanSpy = vi.spyOn(sentryCore, 'startInactiveSpan'); + const result = yield* Effect.withSpan('test-sentry-span')(Effect.succeed('traced')); + expect(result).toBe('traced'); + expect(startInactiveSpanSpy).toHaveBeenCalledWith( + expect.objectContaining({ + name: 'test-sentry-span', + }), + ); + startInactiveSpanSpy.mockRestore(); + }).pipe(Effect.provide(buildEffectLayer({}, mockClient))), + ); + }); +}); diff --git a/packages/effect/test/layer.test.ts b/packages/effect/test/layer.test.ts new file mode 100644 index 000000000000..ee5315e55409 --- /dev/null +++ b/packages/effect/test/layer.test.ts @@ -0,0 +1,106 @@ +import { describe, expect, it } from '@effect/vitest'; +import { getClient, getCurrentScope, getIsolationScope, SDK_VERSION } from '@sentry/core'; +import { Effect, Layer } from 'effect'; +import { afterEach, beforeEach, vi } from 'vitest'; +import * as sentryClient from '../src/index.client'; +import * as sentryServer from '../src/index.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 }], + [{ subSdkName: 'node-light', effectLayer: sentryServer.effectLayer }], +])('effectLayer ($subSdkName)', ({ subSdkName, effectLayer }) => { + beforeEach(() => { + getCurrentScope().clear(); + getIsolationScope().clear(); + }); + + afterEach(() => { + getCurrentScope().setClient(undefined); + }); + + 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 for Effect spans', () => + Effect.gen(function* () { + const result = yield* Effect.withSpan('test-span')(Effect.succeed('traced')); + expect(result).toBe('traced'); + }).pipe( + Effect.provide( + effectLayer({ + dsn: TEST_DSN, + transport: getMockTransport(), + }), + ), + ), + ); + + it.effect('layer can be composed with other layers', () => + Effect.gen(function* () { + const result = yield* Effect.succeed(42).pipe( + Effect.map(n => n * 2), + Effect.withSpan('computation'), + ); + expect(result).toBe(84); + }).pipe( + Effect.provide( + effectLayer({ + dsn: TEST_DSN, + transport: getMockTransport(), + }), + ), + ), + ); +}); diff --git a/packages/effect/test/tracer.test.ts b/packages/effect/test/tracer.test.ts new file mode 100644 index 000000000000..8955200695fa --- /dev/null +++ b/packages/effect/test/tracer.test.ts @@ -0,0 +1,306 @@ +import { describe, expect, it } from '@effect/vitest'; +import * as sentryCore from '@sentry/core'; +import { SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN } from '@sentry/core'; +import { Effect } from 'effect'; +import { afterEach, vi } from 'vitest'; +import { SentryEffectTracerLayer } from '../src/tracer'; + +describe('SentryEffectTracerLayer', () => { + afterEach(() => { + vi.restoreAllMocks(); + }); + + it.effect('traces Effect spans to Sentry', () => + Effect.gen(function* () { + let capturedSpanName: string | undefined; + + yield* Effect.withSpan('test-parent-span')( + Effect.gen(function* () { + yield* Effect.annotateCurrentSpan('test-attribute', 'test-value'); + capturedSpanName = 'effect-span-executed'; + }), + ); + + expect(capturedSpanName).toBe('effect-span-executed'); + }).pipe(Effect.provide(SentryEffectTracerLayer)), + ); + + it.effect('creates spans with correct attributes', () => + Effect.gen(function* () { + const result = yield* Effect.withSpan('my-operation')(Effect.succeed('success')); + + expect(result).toBe('success'); + }).pipe(Effect.provide(SentryEffectTracerLayer)), + ); + + it.effect('handles nested spans', () => + Effect.gen(function* () { + const result = yield* Effect.withSpan('outer')( + Effect.gen(function* () { + const inner = yield* Effect.withSpan('inner')(Effect.succeed('inner-result')); + return `outer-${inner}`; + }), + ); + + expect(result).toBe('outer-inner-result'); + }).pipe(Effect.provide(SentryEffectTracerLayer)), + ); + + it.effect('propagates span context through Effect fibers', () => + Effect.gen(function* () { + const results: string[] = []; + + yield* Effect.withSpan('parent')( + Effect.gen(function* () { + results.push('parent-start'); + yield* Effect.withSpan('child-1')(Effect.sync(() => results.push('child-1'))); + yield* Effect.withSpan('child-2')(Effect.sync(() => results.push('child-2'))); + results.push('parent-end'); + }), + ); + + expect(results).toEqual(['parent-start', 'child-1', 'child-2', 'parent-end']); + }).pipe(Effect.provide(SentryEffectTracerLayer)), + ); + + it.effect('handles span failures correctly', () => + Effect.gen(function* () { + const result = yield* Effect.withSpan('failing-span')(Effect.fail('expected-error')).pipe( + Effect.catchAll(e => Effect.succeed(`caught: ${e}`)), + ); + + expect(result).toBe('caught: expected-error'); + }).pipe(Effect.provide(SentryEffectTracerLayer)), + ); + + it.effect('handles span with defects (die)', () => + Effect.gen(function* () { + const result = yield* Effect.withSpan('defect-span')(Effect.die('defect-value')).pipe( + Effect.catchAllDefect(d => Effect.succeed(`caught-defect: ${d}`)), + ); + + expect(result).toBe('caught-defect: defect-value'); + }).pipe(Effect.provide(SentryEffectTracerLayer)), + ); + + it.effect('works with Effect.all for parallel operations', () => + Effect.gen(function* () { + const results = yield* Effect.withSpan('parallel-parent')( + Effect.all([ + Effect.withSpan('task-1')(Effect.succeed(1)), + Effect.withSpan('task-2')(Effect.succeed(2)), + Effect.withSpan('task-3')(Effect.succeed(3)), + ]), + ); + + expect(results).toEqual([1, 2, 3]); + }).pipe(Effect.provide(SentryEffectTracerLayer)), + ); + + it.effect('supports span annotations', () => + Effect.gen(function* () { + const result = yield* Effect.succeed('annotated').pipe( + Effect.withSpan('annotated-span'), + Effect.tap(() => Effect.annotateCurrentSpan('custom-key', 'custom-value')), + ); + + expect(result).toBe('annotated'); + }).pipe(Effect.provide(SentryEffectTracerLayer)), + ); + + it.effect('sets span status to ok on success', () => + Effect.gen(function* () { + const setStatusCalls: Array<{ code: number; message?: string }> = []; + + const mockStartInactiveSpan = vi.spyOn(sentryCore, 'startInactiveSpan').mockImplementation(_options => { + return { + spanContext: () => ({ spanId: 'test-span-id', traceId: 'test-trace-id' }), + isRecording: () => true, + setAttribute: vi.fn(), + setStatus: (status: { code: number; message?: string }) => setStatusCalls.push(status), + addEvent: vi.fn(), + end: vi.fn(), + } as unknown as sentryCore.Span; + }); + + yield* Effect.withSpan('success-span')(Effect.succeed('ok')); + + expect(setStatusCalls).toContainEqual({ code: 1 }); + + mockStartInactiveSpan.mockRestore(); + }).pipe(Effect.provide(SentryEffectTracerLayer)), + ); + + it.effect('sets span status to error on failure', () => + Effect.gen(function* () { + const setStatusCalls: Array<{ code: number; message?: string }> = []; + + const mockStartInactiveSpan = vi.spyOn(sentryCore, 'startInactiveSpan').mockImplementation(_options => { + return { + spanContext: () => ({ spanId: 'test-span-id', traceId: 'test-trace-id' }), + isRecording: () => true, + setAttribute: vi.fn(), + setStatus: (status: { code: number; message?: string }) => setStatusCalls.push(status), + addEvent: vi.fn(), + end: vi.fn(), + } as unknown as sentryCore.Span; + }); + + yield* Effect.withSpan('error-span')(Effect.fail('test-error')).pipe(Effect.catchAll(() => Effect.void)); + + expect(setStatusCalls).toContainEqual({ code: 2, message: 'test-error' }); + + mockStartInactiveSpan.mockRestore(); + }).pipe(Effect.provide(SentryEffectTracerLayer)), + ); + + it.effect('sets span status to error on defect', () => + Effect.gen(function* () { + const setStatusCalls: Array<{ code: number; message?: string }> = []; + + const mockStartInactiveSpan = vi.spyOn(sentryCore, 'startInactiveSpan').mockImplementation(_options => { + return { + spanContext: () => ({ spanId: 'test-span-id', traceId: 'test-trace-id' }), + isRecording: () => true, + setAttribute: vi.fn(), + setStatus: (status: { code: number; message?: string }) => setStatusCalls.push(status), + addEvent: vi.fn(), + end: vi.fn(), + } as unknown as sentryCore.Span; + }); + + yield* Effect.withSpan('defect-span')(Effect.die('fatal-defect')).pipe(Effect.catchAllDefect(() => Effect.void)); + + expect(setStatusCalls).toContainEqual({ code: 2, message: 'fatal-defect' }); + + mockStartInactiveSpan.mockRestore(); + }).pipe(Effect.provide(SentryEffectTracerLayer)), + ); + + it.effect('propagates Sentry span context via withActiveSpan', () => + Effect.gen(function* () { + const withActiveSpanCalls: sentryCore.Span[] = []; + + const mockWithActiveSpan = vi + .spyOn(sentryCore, 'withActiveSpan') + .mockImplementation((span: sentryCore.Span | null, callback: (scope: sentryCore.Scope) => T): T => { + if (span) { + withActiveSpanCalls.push(span); + } + return callback({} as sentryCore.Scope); + }); + + yield* Effect.withSpan('context-span')(Effect.succeed('done')); + + expect(withActiveSpanCalls.length).toBeGreaterThan(0); + + mockWithActiveSpan.mockRestore(); + }).pipe(Effect.provide(SentryEffectTracerLayer)), + ); + + it.effect('sets origin to auto.function.effect for regular spans', () => + Effect.gen(function* () { + let capturedAttributes: Record | undefined; + + const mockStartInactiveSpan = vi.spyOn(sentryCore, 'startInactiveSpan').mockImplementation(options => { + capturedAttributes = options.attributes; + return { + spanContext: () => ({ spanId: 'test-span-id', traceId: 'test-trace-id' }), + isRecording: () => true, + setAttribute: vi.fn(), + setStatus: vi.fn(), + addEvent: vi.fn(), + end: vi.fn(), + } as unknown as sentryCore.Span; + }); + + yield* Effect.withSpan('my-operation')(Effect.succeed('ok')); + + expect(capturedAttributes).toBeDefined(); + expect(capturedAttributes?.[SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]).toBe('auto.function.effect'); + + mockStartInactiveSpan.mockRestore(); + }).pipe(Effect.provide(SentryEffectTracerLayer)), + ); + + it.effect('sets origin to auto.http.effect for http.server spans', () => + Effect.gen(function* () { + let capturedAttributes: Record | undefined; + + const mockStartInactiveSpan = vi.spyOn(sentryCore, 'startInactiveSpan').mockImplementation(options => { + capturedAttributes = options.attributes; + return { + spanContext: () => ({ spanId: 'test-span-id', traceId: 'test-trace-id' }), + isRecording: () => true, + setAttribute: vi.fn(), + setStatus: vi.fn(), + addEvent: vi.fn(), + end: vi.fn(), + } as unknown as sentryCore.Span; + }); + + yield* Effect.withSpan('http.server GET /api/users')(Effect.succeed('ok')); + + expect(capturedAttributes).toBeDefined(); + expect(capturedAttributes?.[SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]).toBe('auto.http.effect'); + + mockStartInactiveSpan.mockRestore(); + }).pipe(Effect.provide(SentryEffectTracerLayer)), + ); + + it.effect('sets origin to auto.http.effect for http.client spans', () => + Effect.gen(function* () { + let capturedAttributes: Record | undefined; + + const mockStartInactiveSpan = vi.spyOn(sentryCore, 'startInactiveSpan').mockImplementation(options => { + capturedAttributes = options.attributes; + return { + spanContext: () => ({ spanId: 'test-span-id', traceId: 'test-trace-id' }), + isRecording: () => true, + setAttribute: vi.fn(), + setStatus: vi.fn(), + addEvent: vi.fn(), + end: vi.fn(), + } as unknown as sentryCore.Span; + }); + + yield* Effect.withSpan('http.client GET https://api.example.com')(Effect.succeed('ok')); + + expect(capturedAttributes).toBeDefined(); + expect(capturedAttributes?.[SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]).toBe('auto.http.effect'); + + mockStartInactiveSpan.mockRestore(); + }).pipe(Effect.provide(SentryEffectTracerLayer)), + ); + + it.effect('uses transaction name from isolation scope for http.server spans', () => + Effect.gen(function* () { + let capturedSpanName: string | undefined; + + const mockGetIsolationScope = vi.spyOn(sentryCore, 'getIsolationScope').mockReturnValue({ + getScopeData: () => ({ + transactionName: 'GET /users/:id', + }), + } as unknown as sentryCore.Scope); + + const mockStartInactiveSpan = vi.spyOn(sentryCore, 'startInactiveSpan').mockImplementation(options => { + capturedSpanName = options.name; + return { + spanContext: () => ({ spanId: 'test-span-id', traceId: 'test-trace-id' }), + isRecording: () => true, + setAttribute: vi.fn(), + setStatus: vi.fn(), + addEvent: vi.fn(), + end: vi.fn(), + } as unknown as sentryCore.Span; + }); + + yield* Effect.withSpan('http.server GET /users/123', { kind: 'server' })(Effect.succeed('ok')); + + expect(capturedSpanName).toBe('GET /users/:id'); + + mockStartInactiveSpan.mockRestore(); + mockGetIsolationScope.mockRestore(); + }).pipe(Effect.provide(SentryEffectTracerLayer)), + ); +});