Skip to content

Commit 8d8a6b9

Browse files
committed
feat(effect): Add tracing to the effectLayer
1 parent 15170d0 commit 8d8a6b9

7 files changed

Lines changed: 691 additions & 8 deletions

File tree

packages/effect/src/client/index.ts

Lines changed: 10 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,19 @@
11
import type { BrowserOptions } from '@sentry/browser';
2-
import * as EffectLayer from 'effect/Layer';
2+
import * as Sentry from '@sentry/browser';
3+
import type * as EffectLayer from 'effect/Layer';
4+
import { suspend as suspendLayer } from 'effect/Layer';
5+
import { buildEffectLayer } from '../utils/buildEffectLayer';
36

47
/**
58
* Options for the Sentry Effect client layer.
69
*/
710
export type EffectClientLayerOptions = BrowserOptions;
811

912
/**
10-
* Creates an empty Effect Layer
13+
* Creates an Effect Layer that initializes Sentry for browser clients.
14+
*
15+
* This layer provides Effect applications with full Sentry instrumentation including:
16+
* - Effect spans traced as Sentry spans
1117
*
1218
* @example
1319
* ```typescript
@@ -25,6 +31,6 @@ export type EffectClientLayerOptions = BrowserOptions;
2531
* Effect.runPromise(Effect.provide(myEffect, ApiClientWithSentry));
2632
* ```
2733
*/
28-
export function effectLayer(_: EffectClientLayerOptions): EffectLayer.Layer<never, never, never> {
29-
return EffectLayer.empty;
34+
export function effectLayer(options: EffectClientLayerOptions): EffectLayer.Layer<never, never, never> {
35+
return suspendLayer(() => buildEffectLayer(options, Sentry.init(options)));
3036
}

packages/effect/src/server/index.ts

Lines changed: 9 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,18 @@
11
import type { NodeOptions } from '@sentry/node-core';
2-
import * as EffectLayer from 'effect/Layer';
2+
import * as Sentry from '@sentry/node-core/light';
3+
import type * as EffectLayer from 'effect/Layer';
4+
import { buildEffectLayer } from '../utils/buildEffectLayer';
35

46
/**
57
* Options for the Sentry Effect server layer.
68
*/
79
export type EffectServerLayerOptions = NodeOptions;
810

911
/**
10-
* Creates an empty Effect Layer
12+
* Creates an Effect Layer that initializes Sentry for Node.js servers.
13+
*
14+
* This layer provides Effect applications with full Sentry instrumentation including:
15+
* - Effect spans traced as Sentry spans
1116
*
1217
* @example
1318
* ```typescript
@@ -27,6 +32,6 @@ export type EffectServerLayerOptions = NodeOptions;
2732
* MainLive.pipe(Layer.launch, NodeRuntime.runMain);
2833
* ```
2934
*/
30-
export function effectLayer(_: EffectServerLayerOptions): EffectLayer.Layer<never, never, never> {
31-
return EffectLayer.empty;
35+
export function effectLayer(options: EffectServerLayerOptions): EffectLayer.Layer<never, never, never> {
36+
return buildEffectLayer(options, Sentry.init(options));
3237
}

packages/effect/src/tracer.ts

Lines changed: 201 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,201 @@
1+
import type { Span } from '@sentry/core';
2+
import {
3+
getActiveSpan,
4+
getIsolationScope,
5+
SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN,
6+
startInactiveSpan,
7+
withActiveSpan,
8+
} from '@sentry/core';
9+
import type * as Context from 'effect/Context';
10+
import * as Exit from 'effect/Exit';
11+
import type * as Layer from 'effect/Layer';
12+
import { setTracer } from 'effect/Layer';
13+
import * as Option from 'effect/Option';
14+
import * as EffectTracer from 'effect/Tracer';
15+
16+
const KIND_MAP: Record<EffectTracer.SpanKind, 'internal' | 'server' | 'client' | 'producer' | 'consumer'> = {
17+
internal: 'internal',
18+
client: 'client',
19+
server: 'server',
20+
producer: 'producer',
21+
consumer: 'consumer',
22+
};
23+
24+
function deriveOp(name: string, kind: EffectTracer.SpanKind): string {
25+
if (name.startsWith('http.server')) {
26+
return 'http.server';
27+
}
28+
29+
if (name.startsWith('http.client')) {
30+
return 'http.client';
31+
}
32+
33+
return KIND_MAP[kind];
34+
}
35+
36+
function deriveOrigin(name: string): string {
37+
if (name.startsWith('http.server') || name.startsWith('http.client')) {
38+
return 'auto.http.effect';
39+
}
40+
41+
return 'auto.function.effect';
42+
}
43+
44+
function deriveSpanName(name: string, kind: EffectTracer.SpanKind): string {
45+
if (name.startsWith('http.server') && kind === 'server') {
46+
const isolationScope = getIsolationScope();
47+
const transactionName = isolationScope.getScopeData().transactionName;
48+
if (transactionName) {
49+
return transactionName;
50+
}
51+
}
52+
return name;
53+
}
54+
55+
type HrTime = [number, number];
56+
57+
const SENTRY_SPAN_SYMBOL = Symbol.for('@sentry/effect.SentrySpan');
58+
59+
function nanosToHrTime(nanos: bigint): HrTime {
60+
const seconds = Number(nanos / BigInt(1_000_000_000));
61+
const remainingNanos = Number(nanos % BigInt(1_000_000_000));
62+
return [seconds, remainingNanos];
63+
}
64+
65+
interface SentrySpanLike extends EffectTracer.Span {
66+
readonly [SENTRY_SPAN_SYMBOL]: true;
67+
readonly sentrySpan: Span;
68+
}
69+
70+
function isSentrySpan(span: EffectTracer.AnySpan): span is SentrySpanLike {
71+
return SENTRY_SPAN_SYMBOL in span;
72+
}
73+
74+
class SentrySpanWrapper implements SentrySpanLike {
75+
public readonly [SENTRY_SPAN_SYMBOL]: true;
76+
public readonly _tag: 'Span';
77+
public readonly spanId: string;
78+
public readonly traceId: string;
79+
public readonly attributes: Map<string, unknown>;
80+
public readonly sampled: boolean;
81+
public readonly parent: Option.Option<EffectTracer.AnySpan>;
82+
public readonly links: Array<EffectTracer.SpanLink>;
83+
public status: EffectTracer.SpanStatus;
84+
public readonly sentrySpan: Span;
85+
86+
public constructor(
87+
public readonly name: string,
88+
parent: Option.Option<EffectTracer.AnySpan>,
89+
public readonly context: Context.Context<never>,
90+
links: ReadonlyArray<EffectTracer.SpanLink>,
91+
startTime: bigint,
92+
public readonly kind: EffectTracer.SpanKind,
93+
existingSpan: Span,
94+
) {
95+
this[SENTRY_SPAN_SYMBOL] = true as const;
96+
this._tag = 'Span' as const;
97+
this.attributes = new Map<string, unknown>();
98+
this.parent = parent;
99+
this.links = [...links];
100+
this.sentrySpan = existingSpan;
101+
102+
const spanContext = this.sentrySpan.spanContext();
103+
this.spanId = spanContext.spanId;
104+
this.traceId = spanContext.traceId;
105+
this.sampled = this.sentrySpan.isRecording();
106+
this.status = {
107+
_tag: 'Started',
108+
startTime,
109+
};
110+
}
111+
112+
public attribute(key: string, value: unknown): void {
113+
if (!this.sentrySpan.isRecording()) {
114+
return;
115+
}
116+
117+
this.sentrySpan.setAttribute(key, value as Parameters<Span['setAttribute']>[1]);
118+
this.attributes.set(key, value);
119+
}
120+
121+
public addLinks(links: ReadonlyArray<EffectTracer.SpanLink>): void {
122+
this.links.push(...links);
123+
}
124+
125+
public end(endTime: bigint, exit: Exit.Exit<unknown, unknown>): void {
126+
this.status = {
127+
_tag: 'Ended',
128+
endTime,
129+
exit,
130+
startTime: this.status.startTime,
131+
};
132+
133+
if (!this.sentrySpan.isRecording()) {
134+
return;
135+
}
136+
137+
if (Exit.isFailure(exit)) {
138+
const cause = exit.cause;
139+
const message =
140+
cause._tag === 'Fail' ? String(cause.error) : cause._tag === 'Die' ? String(cause.defect) : 'internal_error';
141+
this.sentrySpan.setStatus({ code: 2, message });
142+
} else {
143+
this.sentrySpan.setStatus({ code: 1 });
144+
}
145+
146+
this.sentrySpan.end(nanosToHrTime(endTime));
147+
}
148+
149+
public event(name: string, startTime: bigint, attributes?: Record<string, unknown>): void {
150+
if (!this.sentrySpan.isRecording()) {
151+
return;
152+
}
153+
154+
this.sentrySpan.addEvent(name, attributes as Parameters<Span['addEvent']>[1], nanosToHrTime(startTime));
155+
}
156+
}
157+
158+
function createSentrySpan(
159+
name: string,
160+
parent: Option.Option<EffectTracer.AnySpan>,
161+
context: Context.Context<never>,
162+
links: ReadonlyArray<EffectTracer.SpanLink>,
163+
startTime: bigint,
164+
kind: EffectTracer.SpanKind,
165+
): SentrySpanLike {
166+
const parentSentrySpan =
167+
Option.isSome(parent) && isSentrySpan(parent.value) ? parent.value.sentrySpan : (getActiveSpan() ?? null);
168+
169+
const spanName = deriveSpanName(name, kind);
170+
171+
const newSpan = startInactiveSpan({
172+
name: spanName,
173+
op: deriveOp(name, kind),
174+
startTime: nanosToHrTime(startTime),
175+
attributes: {
176+
[SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: deriveOrigin(name),
177+
},
178+
...(parentSentrySpan ? { parentSpan: parentSentrySpan } : {}),
179+
});
180+
181+
return new SentrySpanWrapper(name, parent, context, links, startTime, kind, newSpan);
182+
}
183+
184+
const makeSentryTracer = (): EffectTracer.Tracer =>
185+
EffectTracer.make({
186+
span(name, parent, context, links, startTime, kind) {
187+
return createSentrySpan(name, parent, context, links, startTime, kind);
188+
},
189+
context(execution, fiber) {
190+
const currentSpan = fiber.currentSpan;
191+
if (currentSpan === undefined || !isSentrySpan(currentSpan)) {
192+
return execution();
193+
}
194+
return withActiveSpan(currentSpan.sentrySpan, execution);
195+
},
196+
});
197+
198+
/**
199+
* Effect Layer that sets up the Sentry tracer for Effect spans.
200+
*/
201+
export const SentryEffectTracerLayer: Layer.Layer<never, never, never> = setTracer(makeSentryTracer());
Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
import type * as EffectLayer from 'effect/Layer';
2+
import { empty as emptyLayer } from 'effect/Layer';
3+
import { SentryEffectTracerLayer } from '../tracer';
4+
5+
// eslint-disable-next-line @typescript-eslint/no-empty-interface
6+
export interface EffectLayerBaseOptions {}
7+
8+
/**
9+
* Builds an Effect layer that integrates Sentry tracing.
10+
*
11+
* Returns an empty layer if no Sentry client is available. Otherwise, starts with
12+
* the Sentry tracer layer and optionally merges logging and metrics layers based
13+
* on the provided options.
14+
*/
15+
export function buildEffectLayer<T extends EffectLayerBaseOptions>(
16+
options: T,
17+
client: unknown,
18+
): EffectLayer.Layer<never, never, never> {
19+
if (!client) {
20+
return emptyLayer;
21+
}
22+
23+
return SentryEffectTracerLayer;
24+
}
Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,57 @@
1+
import { describe, expect, it, vi } from '@effect/vitest';
2+
import * as sentryCore from '@sentry/core';
3+
import { Effect, Layer } from 'effect';
4+
import { empty as emptyLayer } from 'effect/Layer';
5+
import { buildEffectLayer } from '../src/utils/buildEffectLayer';
6+
7+
describe('buildEffectLayer', () => {
8+
describe('when client is falsy', () => {
9+
it('returns empty layer when client is null', () => {
10+
const layer = buildEffectLayer({}, null);
11+
12+
expect(layer).toBeDefined();
13+
expect(Layer.isLayer(layer)).toBe(true);
14+
expect(layer).toBe(emptyLayer);
15+
});
16+
17+
it('returns empty layer when client is undefined', () => {
18+
const layer = buildEffectLayer({}, undefined);
19+
20+
expect(layer).toBeDefined();
21+
expect(Layer.isLayer(layer)).toBe(true);
22+
expect(layer).toBe(emptyLayer);
23+
});
24+
});
25+
26+
describe('when client is truthy', () => {
27+
const mockClient = { mock: true };
28+
29+
it('returns a valid layer with default options', () => {
30+
const layer = buildEffectLayer({}, mockClient);
31+
32+
expect(layer).toBeDefined();
33+
expect(Layer.isLayer(layer)).toBe(true);
34+
});
35+
36+
it.effect('layer can be provided to an Effect program', () =>
37+
Effect.gen(function* () {
38+
const result = yield* Effect.succeed('test-result');
39+
expect(result).toBe('test-result');
40+
}).pipe(Effect.provide(buildEffectLayer({}, mockClient))),
41+
);
42+
43+
it.effect('layer enables tracing for Effect spans via Sentry tracer', () =>
44+
Effect.gen(function* () {
45+
const startInactiveSpanSpy = vi.spyOn(sentryCore, 'startInactiveSpan');
46+
const result = yield* Effect.withSpan('test-sentry-span')(Effect.succeed('traced'));
47+
expect(result).toBe('traced');
48+
expect(startInactiveSpanSpy).toHaveBeenCalledWith(
49+
expect.objectContaining({
50+
name: 'test-sentry-span',
51+
}),
52+
);
53+
startInactiveSpanSpy.mockRestore();
54+
}).pipe(Effect.provide(buildEffectLayer({}, mockClient))),
55+
);
56+
});
57+
});

0 commit comments

Comments
 (0)