Skip to content

Commit 52abaa7

Browse files
committed
feat(core): Add beforeSendSpan hook (#11886)
1 parent d64d458 commit 52abaa7

File tree

6 files changed

+174
-6
lines changed

6 files changed

+174
-6
lines changed

packages/core/src/baseclient.ts

Lines changed: 9 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -892,14 +892,20 @@ function processBeforeSend(
892892
event: Event,
893893
hint: EventHint,
894894
): PromiseLike<Event | null> | Event | null {
895-
const { beforeSend, beforeSendTransaction } = options;
895+
const { beforeSend, beforeSendTransaction, beforeSendSpan } = options;
896896

897897
if (isErrorEvent(event) && beforeSend) {
898898
return beforeSend(event, hint);
899899
}
900900

901-
if (isTransactionEvent(event) && beforeSendTransaction) {
902-
return beforeSendTransaction(event, hint);
901+
if (isTransactionEvent(event)) {
902+
if (event.spans && beforeSendSpan) {
903+
event.spans = event.spans.map(span => beforeSendSpan(span));
904+
}
905+
906+
if (beforeSendTransaction) {
907+
return beforeSendTransaction(event, hint);
908+
}
903909
}
904910

905911
return event;

packages/core/src/envelope.ts

Lines changed: 16 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import type {
2+
Client,
23
DsnComponents,
34
DynamicSamplingContext,
45
Event,
@@ -11,6 +12,8 @@ import type {
1112
SessionEnvelope,
1213
SessionItem,
1314
SpanEnvelope,
15+
SpanItem,
16+
SpanJSON,
1417
} from '@sentry/types';
1518
import {
1619
createEnvelope,
@@ -94,8 +97,10 @@ export function createEventEnvelope(
9497

9598
/**
9699
* Create envelope from Span item.
100+
*
101+
* Takes an optional client and runs spans through `beforeSendSpan` if available.
97102
*/
98-
export function createSpanEnvelope(spans: SentrySpan[]): SpanEnvelope {
103+
export function createSpanEnvelope(spans: SentrySpan[], client?: Client): SpanEnvelope {
99104
function dscHasRequiredProps(dsc: Partial<DynamicSamplingContext>): dsc is DynamicSamplingContext {
100105
return !!dsc.trace_id && !!dsc.public_key;
101106
}
@@ -109,6 +114,15 @@ export function createSpanEnvelope(spans: SentrySpan[]): SpanEnvelope {
109114
sent_at: new Date().toISOString(),
110115
...(dscHasRequiredProps(dsc) && { trace: dsc }),
111116
};
112-
const items = spans.map(span => createSpanEnvelopeItem(spanToJSON(span)));
117+
118+
const beforeSend = client && client.getOptions().beforeSendSpan;
119+
let items: SpanItem[];
120+
121+
if (beforeSend) {
122+
items = spans.map(span => createSpanEnvelopeItem(beforeSend(spanToJSON(span) as SpanJSON)));
123+
} else {
124+
items = spans.map(span => createSpanEnvelopeItem(spanToJSON(span)));
125+
}
126+
113127
return createEnvelope<SpanEnvelope>(headers, items);
114128
}

packages/core/src/tracing/sentrySpan.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -259,7 +259,7 @@ export class SentrySpan implements Span {
259259

260260
// if this is a standalone span, we send it immediately
261261
if (this._isStandaloneSpan) {
262-
sendSpanEnvelope(createSpanEnvelope([this]));
262+
sendSpanEnvelope(createSpanEnvelope([this], client));
263263
return;
264264
}
265265

packages/core/test/lib/base.test.ts

Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -947,6 +947,38 @@ describe('BaseClient', () => {
947947
expect(TestClient.instance!.event!.transaction).toBe('/dogs/are/great');
948948
});
949949

950+
test('calls `beforeSendSpan` and uses original spans without any changes', () => {
951+
expect.assertions(2);
952+
953+
const beforeSendSpan = jest.fn(span => span);
954+
const options = getDefaultTestClientOptions({ dsn: PUBLIC_DSN, beforeSendSpan });
955+
const client = new TestClient(options);
956+
957+
const transaction: Event = {
958+
transaction: '/cats/are/great',
959+
type: 'transaction',
960+
spans: [
961+
{
962+
description: 'first span',
963+
span_id: '9e15bf99fbe4bc80',
964+
start_timestamp: 1591603196.637835,
965+
trace_id: '86f39e84263a4de99c326acab3bfe3bd',
966+
},
967+
{
968+
description: 'second span',
969+
span_id: 'aa554c1f506b0783',
970+
start_timestamp: 1591603196.637835,
971+
trace_id: '86f39e84263a4de99c326acab3bfe3bd',
972+
},
973+
],
974+
};
975+
client.captureEvent(transaction);
976+
977+
expect(beforeSendSpan).toHaveBeenCalledTimes(2);
978+
const capturedEvent = TestClient.instance!.event!;
979+
expect(capturedEvent.spans).toEqual(transaction.spans);
980+
});
981+
950982
test('calls `beforeSend` and uses the modified event', () => {
951983
expect.assertions(2);
952984

@@ -979,6 +1011,45 @@ describe('BaseClient', () => {
9791011
expect(TestClient.instance!.event!.transaction).toBe('/adopt/dont/shop');
9801012
});
9811013

1014+
test('calls `beforeSendSpan` and uses the modified spans', () => {
1015+
expect.assertions(3);
1016+
1017+
const beforeSendSpan = jest.fn(span => {
1018+
span.data = { version: 'bravo' };
1019+
return span;
1020+
});
1021+
1022+
const options = getDefaultTestClientOptions({ dsn: PUBLIC_DSN, beforeSendSpan });
1023+
const client = new TestClient(options);
1024+
const transaction: Event = {
1025+
transaction: '/cats/are/great',
1026+
type: 'transaction',
1027+
spans: [
1028+
{
1029+
description: 'first span',
1030+
span_id: '9e15bf99fbe4bc80',
1031+
start_timestamp: 1591603196.637835,
1032+
trace_id: '86f39e84263a4de99c326acab3bfe3bd',
1033+
},
1034+
{
1035+
description: 'second span',
1036+
span_id: 'aa554c1f506b0783',
1037+
start_timestamp: 1591603196.637835,
1038+
trace_id: '86f39e84263a4de99c326acab3bfe3bd',
1039+
},
1040+
],
1041+
};
1042+
1043+
client.captureEvent(transaction);
1044+
1045+
expect(beforeSendSpan).toHaveBeenCalledTimes(2);
1046+
const capturedEvent = TestClient.instance!.event!;
1047+
for (const [idx, span] of capturedEvent.spans!.entries()) {
1048+
const originalSpan = transaction.spans![idx];
1049+
expect(span).toEqual({ ...originalSpan, data: { version: 'bravo' } });
1050+
}
1051+
});
1052+
9821053
test('calls `beforeSend` and discards the event', () => {
9831054
expect.assertions(4);
9841055

packages/core/test/lib/envelope.test.ts

Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -157,4 +157,71 @@ describe('createSpanEnvelope', () => {
157157
sent_at: expect.any(String),
158158
});
159159
});
160+
161+
it('calls `beforeSendSpan` and uses original span without any changes', () => {
162+
const beforeSendSpan = jest.fn(span => span);
163+
const options = getDefaultTestClientOptions({ dsn: 'https://domain/123', beforeSendSpan });
164+
const client = new TestClient(options);
165+
166+
const span = new SentrySpan({
167+
name: 'test',
168+
isStandalone: true,
169+
startTimestamp: 1,
170+
endTimestamp: 2,
171+
});
172+
173+
const spanEnvelope = createSpanEnvelope([span], client);
174+
175+
expect(beforeSendSpan).toHaveBeenCalled();
176+
177+
const spanItem = spanEnvelope[1][0][1];
178+
expect(spanItem).toEqual({
179+
data: {
180+
'sentry.origin': 'manual',
181+
},
182+
description: 'test',
183+
is_segment: true,
184+
origin: 'manual',
185+
span_id: expect.stringMatching(/^[0-9a-f]{16}$/),
186+
segment_id: spanItem.segment_id,
187+
start_timestamp: 1,
188+
timestamp: 2,
189+
trace_id: expect.stringMatching(/^[0-9a-f]{32}$/),
190+
});
191+
});
192+
193+
it('calls `beforeSendSpan` and uses the modified span', () => {
194+
const beforeSendSpan = jest.fn(span => {
195+
span.description = `mutated description: ${span.description}`;
196+
return span;
197+
});
198+
const options = getDefaultTestClientOptions({ dsn: 'https://domain/123', beforeSendSpan });
199+
const client = new TestClient(options);
200+
201+
const span = new SentrySpan({
202+
name: 'test',
203+
isStandalone: true,
204+
startTimestamp: 1,
205+
endTimestamp: 2,
206+
});
207+
208+
const spanEnvelope = createSpanEnvelope([span], client);
209+
210+
expect(beforeSendSpan).toHaveBeenCalled();
211+
212+
const spanItem = spanEnvelope[1][0][1];
213+
expect(spanItem.description).toEqual({
214+
data: {
215+
'sentry.origin': 'manual',
216+
},
217+
description: 'mutated description: test',
218+
is_segment: true,
219+
origin: 'manual',
220+
span_id: expect.stringMatching(/^[0-9a-f]{16}$/),
221+
segment_id: spanItem.segment_id,
222+
start_timestamp: 1,
223+
timestamp: 2,
224+
trace_id: expect.stringMatching(/^[0-9a-f]{32}$/),
225+
});
226+
});
160227
});

packages/types/src/options.ts

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import type { ErrorEvent, EventHint, TransactionEvent } from './event';
33
import type { Integration } from './integration';
44
import type { CaptureContext } from './scope';
55
import type { SdkMetadata } from './sdkmetadata';
6+
import type { SpanJSON } from './span';
67
import type { StackLineParser, StackParser } from './stacktrace';
78
import type { TracePropagationTargets } from './tracing';
89
import type { SamplingContext } from './transaction';
@@ -281,6 +282,15 @@ export interface ClientOptions<TO extends BaseTransportOptions = BaseTransportOp
281282
*/
282283
beforeSend?: (event: ErrorEvent, hint: EventHint) => PromiseLike<ErrorEvent | null> | ErrorEvent | null;
283284

285+
/**
286+
* An event-processing callback for spans. This allows a span to be modified before it's sent.
287+
*
288+
* Note that you must return a valid span from this callback. If you do not wish to modify the span, simply return
289+
* it at the end.
290+
* @param span The span generated by the SDK.
291+
*/
292+
beforeSendSpan?: (span: SpanJSON) => SpanJSON;
293+
284294
/**
285295
* An event-processing callback for transaction events, guaranteed to be invoked after all other event
286296
* processors. This allows an event to be modified or dropped before it's sent.

0 commit comments

Comments
 (0)