From f2e8a6d90534e1d1f83c22d0cd487bb6de970671 Mon Sep 17 00:00:00 2001 From: Charly Gomez Date: Tue, 5 May 2026 14:55:38 +0200 Subject: [PATCH 1/7] add processSegmentSpan for integration --- .../suites/context-streamed/scenario.ts | 15 ++++ .../suites/context-streamed/test.ts | 25 ++++++ .../node-core/src/integrations/context.ts | 48 +++++++++++- .../test/integrations/context.test.ts | 76 ++++++++++++++++++- 4 files changed, 160 insertions(+), 4 deletions(-) create mode 100644 dev-packages/node-integration-tests/suites/context-streamed/scenario.ts create mode 100644 dev-packages/node-integration-tests/suites/context-streamed/test.ts 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..8aa779a7ac22 --- /dev/null +++ b/dev-packages/node-integration-tests/suites/context-streamed/test.ts @@ -0,0 +1,25 @@ +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!; + + 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['process.runtime.engine.name']).toEqual({ type: 'string', value: 'v8' }); + expect(attrs['process.runtime.engine.version']).toEqual({ type: 'string', value: expect.any(String) }); + }, + }) + .start() + .completed(); +}); diff --git a/packages/node-core/src/integrations/context.ts b/packages/node-core/src/integrations/context.ts index a39f75bfa2a9..c41aeb816ec5 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); @@ -53,6 +53,45 @@ const _nodeContextIntegration = ((options: ContextOptions = {}) => { ...options, }; + const cachedSpanAttributes: Record = { + 'process.runtime.engine.name': 'v8', + 'process.runtime.engine.version': process.versions.v8, + }; + + if (_options.app) { + // oxlint-disable-next-line sdk/no-unsafe-random-apis + cachedSpanAttributes['app.start_time'] = new Date(Date.now() - process.uptime() * 1000).toISOString(); + } + + if (_options.device) { + const deviceOpt = _options.device; + // Convention uses 'device.archs' (string[]), but array attributes are not yet serialized. + cachedSpanAttributes['device.archs'] = [os.arch()]; + if (deviceOpt === true || (typeof deviceOpt === 'object' && deviceOpt.cpu)) { + const cpuInfo = os.cpus() as os.CpuInfo[] | undefined; + if (cpuInfo?.[0]) { + cachedSpanAttributes['device.processor_count'] = cpuInfo.length; + } + } + } + + const osContextPromise = _options.os ? getOsContext() : undefined; + + if (osContextPromise) { + osContextPromise + .then(osContext => { + if (osContext.name) { + cachedSpanAttributes['os.name'] = osContext.name; + } + if (osContext.version) { + cachedSpanAttributes['os.version'] = osContext.version; + } + }) + .catch(() => { + // Ignore - os attributes will be undefined + }); + } + /** Add contexts to the event. Caches the context so we only look it up once. */ async function addContext(event: Event): Promise { if (cachedContext === undefined) { @@ -78,8 +117,8 @@ const _nodeContextIntegration = ((options: ContextOptions = {}) => { async function _getContexts(): Promise { const contexts: Contexts = {}; - if (_options.os) { - contexts.os = await getOsContext(); + if (osContextPromise) { + contexts.os = await osContextPromise; } if (_options.app) { @@ -110,6 +149,9 @@ const _nodeContextIntegration = ((options: ContextOptions = {}) => { processEvent(event) { return addContext(event); }, + processSegmentSpan(span) { + safeSetSpanJSONAttributes(span, cachedSpanAttributes); + }, }; }) satisfies IntegrationFn; diff --git a/packages/node-core/test/integrations/context.test.ts b/packages/node-core/test/integrations/context.test.ts index b8c3f8e3d49b..6a84ad870629 100644 --- a/packages/node-core/test/integrations/context.test.ts +++ b/packages/node-core/test/integrations/context.test.ts @@ -1,6 +1,7 @@ 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 { getAppContext, getDeviceContext, nodeContextIntegration } from '../../src/integrations/context'; import { conditionalTest } from '../helpers/conditional'; vi.mock('node:os', async () => { @@ -53,4 +54,77 @@ describe('Context', () => { expect(deviceCtx.boot_time).toBeUndefined(); }); }); + + describe('processSegmentSpan', () => { + it('sets 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.archs': [os.arch()], + 'device.processor_count': expect.any(Number), + 'process.runtime.engine.name': 'v8', + 'process.runtime.engine.version': process.versions.v8, + }); + }); + + 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 }); + + 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({ + 'process.runtime.engine.name': 'v8', + 'process.runtime.engine.version': process.versions.v8, + }); + expect(span.attributes!['app.start_time']).toBeUndefined(); + expect(span.attributes!['device.archs']).toBeUndefined(); + expect(span.attributes!['device.processor_count']).toBeUndefined(); + }); + }); }); From 26f39fdc618a2431a5446c63284f4367a3b7183d Mon Sep 17 00:00:00 2001 From: Charly Gomez Date: Tue, 5 May 2026 15:45:43 +0200 Subject: [PATCH 2/7] fix test --- .../public-api/startSpan/basic-usage-streamed/test.ts | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) 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..6044c04e761f 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 @@ -124,7 +124,7 @@ test('sends a streamed span envelope with correct spans for a manually started s }); expect(segmentSpan).toEqual({ - attributes: { + attributes: expect.objectContaining({ [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' }, @@ -132,7 +132,11 @@ test('sends a streamed span envelope with correct spans for a manually started s [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) }, + 'device.processor_count': { type: 'integer', value: expect.any(Number) }, + }), name: 'test-span', is_segment: true, trace_id: traceId, From e5c90cdefa2b75e3e70c89bfdf6a42d584f3d884 Mon Sep 17 00:00:00 2001 From: Charly Gomez Date: Tue, 5 May 2026 17:11:27 +0200 Subject: [PATCH 3/7] fix(node-core): Update basic-usage-streamed test for context span attributes Co-Authored-By: Claude Opus 4.6 (1M context) --- .../public-api/startSpan/basic-usage-streamed/test.ts | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) 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..1b50691e53d7 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 @@ -124,7 +124,7 @@ test('sends a streamed span envelope with correct spans for a manually started s }); expect(segmentSpan).toEqual({ - attributes: { + attributes: expect.objectContaining({ [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' }, @@ -132,7 +132,11 @@ test('sends a streamed span envelope with correct spans for a manually started s [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) }, + 'device.processor_count': { type: 'integer', value: expect.any(Number) }, + }), name: 'test-span', is_segment: true, trace_id: traceId, From 9a7069fc372695e523980dab95c8eb2e9654c22a Mon Sep 17 00:00:00 2001 From: Charly Gomez Date: Tue, 5 May 2026 17:17:19 +0200 Subject: [PATCH 4/7] fix: Use strict attribute checks in basic-usage-streamed tests Co-Authored-By: Claude Opus 4.6 (1M context) --- .../suites/public-api/startSpan/basic-usage-streamed/test.ts | 4 ++-- .../suites/public-api/startSpan/basic-usage-streamed/test.ts | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) 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 1b50691e53d7..ed399914dde0 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 @@ -124,7 +124,7 @@ test('sends a streamed span envelope with correct spans for a manually started s }); expect(segmentSpan).toEqual({ - attributes: expect.objectContaining({ + 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' }, @@ -136,7 +136,7 @@ test('sends a streamed span envelope with correct spans for a manually started s 'process.runtime.engine.version': { type: 'string', value: expect.any(String) }, 'app.start_time': { type: 'string', value: expect.any(String) }, 'device.processor_count': { type: 'integer', value: expect.any(Number) }, - }), + }, name: 'test-span', is_segment: true, trace_id: traceId, 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 6044c04e761f..ef0c51803280 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 @@ -124,7 +124,7 @@ test('sends a streamed span envelope with correct spans for a manually started s }); expect(segmentSpan).toEqual({ - attributes: expect.objectContaining({ + 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' }, @@ -136,7 +136,7 @@ test('sends a streamed span envelope with correct spans for a manually started s 'process.runtime.engine.version': { type: 'string', value: expect.any(String) }, 'app.start_time': { type: 'string', value: expect.any(String) }, 'device.processor_count': { type: 'integer', value: expect.any(Number) }, - }), + }, name: 'test-span', is_segment: true, trace_id: traceId, From 10bf715fd8a358948e93bb37184dacab7f87307e Mon Sep 17 00:00:00 2001 From: Charly Gomez Date: Wed, 6 May 2026 14:41:35 +0200 Subject: [PATCH 5/7] refactor --- .../startSpan/basic-usage-streamed/test.ts | 9 + .../suites/context-streamed/test.ts | 10 + .../startSpan/basic-usage-streamed/test.ts | 9 + .../node-core/src/integrations/context.ts | 191 ++++++++++++------ .../test/integrations/context.test.ts | 101 ++++++++- 5 files changed, 248 insertions(+), 72 deletions(-) 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 ed399914dde0..bf6fd86dbd3e 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 @@ -135,7 +135,16 @@ test('sends a streamed span envelope with correct spans for a manually started s '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.archs': expect.anything(), + '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) }, }, name: 'test-span', is_segment: true, diff --git a/dev-packages/node-integration-tests/suites/context-streamed/test.ts b/dev-packages/node-integration-tests/suites/context-streamed/test.ts index 8aa779a7ac22..ed4e66bc7c77 100644 --- a/dev-packages/node-integration-tests/suites/context-streamed/test.ts +++ b/dev-packages/node-integration-tests/suites/context-streamed/test.ts @@ -14,10 +14,20 @@ test('nodeContextIntegration sets context attributes on segment spans', async () 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() 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 ef0c51803280..0754c0c726bf 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 @@ -135,7 +135,16 @@ test('sends a streamed span envelope with correct spans for a manually started s '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.archs': expect.anything(), + '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) }, }, name: 'test-span', is_segment: true, diff --git a/packages/node-core/src/integrations/context.ts b/packages/node-core/src/integrations/context.ts index c41aeb816ec5..f74f628555b8 100644 --- a/packages/node-core/src/integrations/context.ts +++ b/packages/node-core/src/integrations/context.ts @@ -42,8 +42,6 @@ interface ContextOptions { } const _nodeContextIntegration = ((options: ContextOptions = {}) => { - let cachedContext: Promise | undefined; - const _options = { app: true, os: true, @@ -53,52 +51,56 @@ const _nodeContextIntegration = ((options: ContextOptions = {}) => { ...options, }; + // 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 (_options.app) { - // oxlint-disable-next-line sdk/no-unsafe-random-apis - cachedSpanAttributes['app.start_time'] = new Date(Date.now() - process.uptime() * 1000).toISOString(); - } - - if (_options.device) { - const deviceOpt = _options.device; - // Convention uses 'device.archs' (string[]), but array attributes are not yet serialized. - cachedSpanAttributes['device.archs'] = [os.arch()]; - if (deviceOpt === true || (typeof deviceOpt === 'object' && deviceOpt.cpu)) { - const cpuInfo = os.cpus() as os.CpuInfo[] | undefined; - if (cpuInfo?.[0]) { - cachedSpanAttributes['device.processor_count'] = cpuInfo.length; - } - } - } - - const osContextPromise = _options.os ? getOsContext() : undefined; - if (osContextPromise) { osContextPromise - .then(osContext => { - if (osContext.name) { - cachedSpanAttributes['os.name'] = osContext.name; - } - if (osContext.version) { - cachedSpanAttributes['os.version'] = osContext.version; - } - }) + .then(osCtx => Object.assign(cachedSpanAttributes, contextsToSpanAttributes({ os: osCtx }))) .catch(() => { // Ignore - os attributes will be undefined }); } - /** 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(); + // 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 = { @@ -113,37 +115,6 @@ const _nodeContextIntegration = ((options: ContextOptions = {}) => { return event; } - /** Get the contexts from node. */ - async function _getContexts(): Promise { - const contexts: Contexts = {}; - - if (osContextPromise) { - contexts.os = await osContextPromise; - } - - 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) { @@ -151,6 +122,7 @@ const _nodeContextIntegration = ((options: ContextOptions = {}) => { }, processSegmentSpan(span) { safeSetSpanJSONAttributes(span, cachedSpanAttributes); + safeSetSpanJSONAttributes(span, getDynamicSpanAttributes(appContext, deviceContext)); }, }; }) satisfies IntegrationFn; @@ -184,6 +156,95 @@ 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 (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 6a84ad870629..0bf4a52d43cf 100644 --- a/packages/node-core/test/integrations/context.test.ts +++ b/packages/node-core/test/integrations/context.test.ts @@ -1,7 +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, nodeContextIntegration } from '../../src/integrations/context'; +import { + contextsToSpanAttributes, + getAppContext, + getDeviceContext, + getDynamicSpanAttributes, + nodeContextIntegration, +} from '../../src/integrations/context'; import { conditionalTest } from '../helpers/conditional'; vi.mock('node:os', async () => { @@ -55,8 +61,81 @@ describe('Context', () => { }); }); + 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 context attributes on segment span', () => { + it('sets static and dynamic context attributes on segment span', () => { const integration = nodeContextIntegration(); const span: StreamedSpanJSON = { @@ -75,9 +154,14 @@ describe('Context', () => { expect(span.attributes).toMatchObject({ 'app.start_time': expect.any(String), 'device.archs': [os.arch()], + '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), }); }); @@ -103,7 +187,13 @@ describe('Context', () => { }); it('respects disabled options', () => { - const integration = nodeContextIntegration({ app: false, device: false, os: false }); + const integration = nodeContextIntegration({ + app: false, + device: false, + os: false, + culture: false, + cloudResource: false, + }); const span: StreamedSpanJSON = { trace_id: 'abc123', @@ -118,13 +208,10 @@ describe('Context', () => { integration.processSegmentSpan!(span, {} as any); - expect(span.attributes).toMatchObject({ + expect(span.attributes).toEqual({ 'process.runtime.engine.name': 'v8', 'process.runtime.engine.version': process.versions.v8, }); - expect(span.attributes!['app.start_time']).toBeUndefined(); - expect(span.attributes!['device.archs']).toBeUndefined(); - expect(span.attributes!['device.processor_count']).toBeUndefined(); }); }); }); From de2d5c5e9fe0d6105adae54198f736aa2b43fd40 Mon Sep 17 00:00:00 2001 From: Charly Gomez Date: Wed, 6 May 2026 17:16:40 +0200 Subject: [PATCH 6/7] fix tests? --- .../startSpan/basic-usage-streamed/test.ts | 50 +++++++++++-------- .../startSpan/basic-usage-streamed/test.ts | 50 +++++++++++-------- .../node-core/src/integrations/context.ts | 3 ++ .../test/integrations/context.test.ts | 1 - 4 files changed, 59 insertions(+), 45 deletions(-) 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 bf6fd86dbd3e..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,29 +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' }, - '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.archs': expect.anything(), - '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) }, - }, + attributes: expectedAttributes, name: 'test-span', is_segment: true, trace_id: traceId, 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 0754c0c726bf..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,29 +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' }, - '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.archs': expect.anything(), - '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) }, - }, + 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 f74f628555b8..3c3904582315 100644 --- a/packages/node-core/src/integrations/context.ts +++ b/packages/node-core/src/integrations/context.ts @@ -198,6 +198,9 @@ export function contextsToSpanAttributes(contexts: Contexts): Record { expect(span.attributes).toMatchObject({ 'app.start_time': expect.any(String), - 'device.archs': [os.arch()], 'device.memory_size': expect.any(Number), 'device.processor_count': expect.any(Number), 'device.cpu_description': expect.any(String), From ccd3b5db518f574f8e4ec12ba54a9b8b3628711a Mon Sep 17 00:00:00 2001 From: Nicolas Hrubec Date: Thu, 7 May 2026 08:47:57 +0200 Subject: [PATCH 7/7] todos for arch.device and assert on process memory for node 22 --- .../public-api/startSpan/basic-usage-streamed/test.ts | 2 ++ .../suites/context-streamed/test.ts | 8 ++++++++ .../public-api/startSpan/basic-usage-streamed/test.ts | 2 ++ 3 files changed, 12 insertions(+) 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 7e28be462589..ee018e45e53b 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 @@ -143,6 +143,8 @@ test('sends a streamed span envelope with correct spans for a manually started s '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) }, + // TODO: device.archs is an array and currently dropped during serialization + // 'device.archs': { type: 'array', value: [expect.any(String)] }, }; // process.availableMemory is only available in Node 22+ diff --git a/dev-packages/node-integration-tests/suites/context-streamed/test.ts b/dev-packages/node-integration-tests/suites/context-streamed/test.ts index ed4e66bc7c77..9d1a6ca5099a 100644 --- a/dev-packages/node-integration-tests/suites/context-streamed/test.ts +++ b/dev-packages/node-integration-tests/suites/context-streamed/test.ts @@ -16,6 +16,9 @@ test('nodeContextIntegration sets context attributes on segment spans', async () // Static attributes expect(attrs['app.start_time']).toEqual({ type: 'string', value: expect.any(String) }); + // TODO: device.archs is an array and currently dropped during serialization + // expect(attrs['device.archs']).toEqual({ type: 'array', value: [expect.any(String)] }); + expect(attrs['device.boot_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) }); @@ -28,6 +31,11 @@ test('nodeContextIntegration sets context attributes on segment spans', async () // 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) }); + + // process.availableMemory is only available in Node 22+ + if (typeof (process as any).availableMemory === 'function') { + expect(attrs['app.free_memory']).toEqual({ type: 'integer', value: expect.any(Number) }); + } }, }) .start() 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 404ce1476953..88e3f3686622 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 @@ -135,6 +135,8 @@ test('sends a streamed span envelope with correct spans for a manually started s '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) }, + // TODO: device.archs is an array and currently dropped during serialization + // 'device.archs': { type: 'array', value: [expect.any(String)] }, '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) },