Skip to content

Commit e185818

Browse files
chargomeclaudenicohrubec
authored
feat(node-core): Add processSegmentSpan to node context integration (#20678)
Adds `processSegmentSpan` to the `nodeContextIntegration` for span streaming support. Node equivalent to #20613 closes https://linear.app/getsentry/issue/JS-2219 --------- Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com> Co-authored-by: Nicolas Hrubec <nico.hrubec@sentry.io>
1 parent 7e49571 commit e185818

6 files changed

Lines changed: 424 additions & 58 deletions

File tree

dev-packages/node-core-integration-tests/suites/public-api/startSpan/basic-usage-streamed/test.ts

Lines changed: 30 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -123,16 +123,37 @@ test('sends a streamed span envelope with correct spans for a manually started s
123123
status: 'ok',
124124
});
125125

126+
const expectedAttributes: Record<string, unknown> = {
127+
[SEMANTIC_ATTRIBUTE_SENTRY_OP]: { type: 'string', value: 'test' },
128+
[SEMANTIC_ATTRIBUTE_SENTRY_SAMPLE_RATE]: { type: 'integer', value: 1 },
129+
[SEMANTIC_ATTRIBUTE_SENTRY_SDK_NAME]: { type: 'string', value: 'sentry.javascript.node-core' },
130+
[SEMANTIC_ATTRIBUTE_SENTRY_SDK_VERSION]: { type: 'string', value: SDK_VERSION },
131+
[SEMANTIC_ATTRIBUTE_SENTRY_SEGMENT_ID]: { type: 'string', value: segmentSpanId },
132+
[SEMANTIC_ATTRIBUTE_SENTRY_SEGMENT_NAME]: { type: 'string', value: 'test-span' },
133+
[SEMANTIC_ATTRIBUTE_SENTRY_RELEASE]: { type: 'string', value: '1.0.0' },
134+
'process.runtime.engine.name': { type: 'string', value: 'v8' },
135+
'process.runtime.engine.version': { type: 'string', value: expect.any(String) },
136+
'app.start_time': { type: 'string', value: expect.any(String) },
137+
'app.memory': { type: 'integer', value: expect.any(Number) },
138+
'device.boot_time': { type: 'string', value: expect.any(String) },
139+
'device.memory_size': { type: 'integer', value: expect.any(Number) },
140+
'device.free_memory': { type: 'integer', value: expect.any(Number) },
141+
'device.processor_count': { type: 'integer', value: expect.any(Number) },
142+
'device.cpu_description': { type: 'string', value: expect.any(String) },
143+
'device.processor_frequency': { type: 'integer', value: expect.any(Number) },
144+
'culture.locale': { type: 'string', value: expect.any(String) },
145+
'culture.timezone': { type: 'string', value: expect.any(String) },
146+
// TODO: device.archs is an array and currently dropped during serialization
147+
// 'device.archs': { type: 'array', value: [expect.any(String)] },
148+
};
149+
150+
// process.availableMemory is only available in Node 22+
151+
if (typeof (process as any).availableMemory === 'function') {
152+
expectedAttributes['app.free_memory'] = { type: 'integer', value: expect.any(Number) };
153+
}
154+
126155
expect(segmentSpan).toEqual({
127-
attributes: {
128-
[SEMANTIC_ATTRIBUTE_SENTRY_OP]: { type: 'string', value: 'test' },
129-
[SEMANTIC_ATTRIBUTE_SENTRY_SAMPLE_RATE]: { type: 'integer', value: 1 },
130-
[SEMANTIC_ATTRIBUTE_SENTRY_SDK_NAME]: { type: 'string', value: 'sentry.javascript.node-core' },
131-
[SEMANTIC_ATTRIBUTE_SENTRY_SDK_VERSION]: { type: 'string', value: SDK_VERSION },
132-
[SEMANTIC_ATTRIBUTE_SENTRY_SEGMENT_ID]: { type: 'string', value: segmentSpanId },
133-
[SEMANTIC_ATTRIBUTE_SENTRY_SEGMENT_NAME]: { type: 'string', value: 'test-span' },
134-
[SEMANTIC_ATTRIBUTE_SENTRY_RELEASE]: { type: 'string', value: '1.0.0' },
135-
},
156+
attributes: expectedAttributes,
136157
name: 'test-span',
137158
is_segment: true,
138159
trace_id: traceId,
Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
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.startSpan({ name: 'test-span' }, () => {
12+
// noop
13+
});
14+
15+
void Sentry.flush();
Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
import { afterAll, expect, test } from 'vitest';
2+
import { cleanupChildProcesses, createRunner } from '../../utils/runner';
3+
4+
afterAll(() => {
5+
cleanupChildProcesses();
6+
});
7+
8+
test('nodeContextIntegration sets context attributes on segment spans', 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+
// Static attributes
18+
expect(attrs['app.start_time']).toEqual({ type: 'string', value: expect.any(String) });
19+
// TODO: device.archs is an array and currently dropped during serialization
20+
// expect(attrs['device.archs']).toEqual({ type: 'array', value: [expect.any(String)] });
21+
expect(attrs['device.boot_time']).toEqual({ type: 'string', value: expect.any(String) });
22+
expect(attrs['device.processor_count']).toEqual({ type: 'integer', value: expect.any(Number) });
23+
expect(attrs['device.cpu_description']).toEqual({ type: 'string', value: expect.any(String) });
24+
expect(attrs['device.processor_frequency']).toEqual({ type: 'integer', value: expect.any(Number) });
25+
expect(attrs['device.memory_size']).toEqual({ type: 'integer', value: expect.any(Number) });
26+
expect(attrs['culture.locale']).toEqual({ type: 'string', value: expect.any(String) });
27+
expect(attrs['culture.timezone']).toEqual({ type: 'string', value: expect.any(String) });
28+
expect(attrs['process.runtime.engine.name']).toEqual({ type: 'string', value: 'v8' });
29+
expect(attrs['process.runtime.engine.version']).toEqual({ type: 'string', value: expect.any(String) });
30+
31+
// Dynamic attributes
32+
expect(attrs['app.memory']).toEqual({ type: 'integer', value: expect.any(Number) });
33+
expect(attrs['device.free_memory']).toEqual({ type: 'integer', value: expect.any(Number) });
34+
35+
// process.availableMemory is only available in Node 22+
36+
if (typeof (process as any).availableMemory === 'function') {
37+
expect(attrs['app.free_memory']).toEqual({ type: 'integer', value: expect.any(Number) });
38+
}
39+
},
40+
})
41+
.start()
42+
.completed();
43+
});

