Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down
Original file line number Diff line number Diff line change
@@ -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();
Original file line number Diff line number Diff line change
@@ -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();
});
7 changes: 4 additions & 3 deletions packages/core/src/tracing/spans/captureSpan.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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);
}

/**
Expand Down
75 changes: 75 additions & 0 deletions packages/core/src/tracing/spans/scopeContextAttributes.ts
Original file line number Diff line number Diff line change
@@ -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<string, unknown> {
const attrs: Record<string, unknown> = {};

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'];
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

q: Does it make sense to have framework specific code in core? I'm fine with it, just challenging if we really want to do this.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It basically syncs all context centrally that can make its way into browser sdks. But I do see your point obv

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;
}
209 changes: 208 additions & 1 deletion packages/core/test/lib/tracing/spans/captureSpan.test.ts
Original file line number Diff line number Diff line change
@@ -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,
Expand All @@ -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', () => {
Expand Down Expand Up @@ -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');
});
});
Loading