diff --git a/dev-packages/node-core-integration-tests/suites/public-api/startSpan/basic-usage-streamed/test.ts b/dev-packages/node-core-integration-tests/suites/public-api/startSpan/basic-usage-streamed/test.ts index 3184aae69d64..7e28be462589 100644 --- a/dev-packages/node-core-integration-tests/suites/public-api/startSpan/basic-usage-streamed/test.ts +++ b/dev-packages/node-core-integration-tests/suites/public-api/startSpan/basic-usage-streamed/test.ts @@ -123,16 +123,35 @@ test('sends a streamed span envelope with correct spans for a manually started s status: 'ok', }); + const expectedAttributes: Record = { + [SEMANTIC_ATTRIBUTE_SENTRY_OP]: { type: 'string', value: 'test' }, + [SEMANTIC_ATTRIBUTE_SENTRY_SAMPLE_RATE]: { type: 'integer', value: 1 }, + [SEMANTIC_ATTRIBUTE_SENTRY_SDK_NAME]: { type: 'string', value: 'sentry.javascript.node-core' }, + [SEMANTIC_ATTRIBUTE_SENTRY_SDK_VERSION]: { type: 'string', value: SDK_VERSION }, + [SEMANTIC_ATTRIBUTE_SENTRY_SEGMENT_ID]: { type: 'string', value: segmentSpanId }, + [SEMANTIC_ATTRIBUTE_SENTRY_SEGMENT_NAME]: { type: 'string', value: 'test-span' }, + [SEMANTIC_ATTRIBUTE_SENTRY_RELEASE]: { type: 'string', value: '1.0.0' }, + 'process.runtime.engine.name': { type: 'string', value: 'v8' }, + 'process.runtime.engine.version': { type: 'string', value: expect.any(String) }, + 'app.start_time': { type: 'string', value: expect.any(String) }, + 'app.memory': { type: 'integer', value: expect.any(Number) }, + 'device.boot_time': { type: 'string', value: expect.any(String) }, + 'device.memory_size': { type: 'integer', value: expect.any(Number) }, + 'device.free_memory': { type: 'integer', value: expect.any(Number) }, + 'device.processor_count': { type: 'integer', value: expect.any(Number) }, + 'device.cpu_description': { type: 'string', value: expect.any(String) }, + 'device.processor_frequency': { type: 'integer', value: expect.any(Number) }, + 'culture.locale': { type: 'string', value: expect.any(String) }, + 'culture.timezone': { type: 'string', value: expect.any(String) }, + }; + + // process.availableMemory is only available in Node 22+ + if (typeof (process as any).availableMemory === 'function') { + expectedAttributes['app.free_memory'] = { type: 'integer', value: expect.any(Number) }; + } + expect(segmentSpan).toEqual({ - attributes: { - [SEMANTIC_ATTRIBUTE_SENTRY_OP]: { type: 'string', value: 'test' }, - [SEMANTIC_ATTRIBUTE_SENTRY_SAMPLE_RATE]: { type: 'integer', value: 1 }, - [SEMANTIC_ATTRIBUTE_SENTRY_SDK_NAME]: { type: 'string', value: 'sentry.javascript.node-core' }, - [SEMANTIC_ATTRIBUTE_SENTRY_SDK_VERSION]: { type: 'string', value: SDK_VERSION }, - [SEMANTIC_ATTRIBUTE_SENTRY_SEGMENT_ID]: { type: 'string', value: segmentSpanId }, - [SEMANTIC_ATTRIBUTE_SENTRY_SEGMENT_NAME]: { type: 'string', value: 'test-span' }, - [SEMANTIC_ATTRIBUTE_SENTRY_RELEASE]: { type: 'string', value: '1.0.0' }, - }, + attributes: expectedAttributes, name: 'test-span', is_segment: true, trace_id: traceId, diff --git a/dev-packages/node-integration-tests/suites/context-streamed/scenario.ts b/dev-packages/node-integration-tests/suites/context-streamed/scenario.ts new file mode 100644 index 000000000000..7f1b5ddd053f --- /dev/null +++ b/dev-packages/node-integration-tests/suites/context-streamed/scenario.ts @@ -0,0 +1,15 @@ +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.startSpan({ name: 'test-span' }, () => { + // noop +}); + +void Sentry.flush(); diff --git a/dev-packages/node-integration-tests/suites/context-streamed/test.ts b/dev-packages/node-integration-tests/suites/context-streamed/test.ts new file mode 100644 index 000000000000..ed4e66bc7c77 --- /dev/null +++ b/dev-packages/node-integration-tests/suites/context-streamed/test.ts @@ -0,0 +1,35 @@ +import { afterAll, expect, test } from 'vitest'; +import { cleanupChildProcesses, createRunner } from '../../utils/runner'; + +afterAll(() => { + cleanupChildProcesses(); +}); + +test('nodeContextIntegration sets context attributes on segment spans', 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!; + + // Static attributes + expect(attrs['app.start_time']).toEqual({ type: 'string', value: expect.any(String) }); + expect(attrs['device.processor_count']).toEqual({ type: 'integer', value: expect.any(Number) }); + expect(attrs['device.cpu_description']).toEqual({ type: 'string', value: expect.any(String) }); + expect(attrs['device.processor_frequency']).toEqual({ type: 'integer', value: expect.any(Number) }); + expect(attrs['device.memory_size']).toEqual({ type: 'integer', value: expect.any(Number) }); + expect(attrs['culture.locale']).toEqual({ type: 'string', value: expect.any(String) }); + expect(attrs['culture.timezone']).toEqual({ type: 'string', value: expect.any(String) }); + expect(attrs['process.runtime.engine.name']).toEqual({ type: 'string', value: 'v8' }); + expect(attrs['process.runtime.engine.version']).toEqual({ type: 'string', value: expect.any(String) }); + + // Dynamic attributes + expect(attrs['app.memory']).toEqual({ type: 'integer', value: expect.any(Number) }); + expect(attrs['device.free_memory']).toEqual({ type: 'integer', value: expect.any(Number) }); + }, + }) + .start() + .completed(); +}); diff --git a/dev-packages/node-integration-tests/suites/public-api/startSpan/basic-usage-streamed/test.ts b/dev-packages/node-integration-tests/suites/public-api/startSpan/basic-usage-streamed/test.ts index b31ca320df53..404ce1476953 100644 --- a/dev-packages/node-integration-tests/suites/public-api/startSpan/basic-usage-streamed/test.ts +++ b/dev-packages/node-integration-tests/suites/public-api/startSpan/basic-usage-streamed/test.ts @@ -123,16 +123,35 @@ test('sends a streamed span envelope with correct spans for a manually started s status: 'ok', }); + const expectedAttributes: Record = { + [SEMANTIC_ATTRIBUTE_SENTRY_OP]: { type: 'string', value: 'test' }, + [SEMANTIC_ATTRIBUTE_SENTRY_SAMPLE_RATE]: { type: 'integer', value: 1 }, + [SEMANTIC_ATTRIBUTE_SENTRY_SDK_NAME]: { type: 'string', value: 'sentry.javascript.node' }, + [SEMANTIC_ATTRIBUTE_SENTRY_SDK_VERSION]: { type: 'string', value: SDK_VERSION }, + [SEMANTIC_ATTRIBUTE_SENTRY_SEGMENT_ID]: { type: 'string', value: segmentSpanId }, + [SEMANTIC_ATTRIBUTE_SENTRY_SEGMENT_NAME]: { type: 'string', value: 'test-span' }, + [SEMANTIC_ATTRIBUTE_SENTRY_RELEASE]: { type: 'string', value: '1.0.0' }, + 'process.runtime.engine.name': { type: 'string', value: 'v8' }, + 'process.runtime.engine.version': { type: 'string', value: expect.any(String) }, + 'app.start_time': { type: 'string', value: expect.any(String) }, + 'app.memory': { type: 'integer', value: expect.any(Number) }, + 'device.boot_time': { type: 'string', value: expect.any(String) }, + 'device.memory_size': { type: 'integer', value: expect.any(Number) }, + 'device.free_memory': { type: 'integer', value: expect.any(Number) }, + 'device.processor_count': { type: 'integer', value: expect.any(Number) }, + 'device.cpu_description': { type: 'string', value: expect.any(String) }, + 'device.processor_frequency': { type: 'integer', value: expect.any(Number) }, + 'culture.locale': { type: 'string', value: expect.any(String) }, + 'culture.timezone': { type: 'string', value: expect.any(String) }, + }; + + // process.availableMemory is only available in Node 22+ + if (typeof (process as any).availableMemory === 'function') { + expectedAttributes['app.free_memory'] = { type: 'integer', value: expect.any(Number) }; + } + expect(segmentSpan).toEqual({ - attributes: { - [SEMANTIC_ATTRIBUTE_SENTRY_OP]: { type: 'string', value: 'test' }, - [SEMANTIC_ATTRIBUTE_SENTRY_SAMPLE_RATE]: { type: 'integer', value: 1 }, - [SEMANTIC_ATTRIBUTE_SENTRY_SDK_NAME]: { type: 'string', value: 'sentry.javascript.node' }, - [SEMANTIC_ATTRIBUTE_SENTRY_SDK_VERSION]: { type: 'string', value: SDK_VERSION }, - [SEMANTIC_ATTRIBUTE_SENTRY_SEGMENT_ID]: { type: 'string', value: segmentSpanId }, - [SEMANTIC_ATTRIBUTE_SENTRY_SEGMENT_NAME]: { type: 'string', value: 'test-span' }, - [SEMANTIC_ATTRIBUTE_SENTRY_RELEASE]: { type: 'string', value: '1.0.0' }, - }, + attributes: expectedAttributes, name: 'test-span', is_segment: true, trace_id: traceId, diff --git a/packages/node-core/src/integrations/context.ts b/packages/node-core/src/integrations/context.ts index a39f75bfa2a9..3c3904582315 100644 --- a/packages/node-core/src/integrations/context.ts +++ b/packages/node-core/src/integrations/context.ts @@ -15,7 +15,7 @@ import type { IntegrationFn, OsContext, } from '@sentry/core'; -import { defineIntegration } from '@sentry/core'; +import { defineIntegration, safeSetSpanJSONAttributes } from '@sentry/core'; export const readFileAsync = promisify(readFile); export const readDirAsync = promisify(readdir); @@ -42,8 +42,6 @@ interface ContextOptions { } const _nodeContextIntegration = ((options: ContextOptions = {}) => { - let cachedContext: Promise | undefined; - const _options = { app: true, os: true, @@ -53,13 +51,56 @@ const _nodeContextIntegration = ((options: ContextOptions = {}) => { ...options, }; - /** Add contexts to the event. Caches the context so we only look it up once. */ - async function addContext(event: Event): Promise { - if (cachedContext === undefined) { - cachedContext = _getContexts(); + // Compute contexts eagerly (shared between tx and span paths) + const appContext = _options.app ? getAppContext() : undefined; + const deviceContext = _options.device ? getDeviceContext(_options.device) : undefined; + const cultureContext = _options.culture ? getCultureContext() : undefined; + const cloudResourceContext = _options.cloudResource ? getCloudResourceContext() : undefined; + const osContextPromise = _options.os ? getOsContext() : undefined; + + // Map static context data to span attributes + const cachedSpanAttributes: Record = { + 'process.runtime.engine.name': 'v8', + 'process.runtime.engine.version': process.versions.v8, + ...contextsToSpanAttributes({ + app: appContext, + device: deviceContext, + culture: cultureContext, + cloud_resource: cloudResourceContext, + }), + }; + + if (osContextPromise) { + osContextPromise + .then(osCtx => Object.assign(cachedSpanAttributes, contextsToSpanAttributes({ os: osCtx }))) + .catch(() => { + // Ignore - os attributes will be undefined + }); + } + + // Build contexts for event processing (reuses same data, awaits async OS context) + const contextsPromise: Promise = (async () => { + const contexts: Contexts = {}; + if (osContextPromise) { + contexts.os = await osContextPromise; + } + if (appContext) { + contexts.app = appContext; } + if (deviceContext) { + contexts.device = deviceContext; + } + if (cultureContext) { + contexts.culture = cultureContext; + } + if (cloudResourceContext) { + contexts.cloud_resource = cloudResourceContext; + } + return contexts; + })(); - const updatedContext = _updateContext(await cachedContext); + async function addContext(event: Event): Promise { + const updatedContext = _updateContext(await contextsPromise); // TODO(v11): conditional with `sendDefaultPii` here? event.contexts = { @@ -74,42 +115,15 @@ const _nodeContextIntegration = ((options: ContextOptions = {}) => { return event; } - /** Get the contexts from node. */ - async function _getContexts(): Promise { - const contexts: Contexts = {}; - - if (_options.os) { - contexts.os = await getOsContext(); - } - - if (_options.app) { - contexts.app = getAppContext(); - } - - if (_options.device) { - contexts.device = getDeviceContext(_options.device); - } - - if (_options.culture) { - const culture = getCultureContext(); - - if (culture) { - contexts.culture = culture; - } - } - - if (_options.cloudResource) { - contexts.cloud_resource = getCloudResourceContext(); - } - - return contexts; - } - return { name: INTEGRATION_NAME, processEvent(event) { return addContext(event); }, + processSegmentSpan(span) { + safeSetSpanJSONAttributes(span, cachedSpanAttributes); + safeSetSpanJSONAttributes(span, getDynamicSpanAttributes(appContext, deviceContext)); + }, }; }) satisfies IntegrationFn; @@ -142,6 +156,98 @@ function _updateContext(contexts: Contexts): Contexts { return contexts; } +export function contextsToSpanAttributes(contexts: Contexts): Record { + const attrs: Record = {}; + + const { app, device, os: osCtx, culture, cloud_resource } = contexts; + + if (app) { + if (app.app_start_time) { + attrs['app.start_time'] = app.app_start_time; + } + } + + if (device) { + if (device.arch) { + attrs['device.archs'] = [device.arch]; + } + if (device.boot_time) { + attrs['device.boot_time'] = device.boot_time; + } + if (device.memory_size != null) { + attrs['device.memory_size'] = device.memory_size; + } + if (device.processor_count != null) { + attrs['device.processor_count'] = device.processor_count; + } + if (device.cpu_description) { + attrs['device.cpu_description'] = device.cpu_description; + } + if (device.processor_frequency != null) { + attrs['device.processor_frequency'] = device.processor_frequency; + } + } + + if (osCtx) { + if (osCtx.name) { + attrs['os.name'] = osCtx.name; + } + if (osCtx.version) { + attrs['os.version'] = osCtx.version; + } + if (osCtx.kernel_version) { + attrs['os.kernel_version'] = osCtx.kernel_version; + } + if (osCtx.build) { + attrs['os.build'] = osCtx.build; + } + } + + if (culture) { + if (culture.locale) { + attrs['culture.locale'] = culture.locale; + } + if (culture.timezone) { + attrs['culture.timezone'] = culture.timezone; + } + } + + // CloudResourceContext already uses dot-notation keys matching span attribute conventions + if (cloud_resource) { + for (const [key, value] of Object.entries(cloud_resource)) { + if (value != null) { + attrs[key] = value; + } + } + } + + return attrs; +} + +export function getDynamicSpanAttributes( + appContext: AppContext | undefined, + deviceContext: DeviceContext | undefined, +): Record { + const attrs: Record = {}; + + if (appContext) { + attrs['app.memory'] = process.memoryUsage().rss; + if (typeof (process as ProcessWithCurrentValues).availableMemory === 'function') { + const freeMemory = (process as ProcessWithCurrentValues).availableMemory?.(); + if (freeMemory != null) { + attrs['app.free_memory'] = freeMemory; + } + } + } + + // Only include if memory tracking was initially enabled (indicated by free_memory being set) + if (deviceContext?.free_memory != null) { + attrs['device.free_memory'] = os.freemem(); + } + + return attrs; +} + /** * Returns the operating system context. * diff --git a/packages/node-core/test/integrations/context.test.ts b/packages/node-core/test/integrations/context.test.ts index b8c3f8e3d49b..3bcc1af80589 100644 --- a/packages/node-core/test/integrations/context.test.ts +++ b/packages/node-core/test/integrations/context.test.ts @@ -1,6 +1,13 @@ import * as os from 'node:os'; +import type { StreamedSpanJSON } from '@sentry/core'; import { afterAll, describe, expect, it, vi } from 'vitest'; -import { getAppContext, getDeviceContext } from '../../src/integrations/context'; +import { + contextsToSpanAttributes, + getAppContext, + getDeviceContext, + getDynamicSpanAttributes, + nodeContextIntegration, +} from '../../src/integrations/context'; import { conditionalTest } from '../helpers/conditional'; vi.mock('node:os', async () => { @@ -53,4 +60,157 @@ describe('Context', () => { expect(deviceCtx.boot_time).toBeUndefined(); }); }); + + describe('contextsToSpanAttributes', () => { + it('maps app context', () => { + const attrs = contextsToSpanAttributes({ app: { app_start_time: '2026-01-01T00:00:00.000Z', app_memory: 100 } }); + expect(attrs).toEqual({ 'app.start_time': '2026-01-01T00:00:00.000Z' }); + }); + + it('maps device context', () => { + const attrs = contextsToSpanAttributes({ + device: { + arch: 'arm64', + boot_time: '2026-01-01T00:00:00.000Z', + memory_size: 1024, + processor_count: 8, + cpu_description: 'Apple M1', + processor_frequency: 3200, + free_memory: 512, + }, + }); + expect(attrs).toEqual({ + 'device.archs': ['arm64'], + 'device.boot_time': '2026-01-01T00:00:00.000Z', + 'device.memory_size': 1024, + 'device.processor_count': 8, + 'device.cpu_description': 'Apple M1', + 'device.processor_frequency': 3200, + }); + }); + + it('maps os context', () => { + const attrs = contextsToSpanAttributes({ os: { name: 'macOS', version: '15.0', kernel_version: '24.0.0' } }); + expect(attrs).toEqual({ 'os.name': 'macOS', 'os.version': '15.0', 'os.kernel_version': '24.0.0' }); + }); + + it('maps culture context', () => { + const attrs = contextsToSpanAttributes({ culture: { locale: 'en-US', timezone: 'America/New_York' } }); + expect(attrs).toEqual({ 'culture.locale': 'en-US', 'culture.timezone': 'America/New_York' }); + }); + + it('maps cloud resource context', () => { + const attrs = contextsToSpanAttributes({ + cloud_resource: { 'cloud.provider': 'aws', 'cloud.region': 'us-east-1' }, + }); + expect(attrs).toEqual({ 'cloud.provider': 'aws', 'cloud.region': 'us-east-1' }); + }); + + it('skips undefined values', () => { + const attrs = contextsToSpanAttributes({ app: {}, device: {}, os: {} }); + expect(attrs).toEqual({}); + }); + }); + + describe('getDynamicSpanAttributes', () => { + it('includes app memory when app context is provided', () => { + const attrs = getDynamicSpanAttributes(getAppContext(), undefined); + expect(attrs['app.memory']).toEqual(expect.any(Number)); + }); + + it('includes device free memory when device context has free_memory', () => { + const attrs = getDynamicSpanAttributes(undefined, { free_memory: 1024 }); + expect(attrs['device.free_memory']).toEqual(expect.any(Number)); + }); + + it('excludes device free memory when device context has no free_memory', () => { + const attrs = getDynamicSpanAttributes(undefined, { arch: 'arm64' }); + expect(attrs['device.free_memory']).toBeUndefined(); + }); + + it('returns empty when no contexts provided', () => { + const attrs = getDynamicSpanAttributes(undefined, undefined); + expect(attrs).toEqual({}); + }); + }); + + describe('processSegmentSpan', () => { + it('sets static and dynamic context attributes on segment span', () => { + const integration = nodeContextIntegration(); + + const span: StreamedSpanJSON = { + trace_id: 'abc123', + span_id: 'def456', + name: 'test-span', + start_timestamp: Date.now(), + end_timestamp: Date.now(), + status: 'ok', + is_segment: true, + attributes: {}, + }; + + integration.processSegmentSpan!(span, {} as any); + + expect(span.attributes).toMatchObject({ + 'app.start_time': expect.any(String), + 'device.memory_size': expect.any(Number), + 'device.processor_count': expect.any(Number), + 'device.cpu_description': expect.any(String), + 'device.processor_frequency': expect.any(Number), + 'process.runtime.engine.name': 'v8', + 'process.runtime.engine.version': process.versions.v8, + 'app.memory': expect.any(Number), + 'device.free_memory': expect.any(Number), + }); + }); + + it('does not overwrite existing attributes', () => { + const integration = nodeContextIntegration(); + + const span: StreamedSpanJSON = { + trace_id: 'abc123', + span_id: 'def456', + name: 'test-span', + start_timestamp: Date.now(), + end_timestamp: Date.now(), + status: 'ok', + is_segment: true, + attributes: { + 'process.runtime.engine.name': 'custom-engine', + }, + }; + + integration.processSegmentSpan!(span, {} as any); + + expect(span.attributes!['process.runtime.engine.name']).toBe('custom-engine'); + }); + + it('respects disabled options', () => { + const integration = nodeContextIntegration({ + app: false, + device: false, + os: false, + culture: false, + cloudResource: false, + }); + + const span: StreamedSpanJSON = { + trace_id: 'abc123', + span_id: 'def456', + name: 'test-span', + start_timestamp: Date.now(), + end_timestamp: Date.now(), + status: 'ok', + is_segment: true, + attributes: {}, + }; + + integration.processSegmentSpan!(span, {} as any); + + expect(span.attributes).toEqual({ + 'process.runtime.engine.name': 'v8', + 'process.runtime.engine.version': process.versions.v8, + }); + }); + }); });