diff --git a/dev-packages/browser-integration-tests/suites/public-api/logger/integration/test.ts b/dev-packages/browser-integration-tests/suites/public-api/logger/integration/test.ts index dd4bd7e8ebc3..29d1eabab1b3 100644 --- a/dev-packages/browser-integration-tests/suites/public-api/logger/integration/test.ts +++ b/dev-packages/browser-integration-tests/suites/public-api/logger/integration/test.ts @@ -1,14 +1,15 @@ import { expect } from '@playwright/test'; import type { LogEnvelope } from '@sentry/core'; import { sentryTest } from '../../../../utils/fixtures'; -import { getFirstSentryEnvelopeRequest, properFullEnvelopeRequestParser } from '../../../../utils/helpers'; +import { + getFirstSentryEnvelopeRequest, + properFullEnvelopeRequestParser, + testingCdnBundle, +} from '../../../../utils/helpers'; sentryTest('should capture console object calls', async ({ getLocalTestUrl, page }) => { - const bundle = process.env.PW_BUNDLE || ''; // Only run this for npm package exports - if (bundle.startsWith('bundle') || bundle.startsWith('loader')) { - sentryTest.skip(); - } + sentryTest.skip(testingCdnBundle()); const url = await getLocalTestUrl({ testDir: __dirname }); diff --git a/dev-packages/browser-integration-tests/suites/public-api/logger/scopeAttributes/subject.js b/dev-packages/browser-integration-tests/suites/public-api/logger/scopeAttributes/subject.js new file mode 100644 index 000000000000..9bba2c222bdc --- /dev/null +++ b/dev-packages/browser-integration-tests/suites/public-api/logger/scopeAttributes/subject.js @@ -0,0 +1,33 @@ +// only log attribute +Sentry.logger.info('log_before_any_scope', { log_attr: 'log_attr_1' }); + +Sentry.getGlobalScope().setAttributes({ global_scope_attr: true }); + +// this attribute will not be sent for now +Sentry.getGlobalScope().setAttribute('array_attr', [1, 2, 3]); + +// global scope, log attribute +Sentry.logger.info('log_after_global_scope', { log_attr: 'log_attr_2' }); + +Sentry.withIsolationScope(isolationScope => { + isolationScope.setAttribute('isolation_scope_1_attr', { value: 100, unit: 'millisecond' }); + + // global scope, isolation scope, log attribute + Sentry.logger.info('log_with_isolation_scope', { log_attr: 'log_attr_3' }); + + Sentry.withScope(scope => { + scope.setAttributes({ scope_attr: { value: 200, unit: 'millisecond' } }); + + // global scope, isolation scope, current scope attribute, log attribute + Sentry.logger.info('log_with_scope', { log_attr: 'log_attr_4' }); + }); + + Sentry.withScope(scope2 => { + scope2.setAttribute('scope_2_attr', { value: 300, unit: 'millisecond' }); + + // global scope, isolation scope, current scope attribute, log attribute + Sentry.logger.info('log_with_scope_2', { log_attr: 'log_attr_5' }); + }); +}); + +Sentry.flush(); diff --git a/dev-packages/browser-integration-tests/suites/public-api/logger/scopeAttributes/test.ts b/dev-packages/browser-integration-tests/suites/public-api/logger/scopeAttributes/test.ts new file mode 100644 index 000000000000..5f0f49bf21a9 --- /dev/null +++ b/dev-packages/browser-integration-tests/suites/public-api/logger/scopeAttributes/test.ts @@ -0,0 +1,98 @@ +import { expect } from '@playwright/test'; +import type { LogEnvelope } from '@sentry/core'; +import { sentryTest } from '../../../../utils/fixtures'; +import { + getFirstSentryEnvelopeRequest, + properFullEnvelopeRequestParser, + testingCdnBundle, +} from '../../../../utils/helpers'; + +sentryTest('captures logs with scope attributes', async ({ getLocalTestUrl, page }) => { + sentryTest.skip(testingCdnBundle()); + + const url = await getLocalTestUrl({ testDir: __dirname }); + + const event = await getFirstSentryEnvelopeRequest(page, url, properFullEnvelopeRequestParser); + const envelopeItems = event[1]; + + expect(envelopeItems[0]).toEqual([ + { + type: 'log', + item_count: 5, + content_type: 'application/vnd.sentry.items.log+json', + }, + { + items: [ + { + timestamp: expect.any(Number), + level: 'info', + body: 'log_before_any_scope', + severity_number: 9, + trace_id: expect.any(String), + attributes: { + 'sentry.sdk.name': { value: 'sentry.javascript.browser', type: 'string' }, + 'sentry.sdk.version': { value: expect.any(String), type: 'string' }, + log_attr: { value: 'log_attr_1', type: 'string' }, + }, + }, + { + timestamp: expect.any(Number), + level: 'info', + body: 'log_after_global_scope', + severity_number: 9, + trace_id: expect.any(String), + attributes: { + 'sentry.sdk.name': { value: 'sentry.javascript.browser', type: 'string' }, + 'sentry.sdk.version': { value: expect.any(String), type: 'string' }, + global_scope_attr: { value: true, type: 'boolean' }, + log_attr: { value: 'log_attr_2', type: 'string' }, + }, + }, + { + timestamp: expect.any(Number), + level: 'info', + body: 'log_with_isolation_scope', + severity_number: 9, + trace_id: expect.any(String), + attributes: { + 'sentry.sdk.name': { value: 'sentry.javascript.browser', type: 'string' }, + 'sentry.sdk.version': { value: expect.any(String), type: 'string' }, + global_scope_attr: { value: true, type: 'boolean' }, + isolation_scope_1_attr: { value: 100, unit: 'millisecond', type: 'integer' }, + log_attr: { value: 'log_attr_3', type: 'string' }, + }, + }, + { + timestamp: expect.any(Number), + level: 'info', + body: 'log_with_scope', + severity_number: 9, + trace_id: expect.any(String), + attributes: { + 'sentry.sdk.name': { value: 'sentry.javascript.browser', type: 'string' }, + 'sentry.sdk.version': { value: expect.any(String), type: 'string' }, + global_scope_attr: { value: true, type: 'boolean' }, + isolation_scope_1_attr: { value: 100, unit: 'millisecond', type: 'integer' }, + scope_attr: { value: 200, unit: 'millisecond', type: 'integer' }, + log_attr: { value: 'log_attr_4', type: 'string' }, + }, + }, + { + timestamp: expect.any(Number), + level: 'info', + body: 'log_with_scope_2', + severity_number: 9, + trace_id: expect.any(String), + attributes: { + 'sentry.sdk.name': { value: 'sentry.javascript.browser', type: 'string' }, + 'sentry.sdk.version': { value: expect.any(String), type: 'string' }, + global_scope_attr: { value: true, type: 'boolean' }, + isolation_scope_1_attr: { value: 100, unit: 'millisecond', type: 'integer' }, + scope_2_attr: { value: 300, unit: 'millisecond', type: 'integer' }, + log_attr: { value: 'log_attr_5', type: 'string' }, + }, + }, + ], + }, + ]); +}); diff --git a/dev-packages/browser-integration-tests/suites/public-api/logger/simple/test.ts b/dev-packages/browser-integration-tests/suites/public-api/logger/simple/test.ts index 7fc4e02bad07..8477ca6b52c8 100644 --- a/dev-packages/browser-integration-tests/suites/public-api/logger/simple/test.ts +++ b/dev-packages/browser-integration-tests/suites/public-api/logger/simple/test.ts @@ -1,14 +1,15 @@ import { expect } from '@playwright/test'; import type { LogEnvelope } from '@sentry/core'; import { sentryTest } from '../../../../utils/fixtures'; -import { getFirstSentryEnvelopeRequest, properFullEnvelopeRequestParser } from '../../../../utils/helpers'; +import { + getFirstSentryEnvelopeRequest, + properFullEnvelopeRequestParser, + testingCdnBundle, +} from '../../../../utils/helpers'; sentryTest('should capture all logging methods', async ({ getLocalTestUrl, page }) => { - const bundle = process.env.PW_BUNDLE || ''; // Only run this for npm package exports - if (bundle.startsWith('bundle') || bundle.startsWith('loader')) { - sentryTest.skip(); - } + sentryTest.skip(testingCdnBundle()); const url = await getLocalTestUrl({ testDir: __dirname }); diff --git a/dev-packages/browser-integration-tests/utils/helpers.ts b/dev-packages/browser-integration-tests/utils/helpers.ts index dd75d2f6ee86..0888b3e286b0 100644 --- a/dev-packages/browser-integration-tests/utils/helpers.ts +++ b/dev-packages/browser-integration-tests/utils/helpers.ts @@ -314,6 +314,14 @@ export function shouldSkipTracingTest(): boolean { return bundle != null && !bundle.includes('tracing') && !bundle.includes('esm') && !bundle.includes('cjs'); } +/** + * @returns `true` if we are testing a CDN bundle + */ +export function testingCdnBundle(): boolean { + const bundle = process.env.PW_BUNDLE; + return bundle != null && (bundle.startsWith('bundle') || bundle.startsWith('loader')); +} + /** * Today we always run feedback tests, but this can be used to guard this if we ever need to. */ diff --git a/dev-packages/node-integration-tests/suites/public-api/logger/scenario.ts b/dev-packages/node-integration-tests/suites/public-api/logger/scenario.ts new file mode 100644 index 000000000000..a3e4b4c7b8e1 --- /dev/null +++ b/dev-packages/node-integration-tests/suites/public-api/logger/scenario.ts @@ -0,0 +1,49 @@ +import * as Sentry from '@sentry/node'; +import { loggingTransport } from '@sentry-internal/node-integration-tests'; + +Sentry.init({ + dsn: 'https://public@dsn.ingest.sentry.io/1337', + release: '1.0.0', + environment: 'test', + enableLogs: true, + transport: loggingTransport, +}); + +async function run(): Promise { + // only log attribute + Sentry.logger.info('log_before_any_scope', { log_attr: 'log_attr_1' }); + + Sentry.getGlobalScope().setAttribute('global_scope_attr', true); + + // this attribute will not be sent for now + Sentry.getGlobalScope().setAttributes({ array_attr: [1, 2, 3] }); + + // global scope, log attribute + Sentry.logger.info('log_after_global_scope', { log_attr: 'log_attr_2' }); + + Sentry.withIsolationScope(isolationScope => { + isolationScope.setAttribute('isolation_scope_1_attr', { value: 100, unit: 'millisecond' }); + + // global scope, isolation scope, log attribute + Sentry.logger.info('log_with_isolation_scope', { log_attr: 'log_attr_3' }); + + Sentry.withScope(scope => { + scope.setAttribute('scope_attr', { value: 200, unit: 'millisecond' }); + + // global scope, isolation scope, current scope attribute, log attribute + Sentry.logger.info('log_with_scope', { log_attr: 'log_attr_4' }); + }); + + Sentry.withScope(scope2 => { + scope2.setAttribute('scope_2_attr', { value: 300, unit: 'millisecond' }); + + // global scope, isolation scope, current scope attribute, log attribute + Sentry.logger.info('log_with_scope_2', { log_attr: 'log_attr_5' }); + }); + }); + + await Sentry.flush(); +} + +// eslint-disable-next-line @typescript-eslint/no-floating-promises +run(); diff --git a/dev-packages/node-integration-tests/suites/public-api/logger/test.ts b/dev-packages/node-integration-tests/suites/public-api/logger/test.ts new file mode 100644 index 000000000000..c507d51d8ce4 --- /dev/null +++ b/dev-packages/node-integration-tests/suites/public-api/logger/test.ts @@ -0,0 +1,109 @@ +import type { SerializedLog } from '@sentry/core'; +import { afterAll, describe, expect, test } from 'vitest'; +import { cleanupChildProcesses, createRunner } from '../../../utils/runner'; + +const commonAttributes: SerializedLog['attributes'] = { + 'sentry.environment': { + type: 'string', + value: 'test', + }, + 'sentry.release': { + type: 'string', + value: '1.0.0', + }, + 'sentry.sdk.name': { + type: 'string', + value: 'sentry.javascript.node', + }, + 'sentry.sdk.version': { + type: 'string', + value: expect.any(String), + }, + 'server.address': { + type: 'string', + value: expect.any(String), + }, +}; + +describe('logs', () => { + afterAll(() => { + cleanupChildProcesses(); + }); + + test('captures logs with scope and log attributes', async () => { + const runner = createRunner(__dirname, 'scenario.ts') + .expect({ + log: { + items: [ + { + timestamp: expect.any(Number), + level: 'info', + body: 'log_before_any_scope', + severity_number: 9, + trace_id: expect.any(String), + attributes: { + ...commonAttributes, + log_attr: { value: 'log_attr_1', type: 'string' }, + }, + }, + { + timestamp: expect.any(Number), + level: 'info', + body: 'log_after_global_scope', + severity_number: 9, + trace_id: expect.any(String), + attributes: { + ...commonAttributes, + global_scope_attr: { value: true, type: 'boolean' }, + log_attr: { value: 'log_attr_2', type: 'string' }, + }, + }, + { + timestamp: expect.any(Number), + level: 'info', + body: 'log_with_isolation_scope', + severity_number: 9, + trace_id: expect.any(String), + attributes: { + ...commonAttributes, + global_scope_attr: { value: true, type: 'boolean' }, + isolation_scope_1_attr: { value: 100, unit: 'millisecond', type: 'integer' }, + log_attr: { value: 'log_attr_3', type: 'string' }, + }, + }, + { + timestamp: expect.any(Number), + level: 'info', + body: 'log_with_scope', + severity_number: 9, + trace_id: expect.any(String), + attributes: { + ...commonAttributes, + global_scope_attr: { value: true, type: 'boolean' }, + isolation_scope_1_attr: { value: 100, unit: 'millisecond', type: 'integer' }, + scope_attr: { value: 200, unit: 'millisecond', type: 'integer' }, + log_attr: { value: 'log_attr_4', type: 'string' }, + }, + }, + { + timestamp: expect.any(Number), + level: 'info', + body: 'log_with_scope_2', + severity_number: 9, + trace_id: expect.any(String), + attributes: { + ...commonAttributes, + global_scope_attr: { value: true, type: 'boolean' }, + isolation_scope_1_attr: { value: 100, unit: 'millisecond', type: 'integer' }, + scope_2_attr: { value: 300, unit: 'millisecond', type: 'integer' }, + log_attr: { value: 'log_attr_5', type: 'string' }, + }, + }, + ], + }, + }) + .start(); + + await runner.completed(); + }); +}); diff --git a/packages/core/src/attributes.ts b/packages/core/src/attributes.ts index d979d5c4350f..b31e264a59f2 100644 --- a/packages/core/src/attributes.ts +++ b/packages/core/src/attributes.ts @@ -1,6 +1,4 @@ -import { DEBUG_BUILD } from './debug-build'; import type { DurationUnit, FractionUnit, InformationUnit } from './types-hoist/measurement'; -import { debug } from './utils/debug-logger'; export type RawAttributes = T & ValidatedAttributes; // eslint-disable-next-line @typescript-eslint/no-explicit-any @@ -63,36 +61,73 @@ export function isAttributeObject(maybeObj: unknown): maybeObj is AttributeObjec ); } +export function attributeValueToTypedAttributeValue(rawValue: unknown, useFallback?: true): TypedAttributeValue; + /** * Converts an attribute value to a typed attribute value. * - * Does not allow mixed arrays. In case of a mixed array, the value is stringified and the type is 'string'. - * All values besides the supported attribute types (see {@link AttributeTypeMap}) are stringified to a string attribute value. + * For now, we intentionally only support primitive values and attribute objects with primitive values. + * If @param useFallback is true, we stringify non-primitive values to a string attribute value. Otherwise + * we return `undefined` for unsupported values. * * @param value - The value of the passed attribute. + * @param useFallback - If true, unsupported values will be stringified to a string attribute value. + * Defaults to false. In this case, `undefined` is returned for unsupported values. * @returns The typed attribute. */ -export function attributeValueToTypedAttributeValue(rawValue: unknown): TypedAttributeValue { +export function attributeValueToTypedAttributeValue( + rawValue: unknown, + useFallback = false, +): TypedAttributeValue | void { const { value, unit } = isAttributeObject(rawValue) ? rawValue : { value: rawValue, unit: undefined }; - return { ...getTypedAttributeValue(value), ...(unit && typeof unit === 'string' ? { unit } : {}) }; -} + const attributeValue = getTypedAttributeValue(value); + const checkedUnit = unit && typeof unit === 'string' ? { unit } : {}; + if (attributeValue) { + return { ...attributeValue, ...checkedUnit }; + } -// Only allow string, boolean, or number types -const getPrimitiveType: ( - item: unknown, -) => keyof Pick | null = item => - typeof item === 'string' - ? 'string' - : typeof item === 'boolean' - ? 'boolean' - : typeof item === 'number' && !Number.isNaN(item) - ? Number.isInteger(item) - ? 'integer' - : 'double' - : null; + if (!useFallback) { + return; + } -function getTypedAttributeValue(value: unknown): TypedAttributeValue { - const primitiveType = getPrimitiveType(value); + // Fallback: stringify the value + // TODO(v11): be smarter here and use String constructor if stringify fails + // (this is a breaking change for already existing attribute values) + let stringValue = ''; + try { + stringValue = JSON.stringify(value) ?? ''; + } catch { + // Do nothing + } + return { + value: stringValue, + type: 'string', + ...checkedUnit, + }; +} + +/** + * NOTE: We intentionally do not return anything for non-primitive values: + * - array support will come in the future but if we stringify arrays now, + * sending arrays (unstringified) later will be a subtle breaking change. + * - Objects are not supported yet and product support is still TBD. + * - We still keep the type signature for TypedAttributeValue wider to avoid a + * breaking change once we add support for non-primitive values. + * - Once we go back to supporting arrays and stringifying all other values, + * we already implemented the serialization logic here: + * https://github.com/getsentry/sentry-javascript/pull/18165 + */ +function getTypedAttributeValue(value: unknown): TypedAttributeValue | void { + const primitiveType = + typeof value === 'string' + ? 'string' + : typeof value === 'boolean' + ? 'boolean' + : typeof value === 'number' && !Number.isNaN(value) + ? Number.isInteger(value) + ? 'integer' + : 'double' + : null; if (primitiveType) { // @ts-expect-error - TS complains because {@link TypedAttributeValue} is strictly typed to // avoid setting the wrong `type` on the attribute value. @@ -102,40 +137,4 @@ function getTypedAttributeValue(value: unknown): TypedAttributeValue { // Therefore, we ignore it. return { value, type: primitiveType }; } - - if (Array.isArray(value)) { - const coherentArrayType = value.reduce((acc: 'string' | 'boolean' | 'integer' | 'double' | null, item) => { - if (!acc || getPrimitiveType(item) !== acc) { - return null; - } - return acc; - }, getPrimitiveType(value[0])); - - if (coherentArrayType) { - return { value, type: `${coherentArrayType}[]` }; - } - } - - // Fallback: stringify the passed value - let fallbackValue = ''; - try { - fallbackValue = JSON.stringify(value) ?? String(value); - } catch { - try { - fallbackValue = String(value); - } catch { - DEBUG_BUILD && debug.warn('Failed to stringify attribute value', value); - // ignore - } - } - - // This is quite a low-quality message but we cannot safely log the original `value` - // here due to String() or JSON.stringify() potentially throwing. - DEBUG_BUILD && - debug.log(`Stringified attribute value to ${fallbackValue} because it's not a supported attribute value type`); - - return { - value: fallbackValue, - type: 'string', - }; } diff --git a/packages/core/src/logs/internal.ts b/packages/core/src/logs/internal.ts index 819c51c7e3f1..f2dc407dd001 100644 --- a/packages/core/src/logs/internal.ts +++ b/packages/core/src/logs/internal.ts @@ -1,10 +1,11 @@ +import { attributeValueToTypedAttributeValue } from '../attributes'; import { getGlobalSingleton } from '../carrier'; import type { Client } from '../client'; import { getClient, getCurrentScope, getGlobalScope, getIsolationScope } from '../currentScopes'; import { DEBUG_BUILD } from '../debug-build'; import type { Scope, ScopeData } from '../scope'; import type { Integration } from '../types-hoist/integration'; -import type { Log, SerializedLog, SerializedLogAttributeValue } from '../types-hoist/log'; +import type { Log, SerializedLog } from '../types-hoist/log'; import { mergeScopeData } from '../utils/applyScopeDataToEvent'; import { consoleSandbox, debug } from '../utils/debug-logger'; import { isParameterizedString } from '../utils/is'; @@ -16,51 +17,6 @@ import { createLogEnvelope } from './envelope'; const MAX_LOG_BUFFER_SIZE = 100; -/** - * Converts a log attribute to a serialized log attribute. - * - * @param key - The key of the log attribute. - * @param value - The value of the log attribute. - * @returns The serialized log attribute. - */ -export function logAttributeToSerializedLogAttribute(value: unknown): SerializedLogAttributeValue { - switch (typeof value) { - case 'number': - if (Number.isInteger(value)) { - return { - value, - type: 'integer', - }; - } - return { - value, - type: 'double', - }; - case 'boolean': - return { - value, - type: 'boolean', - }; - case 'string': - return { - value, - type: 'string', - }; - default: { - let stringValue = ''; - try { - stringValue = JSON.stringify(value) ?? ''; - } catch { - // Do nothing - } - return { - value: stringValue, - type: 'string', - }; - } - } -} - /** * Sets a log attribute if the value exists and the attribute key is not already present. * @@ -141,7 +97,9 @@ export function _INTERNAL_captureLog( const { user: { id, email, username }, + attributes: scopeAttributes = {}, } = getMergedScopeData(currentScope); + setLogAttribute(processedLogAttributes, 'user.id', id, false); setLogAttribute(processedLogAttributes, 'user.email', email, false); setLogAttribute(processedLogAttributes, 'user.name', username, false); @@ -195,7 +153,17 @@ export function _INTERNAL_captureLog( return; } - const { level, message, attributes = {}, severityNumber } = log; + const { level, message, attributes: logAttributes = {}, severityNumber } = log; + + const serializedScopeAttributes = Object.fromEntries( + Object.entries(scopeAttributes) + .map(([key, value]) => [key, attributeValueToTypedAttributeValue(value)]) + .filter(([, value]) => value != null), + ); + + const serializedLogAttributes = Object.fromEntries( + Object.entries(logAttributes).map(([key, value]) => [key, attributeValueToTypedAttributeValue(value, true)]), + ); const serializedLog: SerializedLog = { timestamp: timestampInSeconds(), @@ -203,13 +171,10 @@ export function _INTERNAL_captureLog( body: message, trace_id: traceContext?.trace_id, severity_number: severityNumber ?? SEVERITY_TEXT_TO_SEVERITY_NUMBER[level], - attributes: Object.keys(attributes).reduce( - (acc, key) => { - acc[key] = logAttributeToSerializedLogAttribute(attributes[key]); - return acc; - }, - {} as Record, - ), + attributes: { + ...serializedScopeAttributes, + ...serializedLogAttributes, + }, }; captureSerializedLog(client, serializedLog); diff --git a/packages/core/src/scope.ts b/packages/core/src/scope.ts index 2ec1f6480788..0639cdb845f1 100644 --- a/packages/core/src/scope.ts +++ b/packages/core/src/scope.ts @@ -306,8 +306,12 @@ export class Scope { /** * Sets attributes onto the scope. * - * TODO: - * Currently, these attributes are not applied to any telemetry data but they will be in the future. + * These attributes are currently only applied to logs. + * In the future, they will also be applied to metrics and spans. + * + * Important: For now, only strings, numbers and boolean attributes are supported, despite types allowing for + * more complex attribute types. We'll add this support in the future but already specify the wider type to + * avoid a breaking change in the future. * * @param newAttributes - The attributes to set on the scope. You can either pass in key-value pairs, or * an object with a `value` and an optional `unit` (if applicable to your attribute). @@ -317,7 +321,6 @@ export class Scope { * scope.setAttributes({ * is_admin: true, * payment_selection: 'credit_card', - * clicked_products: [130, 554, 292], * render_duration: { value: 'render_duration', unit: 'ms' }, * }); * ``` @@ -335,8 +338,12 @@ export class Scope { /** * Sets an attribute onto the scope. * - * TODO: - * Currently, these attributes are not applied to any telemetry data but they will be in the future. + * These attributes are currently only applied to logs. + * In the future, they will also be applied to metrics and spans. + * + * Important: For now, only strings, numbers and boolean attributes are supported, despite types allowing for + * more complex attribute types. We'll add this support in the future but already specify the wider type to + * avoid a breaking change in the future. * * @param key - The attribute key. * @param value - the attribute value. You can either pass in a raw value, or an attribute @@ -345,7 +352,6 @@ export class Scope { * @example * ```typescript * scope.setAttribute('is_admin', true); - * scope.setAttribute('clicked_products', [130, 554, 292]); * scope.setAttribute('render_duration', { value: 'render_duration', unit: 'ms' }); * ``` */ diff --git a/packages/core/src/types-hoist/log.ts b/packages/core/src/types-hoist/log.ts index 1a6e3974e91e..7c704d3caf77 100644 --- a/packages/core/src/types-hoist/log.ts +++ b/packages/core/src/types-hoist/log.ts @@ -1,3 +1,4 @@ +import type { Attributes } from '../attributes'; import type { ParameterizedString } from './parameterize'; export type LogSeverityLevel = 'trace' | 'debug' | 'info' | 'warn' | 'error' | 'fatal'; @@ -30,12 +31,6 @@ export interface Log { severityNumber?: number; } -export type SerializedLogAttributeValue = - | { value: string; type: 'string' } - | { value: number; type: 'integer' } - | { value: number; type: 'double' } - | { value: boolean; type: 'boolean' }; - export interface SerializedLog { /** * Timestamp in seconds (epoch time) indicating when the log occurred. @@ -60,7 +55,7 @@ export interface SerializedLog { /** * Arbitrary structured data that stores information about the log - e.g., userId: 100. */ - attributes?: Record; + attributes?: Attributes; /** * The severity number. diff --git a/packages/core/src/utils/applyScopeDataToEvent.ts b/packages/core/src/utils/applyScopeDataToEvent.ts index 5fcce32be2ba..3770c41977dc 100644 --- a/packages/core/src/utils/applyScopeDataToEvent.ts +++ b/packages/core/src/utils/applyScopeDataToEvent.ts @@ -32,6 +32,7 @@ export function mergeScopeData(data: ScopeData, mergeData: ScopeData): void { const { extra, tags, + attributes, user, contexts, level, @@ -47,6 +48,7 @@ export function mergeScopeData(data: ScopeData, mergeData: ScopeData): void { mergeAndOverwriteScopeData(data, 'extra', extra); mergeAndOverwriteScopeData(data, 'tags', tags); + mergeAndOverwriteScopeData(data, 'attributes', attributes); mergeAndOverwriteScopeData(data, 'user', user); mergeAndOverwriteScopeData(data, 'contexts', contexts); @@ -88,7 +90,7 @@ export function mergeScopeData(data: ScopeData, mergeData: ScopeData): void { * Exported only for tests. */ export function mergeAndOverwriteScopeData< - Prop extends 'extra' | 'tags' | 'user' | 'contexts' | 'sdkProcessingMetadata', + Prop extends 'extra' | 'tags' | 'attributes' | 'user' | 'contexts' | 'sdkProcessingMetadata', Data extends ScopeData, >(data: Data, prop: Prop, mergeVal: Data[Prop]): void { data[prop] = merge(data[prop], mergeVal, 1); diff --git a/packages/core/test/lib/attributes.test.ts b/packages/core/test/lib/attributes.test.ts index 99aa20d07c85..9d9b2d5c1e9a 100644 --- a/packages/core/test/lib/attributes.test.ts +++ b/packages/core/test/lib/attributes.test.ts @@ -2,260 +2,273 @@ import { describe, expect, it } from 'vitest'; import { attributeValueToTypedAttributeValue, isAttributeObject } from '../../src/attributes'; describe('attributeValueToTypedAttributeValue', () => { - describe('primitive values', () => { - it('converts a string value to a typed attribute value', () => { - const result = attributeValueToTypedAttributeValue('test'); - expect(result).toStrictEqual({ - value: 'test', - type: 'string', + describe('without fallback (default behavior)', () => { + describe('valid primitive values', () => { + it('converts a string value to a typed attribute value', () => { + const result = attributeValueToTypedAttributeValue('test'); + expect(result).toStrictEqual({ + value: 'test', + type: 'string', + }); }); - }); - it('converts an interger number value to a typed attribute value', () => { - const result = attributeValueToTypedAttributeValue(42); - expect(result).toStrictEqual({ - value: 42, - type: 'integer', + it.each([42, 42.0])('converts an integer number value to a typed attribute value (%s)', value => { + const result = attributeValueToTypedAttributeValue(value); + expect(result).toStrictEqual({ + value: value, + type: 'integer', + }); }); - }); - it('converts a double number value to a typed attribute value', () => { - const result = attributeValueToTypedAttributeValue(42.34); - expect(result).toStrictEqual({ - value: 42.34, - type: 'double', + it('converts a double number value to a typed attribute value', () => { + const result = attributeValueToTypedAttributeValue(42.34); + expect(result).toStrictEqual({ + value: 42.34, + type: 'double', + }); }); - }); - it('converts a boolean value to a typed attribute value', () => { - const result = attributeValueToTypedAttributeValue(true); - expect(result).toStrictEqual({ - value: true, - type: 'boolean', + it('converts a boolean value to a typed attribute value', () => { + const result = attributeValueToTypedAttributeValue(true); + expect(result).toStrictEqual({ + value: true, + type: 'boolean', + }); }); }); - }); - describe('arrays', () => { - it('converts an array of strings to a typed attribute value', () => { - const result = attributeValueToTypedAttributeValue(['foo', 'bar']); - expect(result).toStrictEqual({ - value: ['foo', 'bar'], - type: 'string[]', + describe('valid attribute objects', () => { + it('converts a primitive value without unit to a typed attribute value', () => { + const result = attributeValueToTypedAttributeValue({ value: 123.45 }); + expect(result).toStrictEqual({ + value: 123.45, + type: 'double', + }); }); - }); - it('converts an array of integer numbers to a typed attribute value', () => { - const result = attributeValueToTypedAttributeValue([1, 2, 3]); - expect(result).toStrictEqual({ - value: [1, 2, 3], - type: 'integer[]', + it('converts a primitive value with unit to a typed attribute value', () => { + const result = attributeValueToTypedAttributeValue({ value: 123.45, unit: 'ms' }); + expect(result).toStrictEqual({ + value: 123.45, + type: 'double', + unit: 'ms', + }); }); - }); - it('converts an array of double numbers to a typed attribute value', () => { - const result = attributeValueToTypedAttributeValue([1.1, 2.2, 3.3]); - expect(result).toStrictEqual({ - value: [1.1, 2.2, 3.3], - type: 'double[]', + it('extracts the value property and ignores other properties', () => { + const result = attributeValueToTypedAttributeValue({ value: 'foo', unit: 'ms', bar: 'baz' }); + expect(result).toStrictEqual({ + value: 'foo', + unit: 'ms', + type: 'string', + }); }); - }); - - it('converts an array of booleans to a typed attribute value', () => { - const result = attributeValueToTypedAttributeValue([true, false, true]); - expect(result).toStrictEqual({ - value: [true, false, true], - type: 'boolean[]', - }); - }); - }); - describe('attribute objects without units', () => { - // Note: These tests only test exemplar type and fallback behaviour (see above for more cases) - it('converts a primitive value to a typed attribute value', () => { - const result = attributeValueToTypedAttributeValue({ value: 123.45 }); - expect(result).toStrictEqual({ - value: 123.45, - type: 'double', - }); + it.each([1, true, null, undefined, NaN, Symbol('test'), { foo: 'bar' }])( + 'ignores invalid (non-string) units (%s)', + unit => { + const result = attributeValueToTypedAttributeValue({ value: 'foo', unit }); + expect(result).toStrictEqual({ + value: 'foo', + type: 'string', + }); + }, + ); }); - it('converts an array of primitive values to a typed attribute value', () => { - const result = attributeValueToTypedAttributeValue({ value: [true, false] }); - expect(result).toStrictEqual({ - value: [true, false], - type: 'boolean[]', + describe('invalid values (non-primitives)', () => { + it.each([ + ['foo', 'bar'], + [1, 2, 3], + [true, false, true], + [1, 'foo', true], + { foo: 'bar' }, + () => 'test', + Symbol('test'), + ])('returns undefined for non-primitive raw values (%s)', value => { + const result = attributeValueToTypedAttributeValue(value); + expect(result).toBeUndefined(); }); - }); - it('converts an unsupported object value to a string attribute value', () => { - const result = attributeValueToTypedAttributeValue({ value: { foo: 'bar' } }); - expect(result).toStrictEqual({ - value: '{"foo":"bar"}', - type: 'string', + it.each([ + ['foo', 'bar'], + [1, 2, 3], + [true, false, true], + [1, 'foo', true], + { foo: 'bar' }, + () => 'test', + Symbol('test'), + ])('returns undefined for non-primitive attribute object values (%s)', value => { + const result = attributeValueToTypedAttributeValue({ value }); + expect(result).toBeUndefined(); }); }); }); - describe('attribute objects with units', () => { - // Note: These tests only test exemplar type and fallback behaviour (see above for more cases) - it('converts a primitive value to a typed attribute value', () => { - const result = attributeValueToTypedAttributeValue({ value: 123.45, unit: 'ms' }); - expect(result).toStrictEqual({ - value: 123.45, - type: 'double', - unit: 'ms', + describe('with fallback=true', () => { + describe('valid primitive values', () => { + it('converts a string value to a typed attribute value', () => { + const result = attributeValueToTypedAttributeValue('test', true); + expect(result).toStrictEqual({ + value: 'test', + type: 'string', + }); }); - }); - it('converts an array of primitive values to a typed attribute value', () => { - const result = attributeValueToTypedAttributeValue({ value: [true, false], unit: 'count' }); - expect(result).toStrictEqual({ - value: [true, false], - type: 'boolean[]', - unit: 'count', + it('converts an integer number value to a typed attribute value', () => { + const result = attributeValueToTypedAttributeValue(42, true); + expect(result).toStrictEqual({ + value: 42, + type: 'integer', + }); }); - }); - it('converts an unsupported object value to a string attribute value', () => { - const result = attributeValueToTypedAttributeValue({ value: { foo: 'bar' }, unit: 'bytes' }); - expect(result).toStrictEqual({ - value: '{"foo":"bar"}', - type: 'string', - unit: 'bytes', + it('converts a double number value to a typed attribute value', () => { + const result = attributeValueToTypedAttributeValue(42.34, true); + expect(result).toStrictEqual({ + value: 42.34, + type: 'double', + }); }); - }); - it('extracts the value property of an object with a value property', () => { - // and ignores other properties. - // For now we're fine with this but we may reconsider in the future. - const result = attributeValueToTypedAttributeValue({ value: 'foo', unit: 'ms', bar: 'baz' }); - expect(result).toStrictEqual({ - value: 'foo', - unit: 'ms', - type: 'string', + it('converts a boolean value to a typed attribute value', () => { + const result = attributeValueToTypedAttributeValue(true, true); + expect(result).toStrictEqual({ + value: true, + type: 'boolean', + }); }); }); - }); - describe('unsupported value types', () => { - it('stringifies mixed float and integer numbers to a string attribute value', () => { - const result = attributeValueToTypedAttributeValue([1, 2.2, 3]); - expect(result).toStrictEqual({ - value: '[1,2.2,3]', - type: 'string', + describe('valid attribute objects', () => { + it('converts a primitive value without unit to a typed attribute value', () => { + const result = attributeValueToTypedAttributeValue({ value: 123.45 }, true); + expect(result).toStrictEqual({ + value: 123.45, + type: 'double', + }); }); - }); - it('stringifies an array of allowed but incoherent types to a string attribute value', () => { - const result = attributeValueToTypedAttributeValue([1, 'foo', true]); - expect(result).toStrictEqual({ - value: '[1,"foo",true]', - type: 'string', + it('converts a primitive value with unit to a typed attribute value', () => { + const result = attributeValueToTypedAttributeValue({ value: 123.45, unit: 'ms' }, true); + expect(result).toStrictEqual({ + value: 123.45, + type: 'double', + unit: 'ms', + }); }); - }); - it('stringifies an array of disallowed and incoherent types to a string attribute value', () => { - const result = attributeValueToTypedAttributeValue([null, undefined, NaN]); - expect(result).toStrictEqual({ - value: '[null,null,null]', - type: 'string', + it('extracts the value property and ignores other properties', () => { + const result = attributeValueToTypedAttributeValue({ value: 'foo', unit: 'ms', bar: 'baz' }, true); + expect(result).toStrictEqual({ + value: 'foo', + unit: 'ms', + type: 'string', + }); }); - }); - it('stringifies an object value to a string attribute value', () => { - const result = attributeValueToTypedAttributeValue({ foo: 'bar' }); - expect(result).toStrictEqual({ - value: '{"foo":"bar"}', - type: 'string', - }); - }); + it.each([1, true, null, undefined, NaN, { foo: 'bar' }])( + 'ignores invalid (non-string) units and preserves unit on fallback (%s)', + unit => { + const result = attributeValueToTypedAttributeValue({ value: 'foo', unit }, true); + expect(result).toStrictEqual({ + value: 'foo', + type: 'string', + }); + }, + ); - it('stringifies a null value to a string attribute value', () => { - const result = attributeValueToTypedAttributeValue(null); - expect(result).toStrictEqual({ - value: 'null', - type: 'string', + it('preserves valid unit when falling back on invalid value', () => { + const result = attributeValueToTypedAttributeValue({ value: { nested: 'object' }, unit: 'ms' }, true); + expect(result).toStrictEqual({ + value: '{"nested":"object"}', + type: 'string', + unit: 'ms', + }); }); }); - it('stringifies an undefined value to a string attribute value', () => { - const result = attributeValueToTypedAttributeValue(undefined); - expect(result).toStrictEqual({ - value: 'undefined', - type: 'string', + describe('invalid values (non-primitives) - stringified fallback', () => { + it('stringifies string arrays', () => { + const result = attributeValueToTypedAttributeValue(['foo', 'bar'], true); + expect(result).toStrictEqual({ + value: '["foo","bar"]', + type: 'string', + }); }); - }); - it('stringifies an NaN number value to a string attribute value', () => { - const result = attributeValueToTypedAttributeValue(NaN); - expect(result).toStrictEqual({ - value: 'null', - type: 'string', + it('stringifies number arrays', () => { + const result = attributeValueToTypedAttributeValue([1, 2, 3], true); + expect(result).toStrictEqual({ + value: '[1,2,3]', + type: 'string', + }); }); - }); - it('converts an object toString if stringification fails', () => { - const result = attributeValueToTypedAttributeValue({ - value: { - toJson: () => { - throw new Error('test'); - }, - }, + it('stringifies boolean arrays', () => { + const result = attributeValueToTypedAttributeValue([true, false, true], true); + expect(result).toStrictEqual({ + value: '[true,false,true]', + type: 'string', + }); }); - expect(result).toStrictEqual({ - value: '{}', - type: 'string', + + it('stringifies mixed arrays', () => { + const result = attributeValueToTypedAttributeValue([1, 'foo', true], true); + expect(result).toStrictEqual({ + value: '[1,"foo",true]', + type: 'string', + }); }); - }); - it('falls back to an empty string if stringification and toString fails', () => { - const result = attributeValueToTypedAttributeValue({ - value: { - toJSON: () => { - throw new Error('test'); - }, - toString: () => { - throw new Error('test'); - }, - }, + it('stringifies objects', () => { + const result = attributeValueToTypedAttributeValue({ foo: 'bar' }, true); + expect(result).toStrictEqual({ + value: '{"foo":"bar"}', + type: 'string', + }); }); - expect(result).toStrictEqual({ - value: '', - type: 'string', + + it('returns empty string for non-stringifiable values (functions)', () => { + const result = attributeValueToTypedAttributeValue(() => 'test', true); + expect(result).toStrictEqual({ + value: '', + type: 'string', + }); }); - }); - it('converts a function toString ', () => { - const result = attributeValueToTypedAttributeValue(() => { - return 'test'; + it('returns empty string for non-stringifiable values (symbols)', () => { + const result = attributeValueToTypedAttributeValue(Symbol('test'), true); + expect(result).toStrictEqual({ + value: '', + type: 'string', + }); }); - expect(result).toStrictEqual({ - value: '() => {\n return "test";\n }', - type: 'string', + it('returns empty string if JSON.stringify fails', () => { + const result = attributeValueToTypedAttributeValue( + { + toJSON: () => { + throw new Error('test'); + }, + }, + true, + ); + expect(result).toStrictEqual({ + value: '', + type: 'string', + }); }); - }); - it('converts a symbol toString', () => { - const result = attributeValueToTypedAttributeValue(Symbol('test')); - expect(result).toStrictEqual({ - value: 'Symbol(test)', - type: 'string', + it('stringifies non-primitive attribute object values', () => { + const result = attributeValueToTypedAttributeValue({ value: { nested: 'object' } }, true); + expect(result).toStrictEqual({ + value: '{"nested":"object"}', + type: 'string', + }); }); }); }); - - it.each([1, true, null, undefined, NaN, Symbol('test'), { foo: 'bar' }])( - 'ignores invalid (non-string) units (%s)', - unit => { - const result = attributeValueToTypedAttributeValue({ value: 'foo', unit }); - expect(result).toStrictEqual({ - value: 'foo', - type: 'string', - }); - }, - ); }); describe('isAttributeObject', () => { diff --git a/packages/core/test/lib/logs/internal.test.ts b/packages/core/test/lib/logs/internal.test.ts index 563139aba36d..2eec7c64dcbc 100644 --- a/packages/core/test/lib/logs/internal.test.ts +++ b/packages/core/test/lib/logs/internal.test.ts @@ -1,85 +1,12 @@ import { describe, expect, it, vi } from 'vitest'; import { fmt, Scope } from '../../../src'; -import { - _INTERNAL_captureLog, - _INTERNAL_flushLogsBuffer, - _INTERNAL_getLogBuffer, - logAttributeToSerializedLogAttribute, -} from '../../../src/logs/internal'; +import { _INTERNAL_captureLog, _INTERNAL_flushLogsBuffer, _INTERNAL_getLogBuffer } from '../../../src/logs/internal'; import type { Log } from '../../../src/types-hoist/log'; import * as loggerModule from '../../../src/utils/debug-logger'; import { getDefaultTestClientOptions, TestClient } from '../../mocks/client'; const PUBLIC_DSN = 'https://username@domain/123'; -describe('logAttributeToSerializedLogAttribute', () => { - it('serializes integer values', () => { - const result = logAttributeToSerializedLogAttribute(42); - expect(result).toEqual({ - value: 42, - type: 'integer', - }); - }); - - it('serializes double values', () => { - const result = logAttributeToSerializedLogAttribute(42.34); - expect(result).toEqual({ - value: 42.34, - type: 'double', - }); - }); - - it('serializes boolean values', () => { - const result = logAttributeToSerializedLogAttribute(true); - expect(result).toEqual({ - value: true, - type: 'boolean', - }); - }); - - it('serializes string values', () => { - const result = logAttributeToSerializedLogAttribute('username'); - expect(result).toEqual({ - value: 'username', - type: 'string', - }); - }); - - it('serializes object values as JSON strings', () => { - const obj = { name: 'John', age: 30 }; - const result = logAttributeToSerializedLogAttribute(obj); - expect(result).toEqual({ - value: JSON.stringify(obj), - type: 'string', - }); - }); - - it('serializes array values as JSON strings', () => { - const array = [1, 2, 3, 'test']; - const result = logAttributeToSerializedLogAttribute(array); - expect(result).toEqual({ - value: JSON.stringify(array), - type: 'string', - }); - }); - - it('serializes undefined values as empty strings', () => { - const result = logAttributeToSerializedLogAttribute(undefined); - expect(result).toEqual({ - value: '', - type: 'string', - }); - }); - - it('serializes null values as JSON strings', () => { - const result = logAttributeToSerializedLogAttribute(null); - expect(result).toEqual({ - value: 'null', - type: 'string', - }); - }); -}); - describe('_INTERNAL_captureLog', () => { it('captures and sends logs', () => { const options = getDefaultTestClientOptions({ dsn: PUBLIC_DSN, enableLogs: true }); @@ -215,31 +142,84 @@ describe('_INTERNAL_captureLog', () => { ); }); - it('includes custom attributes in log', () => { - const options = getDefaultTestClientOptions({ dsn: PUBLIC_DSN, enableLogs: true }); - const client = new TestClient(options); - const scope = new Scope(); - scope.setClient(client); + describe('attributes', () => { + it('includes custom attributes in log', () => { + const options = getDefaultTestClientOptions({ dsn: PUBLIC_DSN, enableLogs: true }); + const client = new TestClient(options); + const scope = new Scope(); + scope.setClient(client); - _INTERNAL_captureLog( - { - level: 'info', - message: 'test log with custom attributes', - attributes: { userId: '123', component: 'auth' }, - }, - scope, - ); + _INTERNAL_captureLog( + { + level: 'info', + message: 'test log with custom attributes', + attributes: { userId: '123', component: 'auth' }, + }, + scope, + ); - const logAttributes = _INTERNAL_getLogBuffer(client)?.[0]?.attributes; - expect(logAttributes).toEqual({ - userId: { - value: '123', - type: 'string', - }, - component: { - value: 'auth', - type: 'string', - }, + const logAttributes = _INTERNAL_getLogBuffer(client)?.[0]?.attributes; + expect(logAttributes).toEqual({ + userId: { + value: '123', + type: 'string', + }, + component: { + value: 'auth', + type: 'string', + }, + }); + }); + + it('applies scope attributes attributes to log', () => { + const options = getDefaultTestClientOptions({ dsn: PUBLIC_DSN, enableLogs: true }); + const client = new TestClient(options); + const scope = new Scope(); + scope.setClient(client); + + scope.setAttribute('scope_1', 'attribute_value'); + scope.setAttribute('scope_2', { value: 38, unit: 'gigabyte' }); + scope.setAttributes({ + scope_3: true, + // these are invalid since for now we don't support arrays + scope_4: [1, 2, 3], + scope_5: { value: [true, false, true], unit: 'second' }, + }); + + _INTERNAL_captureLog( + { + level: 'info', + message: 'test log with custom attributes', + attributes: { userId: '123', component: 'auth' }, + }, + scope, + ); + + const logAttributes = _INTERNAL_getLogBuffer(client)?.[0]?.attributes; + + expect(logAttributes).toStrictEqual({ + userId: { + value: '123', + type: 'string', + }, + component: { + value: 'auth', + type: 'string', + }, + scope_1: { + type: 'string', + value: 'attribute_value', + }, + scope_2: { + type: 'integer', + unit: 'gigabyte', + value: 38, + }, + scope_3: { + type: 'boolean', + value: true, + }, + }); }); }); @@ -329,6 +309,9 @@ describe('_INTERNAL_captureLog', () => { const scope = new Scope(); scope.setClient(client); + scope.setAttribute('scope_1', 'attribute_value'); + scope.setAttribute('scope_2', { value: 38, unit: 'gigabytes' }); + _INTERNAL_captureLog( { level: 'info', @@ -341,7 +324,10 @@ describe('_INTERNAL_captureLog', () => { expect(beforeSendLog).toHaveBeenCalledWith({ level: 'info', message: 'original message', - attributes: { original: true }, + attributes: { + original: true, + // scope attributes are not included in beforeSendLog - they're only added during serialization + }, }); const logBuffer = _INTERNAL_getLogBuffer(client); @@ -358,6 +344,16 @@ describe('_INTERNAL_captureLog', () => { value: true, type: 'boolean', }, + // during serialization, they're converted to the typed attribute format + scope_1: { + value: 'attribute_value', + type: 'string', + }, + scope_2: { + value: 38, + unit: 'gigabytes', + type: 'integer', + }, }, }), ); diff --git a/packages/core/test/lib/utils/applyScopeDataToEvent.test.ts b/packages/core/test/lib/utils/applyScopeDataToEvent.test.ts index f85fa0f02617..a23404eaf70f 100644 --- a/packages/core/test/lib/utils/applyScopeDataToEvent.test.ts +++ b/packages/core/test/lib/utils/applyScopeDataToEvent.test.ts @@ -83,6 +83,7 @@ describe('mergeScopeData', () => { breadcrumbs: [], user: {}, tags: {}, + attributes: {}, extra: {}, contexts: {}, attachments: [], @@ -95,6 +96,7 @@ describe('mergeScopeData', () => { breadcrumbs: [], user: {}, tags: {}, + attributes: {}, extra: {}, contexts: {}, attachments: [], @@ -108,6 +110,7 @@ describe('mergeScopeData', () => { breadcrumbs: [], user: {}, tags: {}, + attributes: {}, extra: {}, contexts: {}, attachments: [], @@ -135,6 +138,7 @@ describe('mergeScopeData', () => { breadcrumbs: [breadcrumb1], user: { id: '1', email: 'test@example.com' }, tags: { tag1: 'aa', tag2: 'aa' }, + attributes: { attr1: { value: 'value1', type: 'string' }, attr2: { value: 123, type: 'integer' } }, extra: { extra1: 'aa', extra2: 'aa' }, contexts: { os: { name: 'os1' }, culture: { display_name: 'name1' } }, attachments: [attachment1], @@ -155,6 +159,7 @@ describe('mergeScopeData', () => { breadcrumbs: [breadcrumb2, breadcrumb3], user: { id: '2', name: 'foo' }, tags: { tag2: 'bb', tag3: 'bb' }, + attributes: { attr2: { value: 456, type: 'integer' }, attr3: { value: 'value3', type: 'string' } }, extra: { extra2: 'bb', extra3: 'bb' }, contexts: { os: { name: 'os2' } }, attachments: [attachment2, attachment3], @@ -176,6 +181,11 @@ describe('mergeScopeData', () => { breadcrumbs: [breadcrumb1, breadcrumb2, breadcrumb3], user: { id: '2', name: 'foo', email: 'test@example.com' }, tags: { tag1: 'aa', tag2: 'bb', tag3: 'bb' }, + attributes: { + attr1: { value: 'value1', type: 'string' }, + attr2: { value: 456, type: 'integer' }, + attr3: { value: 'value3', type: 'string' }, + }, extra: { extra1: 'aa', extra2: 'bb', extra3: 'bb' }, contexts: { os: { name: 'os2' }, culture: { display_name: 'name1' } }, attachments: [attachment1, attachment2, attachment3],