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 @@ -123,16 +123,35 @@ test('sends a streamed span envelope with correct spans for a manually started s
status: 'ok',
});

const expectedAttributes: Record<string, unknown> = {
[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,
Expand Down
Original file line number Diff line number Diff line change
@@ -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();
Original file line number Diff line number Diff line change
@@ -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();
});
Original file line number Diff line number Diff line change
Expand Up @@ -123,16 +123,35 @@ test('sends a streamed span envelope with correct spans for a manually started s
status: 'ok',
});

const expectedAttributes: Record<string, unknown> = {
[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,
Expand Down
184 changes: 145 additions & 39 deletions packages/node-core/src/integrations/context.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand All @@ -42,8 +42,6 @@ interface ContextOptions {
}

const _nodeContextIntegration = ((options: ContextOptions = {}) => {
let cachedContext: Promise<Contexts> | undefined;

const _options = {
app: true,
os: true,
Expand All @@ -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<Event> {
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<string, unknown> = {
'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
});
}
Comment thread
chargome marked this conversation as resolved.

// Build contexts for event processing (reuses same data, awaits async OS context)
const contextsPromise: Promise<Contexts> = (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<Event> {
const updatedContext = _updateContext(await contextsPromise);

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

/** Get the contexts from node. */
async function _getContexts(): Promise<Contexts> {
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);
Comment thread
chargome marked this conversation as resolved.
safeSetSpanJSONAttributes(span, getDynamicSpanAttributes(appContext, deviceContext));
},
};
}) satisfies IntegrationFn;

Expand Down Expand Up @@ -142,6 +156,98 @@ function _updateContext(contexts: Contexts): Contexts {
return contexts;
}

export function contextsToSpanAttributes(contexts: Contexts): Record<string, unknown> {
const attrs: Record<string, unknown> = {};

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];
}
Comment thread
chargome marked this conversation as resolved.
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<string, unknown> {
const attrs: Record<string, unknown> = {};

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.
*
Expand Down
Loading
Loading