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
54 changes: 51 additions & 3 deletions packages/core/src/js/integrations/logEnricherIntegration.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
/* eslint-disable complexity */
import type { Integration, Log } from '@sentry/core';
import { debug } from '@sentry/core';
import { debug, getCurrentScope, getGlobalScope, getIsolationScope } from '@sentry/core';
import type { ReactNativeClient } from '../client';
import { NATIVE } from '../wrapper';

Expand Down Expand Up @@ -33,7 +33,7 @@ let NativeCache: Record<string, unknown> | undefined = undefined;
*
* @param logAttributes - The log attributes object to modify.
* @param key - The attribute key to set.
* @param value - The value to set (only sets if truthy and key not present).
* @param value - The value to set (only sets if not null/undefined and key not present).
* @param setEvenIfPresent - Whether to set the attribute if it is present. Defaults to true.
*/
function setLogAttribute(
Expand All @@ -42,7 +42,7 @@ function setLogAttribute(
value: unknown,
setEvenIfPresent = true,
): void {
if (value && (!logAttributes[key] || setEvenIfPresent)) {
if (value != null && (!logAttributes[key] || setEvenIfPresent)) {
logAttributes[key] = value;
}
}
Expand Down Expand Up @@ -79,6 +79,13 @@ function processLog(log: Log, client: ReactNativeClient): void {
// Save log.attributes to a new variable
const logAttributes = log.attributes ?? {};

// Apply scope attributes from all active scopes (global, isolation, and current)
// These are applied first so they can be overridden by more specific attributes
const scopeAttributes = collectScopeAttributes();
Copy link
Collaborator

Choose a reason for hiding this comment

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

could we try using

  const {
    user: { id, email, username },
    attributes: scopeAttributes = {},
  } = getCombinedScopeData(getIsolationScope(), currentScope);

I am not sure why Sentry JS Opted to only load data from isolationScope and currentScope, might be worth checking why.

Object.keys(scopeAttributes).forEach((key: string) => {
setLogAttribute(logAttributes, key, scopeAttributes[key], false);
});

// Use setLogAttribute with the variable instead of direct assignment
setLogAttribute(logAttributes, 'device.brand', NativeCache.brand);
setLogAttribute(logAttributes, 'device.model', NativeCache.model);
Expand All @@ -93,3 +100,44 @@ function processLog(log: Log, client: ReactNativeClient): void {
// Set log.attributes to the variable
log.attributes = logAttributes;
}

/**
* Extracts primitive attributes from a scope and merges them into the target object.
* Only string, number, and boolean attribute values are included.
*
* @param scope - The scope to extract attributes from
* @param target - The target object to merge attributes into
*/
function extractScopeAttributes(
scope: ReturnType<typeof getCurrentScope>,
target: Record<string, string | number | boolean>,
): void {
if (scope && typeof scope.getScopeData === 'function') {
const scopeData = scope.getScopeData();
const scopeAttrs = scopeData.attributes || {};
Object.keys(scopeAttrs).forEach((key: string) => {
const value = scopeAttrs[key];
if (typeof value === 'string' || typeof value === 'number' || typeof value === 'boolean') {
target[key] = value;
}
});
}
}

/**
* Collects attributes from all active scopes (global, isolation, and current).
* Only string, number, and boolean attribute values are supported.
* Attributes are merged in order of precedence: global < isolation < current.
*
* @returns A merged object containing all scope attributes.
*/
function collectScopeAttributes(): Record<string, string | number | boolean> {
const attributes: Record<string, string | number | boolean> = {};

// Collect attributes from all scopes in order of precedence
extractScopeAttributes(getGlobalScope(), attributes);
extractScopeAttributes(getIsolationScope(), attributes);
extractScopeAttributes(getCurrentScope(), attributes);

return attributes;
}
237 changes: 236 additions & 1 deletion packages/core/test/integrations/logEnricherIntegration.test.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import type { Client, Log } from '@sentry/core';
import { debug } from '@sentry/core';
import { debug, getCurrentScope, getGlobalScope, getIsolationScope } from '@sentry/core';
import { logEnricherIntegration } from '../../src/js/integrations/logEnricherIntegration';
import type { NativeDeviceContextsResponse } from '../../src/js/NativeRNSentry';
import { NATIVE } from '../../src/js/wrapper';
Expand All @@ -11,6 +11,9 @@ jest.mock('@sentry/core', () => ({
debug: {
log: jest.fn(),
},
getCurrentScope: jest.fn(),
getGlobalScope: jest.fn(),
getIsolationScope: jest.fn(),
}));

const mockLogger = debug as jest.Mocked<typeof debug>;
Expand Down Expand Up @@ -49,6 +52,13 @@ describe('LogEnricher Integration', () => {
} as unknown as jest.Mocked<Client>;

(NATIVE as jest.Mocked<typeof NATIVE>).fetchNativeLogAttributes = mockFetchNativeLogAttributes;

// Mock scope methods
(getCurrentScope as jest.Mock).mockReturnValue({
getScopeData: jest.fn().mockReturnValue({ attributes: {} }),
});
(getGlobalScope as jest.Mock).mockReturnValue({ getScopeData: jest.fn().mockReturnValue({ attributes: {} }) });
(getIsolationScope as jest.Mock).mockReturnValue({ getScopeData: jest.fn().mockReturnValue({ attributes: {} }) });
});

afterEach(() => {
Expand Down Expand Up @@ -516,4 +526,229 @@ describe('LogEnricher Integration', () => {
expect(mockGetIntegrationByName).toHaveBeenCalledWith('MobileReplay');
});
});

describe('scope attributes', () => {
let logHandler: (log: Log) => void;
let mockLog: Log;

beforeEach(async () => {
const integration = logEnricherIntegration();

const mockNativeResponse: NativeDeviceContextsResponse = {
contexts: {
device: {
brand: 'Apple',
model: 'iPhone 14',
} as Record<string, unknown>,
},
};

mockFetchNativeLogAttributes.mockResolvedValue(mockNativeResponse);

integration.setup(mockClient);

triggerAfterInit();

await jest.runAllTimersAsync();

const beforeCaptureLogCall = mockOn.mock.calls.find(call => call[0] === 'beforeCaptureLog');
expect(beforeCaptureLogCall).toBeDefined();
logHandler = beforeCaptureLogCall[1];

mockLog = {
message: 'Test log message',
level: 'info',
attributes: {},
};
});

it('should apply attributes from global scope to logs', () => {
(getGlobalScope as jest.Mock).mockReturnValue({
getScopeData: jest.fn().mockReturnValue({
attributes: {
is_admin: true,
auth_provider: 'google',
},
}),
});

logHandler(mockLog);

expect(mockLog.attributes).toMatchObject({
is_admin: true,
auth_provider: 'google',
});
});

it('should apply attributes from isolation scope to logs', () => {
(getIsolationScope as jest.Mock).mockReturnValue({
getScopeData: jest.fn().mockReturnValue({
attributes: {
session_id: 'abc123',
user_tier: 'premium',
},
}),
});

logHandler(mockLog);

expect(mockLog.attributes).toMatchObject({
session_id: 'abc123',
user_tier: 'premium',
});
});

it('should apply attributes from current scope to logs', () => {
(getCurrentScope as jest.Mock).mockReturnValue({
getScopeData: jest.fn().mockReturnValue({
attributes: {
step: 'authentication',
attempt: 1,
},
}),
});

logHandler(mockLog);

expect(mockLog.attributes).toMatchObject({
step: 'authentication',
attempt: 1,
});
});

it('should merge attributes from all scopes with correct precedence', () => {
(getGlobalScope as jest.Mock).mockReturnValue({
getScopeData: jest.fn().mockReturnValue({
attributes: {
is_admin: true,
environment: 'production',
},
}),
});

(getIsolationScope as jest.Mock).mockReturnValue({
getScopeData: jest.fn().mockReturnValue({
attributes: {
environment: 'staging',
session_id: 'xyz789',
},
}),
});

(getCurrentScope as jest.Mock).mockReturnValue({
getScopeData: jest.fn().mockReturnValue({
attributes: {
environment: 'development',
step: 'login',
},
}),
});

logHandler(mockLog);

expect(mockLog.attributes).toMatchObject({
is_admin: true,
environment: 'development', // Current scope wins
session_id: 'xyz789',
step: 'login',
});
});

it('should only include string, number, and boolean attribute values', () => {
(getCurrentScope as jest.Mock).mockReturnValue({
getScopeData: jest.fn().mockReturnValue({
attributes: {
stringAttr: 'value',
numberAttr: 42,
boolAttr: false,
objectAttr: { nested: 'object' }, // Should be filtered out
arrayAttr: [1, 2, 3], // Should be filtered out
nullAttr: null, // Should be filtered out
undefinedAttr: undefined, // Should be filtered out
},
}),
});

logHandler(mockLog);

expect(mockLog.attributes).toMatchObject({
stringAttr: 'value',
numberAttr: 42,
boolAttr: false,
});
expect(mockLog.attributes).not.toHaveProperty('objectAttr');
expect(mockLog.attributes).not.toHaveProperty('arrayAttr');
expect(mockLog.attributes).not.toHaveProperty('nullAttr');
expect(mockLog.attributes).not.toHaveProperty('undefinedAttr');
});

it('should not override existing log attributes with scope attributes', () => {
(getCurrentScope as jest.Mock).mockReturnValue({
getScopeData: jest.fn().mockReturnValue({
attributes: {
step: 'authentication',
user_id: 'scope-user',
},
}),
});

mockLog.attributes = {
user_id: 'log-user', // This should not be overridden
custom: 'value',
};

logHandler(mockLog);

expect(mockLog.attributes).toMatchObject({
user_id: 'log-user', // Original value preserved
custom: 'value',
step: 'authentication',
});
});

it('should handle scopes without getScopeData method', () => {
(getCurrentScope as jest.Mock).mockReturnValue({});
(getGlobalScope as jest.Mock).mockReturnValue({});
(getIsolationScope as jest.Mock).mockReturnValue({});

logHandler(mockLog);

// Should not throw and should still add device attributes
expect(mockLog.attributes).toMatchObject({
'device.brand': 'Apple',
'device.model': 'iPhone 14',
});
});

it('should handle null or undefined scopes', () => {
(getCurrentScope as jest.Mock).mockReturnValue(null);
(getGlobalScope as jest.Mock).mockReturnValue(undefined);
(getIsolationScope as jest.Mock).mockReturnValue(null);

logHandler(mockLog);

// Should not throw and should still add device attributes
expect(mockLog.attributes).toMatchObject({
'device.brand': 'Apple',
'device.model': 'iPhone 14',
});
});

it('should apply scope attributes before device attributes so they can be overridden', () => {
(getCurrentScope as jest.Mock).mockReturnValue({
getScopeData: jest.fn().mockReturnValue({
attributes: {
'device.brand': 'CustomBrand', // Should be overridden by native
},
}),
});

logHandler(mockLog);

expect(mockLog.attributes).toMatchObject({
'device.brand': 'Apple', // Native value should override
'device.model': 'iPhone 14',
});
});
});
});
12 changes: 12 additions & 0 deletions samples/expo/utils/setScopeProperties.ts
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,18 @@ export const setScopeProperties = () => {
undefinedTest: undefined,
});

// Set scope attributes that will be automatically applied to logs
Sentry.getGlobalScope().setAttributes({
is_admin: true,
auth_provider: 'auth',
});

// Set scope attributes on the current scope
Sentry.getCurrentScope().setAttributes({
session_type: 'test',
request_count: 1,
});

Sentry.addBreadcrumb({
level: 'info' as SeverityLevel,
message: `TEST-BREADCRUMB-INFO: ${dateString}`,
Expand Down
12 changes: 12 additions & 0 deletions samples/react-native-macos/src/setScopeProperties.ts
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,18 @@ export const setScopeProperties = () => {
undefinedTest: undefined,
});

// Set scope attributes that will be automatically applied to logs
Sentry.getGlobalScope().setAttributes({
is_admin: true,
auth_provider: 'google',
});

// Set scope attributes on the current scope
Sentry.getCurrentScope().setAttributes({
session_type: 'test',
request_count: 42,
});

Sentry.addBreadcrumb({
level: 'info' as SeverityLevel,
message: `TEST-BREADCRUMB-INFO: ${dateString}`,
Expand Down
Loading
Loading