Skip to content

Commit 8a32bfb

Browse files
JPeer264claude
andcommitted
fixup! fix(effect): Honor external parents and isolate root spans
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
1 parent 1f7229d commit 8a32bfb

10 files changed

Lines changed: 323 additions & 181 deletions

File tree

dev-packages/e2e-tests/test-applications/effect-3-node/src/app.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -65,7 +65,7 @@ const router = HttpRouter.empty.pipe(
6565
}),
6666
);
6767
return yield* HttpServerResponse.json({ status: 'ok' });
68-
}),
68+
}).pipe(Effect.provide(Sentry.SentryEffectExternalSpanLayer)),
6969
),
7070

7171
HttpRouter.get(

dev-packages/e2e-tests/test-applications/effect-3-node/tests/spans.test.ts

Lines changed: 10 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -80,7 +80,7 @@ test('Sends a root: true span as its own segment in a new trace', async ({ baseU
8080
await fetch(`${baseURL}/test-root-span`);
8181

8282
const [requestSpans, detachedSpan] = await Promise.all([requestSpansPromise, detachedSpanPromise]);
83-
const segment = requestSpans.find(span => span.is_segment)!;
83+
const segment = requestSpans.find(span => span.is_segment && getSpanOp(span) === 'http.server')!;
8484
expect(segment.name).toBe('http.server GET');
8585
expect(requestSpans.filter(span => !span.is_segment).map(span => span.name)).toEqual(['root-span-request-marker']);
8686

@@ -89,7 +89,7 @@ test('Sends a root: true span as its own segment in a new trace', async ({ baseU
8989
expect(detachedSpan.trace_id).not.toBe(segment.trace_id);
9090
});
9191

92-
test('Continues the trace of a Tracer.externalSpan parent', async ({ baseURL }) => {
92+
test('Continues the trace of a Tracer.externalSpan parent with the external span layer', async ({ baseURL }) => {
9393
const spanPromise = waitForStreamedSpan('effect-3-node', span => span.name === 'continued-span');
9494

9595
await fetch(`${baseURL}/test-external-parent`);
@@ -102,15 +102,20 @@ test('Continues the trace of a Tracer.externalSpan parent', async ({ baseURL })
102102
});
103103
});
104104

105-
test('Continues the trace of an incoming traceparent header', async ({ baseURL }) => {
105+
test('Ignores an incoming traceparent header without the external span layer', async ({ baseURL }) => {
106106
const traceId = '1234567890abcdef1234567890abcdef';
107107
const parentSpanId = 'abcdef1234567890';
108108

109-
const spanPromise = waitForStreamedSpan('effect-3-node', span => span.is_segment && span.trace_id === traceId);
109+
const spanPromise = waitForStreamedSpan(
110+
'effect-3-node',
111+
span =>
112+
span.is_segment && getSpanOp(span) === 'http.server' && span.attributes['url.path']?.value === '/test-success',
113+
);
110114

111115
await fetch(`${baseURL}/test-success`, { headers: { traceparent: `00-${traceId}-${parentSpanId}-01` } });
112116

113117
const span = await spanPromise;
114118
expect(span.name).toBe('http.server GET');
115-
expect(span.parent_span_id).toBe(parentSpanId);
119+
expect(span.trace_id).not.toBe(traceId);
120+
expect(span.parent_span_id).toBeUndefined();
116121
});

dev-packages/e2e-tests/test-applications/effect-4-node/src/app.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -70,7 +70,7 @@ const Routes = Layer.mergeAll(
7070
}),
7171
);
7272
return yield* HttpServerResponse.json({ status: 'ok' });
73-
}),
73+
}).pipe(Effect.provide(Sentry.SentryEffectExternalSpanLayer)),
7474
),
7575

7676
HttpRouter.add(

dev-packages/e2e-tests/test-applications/effect-4-node/tests/spans.test.ts

Lines changed: 10 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -80,7 +80,7 @@ test('Sends a root: true span as its own segment in a new trace', async ({ baseU
8080
await fetch(`${baseURL}/test-root-span`);
8181

8282
const [requestSpans, detachedSpan] = await Promise.all([requestSpansPromise, detachedSpanPromise]);
83-
const segment = requestSpans.find(span => span.is_segment)!;
83+
const segment = requestSpans.find(span => span.is_segment && getSpanOp(span) === 'http.server')!;
8484
expect(segment.name).toBe('http.server GET');
8585
expect(requestSpans.filter(span => !span.is_segment).map(span => span.name)).toEqual(['root-span-request-marker']);
8686

@@ -89,7 +89,7 @@ test('Sends a root: true span as its own segment in a new trace', async ({ baseU
8989
expect(detachedSpan.trace_id).not.toBe(segment.trace_id);
9090
});
9191

92-
test('Continues the trace of a Tracer.externalSpan parent', async ({ baseURL }) => {
92+
test('Continues the trace of a Tracer.externalSpan parent with the external span layer', async ({ baseURL }) => {
9393
const spanPromise = waitForStreamedSpan('effect-4-node', span => span.name === 'continued-span');
9494

9595
await fetch(`${baseURL}/test-external-parent`);
@@ -102,15 +102,20 @@ test('Continues the trace of a Tracer.externalSpan parent', async ({ baseURL })
102102
});
103103
});
104104

105-
test('Continues the trace of an incoming traceparent header', async ({ baseURL }) => {
105+
test('Ignores an incoming traceparent header without the external span layer', async ({ baseURL }) => {
106106
const traceId = '1234567890abcdef1234567890abcdef';
107107
const parentSpanId = 'abcdef1234567890';
108108

109-
const spanPromise = waitForStreamedSpan('effect-4-node', span => span.is_segment && span.trace_id === traceId);
109+
const spanPromise = waitForStreamedSpan(
110+
'effect-4-node',
111+
span =>
112+
span.is_segment && getSpanOp(span) === 'http.server' && span.attributes['url.path']?.value === '/test-success',
113+
);
110114

111115
await fetch(`${baseURL}/test-success`, { headers: { traceparent: `00-${traceId}-${parentSpanId}-01` } });
112116

113117
const span = await spanPromise;
114118
expect(span.name).toBe('http.server GET');
115-
expect(span.parent_span_id).toBe(parentSpanId);
119+
expect(span.trace_id).not.toBe(traceId);
120+
expect(span.parent_span_id).toBeUndefined();
116121
});

packages/effect/README.md

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -72,6 +72,33 @@ const HttpLive = HttpRouter.serve(Routes).pipe(
7272
NodeRuntime.runMain(Layer.launch(HttpLive));
7373
```
7474

75+
## Continuing external traces
76+
77+
The tracer ignores `Tracer.externalSpan` parents by default, so Sentry's own
78+
trace continuation stays in charge. This includes the parent that
79+
`@effect/platform` builds from an incoming `traceparent` or `b3` header. To
80+
continue the trace of an external span instead, provide
81+
`SentryEffectExternalSpanLayer`. Next to the tracer layer it applies to every
82+
span in the runtime, including the HTTP server spans. On Effect v4, replace the
83+
`Layer.setTracer` line with the `Layer.succeed` call from the example above.
84+
85+
```typescript
86+
const SentryLive = Layer.mergeAll(
87+
Sentry.effectLayer({ dsn: '__DSN__', tracesSampleRate: 1.0 }),
88+
Layer.setTracer(Sentry.SentryEffectTracer),
89+
Sentry.SentryEffectExternalSpanLayer,
90+
);
91+
```
92+
93+
Provided to a single effect, it applies to that effect only:
94+
95+
```typescript
96+
const processJob = handleMessage(message).pipe(
97+
Effect.withSpan('process-job', { parent: Tracer.externalSpan(message.trace) }),
98+
Effect.provide(Sentry.SentryEffectExternalSpanLayer),
99+
);
100+
```
101+
75102
## Links
76103

77104
- [Official SDK Docs](https://docs.sentry.io/platforms/javascript/guides/effect/)

packages/effect/src/index.client.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,5 +7,6 @@ export { effectLayer, init } from './client/index';
77
export type { EffectClientLayerOptions } from './client/index';
88

99
export { SentryEffectTracer } from './client/tracer';
10+
export { SentryEffectExternalSpanLayer } from './tracer';
1011
export { SentryEffectLogger } from './logger';
1112
export { SentryEffectMetricsLayer } from './metrics';

packages/effect/src/index.server.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,5 +4,6 @@ export { effectLayer, init } from './server/index';
44
export type { EffectServerLayerOptions } from './server/index';
55

66
export { SentryEffectTracer } from './server/tracer';
7+
export { SentryEffectExternalSpanLayer } from './tracer';
78
export { SentryEffectLogger } from './logger';
89
export { SentryEffectMetricsLayer } from './metrics';

packages/effect/src/span.ts

Lines changed: 144 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,144 @@
1+
import type { Span } from '@sentry/core';
2+
import { isObjectLike } from '@sentry/core';
3+
import type * as Context from 'effect/Context';
4+
import * as Exit from 'effect/Exit';
5+
import type * as Option from 'effect/Option';
6+
import type * as EffectTracer from 'effect/Tracer';
7+
8+
type HrTime = [number, number];
9+
10+
const SENTRY_SPAN_SYMBOL = Symbol.for('@sentry/effect.SentrySpan');
11+
12+
export function nanosToHrTime(nanos: bigint): HrTime {
13+
const seconds = Number(nanos / BigInt(1_000_000_000));
14+
const remainingNanos = Number(nanos % BigInt(1_000_000_000));
15+
return [seconds, remainingNanos];
16+
}
17+
18+
export interface SentrySpanLike extends EffectTracer.Span {
19+
readonly [SENTRY_SPAN_SYMBOL]: true;
20+
readonly sentrySpan: Span;
21+
}
22+
23+
export function isSentrySpan(span: EffectTracer.AnySpan): span is SentrySpanLike {
24+
return SENTRY_SPAN_SYMBOL in span;
25+
}
26+
27+
function getErrorMessage(exit: Exit.Exit<unknown, unknown>): string | undefined {
28+
if (!Exit.isFailure(exit)) {
29+
return undefined;
30+
}
31+
32+
const cause = exit.cause as unknown;
33+
34+
// Effect v4: cause.reasons is an array of Reason objects
35+
if (isObjectLike(cause) && 'reasons' in cause && Array.isArray((cause as { reasons: unknown }).reasons)) {
36+
const reasons = (cause as { reasons: Array<{ _tag?: string; error?: unknown; defect?: unknown }> }).reasons;
37+
for (const reason of reasons) {
38+
if (reason._tag === 'Fail' && reason.error !== undefined) {
39+
return String(reason.error);
40+
}
41+
if (reason._tag === 'Die' && reason.defect !== undefined) {
42+
return String(reason.defect);
43+
}
44+
}
45+
return 'internal_error';
46+
}
47+
48+
// Effect v3: cause has _tag directly
49+
if (isObjectLike(cause) && '_tag' in cause) {
50+
const v3Cause = cause as { _tag: string; error?: unknown; defect?: unknown };
51+
if (v3Cause._tag === 'Fail') {
52+
return String(v3Cause.error);
53+
}
54+
if (v3Cause._tag === 'Die') {
55+
return String(v3Cause.defect);
56+
}
57+
}
58+
59+
return 'internal_error';
60+
}
61+
62+
export class SentrySpanWrapper implements SentrySpanLike {
63+
public readonly [SENTRY_SPAN_SYMBOL]: true;
64+
public readonly _tag: 'Span';
65+
public readonly spanId: string;
66+
public readonly traceId: string;
67+
public readonly attributes: Map<string, unknown>;
68+
public readonly sampled: boolean;
69+
public readonly parent: Option.Option<EffectTracer.AnySpan>;
70+
public readonly links: Array<EffectTracer.SpanLink>;
71+
public status: EffectTracer.SpanStatus;
72+
public readonly sentrySpan: Span;
73+
public readonly annotations: Context.Context<never>;
74+
75+
public constructor(
76+
public readonly name: string,
77+
parent: Option.Option<EffectTracer.AnySpan>,
78+
public readonly context: Context.Context<never>,
79+
links: ReadonlyArray<EffectTracer.SpanLink>,
80+
startTime: bigint,
81+
public readonly kind: EffectTracer.SpanKind,
82+
existingSpan: Span,
83+
) {
84+
this[SENTRY_SPAN_SYMBOL] = true as const;
85+
this._tag = 'Span' as const;
86+
this.attributes = new Map<string, unknown>();
87+
this.parent = parent;
88+
this.links = [...links];
89+
this.sentrySpan = existingSpan;
90+
this.annotations = context;
91+
92+
const spanContext = this.sentrySpan.spanContext();
93+
this.spanId = spanContext.spanId;
94+
this.traceId = spanContext.traceId;
95+
this.sampled = this.sentrySpan.isRecording();
96+
this.status = {
97+
_tag: 'Started',
98+
startTime,
99+
};
100+
}
101+
102+
public attribute(key: string, value: unknown): void {
103+
if (!this.sentrySpan.isRecording()) {
104+
return;
105+
}
106+
107+
this.sentrySpan.setAttribute(key, value as Parameters<Span['setAttribute']>[1]);
108+
this.attributes.set(key, value);
109+
}
110+
111+
public addLinks(links: ReadonlyArray<EffectTracer.SpanLink>): void {
112+
this.links.push(...links);
113+
}
114+
115+
public end(endTime: bigint, exit: Exit.Exit<unknown, unknown>): void {
116+
this.status = {
117+
_tag: 'Ended',
118+
endTime,
119+
exit,
120+
startTime: this.status.startTime,
121+
};
122+
123+
if (!this.sentrySpan.isRecording()) {
124+
return;
125+
}
126+
127+
if (Exit.isFailure(exit)) {
128+
const message = getErrorMessage(exit) ?? 'internal_error';
129+
this.sentrySpan.setStatus({ code: 2, message });
130+
} else {
131+
this.sentrySpan.setStatus({ code: 1 });
132+
}
133+
134+
this.sentrySpan.end(nanosToHrTime(endTime));
135+
}
136+
137+
public event(name: string, startTime: bigint, attributes?: Record<string, unknown>): void {
138+
if (!this.sentrySpan.isRecording()) {
139+
return;
140+
}
141+
142+
this.sentrySpan.addEvent(name, attributes as Parameters<Span['addEvent']>[1], nanosToHrTime(startTime));
143+
}
144+
}

0 commit comments

Comments
 (0)