Skip to content

Commit e65c76c

Browse files
chargomeclaude
andauthored
feat(core): Convert scope contexts to segment span attributes in span streaming (#20828)
- Implements `applyScopeToSegmentSpan` in the captureSpan pipeline to convert known scope contexts (set via `scope.setContext()`) to segment span attributes - Only maps browser-relevant contexts in core: `response, profile, cloud_resource, culture, state, angular, react` - Server-only contexts (aws, gcp, missing_instrumentation, trpc) will be handled by processSegmentSpan hooks in their respective packages in a follow-up PR ref #20385 --------- Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent 412036c commit e65c76c

6 files changed

Lines changed: 339 additions & 4 deletions

File tree

dev-packages/cloudflare-integration-tests/suites/public-api/startSpan-streamed/test.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -248,6 +248,14 @@ it('sends a streamed span envelope with correct spans for a manually started spa
248248
type: 'integer',
249249
value: 200,
250250
},
251+
'cloud.provider': {
252+
type: 'string',
253+
value: 'cloudflare',
254+
},
255+
'culture.timezone': {
256+
type: 'string',
257+
value: expect.any(String),
258+
},
251259
'network.protocol.name': {
252260
type: 'string',
253261
value: 'HTTP/1.1',
Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
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+
tracesSampleRate: 1.0,
7+
traceLifecycle: 'stream',
8+
transport: loggingTransport,
9+
});
10+
11+
Sentry.withIsolationScope(isolationScope => {
12+
isolationScope.setContext('response', { status_code: 200 });
13+
isolationScope.setContext('cloud_resource', { 'cloud.provider': 'aws', 'cloud.region': 'us-east-1' });
14+
isolationScope.setContext('profile', { profile_id: 'abc123' });
15+
isolationScope.setContext('react', { version: '18.2.0' });
16+
17+
Sentry.startSpan({ name: 'test-span' }, () => {
18+
// noop
19+
});
20+
});
21+
22+
void Sentry.flush();
Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
import { afterAll, expect, test } from 'vitest';
2+
import { cleanupChildProcesses, createRunner } from '../../../utils/runner';
3+
4+
afterAll(() => {
5+
cleanupChildProcesses();
6+
});
7+
8+
test('scope contexts are converted to segment span attributes in span streaming', async () => {
9+
await createRunner(__dirname, 'scenario.ts')
10+
.expect({
11+
span: container => {
12+
const segmentSpan = container.items.find(s => !!s.is_segment);
13+
expect(segmentSpan).toBeDefined();
14+
15+
const attrs = segmentSpan!.attributes!;
16+
17+
// response context -> http.response.* attributes
18+
expect(attrs['http.response.status_code']).toEqual({ type: 'integer', value: 200 });
19+
20+
// cloud_resource context (dot-notation passthrough)
21+
expect(attrs['cloud.provider']).toEqual({ type: 'string', value: 'aws' });
22+
expect(attrs['cloud.region']).toEqual({ type: 'string', value: 'us-east-1' });
23+
24+
// profile context
25+
expect(attrs['sentry.profile_id']).toEqual({ type: 'string', value: 'abc123' });
26+
27+
// framework version context
28+
expect(attrs['react.version']).toEqual({ type: 'string', value: '18.2.0' });
29+
},
30+
})
31+
.start()
32+
.completed();
33+
});

packages/core/src/tracing/spans/captureSpan.ts

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@ import {
2828
} from '../../utils/spanUtils';
2929
import { getCapturedScopesOnSpan } from '../utils';
3030
import { isStreamedBeforeSendSpanCallback } from './beforeSendSpan';
31+
import { scopeContextsToSpanAttributes } from './scopeContextAttributes';
3132

3233
export type SerializedStreamedSpanWithSegmentSpan = SerializedStreamedSpan & {
3334
_segmentSpan: Span;
@@ -98,9 +99,9 @@ export function captureSpan(span: Span, client: Client): SerializedStreamedSpanW
9899
};
99100
}
100101

