Skip to content

Commit f1d1a16

Browse files
authored
feat(core): Add beforeSendSpan hook (#11886) (#11918)
1 parent 6c37df1 commit f1d1a16

File tree

7 files changed

+285
-8
lines changed

7 files changed

+285
-8
lines changed

packages/core/src/baseclient.ts

Lines changed: 17 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@ import type {
2424
Span,
2525
SpanAttributes,
2626
SpanContextData,
27+
SpanJSON,
2728
StartSpanOptions,
2829
TransactionEvent,
2930
Transport,
@@ -896,14 +897,27 @@ function processBeforeSend(
896897
event: Event,
897898
hint: EventHint,
898899
): PromiseLike<Event | null> | Event | null {
899-
const { beforeSend, beforeSendTransaction } = options;
900+
const { beforeSend, beforeSendTransaction, beforeSendSpan } = options;
900901

901902
if (isErrorEvent(event) && beforeSend) {
902903
return beforeSend(event, hint);
903904
}
904905

905-
if (isTransactionEvent(event) && beforeSendTransaction) {
906-
return beforeSendTransaction(event, hint);
906+
if (isTransactionEvent(event)) {
907+
if (event.spans && beforeSendSpan) {
908+
const processedSpans: SpanJSON[] = [];
909+
for (const span of event.spans) {
910+
const processedSpan = beforeSendSpan(span);
911+
if (processedSpan) {
912+
processedSpans.push(processedSpan);
913+
}
914+
}
915+
event.spans = processedSpans;
916+
}
917+
918+
if (beforeSendTransaction) {
919+
return beforeSendTransaction(event, hint);
920+
}
907921
}
908922

909923
return event;

packages/core/src/envelope.ts

Lines changed: 20 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,19 @@ 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 beforeSendSpan = client && client.getOptions().beforeSendSpan;
119+
const convertToSpanJSON = beforeSendSpan
120+
? (span: SentrySpan) => beforeSendSpan(spanToJSON(span) as SpanJSON)
121+
: (span: SentrySpan) => spanToJSON(span);
122+
123+
const items: SpanItem[] = [];
124+
for (const span of spans) {
125+
const spanJson = convertToSpanJSON(span);
126+
if (spanJson) {
127+
items.push(createSpanEnvelopeItem(spanJson));
128+
}
129+
}
130+
113131
return createEnvelope<SpanEnvelope>(headers, items);
114132
}

packages/core/src/tracing/sentrySpan.ts

Lines changed: 15 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -97,12 +97,12 @@ export class SentrySpan implements Span {
9797

9898
this._events = [];
9999

100+
this._isStandaloneSpan = spanContext.isStandalone;
101+
100102
// If the span is already ended, ensure we finalize the span immediately
101103
if (this._endTime) {
102104
this._onSpanEnded();
103105
}
104-
105-
this._isStandaloneSpan = spanContext.isStandalone;
106106
}
107107

108108
/** @inheritdoc */
@@ -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

@@ -357,12 +357,24 @@ function isStandaloneSpan(span: Span): boolean {
357357
return span instanceof SentrySpan && span.isStandaloneSpan();
358358
}
359359

360+
/**
361+
* Sends a `SpanEnvelope`.
362+
*
363+
* Note: If the envelope's spans are dropped, e.g. via `beforeSendSpan`,
364+
* the envelope will not be sent either.
365+
*/
360366
function sendSpanEnvelope(envelope: SpanEnvelope): void {
361367
const client = getClient();
362368
if (!client) {
363369
return;
364370
}
365371

372+
const spanItems = envelope[1];
373+
if (!spanItems || spanItems.length === 0) {
374+
client.recordDroppedEvent('before_send', 'span');
375+
return;
376+
}
377+
366378
const transport = client.getTransport();
367379
if (transport) {
368380
transport.send(envelope).then(null, reason => {

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

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

981+
test('calls `beforeSendSpan` and uses original spans without any changes', () => {
982+
expect.assertions(2);
983+
984+
const beforeSendSpan = jest.fn(span => span);
985+
const options = getDefaultTestClientOptions({ dsn: PUBLIC_DSN, beforeSendSpan });
986+
const client = new TestClient(options);
987+
988+
const transaction: Event = {
989+
transaction: '/cats/are/great',
990+
type: 'transaction',
991+
spans: [
992+
{
993+
description: 'first span',
994+
span_id: '9e15bf99fbe4bc80',
995+
start_timestamp: 1591603196.637835,
996+
trace_id: '86f39e84263a4de99c326acab3bfe3bd',
997+
},
998+
{
999+
description: 'second span',
1000+
span_id: 'aa554c1f506b0783',
1001+
start_timestamp: 1591603196.637835,
1002+
trace_id: '86f39e84263a4de99c326acab3bfe3bd',
1003+
},
1004+
],
1005+
};
1006+
client.captureEvent(transaction);
1007+
1008+
expect(beforeSendSpan).toHaveBeenCalledTimes(2);
1009+
const capturedEvent = TestClient.instance!.event!;
1010+
expect(capturedEvent.spans).toEqual(transaction.spans);
1011+
});
1012+
9811013
test('calls `beforeSend` and uses the modified event', () => {
9821014
expect.assertions(2);
9831015

@@ -1010,6 +1042,45 @@ describe('BaseClient', () => {
10101042
expect(TestClient.instance!.event!.transaction).toBe('/adopt/dont/shop');
10111043
});
10121044

1045+
test('calls `beforeSendSpan` and uses the modified spans', () => {
1046+
expect.assertions(3);
1047+
1048+
const beforeSendSpan = jest.fn(span => {
1049+
span.data = { version: 'bravo' };
1050+
return span;
1051+
});
1052+
1053+
const options = getDefaultTestClientOptions({ dsn: PUBLIC_DSN, beforeSendSpan });
1054+
const client = new TestClient(options);
1055+
const transaction: Event = {
1056+
transaction: '/cats/are/great',
1057+
type: 'transaction',
1058+
spans: [
1059+
{
1060+
description: 'first span',
1061+
span_id: '9e15bf99fbe4bc80',
1062+
start_timestamp: 1591603196.637835,
1063+
trace_id: '86f39e84263a4de99c326acab3bfe3bd',
1064+
},
1065+
{
1066+
description: 'second span',
1067+
span_id: 'aa554c1f506b0783',
1068+
start_timestamp: 1591603196.637835,
1069+
trace_id: '86f39e84263a4de99c326acab3bfe3bd',
1070+
},
1071+
],
1072+
};
1073+
1074+
client.captureEvent(transaction);
1075+
1076+
expect(beforeSendSpan).toHaveBeenCalledTimes(2);
1077+
const capturedEvent = TestClient.instance!.event!;
1078+
for (const [idx, span] of capturedEvent.spans!.entries()) {
1079+
const originalSpan = transaction.spans![idx];
1080+
expect(span).toEqual({ ...originalSpan, data: { version: 'bravo' } });
1081+
}
1082+
});
1083+
10131084
test('calls `beforeSend` and discards the event', () => {
10141085
expect.assertions(4);
10151086

@@ -1048,6 +1119,38 @@ describe('BaseClient', () => {
10481119
expect(loggerWarnSpy).toBeCalledWith('before send for type `transaction` returned `null`, will not send event.');
10491120
});
10501121

1122+
test('calls `beforeSendSpan` and discards the span', () => {
1123+
expect.assertions(2);
1124+
1125+
const beforeSendSpan = jest.fn(() => null);
1126+
const options = getDefaultTestClientOptions({ dsn: PUBLIC_DSN, beforeSendSpan });
1127+
const client = new TestClient(options);
1128+
1129+
const transaction: Event = {
1130+
transaction: '/cats/are/great',
1131+
type: 'transaction',
1132+
spans: [
1133+
{
1134+
description: 'first span',
1135+
span_id: '9e15bf99fbe4bc80',
1136+
start_timestamp: 1591603196.637835,
1137+
trace_id: '86f39e84263a4de99c326acab3bfe3bd',
1138+
},
1139+
{
1140+
description: 'second span',
1141+
span_id: 'aa554c1f506b0783',
1142+
start_timestamp: 1591603196.637835,
1143+
trace_id: '86f39e84263a4de99c326acab3bfe3bd',
1144+
},
1145+
],
1146+
};
1147+
client.captureEvent(transaction);
1148+
1149+
expect(beforeSendSpan).toHaveBeenCalledTimes(2);
1150+
const capturedEvent = TestClient.instance!.event!;
1151+
expect(capturedEvent.spans).toHaveLength(0);
1152+
});
1153+
10511154
test('calls `beforeSend` and logs info about invalid return value', () => {
10521155
const invalidValues = [undefined, false, true, [], 1];
10531156
expect.assertions(invalidValues.length * 3);

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).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/core/test/lib/tracing/sentrySpan.test.ts

Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,9 @@
11
import { timestampInSeconds } from '@sentry/utils';
2+
import { setCurrentClient } from '../../../src';
23
import { SentrySpan } from '../../../src/tracing/sentrySpan';
34
import { SPAN_STATUS_ERROR } from '../../../src/tracing/spanstatus';
45
import { TRACE_FLAG_NONE, TRACE_FLAG_SAMPLED, spanToJSON } from '../../../src/utils/spanUtils';
6+
import { TestClient, getDefaultTestClientOptions } from '../../mocks/client';
57

68
describe('SentrySpan', () => {
79
describe('name', () => {
@@ -88,6 +90,57 @@ describe('SentrySpan', () => {
8890
span.end();
8991
expect(spanToJSON(span).timestamp).toBeGreaterThan(1);
9092
});
93+
94+
test('sends the span if `beforeSendSpan` does not modify the span ', () => {
95+
const beforeSendSpan = jest.fn(span => span);
96+
const client = new TestClient(
97+
getDefaultTestClientOptions({
98+
dsn: 'https://username@domain/123',
99+
enableSend: true,
100+
beforeSendSpan,
101+
}),
102+
);
103+
setCurrentClient(client);
104+
105+
// @ts-expect-error Accessing private transport API
106+
const mockSend = jest.spyOn(client._transport, 'send');
107+
const span = new SentrySpan({
108+
name: 'test',
109+
isStandalone: true,
110+
startTimestamp: 1,
111+
endTimestamp: 2,
112+
sampled: true,
113+
});
114+
span.end();
115+
expect(mockSend).toHaveBeenCalled();
116+
});
117+
118+
test('does not send the span if `beforeSendSpan` drops the span', () => {
119+
const beforeSendSpan = jest.fn(() => null);
120+
const client = new TestClient(
121+
getDefaultTestClientOptions({
122+
dsn: 'https://username@domain/123',
123+
enableSend: true,
124+
beforeSendSpan,
125+
}),
126+
);
127+
setCurrentClient(client);
128+
129+
const recordDroppedEventSpy = jest.spyOn(client, 'recordDroppedEvent');
130+
// @ts-expect-error Accessing private transport API
131+
const mockSend = jest.spyOn(client._transport, 'send');
132+
const span = new SentrySpan({
133+
name: 'test',
134+
isStandalone: true,
135+
startTimestamp: 1,
136+
endTimestamp: 2,
137+
sampled: true,
138+
});
139+
span.end();
140+
141+
expect(mockSend).not.toHaveBeenCalled();
142+
expect(recordDroppedEventSpy).toHaveBeenCalledWith('before_send', 'span');
143+
});
91144
});
92145

93146
describe('end', () => {

0 commit comments

Comments
 (0)