diff --git a/dev-packages/cloudflare-integration-tests/suites/public-api/startSpan-streamed/test.ts b/dev-packages/cloudflare-integration-tests/suites/public-api/startSpan-streamed/test.ts index d9c18431202b..b59fd3d6991c 100644 --- a/dev-packages/cloudflare-integration-tests/suites/public-api/startSpan-streamed/test.ts +++ b/dev-packages/cloudflare-integration-tests/suites/public-api/startSpan-streamed/test.ts @@ -243,6 +243,14 @@ it('sends a streamed span envelope with correct spans for a manually started spa type: 'integer', value: 200, }, + 'cloud.provider': { + type: 'string', + value: 'cloudflare', + }, + 'culture.timezone': { + type: 'string', + value: expect.any(String), + }, 'network.protocol.name': { type: 'string', value: 'HTTP/1.1', diff --git a/dev-packages/node-integration-tests/suites/context-streamed/scope-contexts/scenario.ts b/dev-packages/node-integration-tests/suites/context-streamed/scope-contexts/scenario.ts new file mode 100644 index 000000000000..f29857444e4d --- /dev/null +++ b/dev-packages/node-integration-tests/suites/context-streamed/scope-contexts/scenario.ts @@ -0,0 +1,22 @@ +import * as Sentry from '@sentry/node'; +import { loggingTransport } from '@sentry-internal/node-integration-tests'; + +Sentry.init({ + dsn: 'https://public@dsn.ingest.sentry.io/1337', + tracesSampleRate: 1.0, + traceLifecycle: 'stream', + transport: loggingTransport, +}); + +Sentry.withIsolationScope(isolationScope => { + isolationScope.setContext('response', { status_code: 200 }); + isolationScope.setContext('cloud_resource', { 'cloud.provider': 'aws', 'cloud.region': 'us-east-1' }); + isolationScope.setContext('profile', { profile_id: 'abc123' }); + isolationScope.setContext('react', { version: '18.2.0' }); + + Sentry.startSpan({ name: 'test-span' }, () => { + // noop + }); +}); + +void Sentry.flush(); diff --git a/dev-packages/node-integration-tests/suites/context-streamed/scope-contexts/test.ts b/dev-packages/node-integration-tests/suites/context-streamed/scope-contexts/test.ts new file mode 100644 index 000000000000..c344fcf5052f --- /dev/null +++ b/dev-packages/node-integration-tests/suites/context-streamed/scope-contexts/test.ts @@ -0,0 +1,33 @@ +import { afterAll, expect, test } from 'vitest'; +import { cleanupChildProcesses, createRunner } from '../../../utils/runner'; + +afterAll(() => { + cleanupChildProcesses(); +}); + +test('scope contexts are converted to segment span attributes in span streaming', async () => { + await createRunner(__dirname, 'scenario.ts') + .expect({ + span: container => { + const segmentSpan = container.items.find(s => !!s.is_segment); + expect(segmentSpan).toBeDefined(); + + const attrs = segmentSpan!.attributes!; + + // response context -> http.response.* attributes + expect(attrs['http.response.status_code']).toEqual({ type: 'integer', value: 200 }); + + // cloud_resource context (dot-notation passthrough) + expect(attrs['cloud.provider']).toEqual({ type: 'string', value: 'aws' }); + expect(attrs['cloud.region']).toEqual({ type: 'string', value: 'us-east-1' }); + + // profile context + expect(attrs['sentry.profile_id']).toEqual({ type: 'string', value: 'abc123' }); + + // framework version context + expect(attrs['react.version']).toEqual({ type: 'string', value: '18.2.0' }); + }, + }) + .start() + .completed(); +}); diff --git a/packages/core/src/tracing/spans/captureSpan.ts b/packages/core/src/tracing/spans/captureSpan.ts index bed3f1790740..2bb86f8cb0d0 100644 --- a/packages/core/src/tracing/spans/captureSpan.ts +++ b/packages/core/src/tracing/spans/captureSpan.ts @@ -27,6 +27,7 @@ import { } from '../../utils/spanUtils'; import { getCapturedScopesOnSpan } from '../utils'; import { isStreamedBeforeSendSpanCallback } from './beforeSendSpan'; +import { scopeContextsToSpanAttributes } from './scopeContextAttributes'; export type SerializedStreamedSpanWithSegmentSpan = SerializedStreamedSpan & { _segmentSpan: Span; @@ -96,9 +97,9 @@ export function captureSpan(span: Span, client: Client): SerializedStreamedSpanW }; } -function applyScopeToSegmentSpan(_segmentSpanJSON: StreamedSpanJSON, _scopeData: ScopeData): void { - // TODO: Apply contexts data from auto instrumentation to segment span - // This will follow in a separate PR +function applyScopeToSegmentSpan(segmentSpanJSON: StreamedSpanJSON, scopeData: ScopeData): void { + const contextAttributes = scopeContextsToSpanAttributes(scopeData.contexts); + safeSetSpanJSONAttributes(segmentSpanJSON, contextAttributes); } /** diff --git a/packages/core/src/tracing/spans/scopeContextAttributes.ts b/packages/core/src/tracing/spans/scopeContextAttributes.ts new file mode 100644 index 000000000000..17f58f53fdde --- /dev/null +++ b/packages/core/src/tracing/spans/scopeContextAttributes.ts @@ -0,0 +1,75 @@ +import type { Contexts } from '../../types-hoist/context'; + +/** + * Convert known scope contexts set by SDK integrations to span attributes. + * Only maps context keys that are relevant to browser SDKs. + * Server-only contexts (aws, gcp, missing_instrumentation, trpc) are handled + * by processSegmentSpan hooks in their respective packages. + */ +export function scopeContextsToSpanAttributes(contexts: Contexts): Record { + const attrs: Record = {}; + + const { response, profile, cloud_resource, culture, state } = contexts; + + if (response) { + if (response.status_code != null) { + attrs['http.response.status_code'] = response.status_code; + } + if (response.body_size != null) { + attrs['http.response.body.size'] = response.body_size; + } + } + + if (profile) { + if (profile.profile_id) { + attrs['sentry.profile_id'] = profile.profile_id; + } + if (profile.profiler_id) { + attrs['sentry.profiler_id'] = profile.profiler_id; + } + if (profile.start_timestamp != null) { + attrs['profile.start_timestamp'] = profile.start_timestamp; + } + } + + // CloudResourceContext keys are already in dot-notation (OTel resource conventions) + if (cloud_resource) { + for (const [key, value] of Object.entries(cloud_resource)) { + if (value != null) { + attrs[key] = value; + } + } + } + + if (culture) { + if (culture.locale) { + attrs['culture.locale'] = culture.locale; + } + if (culture.timezone) { + attrs['culture.timezone'] = culture.timezone; + } + } + + if (state?.state && typeof state.state.type === 'string') { + attrs['state.type'] = state.state.type; + } + + // Framework version contexts + const angular = contexts['angular']; + if (angular) { + const version = angular['version']; + if (typeof version === 'string' || typeof version === 'number') { + attrs['angular.version'] = version; + } + } + + const react = contexts['react']; + if (react) { + const version = react['version']; + if (typeof version === 'string' || typeof version === 'number') { + attrs['react.version'] = version; + } + } + + return attrs; +} diff --git a/packages/core/test/lib/tracing/spans/captureSpan.test.ts b/packages/core/test/lib/tracing/spans/captureSpan.test.ts index 186f7f23a536..e1d4861a110f 100644 --- a/packages/core/test/lib/tracing/spans/captureSpan.test.ts +++ b/packages/core/test/lib/tracing/spans/captureSpan.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it, vi } from 'vitest'; -import type { StreamedSpanJSON } from '../../../../src'; +import type { Contexts, StreamedSpanJSON } from '../../../../src'; import { captureSpan, SEMANTIC_ATTRIBUTE_SENTRY_ENVIRONMENT, @@ -22,6 +22,7 @@ import { withStreamedSpan, } from '../../../../src'; import { inferSpanDataFromOtelAttributes, safeSetSpanJSONAttributes } from '../../../../src/tracing/spans/captureSpan'; +import { scopeContextsToSpanAttributes } from '../../../../src/tracing/spans/scopeContextAttributes'; import { getDefaultTestClientOptions, TestClient } from '../../../mocks/client'; describe('captureSpan', () => { @@ -638,3 +639,209 @@ describe('inferSpanDataFromOtelAttributes', () => { expect(spanJSON.name).toBe('test'); }); }); + +describe('scopeContextsToSpanAttributes', () => { + it('returns empty object for empty contexts', () => { + expect(scopeContextsToSpanAttributes({})).toEqual({}); + }); + + it('ignores unknown context names', () => { + const contexts: Contexts = { my_custom_context: { foo: 'bar' } }; + expect(scopeContextsToSpanAttributes(contexts)).toEqual({}); + }); + + describe('response context', () => { + it('maps status_code and body_size', () => { + const contexts: Contexts = { response: { status_code: 200, body_size: 1024 } }; + expect(scopeContextsToSpanAttributes(contexts)).toEqual({ + 'http.response.status_code': 200, + 'http.response.body.size': 1024, + }); + }); + + it('omits missing fields', () => { + const contexts: Contexts = { response: { status_code: 404 } }; + expect(scopeContextsToSpanAttributes(contexts)).toEqual({ + 'http.response.status_code': 404, + }); + }); + }); + + describe('profile context', () => { + it('maps profile_id to sentry.profile_id', () => { + const contexts: Contexts = { profile: { profile_id: 'abc123' } }; + expect(scopeContextsToSpanAttributes(contexts)).toEqual({ + 'sentry.profile_id': 'abc123', + }); + }); + + it('maps profiler_id to sentry.profiler_id', () => { + const contexts: Contexts = { profile: { profile_id: '', profiler_id: 'prof-1' } }; + expect(scopeContextsToSpanAttributes(contexts)).toEqual({ + 'sentry.profiler_id': 'prof-1', + }); + }); + + it('maps start_timestamp', () => { + const contexts: Contexts = { profile: { profile_id: 'abc', start_timestamp: 1234567890 } }; + expect(scopeContextsToSpanAttributes(contexts)).toEqual({ + 'sentry.profile_id': 'abc', + 'profile.start_timestamp': 1234567890, + }); + }); + + it('produces no attributes for empty profile context', () => { + const contexts: Contexts = { profile: { profile_id: '' } }; + expect(scopeContextsToSpanAttributes(contexts)).toEqual({}); + }); + }); + + describe('cloud_resource context', () => { + it('passes through dot-notation keys', () => { + const contexts: Contexts = { + cloud_resource: { 'cloud.provider': 'cloudflare', 'cloud.region': 'us-east-1' }, + }; + expect(scopeContextsToSpanAttributes(contexts)).toEqual({ + 'cloud.provider': 'cloudflare', + 'cloud.region': 'us-east-1', + }); + }); + + it('filters out null values', () => { + const contexts: Contexts = { + cloud_resource: { 'cloud.provider': 'aws', 'cloud.region': undefined }, + }; + expect(scopeContextsToSpanAttributes(contexts)).toEqual({ + 'cloud.provider': 'aws', + }); + }); + }); + + describe('culture context', () => { + it('maps locale and timezone', () => { + const contexts: Contexts = { culture: { locale: 'en-US', timezone: 'America/New_York' } }; + expect(scopeContextsToSpanAttributes(contexts)).toEqual({ + 'culture.locale': 'en-US', + 'culture.timezone': 'America/New_York', + }); + }); + + it('omits missing fields', () => { + const contexts: Contexts = { culture: { timezone: 'UTC' } }; + expect(scopeContextsToSpanAttributes(contexts)).toEqual({ + 'culture.timezone': 'UTC', + }); + }); + }); + + describe('state context', () => { + it('maps state.type only', () => { + const contexts: Contexts = { + state: { state: { type: 'redux', value: { counter: 42, user: { name: 'test' } } } }, + }; + expect(scopeContextsToSpanAttributes(contexts)).toEqual({ + 'state.type': 'redux', + }); + }); + + it('does not map state.value', () => { + const contexts: Contexts = { + state: { state: { type: 'pinia', value: { items: [1, 2, 3] } } }, + }; + const attrs = scopeContextsToSpanAttributes(contexts); + expect(attrs).not.toHaveProperty('state.value'); + expect(attrs).not.toHaveProperty('state.state.value'); + }); + + it('handles missing state.state gracefully', () => { + const contexts: Contexts = { state: {} as any }; + expect(scopeContextsToSpanAttributes(contexts)).toEqual({}); + }); + }); + + describe('framework version contexts', () => { + it('maps angular.version', () => { + const contexts: Contexts = { angular: { version: 17 } }; + expect(scopeContextsToSpanAttributes(contexts)).toEqual({ + 'angular.version': 17, + }); + }); + + it('maps react.version', () => { + const contexts: Contexts = { react: { version: '18.2.0' } }; + expect(scopeContextsToSpanAttributes(contexts)).toEqual({ + 'react.version': '18.2.0', + }); + }); + }); + + it('maps multiple contexts at once', () => { + const contexts: Contexts = { + response: { status_code: 200 }, + culture: { timezone: 'UTC' }, + react: { version: '18.2.0' }, + }; + expect(scopeContextsToSpanAttributes(contexts)).toEqual({ + 'http.response.status_code': 200, + 'culture.timezone': 'UTC', + 'react.version': '18.2.0', + }); + }); +}); + +describe('applyScopeToSegmentSpan integration', () => { + it('applies scope contexts to segment span attributes', () => { + const client = new TestClient( + getDefaultTestClientOptions({ + dsn: 'https://dsn@ingest.f00.f00/1', + tracesSampleRate: 1, + release: '1.0.0', + environment: 'production', + }), + ); + + const span = withScope(scope => { + scope.setClient(client); + scope.setContext('response', { status_code: 201 }); + scope.setContext('culture', { timezone: 'Europe/Berlin' }); + + const span = startInactiveSpan({ name: 'test-span' }); + span.end(); + return span; + }); + + const serialized = captureSpan(span, client); + + expect(serialized.attributes).toEqual( + expect.objectContaining({ + 'http.response.status_code': { type: 'integer', value: 201 }, + 'culture.timezone': { type: 'string', value: 'Europe/Berlin' }, + }), + ); + }); + + it('does not apply scope contexts to child spans', () => { + const client = new TestClient( + getDefaultTestClientOptions({ + dsn: 'https://dsn@ingest.f00.f00/1', + tracesSampleRate: 1, + release: '1.0.0', + environment: 'production', + }), + ); + + const serializedChild = withScope(scope => { + scope.setClient(client); + scope.setContext('response', { status_code: 200 }); + + return startSpan({ name: 'segment' }, () => { + const childSpan = startInactiveSpan({ name: 'child' }); + childSpan.end(); + return captureSpan(childSpan, client); + }); + }); + + expect(serializedChild?.is_segment).toBe(false); + expect(serializedChild?.attributes).not.toHaveProperty('http.response.status_code'); + }); +});