101-
function applyScopeToSegmentSpan(_segmentSpanJSON: StreamedSpanJSON, _scopeData: ScopeData): void {
102-
// TODO: Apply contexts data from auto instrumentation to segment span
103-
// This will follow in a separate PR
102+
function applyScopeToSegmentSpan(segmentSpanJSON: StreamedSpanJSON, scopeData: ScopeData): void {
103+
const contextAttributes = scopeContextsToSpanAttributes(scopeData.contexts);
104+
safeSetSpanJSONAttributes(segmentSpanJSON, contextAttributes);
104105
}
105106

106107
/**
Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,72 @@
1+
import type { Contexts } from '../../types-hoist/context';
2+
3+
/**
4+
* Convert known scope contexts set by SDK integrations to span attributes.
5+
* Only maps context keys that are relevant to browser SDKs.
6+
* Server-only contexts (aws, gcp, missing_instrumentation, trpc) are handled
7+
* by processSegmentSpan hooks in their respective packages.
8+
*/
9+
export function scopeContextsToSpanAttributes(contexts: Contexts): Record<string, unknown> {
10+
const attrs: Record<string, unknown> = {};
11+
12+
const { response, profile, cloud_resource, culture, state } = contexts;
13+
14+
if (response) {
15+
if (response.status_code != null) {
16+
attrs['http.response.status_code'] = response.status_code;
17+
}
18+
if (response.body_size != null) {
19+
attrs['http.response.body.size'] = response.body_size;
20+
}
21+
}
22+
23+
if (profile) {
24+
if (profile.profile_id) {
25+
attrs['sentry.profile_id'] = profile.profile_id;
26+
}
27+
if (profile.profiler_id) {
28+
attrs['sentry.profiler_id'] = profile.profiler_id;
29+
}
30+
}
31+
32+
// CloudResourceContext keys are already in dot-notation (OTel resource conventions)
33+
if (cloud_resource) {
34+
for (const [key, value] of Object.entries(cloud_resource)) {
35+
if (value != null) {
36+
attrs[key] = value;
37+
}
38+
}
39+
}
40+
41+
if (culture) {
42+
if (culture.locale) {
43+
attrs['culture.locale'] = culture.locale;
44+
}
45+
if (culture.timezone) {
46+
attrs['culture.timezone'] = culture.timezone;
47+
}
48+
}
49+
50+
if (state?.state && typeof state.state.type === 'string') {
51+
attrs['state.type'] = state.state.type;
52+
}
53+
54+
// Framework version contexts
55+
const angular = contexts['angular'];
56+
if (angular) {
57+
const version = angular['version'];
58+
if (typeof version === 'string' || typeof version === 'number') {
59+
attrs['angular.version'] = version;
60+
}
61+
}
62+
63+
const react = contexts['react'];
64+
if (react) {
65+
const version = react['version'];
66+
if (typeof version === 'string' || typeof version === 'number') {
67+
attrs['react.version'] = version;
68+
}
69+
}
70+
71+
return attrs;
72+
}

packages/core/test/lib/tracing/spans/captureSpan.test.ts

Lines changed: 200 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import { describe, expect, it, vi } from 'vitest';
2-
import type { StreamedSpanJSON } from '../../../../src';
2+
import type { Contexts, StreamedSpanJSON } from '../../../../src';
33
import {
44
captureSpan,
55
SEMANTIC_ATTRIBUTE_SENTRY_ENVIRONMENT,
@@ -23,6 +23,7 @@ import {
2323
withStreamedSpan,
2424
} from '../../../../src';
2525
import { inferSpanDataFromOtelAttributes, safeSetSpanJSONAttributes } from '../../../../src/tracing/spans/captureSpan';
26+
import { scopeContextsToSpanAttributes } from '../../../../src/tracing/spans/scopeContextAttributes';
2627
import { getDefaultTestClientOptions, TestClient } from '../../../mocks/client';
2728

2829
describe('captureSpan', () => {
@@ -660,3 +661,201 @@ describe('inferSpanDataFromOtelAttributes', () => {
660661
expect(spanJSON.name).toBe('test');
661662
});
662663
});
664+
665+
describe('scopeContextsToSpanAttributes', () => {
666+
it('returns empty object for empty contexts', () => {
667+
expect(scopeContextsToSpanAttributes({})).toEqual({});
668+
});
669+
670+
it('ignores unknown context names', () => {
671+
const contexts: Contexts = { my_custom_context: { foo: 'bar' } };
672+
expect(scopeContextsToSpanAttributes(contexts)).toEqual({});
673+
});
674+
675+
describe('response context', () => {
676+
it('maps status_code and body_size', () => {
677+
const contexts: Contexts = { response: { status_code: 200, body_size: 1024 } };
678+
expect(scopeContextsToSpanAttributes(contexts)).toEqual({
679+
'http.response.status_code': 200,
680+
'http.response.body.size': 1024,
681+
});
682+
});
683+
684+
it('omits missing fields', () => {
685+
const contexts: Contexts = { response: { status_code: 404 } };
686+
expect(scopeContextsToSpanAttributes(contexts)).toEqual({
687+
'http.response.status_code': 404,
688+
});
689+
});
690+
});
691+
692+
describe('profile context', () => {
693+
it('maps profile_id to sentry.profile_id', () => {
694+
const contexts: Contexts = { profile: { profile_id: 'abc123' } };
695+
expect(scopeContextsToSpanAttributes(contexts)).toEqual({
696+
'sentry.profile_id': 'abc123',
697+
});
698+
});
699+
700+
it('maps profiler_id to sentry.profiler_id', () => {
701+
const contexts: Contexts = { profile: { profile_id: '', profiler_id: 'prof-1' } };
702+
expect(scopeContextsToSpanAttributes(contexts)).toEqual({
703+
'sentry.profiler_id': 'prof-1',
704+
});
705+
});
706+
707+
it('produces no attributes for empty profile context', () => {
708+
const contexts: Contexts = { profile: { profile_id: '' } };
709+
expect(scopeContextsToSpanAttributes(contexts)).toEqual({});
710+
});
711+
});
712+
713+
describe('cloud_resource context', () => {
714+
it('passes through dot-notation keys', () => {
715+
const contexts: Contexts = {
716+
cloud_resource: { 'cloud.provider': 'cloudflare', 'cloud.region': 'us-east-1' },
717+
};
718+
expect(scopeContextsToSpanAttributes(contexts)).toEqual({
719+
'cloud.provider': 'cloudflare',
720+
'cloud.region': 'us-east-1',
721+
});
722+
});
723+
724+
it('filters out null values', () => {
725+
const contexts: Contexts = {
726+
cloud_resource: { 'cloud.provider': 'aws', 'cloud.region': undefined },
727+
};
728+
expect(scopeContextsToSpanAttributes(contexts)).toEqual({
729+
'cloud.provider': 'aws',
730+
});
731+
});
732+
});
733+
734+
describe('culture context', () => {
735+
it('maps locale and timezone', () => {
736+
const contexts: Contexts = { culture: { locale: 'en-US', timezone: 'America/New_York' } };
737+
expect(scopeContextsToSpanAttributes(contexts)).toEqual({
738+
'culture.locale': 'en-US',
739+
'culture.timezone': 'America/New_York',
740+
});
741+
});
742+
743+
it('omits missing fields', () => {
744+
const contexts: Contexts = { culture: { timezone: 'UTC' } };
745+
expect(scopeContextsToSpanAttributes(contexts)).toEqual({
746+
'culture.timezone': 'UTC',
747+
});
748+
});
749+
});
750+
751+
describe('state context', () => {
752+
it('maps state.type only', () => {
753+
const contexts: Contexts = {
754+
state: { state: { type: 'redux', value: { counter: 42, user: { name: 'test' } } } },
755+
};
756+
expect(scopeContextsToSpanAttributes(contexts)).toEqual({
757+
'state.type': 'redux',
758+
});
759+
});
760+
761+
it('does not map state.value', () => {
762+
const contexts: Contexts = {
763+
state: { state: { type: 'pinia', value: { items: [1, 2, 3] } } },
764+
};
765+
const attrs = scopeContextsToSpanAttributes(contexts);
766+
expect(attrs).not.toHaveProperty('state.value');
767+
expect(attrs).not.toHaveProperty('state.state.value');
768+
});
769+
770+
it('handles missing state.state gracefully', () => {
771+
const contexts: Contexts = { state: {} as any };
772+
expect(scopeContextsToSpanAttributes(contexts)).toEqual({});
773+
});
774+
});
775+
776+
describe('framework version contexts', () => {
777+
it('maps angular.version', () => {
778+
const contexts: Contexts = { angular: { version: 17 } };
779+
expect(scopeContextsToSpanAttributes(contexts)).toEqual({
780+
'angular.version': 17,
781+
});
782+
});
783+
784+
it('maps react.version', () => {
785+
const contexts: Contexts = { react: { version: '18.2.0' } };
786+
expect(scopeContextsToSpanAttributes(contexts)).toEqual({
787+
'react.version': '18.2.0',
788+
});
789+
});
790+
});
791+
792+
it('maps multiple contexts at once', () => {
793+
const contexts: Contexts = {
794+
response: { status_code: 200 },
795+
culture: { timezone: 'UTC' },
796+
react: { version: '18.2.0' },
797+
};
798+
expect(scopeContextsToSpanAttributes(contexts)).toEqual({
799+
'http.response.status_code': 200,
800+
'culture.timezone': 'UTC',
801+
'react.version': '18.2.0',
802+
});
803+
});
804+
});
805+
806+
describe('applyScopeToSegmentSpan integration', () => {
807+
it('applies scope contexts to segment span attributes', () => {
808+
const client = new TestClient(
809+
getDefaultTestClientOptions({
810+
dsn: 'https://dsn@ingest.f00.f00/1',
811+
tracesSampleRate: 1,
812+
release: '1.0.0',
813+
environment: 'production',
814+
}),
815+
);
816+
817+
const span = withScope(scope => {
818+
scope.setClient(client);
819+
scope.setContext('response', { status_code: 201 });
820+
scope.setContext('culture', { timezone: 'Europe/Berlin' });
821+
822+
const span = startInactiveSpan({ name: 'test-span' });
823+
span.end();
824+
return span;
825+
});
826+
827+
const serialized = captureSpan(span, client);
828+
829+
expect(serialized.attributes).toEqual(
830+
expect.objectContaining({
831+
'http.response.status_code': { type: 'integer', value: 201 },
832+
'culture.timezone': { type: 'string', value: 'Europe/Berlin' },
833+
}),
834+
);
835+
});
836+
837+
it('does not apply scope contexts to child spans', () => {
838+
const client = new TestClient(
839+
getDefaultTestClientOptions({
840+
dsn: 'https://dsn@ingest.f00.f00/1',
841+
tracesSampleRate: 1,
842+
release: '1.0.0',
843+
environment: 'production',
844+
}),
845+
);
846+
847+
const serializedChild = withScope(scope => {
848+
scope.setClient(client);
849+
scope.setContext('response', { status_code: 200 });
850+
851+
return startSpan({ name: 'segment' }, () => {
852+
const childSpan = startInactiveSpan({ name: 'child' });
853+
childSpan.end();
854+
return captureSpan(childSpan, client);
855+
});
856+
});
857+
858+
expect(serializedChild?.is_segment).toBe(false);
859+
expect(serializedChild?.attributes).not.toHaveProperty('http.response.status_code');
860+
});
861+
});

0 commit comments

Comments
 (0)