dev-packages/node-integration-tests/suites/public-api/startSpan/basic-usage-streamed/test.ts

Lines changed: 30 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -123,16 +123,37 @@ test('sends a streamed span envelope with correct spans for a manually started s
123123
status: 'ok',
124124
});
125125

126+
const expectedAttributes: Record<string, unknown> = {
127+
[SEMANTIC_ATTRIBUTE_SENTRY_OP]: { type: 'string', value: 'test' },
128+
[SEMANTIC_ATTRIBUTE_SENTRY_SAMPLE_RATE]: { type: 'integer', value: 1 },
129+
[SEMANTIC_ATTRIBUTE_SENTRY_SDK_NAME]: { type: 'string', value: 'sentry.javascript.node' },
130+
[SEMANTIC_ATTRIBUTE_SENTRY_SDK_VERSION]: { type: 'string', value: SDK_VERSION },
131+
[SEMANTIC_ATTRIBUTE_SENTRY_SEGMENT_ID]: { type: 'string', value: segmentSpanId },
132+
[SEMANTIC_ATTRIBUTE_SENTRY_SEGMENT_NAME]: { type: 'string', value: 'test-span' },
133+
[SEMANTIC_ATTRIBUTE_SENTRY_RELEASE]: { type: 'string', value: '1.0.0' },
134+
'process.runtime.engine.name': { type: 'string', value: 'v8' },
135+
'process.runtime.engine.version': { type: 'string', value: expect.any(String) },
136+
'app.start_time': { type: 'string', value: expect.any(String) },
137+
'app.memory': { type: 'integer', value: expect.any(Number) },
138+
// TODO: device.archs is an array and currently dropped during serialization
139+
// 'device.archs': { type: 'array', value: [expect.any(String)] },
140+
'device.boot_time': { type: 'string', value: expect.any(String) },
141+
'device.memory_size': { type: 'integer', value: expect.any(Number) },
142+
'device.free_memory': { type: 'integer', value: expect.any(Number) },
143+
'device.processor_count': { type: 'integer', value: expect.any(Number) },
144+
'device.cpu_description': { type: 'string', value: expect.any(String) },
145+
'device.processor_frequency': { type: 'integer', value: expect.any(Number) },
146+
'culture.locale': { type: 'string', value: expect.any(String) },
147+
'culture.timezone': { type: 'string', value: expect.any(String) },
148+
};
149+
150+
// process.availableMemory is only available in Node 22+
151+
if (typeof (process as any).availableMemory === 'function') {
152+
expectedAttributes['app.free_memory'] = { type: 'integer', value: expect.any(Number) };
153+
}
154+
126155
expect(segmentSpan).toEqual({
127-
attributes: {
128-
[SEMANTIC_ATTRIBUTE_SENTRY_OP]: { type: 'string', value: 'test' },
129-
[SEMANTIC_ATTRIBUTE_SENTRY_SAMPLE_RATE]: { type: 'integer', value: 1 },
130-
[SEMANTIC_ATTRIBUTE_SENTRY_SDK_NAME]: { type: 'string', value: 'sentry.javascript.node' },
131-
[SEMANTIC_ATTRIBUTE_SENTRY_SDK_VERSION]: { type: 'string', value: SDK_VERSION },
132-
[SEMANTIC_ATTRIBUTE_SENTRY_SEGMENT_ID]: { type: 'string', value: segmentSpanId },
133-
[SEMANTIC_ATTRIBUTE_SENTRY_SEGMENT_NAME]: { type: 'string', value: 'test-span' },
134-
[SEMANTIC_ATTRIBUTE_SENTRY_RELEASE]: { type: 'string', value: '1.0.0' },
135-
},
156+
attributes: expectedAttributes,
136157
name: 'test-span',
137158
is_segment: true,
138159
trace_id: traceId,

packages/node-core/src/integrations/context.ts

Lines changed: 145 additions & 39 deletions
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,7 @@ import type {
1515
IntegrationFn,
1616
OsContext,
1717
} from '@sentry/core';
18-
import { defineIntegration } from '@sentry/core';
18+
import { defineIntegration, safeSetSpanJSONAttributes } from '@sentry/core';
1919

2020
export const readFileAsync = promisify(readFile);
2121
export const readDirAsync = promisify(readdir);
@@ -42,8 +42,6 @@ interface ContextOptions {
4242
}
4343

4444
const _nodeContextIntegration = ((options: ContextOptions = {}) => {
45-
let cachedContext: Promise<Contexts> | undefined;
46-
4745
const _options = {
4846
app: true,
4947
os: true,
@@ -53,13 +51,56 @@ const _nodeContextIntegration = ((options: ContextOptions = {}) => {
5351
...options,
5452
};
5553

56-
/** Add contexts to the event. Caches the context so we only look it up once. */
57-
async function addContext(event: Event): Promise<Event> {
58-
if (cachedContext === undefined) {
59-
cachedContext = _getContexts();
54+
// Compute contexts eagerly (shared between tx and span paths)
55+
const appContext = _options.app ? getAppContext() : undefined;
56+
const deviceContext = _options.device ? getDeviceContext(_options.device) : undefined;
57+
const cultureContext = _options.culture ? getCultureContext() : undefined;
58+
const cloudResourceContext = _options.cloudResource ? getCloudResourceContext() : undefined;
59+
const osContextPromise = _options.os ? getOsContext() : undefined;
60+
61+
// Map static context data to span attributes
62+
const cachedSpanAttributes: Record<string, unknown> = {
63+
'process.runtime.engine.name': 'v8',
64+
'process.runtime.engine.version': process.versions.v8,
65+
...contextsToSpanAttributes({
66+
app: appContext,
67+
device: deviceContext,
68+
culture: cultureContext,
69+
cloud_resource: cloudResourceContext,
70+
}),
71+
};
72+
73+
if (osContextPromise) {
74+
osContextPromise
75+
.then(osCtx => Object.assign(cachedSpanAttributes, contextsToSpanAttributes({ os: osCtx })))
76+
.catch(() => {
77+
// Ignore - os attributes will be undefined
78+
});
79+
}
80+
81+
// Build contexts for event processing (reuses same data, awaits async OS context)
82+
const contextsPromise: Promise<Contexts> = (async () => {
83+
const contexts: Contexts = {};
84+
if (osContextPromise) {
85+
contexts.os = await osContextPromise;
86+
}
87+
if (appContext) {
88+
contexts.app = appContext;
6089
}
90+
if (deviceContext) {
91+
contexts.device = deviceContext;
92+
}
93+
if (cultureContext) {
94+
contexts.culture = cultureContext;
95+
}
96+
if (cloudResourceContext) {
97+
contexts.cloud_resource = cloudResourceContext;
98+
}
99+
return contexts;
100+
})();
61101

62-
const updatedContext = _updateContext(await cachedContext);
102+
async function addContext(event: Event): Promise<Event> {
103+
const updatedContext = _updateContext(await contextsPromise);
63104

64105
// TODO(v11): conditional with `sendDefaultPii` here?
65106
event.contexts = {
@@ -74,42 +115,15 @@ const _nodeContextIntegration = ((options: ContextOptions = {}) => {
74115
return event;
75116
}
76117

77-
/** Get the contexts from node. */
78-
async function _getContexts(): Promise<Contexts> {
79-
const contexts: Contexts = {};
80-
81-
if (_options.os) {
82-
contexts.os = await getOsContext();
83-
}
84-
85-
if (_options.app) {
86-
contexts.app = getAppContext();
87-
}
88-
89-
if (_options.device) {
90-
contexts.device = getDeviceContext(_options.device);
91-
}
92-
93-
if (_options.culture) {
94-
const culture = getCultureContext();
95-
96-
if (culture) {
97-
contexts.culture = culture;
98-
}
99-
}
100-
101-
if (_options.cloudResource) {
102-
contexts.cloud_resource = getCloudResourceContext();
103-
}
104-
105-
return contexts;
106-
}
107-
108118
return {
109119
name: INTEGRATION_NAME,
110120
processEvent(event) {
111121
return addContext(event);
112122
},
123+
processSegmentSpan(span) {
124+
safeSetSpanJSONAttributes(span, cachedSpanAttributes);
125+
safeSetSpanJSONAttributes(span, getDynamicSpanAttributes(appContext, deviceContext));
126+
},
113127
};
114128
}) satisfies IntegrationFn;
115129

@@ -142,6 +156,98 @@ function _updateContext(contexts: Contexts): Contexts {
142156
return contexts;
143157
}
144158

159+
export function contextsToSpanAttributes(contexts: Contexts): Record<string, unknown> {
160+
const attrs: Record<string, unknown> = {};
161+
162+
const { app, device, os: osCtx, culture, cloud_resource } = contexts;
163+
164+
if (app) {
165+
if (app.app_start_time) {
166+
attrs['app.start_time'] = app.app_start_time;
167+
}
168+
}
169+
170+
if (device) {
171+
if (device.arch) {
172+
attrs['device.archs'] = [device.arch];
173+
}
174+
if (device.boot_time) {
175+
attrs['device.boot_time'] = device.boot_time;
176+
}
177+
if (device.memory_size != null) {
178+
attrs['device.memory_size'] = device.memory_size;
179+
}
180+
if (device.processor_count != null) {
181+
attrs['device.processor_count'] = device.processor_count;
182+
}
183+
if (device.cpu_description) {
184+
attrs['device.cpu_description'] = device.cpu_description;
185+
}
186+
if (device.processor_frequency != null) {
187+
attrs['device.processor_frequency'] = device.processor_frequency;
188+
}
189+
}
190+
191+
if (osCtx) {
192+
if (osCtx.name) {
193+
attrs['os.name'] = osCtx.name;
194+
}
195+
if (osCtx.version) {
196+
attrs['os.version'] = osCtx.version;
197+
}
198+
if (osCtx.kernel_version) {
199+
attrs['os.kernel_version'] = osCtx.kernel_version;
200+
}
201+
if (osCtx.build) {
202+
attrs['os.build'] = osCtx.build;
203+
}
204+
}
205+
206+
if (culture) {
207+
if (culture.locale) {
208+
attrs['culture.locale'] = culture.locale;
209+
}
210+
if (culture.timezone) {
211+
attrs['culture.timezone'] = culture.timezone;
212+
}
213+
}
214+
215+
// CloudResourceContext already uses dot-notation keys matching span attribute conventions
216+
if (cloud_resource) {
217+
for (const [key, value] of Object.entries(cloud_resource)) {
218+
if (value != null) {
219+
attrs[key] = value;
220+
}
221+
}
222+
}
223+
224+
return attrs;
225+
}
226+
227+
export function getDynamicSpanAttributes(
228+
appContext: AppContext | undefined,
229+
deviceContext: DeviceContext | undefined,
230+
): Record<string, unknown> {
231+
const attrs: Record<string, unknown> = {};
232+
233+
if (appContext) {
234+
attrs['app.memory'] = process.memoryUsage().rss;
235+
if (typeof (process as ProcessWithCurrentValues).availableMemory === 'function') {
236+
const freeMemory = (process as ProcessWithCurrentValues).availableMemory?.();
237+
if (freeMemory != null) {
238+
attrs['app.free_memory'] = freeMemory;
239+
}
240+
}
241+
}
242+
243+
// Only include if memory tracking was initially enabled (indicated by free_memory being set)
244+
if (deviceContext?.free_memory != null) {
245+
attrs['device.free_memory'] = os.freemem();
246+
}
247+
248+
return attrs;
249+
}
250+
145251
/**
146252
* Returns the operating system context.
147253
*

0 commit comments

Comments
 (0)