Skip to content

Commit 4f323cf

Browse files
committed
feat(effect): Add tracing to the effectLayer (#19655)
This adds tracing to the `Sentry.effectLayer`. By setting `tracesSampleRate: 1.0` in the options tracing is enabled and spans can be send to Sentry
1 parent aa3c901 commit 4f323cf

11 files changed

Lines changed: 763 additions & 11 deletions

File tree

packages/effect/src/client/index.ts

Lines changed: 12 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,21 @@
11
import type { BrowserOptions } from '@sentry/browser';
2-
import * as EffectLayer from 'effect/Layer';
2+
import type * as EffectLayer from 'effect/Layer';
3+
import { suspend as suspendLayer } from 'effect/Layer';
4+
import { buildEffectLayer } from '../utils/buildEffectLayer';
5+
import { init } from './sdk';
6+
7+
export { init } from './sdk';
38

49
/**
510
* Options for the Sentry Effect client layer.
611
*/
712
export type EffectClientLayerOptions = BrowserOptions;
813

914
/**
10-
* Creates an empty Effect Layer
15+
* Creates an Effect Layer that initializes Sentry for browser clients.
16+
*
17+
* This layer provides Effect applications with full Sentry instrumentation including:
18+
* - Effect spans traced as Sentry spans
1119
*
1220
* @example
1321
* ```typescript
@@ -25,6 +33,6 @@ export type EffectClientLayerOptions = BrowserOptions;
2533
* Effect.runPromise(Effect.provide(myEffect, ApiClientWithSentry));
2634
* ```
2735
*/
28-
export function effectLayer(_: EffectClientLayerOptions): EffectLayer.Layer<never, never, never> {
29-
return EffectLayer.empty;
36+
export function effectLayer(options: EffectClientLayerOptions): EffectLayer.Layer<never, never, never> {
37+
return suspendLayer(() => buildEffectLayer(options, init(options)));
3038
}

packages/effect/src/client/sdk.ts

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
import type { BrowserOptions } from '@sentry/browser';
2+
import { init as initBrowser } from '@sentry/browser';
3+
import type { Client } from '@sentry/core';
4+
import { applySdkMetadata } from '@sentry/core';
5+
6+
/**
7+
* Initializes the Sentry Effect SDK for browser clients.
8+
*
9+
* @param options - Configuration options for the SDK
10+
* @returns The initialized Sentry client, or undefined if initialization failed
11+
*/
12+
export function init(options: BrowserOptions): Client | undefined {
13+
const opts = {
14+
...options,
15+
};
16+
17+
applySdkMetadata(opts, 'effect', ['effect', 'browser']);
18+
19+
return initBrowser(opts);
20+
}
Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,7 @@
1+
// import/export got a false positive, and affects most of our index barrel files
2+
// can be removed once following issue is fixed: https://github.com/import-js/eslint-plugin-import/issues/703
3+
/* eslint-disable import/export */
14
export * from '@sentry/browser';
25

3-
export { effectLayer } from './client/index';
6+
export { effectLayer, init } from './client/index';
47
export type { EffectClientLayerOptions } from './client/index';
Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
11
export * from '@sentry/node-core/light';
22

3-
export { effectLayer } from './server/index';
3+
export { effectLayer, init } from './server/index';
44
export type { EffectServerLayerOptions } from './server/index';

packages/effect/src/server/index.ts

Lines changed: 12 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,20 @@
1-
import type { NodeOptions } from '@sentry/node-core';
2-
import * as EffectLayer from 'effect/Layer';
1+
import type { NodeOptions } from '@sentry/node-core/light';
2+
import type * as EffectLayer from 'effect/Layer';
3+
import { buildEffectLayer } from '../utils/buildEffectLayer';
4+
import { init } from './sdk';
5+
6+
export { init } from './sdk';
37

48
/**
59
* Options for the Sentry Effect server layer.
610
*/
711
export type EffectServerLayerOptions = NodeOptions;
812

913
/**
10-
* Creates an empty Effect Layer
14+
* Creates an Effect Layer that initializes Sentry for Node.js servers.
15+
*
16+
* This layer provides Effect applications with full Sentry instrumentation including:
17+
* - Effect spans traced as Sentry spans
1118
*
1219
* @example
1320
* ```typescript
@@ -27,6 +34,6 @@ export type EffectServerLayerOptions = NodeOptions;
2734
* MainLive.pipe(Layer.launch, NodeRuntime.runMain);
2835
* ```
2936
*/
30-
export function effectLayer(_: EffectServerLayerOptions): EffectLayer.Layer<never, never, never> {
31-
return EffectLayer.empty;
37+
export function effectLayer(options: EffectServerLayerOptions): EffectLayer.Layer<never, never, never> {
38+
return buildEffectLayer(options, init(options));
3239
}

packages/effect/src/server/sdk.ts

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
import type { Client } from '@sentry/core';
2+
import { applySdkMetadata } from '@sentry/core';
3+
import type { NodeOptions } from '@sentry/node-core/light';
4+
import { init as initNode } from '@sentry/node-core/light';
5+
6+
/**
7+
* Initializes the Sentry Effect SDK for Node.js servers.
8+
*
9+
* @param options - Configuration options for the SDK
10+
* @returns The initialized Sentry client, or undefined if initialization failed
11+
*/
12+
export function init(options: NodeOptions): Client | undefined {
13+
const opts = {
14+
...options,
15+
};
16+
17+
applySdkMetadata(opts, 'effect', ['effect', 'node-light']);
18+
19+
return initNode(opts);
20+
}

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+
}

0 commit comments

Comments
 (0)