Skip to content

Commit 73ad7b6

Browse files
committed
tests
1 parent 58f793c commit 73ad7b6

3 files changed

Lines changed: 92 additions & 24 deletions

File tree

  • dev-packages/node-integration-tests/suites/tracing/http-client-spans/fetch-basic-streamed
  • packages/opentelemetry/src
Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
import * as Sentry from '@sentry/node';
2+
import { loggingTransport } from '@sentry-internal/node-integration-tests';
3+
4+
Sentry.init({
5+
dsn: 'https://public@dsn.ingest.sentry.io/1337',
6+
release: '1.0',
7+
tracesSampleRate: 1.0,
8+
traceLifecycle: 'stream',
9+
transport: loggingTransport,
10+
});
11+
12+
async function run(): Promise<void> {
13+
await Sentry.startSpan({ name: 'test_transaction' }, async () => {
14+
await fetch(`${process.env.SERVER_URL}/api/v0`);
15+
});
16+
17+
await Sentry.flush();
18+
}
19+
20+
void run();
Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
import { createTestServer } from '@sentry-internal/test-utils';
2+
import { expect, test } from 'vitest';
3+
import { createRunner } from '../../../../utils/runner';
4+
5+
test('captures streamed spans with sentry.op for outgoing fetch requests', async () => {
6+
expect.assertions(2);
7+
8+
const [SERVER_URL, closeTestServer] = await createTestServer()
9+
.get('/api/v0', () => {
10+
// Just ensure we're called
11+
expect(true).toBe(true);
12+
})
13+
.start();
14+
15+
await createRunner(__dirname, 'scenario.ts')
16+
.withEnv({ SERVER_URL })
17+
.expect({
18+
span: container => {
19+
const httpClientSpan = container.items.find(
20+
item =>
21+
item.attributes?.['sentry.op']?.type === 'string' && item.attributes['sentry.op'].value === 'http.client',
22+
);
23+
24+
expect(httpClientSpan).toBeDefined();
25+
},
26+
})
27+
.start()
28+
.completed();
29+
closeTestServer();
30+
});

packages/opentelemetry/src/sampler.ts

Lines changed: 42 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -88,33 +88,52 @@ export class SentrySampler implements Sampler {
8888
// Non-root-spans simply inherit the sampling decision from their parent.
8989
if (!isRootSpan) {
9090
if (this._isSpanStreaming) {
91+
// We only record client outcomes for child spans when streaming
92+
if (!parentSampled) {
93+
const parentSegmentIgnored = parentContext?.traceState?.get(SENTRY_TRACE_STATE_SEGMENT_IGNORED) === '1';
94+
this._client.recordDroppedEvent(parentSegmentIgnored ? 'ignored' : 'sample_rate', 'span');
95+
return wrapSamplingDecision({
96+
decision: SamplingDecision.NOT_RECORD,
97+
context,
98+
spanAttributes,
99+
});
100+
}
101+
91102
// `ignoreSpans` is only applied at span start for streamed spans.
92103
// Static spans/transactions get filtered at transaction end.
93-
// Likewise, we only record client outcomes for child spans when streaming
94-
if (parentSampled) {
95-
if (ignoreSpans?.length) {
96-
const { description: inferredChildName, op: childOp } = inferSpanData(spanName, spanAttributes, spanKind);
97-
if (
98-
shouldIgnoreSpan(
99-
{ description: inferredChildName, op: spanAttributes[SEMANTIC_ATTRIBUTE_SENTRY_OP] ?? childOp },
100-
ignoreSpans,
101-
)
102-
) {
103-
this._client.recordDroppedEvent('ignored', 'span');
104-
return wrapSamplingDecision({
105-
decision: SamplingDecision.NOT_RECORD,
106-
context,
107-
spanAttributes,
108-
ignoredChildSpan: true,
109-
});
110-
}
104+
const { description: inferredChildName, op: childOp } = inferSpanData(spanName, spanAttributes, spanKind);
105+
if (ignoreSpans?.length) {
106+
if (
107+
shouldIgnoreSpan(
108+
{ description: inferredChildName, op: spanAttributes[SEMANTIC_ATTRIBUTE_SENTRY_OP] ?? childOp },
109+
ignoreSpans,
110+
)
111+
) {
112+
this._client.recordDroppedEvent('ignored', 'span');
113+
return wrapSamplingDecision({
114+
decision: SamplingDecision.NOT_RECORD,
115+
context,
116+
spanAttributes,
117+
ignoredChildSpan: true,
118+
});
111119
}
112120
}
113121

114-
if (!parentSampled) {
115-
const parentSegmentIgnored = parentContext?.traceState?.get(SENTRY_TRACE_STATE_SEGMENT_IGNORED) === '1';
116-
this._client.recordDroppedEvent(parentSegmentIgnored ? 'ignored' : 'sample_rate', 'span');
117-
}
122+
// For streamed spans, we need to propagate the inferred `sentry.op` via the SamplingResult attributes
123+
// so that OTel sets it on the span at creation time. This is necessary because the streaming serialization
124+
// path (`spanToStreamedSpanJSON`) passes raw OTel attributes through without inference, unlike the
125+
// non-streamed path where the `SentrySpanExporter` infers the op during export.
126+
const inferredOp = spanAttributes[SEMANTIC_ATTRIBUTE_SENTRY_OP] ?? childOp;
127+
return {
128+
...wrapSamplingDecision({
129+
decision: SamplingDecision.RECORD_AND_SAMPLED,
130+
context,
131+
spanAttributes,
132+
}),
133+
attributes: {
134+
...(inferredOp ? { [SEMANTIC_ATTRIBUTE_SENTRY_OP]: inferredOp } : {}),
135+
},
136+
};
118137
}
119138

120139
return wrapSamplingDecision({
@@ -224,8 +243,7 @@ export class SentrySampler implements Sampler {
224243
attributes: {
225244
// We set the sample rate on the span when a local sample rate was applied to better understand how traces were sampled in Sentry
226245
[SEMANTIC_ATTRIBUTE_SENTRY_SAMPLE_RATE]: localSampleRateWasApplied ? sampleRate : undefined,
227-
// For streamed spans, propagate the inferred op onto the span so it's included in the serialized attributes.
228-
// Non-streamed spans get the op set during export in the SentrySpanExporter.
246+
// See comment above in the `!isRootSpan` branch for why we propagate the op for streamed spans.
229247
...(this._isSpanStreaming && op ? { [SEMANTIC_ATTRIBUTE_SENTRY_OP]: op } : {}),
230248
},
231249
};

0 commit comments

Comments
 (0)