From 848a44d12420147b1bd068142544fe8fb94d7079 Mon Sep 17 00:00:00 2001 From: Lukas Stracke Date: Wed, 12 Nov 2025 12:42:08 +0100 Subject: [PATCH 01/19] feat(core): Apply scope attributes to logs --- packages/core/src/logs/internal.ts | 62 +++++----------------------- packages/core/src/types-hoist/log.ts | 9 +--- 2 files changed, 12 insertions(+), 59 deletions(-) diff --git a/packages/core/src/logs/internal.ts b/packages/core/src/logs/internal.ts index 819c51c7e3f1..19e4f3871355 100644 --- a/packages/core/src/logs/internal.ts +++ b/packages/core/src/logs/internal.ts @@ -1,10 +1,12 @@ +import type { TypedAttributes } from '../attributes'; +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 +18,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,6 +98,7 @@ 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); @@ -203,13 +161,13 @@ 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]); + attributes: { + ...scopeAttributes, + ...Object.keys(attributes).reduce((acc, key) => { + acc[key] = attributeValueToTypedAttributeValue(attributes[key]); return acc; - }, - {} as Record, - ), + }, {} as TypedAttributes), + }, }; captureSerializedLog(client, serializedLog); diff --git a/packages/core/src/types-hoist/log.ts b/packages/core/src/types-hoist/log.ts index 1a6e3974e91e..04dbb4f3a149 100644 --- a/packages/core/src/types-hoist/log.ts +++ b/packages/core/src/types-hoist/log.ts @@ -1,3 +1,4 @@ +import type { TypedAttributes } 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?: TypedAttributes; /** * The severity number. From 3741fa259e2ca6cfa2314dda84ed36805e1bc08a Mon Sep 17 00:00:00 2001 From: Lukas Stracke Date: Thu, 13 Nov 2025 11:00:21 +0100 Subject: [PATCH 02/19] wip --- packages/core/src/logs/internal.ts | 1 + packages/core/src/types-hoist/options.ts | 19 ++ .../core/src/utils/applyScopeDataToEvent.ts | 4 +- packages/core/test/lib/logs/internal.test.ts | 192 +++++++++--------- .../lib/utils/applyScopeDataToEvent.test.ts | 10 + 5 files changed, 127 insertions(+), 99 deletions(-) diff --git a/packages/core/src/logs/internal.ts b/packages/core/src/logs/internal.ts index 19e4f3871355..e9e8395c0fdc 100644 --- a/packages/core/src/logs/internal.ts +++ b/packages/core/src/logs/internal.ts @@ -162,6 +162,7 @@ export function _INTERNAL_captureLog( trace_id: traceContext?.trace_id, severity_number: severityNumber ?? SEVERITY_TEXT_TO_SEVERITY_NUMBER[level], attributes: { + // TODO: This is too late to apply scope attributes because we already invoked beforeSendLog earlier. ...scopeAttributes, ...Object.keys(attributes).reduce((acc, key) => { acc[key] = attributeValueToTypedAttributeValue(attributes[key]); diff --git a/packages/core/src/types-hoist/options.ts b/packages/core/src/types-hoist/options.ts index c33d0107df5f..57a6ac6d993a 100644 --- a/packages/core/src/types-hoist/options.ts +++ b/packages/core/src/types-hoist/options.ts @@ -526,3 +526,22 @@ export interface CoreOptions(data: Data, prop: Prop, mergeVal: Data[Prop]): void { data[prop] = merge(data[prop], mergeVal, 1); diff --git a/packages/core/test/lib/logs/internal.test.ts b/packages/core/test/lib/logs/internal.test.ts index 563139aba36d..2fb42aa8a3e6 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,92 @@ 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: 143.5, type: 'double', unit: 'bytes' }); + scope.setAttributes({ + scope_3: true, + scope_4: [1, 2, 3], + scope_5: { value: [true, false, true], type: 'boolean[]', unit: 's' }, + }); + + _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', + }, + scope_1: { + type: 'string', + value: 'attribute_value', + }, + scope_2: { + type: 'double', + unit: 'bytes', + value: 143.5, + }, + scope_3: { + type: 'boolean', + value: true, + }, + scope_4: { + type: 'integer[]', + value: [1, 2, 3], + }, + scope_5: { + type: 'boolean[]', + value: [true, false, true], + unit: 's', + }, + }); }); }); @@ -329,6 +317,9 @@ describe('_INTERNAL_captureLog', () => { const scope = new Scope(); scope.setClient(client); + scope.setAttribute('scope_1', 'attribute_value'); + scope.setAttribute('scope_2', { value: 143.5, type: 'double', unit: 'bytes' }); + _INTERNAL_captureLog( { level: 'info', @@ -341,7 +332,12 @@ describe('_INTERNAL_captureLog', () => { expect(beforeSendLog).toHaveBeenCalledWith({ level: 'info', message: 'original message', - attributes: { original: true }, + attributes: { + original: true, + // scope attributes should already be applied prior to beforeSendLog + scope_1: 'attribute_value', + scope_2: { value: 143.5, type: 'double', unit: 'bytes' }, + }, }); const logBuffer = _INTERNAL_getLogBuffer(client); 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], From 4fef1b6037934a0dc148765d52388d956d76533f Mon Sep 17 00:00:00 2001 From: Lukas Stracke Date: Mon, 17 Nov 2025 17:32:07 +0100 Subject: [PATCH 03/19] adjust after scope attribute changes --- .../public-api/logger/integration/test.ts | 4 +-- packages/core/src/attributes.ts | 2 +- packages/core/src/logs/internal.ts | 15 +++++------ packages/core/test/lib/logs/internal.test.ts | 26 +++++++++++++------ 4 files changed, 28 insertions(+), 19 deletions(-) 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..fc3fa61163c6 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 @@ -166,7 +166,7 @@ sentryTest('should capture console object calls', async ({ getLocalTestUrl, page 'sentry.message.template': { value: 'Mixed: {} {} {} {}', type: 'string' }, 'sentry.message.parameter.0': { value: 'prefix', type: 'string' }, 'sentry.message.parameter.1': { value: '{"obj":true}', type: 'string' }, - 'sentry.message.parameter.2': { value: '[4,5,6]', type: 'string' }, + 'sentry.message.parameter.2': { value: [4, 5, 6], type: 'integer[]' }, 'sentry.message.parameter.3': { value: 'suffix', type: 'string' }, }, }, @@ -235,7 +235,7 @@ sentryTest('should capture console object calls', async ({ getLocalTestUrl, page 'sentry.message.template': { value: 'hello {} {} {}', type: 'string' }, 'sentry.message.parameter.0': { value: true, type: 'boolean' }, 'sentry.message.parameter.1': { value: 'null', type: 'string' }, - 'sentry.message.parameter.2': { value: '', type: 'string' }, + 'sentry.message.parameter.2': { value: 'undefined', type: 'string' }, }, }, ], diff --git a/packages/core/src/attributes.ts b/packages/core/src/attributes.ts index d979d5c4350f..28f2f80a63f6 100644 --- a/packages/core/src/attributes.ts +++ b/packages/core/src/attributes.ts @@ -6,7 +6,7 @@ export type RawAttributes = T & ValidatedAttributes; // eslint-disable-next-line @typescript-eslint/no-explicit-any export type RawAttribute = T extends { value: any } | { unit: any } ? AttributeObject : T; -export type Attributes = Record; +export type TypedAttributes = Record; export type AttributeValueType = string | number | boolean | Array | Array | Array; diff --git a/packages/core/src/logs/internal.ts b/packages/core/src/logs/internal.ts index e9e8395c0fdc..ac17d036ca5e 100644 --- a/packages/core/src/logs/internal.ts +++ b/packages/core/src/logs/internal.ts @@ -1,4 +1,4 @@ -import type { TypedAttributes } from '../attributes'; +import type { TypedAttributeValue } from '../attributes'; import { attributeValueToTypedAttributeValue } from '../attributes'; import { getGlobalSingleton } from '../carrier'; import type { Client } from '../client'; @@ -141,7 +141,7 @@ export function _INTERNAL_captureLog( // Add the parent span ID to the log attributes for trace context setLogAttribute(processedLogAttributes, 'sentry.trace.parent_span_id', span?.spanContext().spanId); - const processedLog = { ...beforeLog, attributes: processedLogAttributes }; + const processedLog = { ...beforeLog, attributes: { ...scopeAttributes, ...processedLogAttributes } }; client.emit('beforeCaptureLog', processedLog); @@ -161,14 +161,13 @@ export function _INTERNAL_captureLog( body: message, trace_id: traceContext?.trace_id, severity_number: severityNumber ?? SEVERITY_TEXT_TO_SEVERITY_NUMBER[level], - attributes: { - // TODO: This is too late to apply scope attributes because we already invoked beforeSendLog earlier. - ...scopeAttributes, - ...Object.keys(attributes).reduce((acc, key) => { + attributes: Object.keys(attributes).reduce( + (acc, key) => { acc[key] = attributeValueToTypedAttributeValue(attributes[key]); return acc; - }, {} as TypedAttributes), - }, + }, + {} as Record, + ), }; captureSerializedLog(client, serializedLog); diff --git a/packages/core/test/lib/logs/internal.test.ts b/packages/core/test/lib/logs/internal.test.ts index 2fb42aa8a3e6..0c6cae44e1a7 100644 --- a/packages/core/test/lib/logs/internal.test.ts +++ b/packages/core/test/lib/logs/internal.test.ts @@ -178,11 +178,11 @@ describe('_INTERNAL_captureLog', () => { scope.setClient(client); scope.setAttribute('scope_1', 'attribute_value'); - scope.setAttribute('scope_2', { value: 143.5, type: 'double', unit: 'bytes' }); + scope.setAttribute('scope_2', { value: 38, unit: 'gigabytes' }); scope.setAttributes({ scope_3: true, scope_4: [1, 2, 3], - scope_5: { value: [true, false, true], type: 'boolean[]', unit: 's' }, + scope_5: { value: [true, false, true], unit: 's' }, }); _INTERNAL_captureLog( @@ -210,9 +210,9 @@ describe('_INTERNAL_captureLog', () => { value: 'attribute_value', }, scope_2: { - type: 'double', - unit: 'bytes', - value: 143.5, + type: 'integer', + unit: 'gigabytes', + value: 38, }, scope_3: { type: 'boolean', @@ -318,7 +318,7 @@ describe('_INTERNAL_captureLog', () => { scope.setClient(client); scope.setAttribute('scope_1', 'attribute_value'); - scope.setAttribute('scope_2', { value: 143.5, type: 'double', unit: 'bytes' }); + scope.setAttribute('scope_2', { value: 38, unit: 'gigabytes' }); _INTERNAL_captureLog( { @@ -334,9 +334,9 @@ describe('_INTERNAL_captureLog', () => { message: 'original message', attributes: { original: true, - // scope attributes should already be applied prior to beforeSendLog + // attributes here still have the same form as originally set on the scope or log scope_1: 'attribute_value', - scope_2: { value: 143.5, type: 'double', unit: 'bytes' }, + scope_2: { value: 38, unit: 'gigabytes' }, }, }); @@ -354,6 +354,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', + }, }, }), ); From 54e0b9a4ae9b3b9c20ff5244dd92c28059354eb9 Mon Sep 17 00:00:00 2001 From: Lukas Stracke Date: Tue, 18 Nov 2025 13:15:09 +0100 Subject: [PATCH 04/19] add browser integration test --- .../logger/scopeAttributes/subject.js | 34 +++++++ .../public-api/logger/scopeAttributes/test.ts | 98 +++++++++++++++++++ 2 files changed, 132 insertions(+) create mode 100644 dev-packages/browser-integration-tests/suites/public-api/logger/scopeAttributes/subject.js create mode 100644 dev-packages/browser-integration-tests/suites/public-api/logger/scopeAttributes/test.ts 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..54b0dcf5ce29 --- /dev/null +++ b/dev-packages/browser-integration-tests/suites/public-api/logger/scopeAttributes/subject.js @@ -0,0 +1,34 @@ +// only log attribute +Sentry.logger.info('log_before_any_scope', { log_attr: 'scope_attr_1' }); + +Sentry.getGlobalScope().setAttribute('global_scope_attr', true); + +// global scope, log attribute +Sentry.logger.info('log_after_global_scope', { log_attr: 'scope_attr_2' }); + +let isolScope = null; +let isolScope2 = null; + +Sentry.withIsolationScope(isolationScope => { + isolScope = isolationScope; + isolationScope.setAttribute('isolation_scope_1_attr', { value: 100, unit: 'ms' }); + + // global scope, isolation scope, log attribute + Sentry.logger.info('log_with_isolation_scope', { log_attr: 'scope_attr_3' }); + + Sentry.withScope(scope => { + scope.setAttribute('scope_attr', { value: 200, unit: 'ms' }); + + // global scope, isolation scope, current scope attribute, log attribute + Sentry.logger.info('log_with_scope', { log_attr: 'scope_attr_4' }); + }); + + Sentry.withScope(scope2 => { + scope2.setAttribute('scope_2_attr', { value: 300, unit: 'ms' }); + + // global scope, isolation scope, current scope attribute, log attribute + Sentry.logger.info('log_with_scope_2', { log_attr: 'scope_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..fbf268771996 --- /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 } from '../../../../utils/helpers'; + +sentryTest('captures logs with scope attributes', 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(); + } + + 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: 'scope_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: 'scope_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: 'ms', type: 'integer' }, + log_attr: { value: 'scope_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: 'ms', type: 'integer' }, + scope_attr: { value: 200, unit: 'ms', type: 'integer' }, + log_attr: { value: 'scope_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: 'ms', type: 'integer' }, + scope_2_attr: { value: 300, unit: 'ms', type: 'integer' }, + log_attr: { value: 'scope_attr_5', type: 'string' }, + }, + }, + ], + }, + ]); +}); From c71e1c158aa46b6c8c1e38e772b4fed667433fb3 Mon Sep 17 00:00:00 2001 From: Lukas Stracke Date: Tue, 18 Nov 2025 14:04:46 +0100 Subject: [PATCH 05/19] adjust browser integration test --- .../public-api/logger/scopeAttributes/subject.js | 14 +++++--------- .../public-api/logger/scopeAttributes/test.ts | 10 +++++----- 2 files changed, 10 insertions(+), 14 deletions(-) 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 index 54b0dcf5ce29..1bc9c9a62f0a 100644 --- 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 @@ -1,33 +1,29 @@ // only log attribute -Sentry.logger.info('log_before_any_scope', { log_attr: 'scope_attr_1' }); +Sentry.logger.info('log_before_any_scope', { log_attr: 'log_attr_1' }); Sentry.getGlobalScope().setAttribute('global_scope_attr', true); // global scope, log attribute -Sentry.logger.info('log_after_global_scope', { log_attr: 'scope_attr_2' }); - -let isolScope = null; -let isolScope2 = null; +Sentry.logger.info('log_after_global_scope', { log_attr: 'log_attr_2' }); Sentry.withIsolationScope(isolationScope => { - isolScope = isolationScope; isolationScope.setAttribute('isolation_scope_1_attr', { value: 100, unit: 'ms' }); // global scope, isolation scope, log attribute - Sentry.logger.info('log_with_isolation_scope', { log_attr: 'scope_attr_3' }); + Sentry.logger.info('log_with_isolation_scope', { log_attr: 'log_attr_3' }); Sentry.withScope(scope => { scope.setAttribute('scope_attr', { value: 200, unit: 'ms' }); // global scope, isolation scope, current scope attribute, log attribute - Sentry.logger.info('log_with_scope', { log_attr: 'scope_attr_4' }); + Sentry.logger.info('log_with_scope', { log_attr: 'log_attr_4' }); }); Sentry.withScope(scope2 => { scope2.setAttribute('scope_2_attr', { value: 300, unit: 'ms' }); // global scope, isolation scope, current scope attribute, log attribute - Sentry.logger.info('log_with_scope_2', { log_attr: 'scope_attr_5' }); + Sentry.logger.info('log_with_scope_2', { log_attr: 'log_attr_5' }); }); }); 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 index fbf268771996..6015a952b939 100644 --- 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 @@ -32,7 +32,7 @@ sentryTest('captures logs with scope attributes', async ({ getLocalTestUrl, page attributes: { 'sentry.sdk.name': { value: 'sentry.javascript.browser', type: 'string' }, 'sentry.sdk.version': { value: expect.any(String), type: 'string' }, - log_attr: { value: 'scope_attr_1', type: 'string' }, + log_attr: { value: 'log_attr_1', type: 'string' }, }, }, { @@ -45,7 +45,7 @@ sentryTest('captures logs with scope attributes', async ({ getLocalTestUrl, page '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: 'scope_attr_2', type: 'string' }, + log_attr: { value: 'log_attr_2', type: 'string' }, }, }, { @@ -59,7 +59,7 @@ sentryTest('captures logs with scope attributes', async ({ getLocalTestUrl, page 'sentry.sdk.version': { value: expect.any(String), type: 'string' }, global_scope_attr: { value: true, type: 'boolean' }, isolation_scope_1_attr: { value: 100, unit: 'ms', type: 'integer' }, - log_attr: { value: 'scope_attr_3', type: 'string' }, + log_attr: { value: 'log_attr_3', type: 'string' }, }, }, { @@ -74,7 +74,7 @@ sentryTest('captures logs with scope attributes', async ({ getLocalTestUrl, page global_scope_attr: { value: true, type: 'boolean' }, isolation_scope_1_attr: { value: 100, unit: 'ms', type: 'integer' }, scope_attr: { value: 200, unit: 'ms', type: 'integer' }, - log_attr: { value: 'scope_attr_4', type: 'string' }, + log_attr: { value: 'log_attr_4', type: 'string' }, }, }, { @@ -89,7 +89,7 @@ sentryTest('captures logs with scope attributes', async ({ getLocalTestUrl, page global_scope_attr: { value: true, type: 'boolean' }, isolation_scope_1_attr: { value: 100, unit: 'ms', type: 'integer' }, scope_2_attr: { value: 300, unit: 'ms', type: 'integer' }, - log_attr: { value: 'scope_attr_5', type: 'string' }, + log_attr: { value: 'log_attr_5', type: 'string' }, }, }, ], From d81eb3b7475fbe01e0e3c48d3818a6e29f8ddbd9 Mon Sep 17 00:00:00 2001 From: Lukas Stracke Date: Tue, 18 Nov 2025 14:19:33 +0100 Subject: [PATCH 06/19] add node integration test --- .../logger/scopeAttributes/subject.js | 8 +- .../public-api/logger/scopeAttributes/test.ts | 10 +- .../suites/public-api/logger/scenario.ts | 46 ++++++++ .../suites/public-api/logger/test.ts | 109 ++++++++++++++++++ 4 files changed, 164 insertions(+), 9 deletions(-) create mode 100644 dev-packages/node-integration-tests/suites/public-api/logger/scenario.ts create mode 100644 dev-packages/node-integration-tests/suites/public-api/logger/test.ts 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 index 1bc9c9a62f0a..f7696b0ff458 100644 --- 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 @@ -1,26 +1,26 @@ // only log attribute Sentry.logger.info('log_before_any_scope', { log_attr: 'log_attr_1' }); -Sentry.getGlobalScope().setAttribute('global_scope_attr', true); +Sentry.getGlobalScope().setAttributes({ global_scope_attr: true }); // 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: 'ms' }); + 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: 'ms' }); + 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: 'ms' }); + 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' }); 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 index 6015a952b939..b9ad6c9c8a72 100644 --- 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 @@ -58,7 +58,7 @@ sentryTest('captures logs with scope attributes', async ({ getLocalTestUrl, page '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: 'ms', type: 'integer' }, + isolation_scope_1_attr: { value: 100, unit: 'millisecond', type: 'integer' }, log_attr: { value: 'log_attr_3', type: 'string' }, }, }, @@ -72,8 +72,8 @@ sentryTest('captures logs with scope attributes', async ({ getLocalTestUrl, page '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: 'ms', type: 'integer' }, - scope_attr: { value: 200, unit: 'ms', type: 'integer' }, + 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' }, }, }, @@ -87,8 +87,8 @@ sentryTest('captures logs with scope attributes', async ({ getLocalTestUrl, page '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: 'ms', type: 'integer' }, - scope_2_attr: { value: 300, unit: 'ms', type: 'integer' }, + 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/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..c5ba3ed29eb7 --- /dev/null +++ b/dev-packages/node-integration-tests/suites/public-api/logger/scenario.ts @@ -0,0 +1,46 @@ +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); + + // 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..661ea0436acc --- /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('metrics', () => { + afterAll(() => { + cleanupChildProcesses(); + }); + + test('should capture all metric types', 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(); + }); +}); From 5388d82ef5c2e089db0d7c610bebda76a189ff53 Mon Sep 17 00:00:00 2001 From: Lukas Stracke Date: Fri, 21 Nov 2025 16:12:05 +0100 Subject: [PATCH 07/19] remove accidental code --- packages/core/src/types-hoist/options.ts | 19 ------------------- 1 file changed, 19 deletions(-) diff --git a/packages/core/src/types-hoist/options.ts b/packages/core/src/types-hoist/options.ts index 57a6ac6d993a..c33d0107df5f 100644 --- a/packages/core/src/types-hoist/options.ts +++ b/packages/core/src/types-hoist/options.ts @@ -526,22 +526,3 @@ export interface CoreOptions Date: Fri, 12 Dec 2025 14:02:51 +0100 Subject: [PATCH 08/19] test optimizations --- .../suites/public-api/logger/integration/test.ts | 11 ++++++----- .../suites/public-api/logger/scopeAttributes/test.ts | 12 ++++++------ .../suites/public-api/logger/simple/test.ts | 11 ++++++----- .../browser-integration-tests/utils/helpers.ts | 8 ++++++++ 4 files changed, 26 insertions(+), 16 deletions(-) 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 fc3fa61163c6..cbb21e9214d0 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/test.ts b/dev-packages/browser-integration-tests/suites/public-api/logger/scopeAttributes/test.ts index b9ad6c9c8a72..5f0f49bf21a9 100644 --- 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 @@ -1,14 +1,14 @@ 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('captures logs with scope attributes', 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/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. */ From 0ccf27833f926a7852f190448b35a2b0c9cdc70f Mon Sep 17 00:00:00 2001 From: Lukas Stracke Date: Tue, 16 Dec 2025 11:47:58 +0100 Subject: [PATCH 09/19] only serilialize primitive scope attributes --- packages/core/src/attributes.ts | 85 +++----- packages/core/src/logs/internal.ts | 5 +- packages/core/test/lib/attributes.test.ts | 193 +++---------------- packages/core/test/lib/logs/internal.test.ts | 18 +- 4 files changed, 59 insertions(+), 242 deletions(-) diff --git a/packages/core/src/attributes.ts b/packages/core/src/attributes.ts index 28f2f80a63f6..285a08c51fb2 100644 --- a/packages/core/src/attributes.ts +++ b/packages/core/src/attributes.ts @@ -1,12 +1,10 @@ -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 export type RawAttribute = T extends { value: any } | { unit: any } ? AttributeObject : T; -export type TypedAttributes = Record; +export type Attributes = Record; export type AttributeValueType = string | number | boolean | Array | Array | Array; @@ -72,27 +70,36 @@ export function isAttributeObject(maybeObj: unknown): maybeObj is AttributeObjec * @param value - The value of the passed attribute. * @returns The typed attribute. */ -export function attributeValueToTypedAttributeValue(rawValue: unknown): TypedAttributeValue { +export function attributeValueToTypedAttributeValue(rawValue: unknown): TypedAttributeValue | void { const { value, unit } = isAttributeObject(rawValue) ? rawValue : { value: rawValue, unit: undefined }; - return { ...getTypedAttributeValue(value), ...(unit && typeof unit === 'string' ? { unit } : {}) }; + const attributeValue = getTypedAttributeValue(value); + if (attributeValue) { + return { ...attributeValue, ...(unit && typeof unit === 'string' ? { unit } : {}) }; + } } -// 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; - -function getTypedAttributeValue(value: unknown): TypedAttributeValue { - const primitiveType = getPrimitiveType(value); +/** + * 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 +109,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 ac17d036ca5e..7bf74407725d 100644 --- a/packages/core/src/logs/internal.ts +++ b/packages/core/src/logs/internal.ts @@ -163,7 +163,10 @@ export function _INTERNAL_captureLog( severity_number: severityNumber ?? SEVERITY_TEXT_TO_SEVERITY_NUMBER[level], attributes: Object.keys(attributes).reduce( (acc, key) => { - acc[key] = attributeValueToTypedAttributeValue(attributes[key]); + const typedAttributeValue = attributeValueToTypedAttributeValue(attributes[key]); + if (typedAttributeValue) { + acc[key] = typedAttributeValue; + } return acc; }, {} as Record, diff --git a/packages/core/test/lib/attributes.test.ts b/packages/core/test/lib/attributes.test.ts index 99aa20d07c85..7cfd5f04c6c4 100644 --- a/packages/core/test/lib/attributes.test.ts +++ b/packages/core/test/lib/attributes.test.ts @@ -37,41 +37,21 @@ describe('attributeValueToTypedAttributeValue', () => { }); 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[]', - }); - }); - - 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 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('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[]', - }); + it.each([ + ['foo', 'bar'], + [1, 2, 3], + [true, false, true], + [1, 'foo', true], + { foo: 'bar' }, + () => 'test', + Symbol('test'), + ])('returns undefined for none-primitive values (%s)', value => { + const result = attributeValueToTypedAttributeValue(value); + expect(result).toBeUndefined(); }); }); 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({ @@ -80,20 +60,17 @@ describe('attributeValueToTypedAttributeValue', () => { }); }); - 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[]', - }); - }); - - 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 none-primitive values (%s)', value => { + const result = attributeValueToTypedAttributeValue({ value }); + expect(result).toBeUndefined(); }); }); @@ -108,24 +85,6 @@ describe('attributeValueToTypedAttributeValue', () => { }); }); - 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 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('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. @@ -138,114 +97,6 @@ describe('attributeValueToTypedAttributeValue', () => { }); }); - 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', - }); - }); - - 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('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('stringifies an object value to a string attribute value', () => { - const result = attributeValueToTypedAttributeValue({ foo: 'bar' }); - expect(result).toStrictEqual({ - value: '{"foo":"bar"}', - type: 'string', - }); - }); - - it('stringifies a null value to a string attribute value', () => { - const result = attributeValueToTypedAttributeValue(null); - expect(result).toStrictEqual({ - value: 'null', - type: 'string', - }); - }); - - it('stringifies an undefined value to a string attribute value', () => { - const result = attributeValueToTypedAttributeValue(undefined); - expect(result).toStrictEqual({ - value: 'undefined', - 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('converts an object toString if stringification fails', () => { - const result = attributeValueToTypedAttributeValue({ - value: { - toJson: () => { - throw new Error('test'); - }, - }, - }); - expect(result).toStrictEqual({ - value: '{}', - 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'); - }, - }, - }); - expect(result).toStrictEqual({ - value: '', - type: 'string', - }); - }); - - it('converts a function toString ', () => { - const result = attributeValueToTypedAttributeValue(() => { - return 'test'; - }); - - expect(result).toStrictEqual({ - value: '() => {\n return "test";\n }', - type: 'string', - }); - }); - - it('converts a symbol toString', () => { - const result = attributeValueToTypedAttributeValue(Symbol('test')); - expect(result).toStrictEqual({ - value: 'Symbol(test)', - type: 'string', - }); - }); - }); - it.each([1, true, null, undefined, NaN, Symbol('test'), { foo: 'bar' }])( 'ignores invalid (non-string) units (%s)', unit => { diff --git a/packages/core/test/lib/logs/internal.test.ts b/packages/core/test/lib/logs/internal.test.ts index 0c6cae44e1a7..2a1055c95843 100644 --- a/packages/core/test/lib/logs/internal.test.ts +++ b/packages/core/test/lib/logs/internal.test.ts @@ -178,11 +178,12 @@ describe('_INTERNAL_captureLog', () => { scope.setClient(client); scope.setAttribute('scope_1', 'attribute_value'); - scope.setAttribute('scope_2', { value: 38, unit: 'gigabytes' }); + 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: 's' }, + scope_5: { value: [true, false, true], unit: 'second' }, }); _INTERNAL_captureLog( @@ -196,7 +197,7 @@ describe('_INTERNAL_captureLog', () => { const logAttributes = _INTERNAL_getLogBuffer(client)?.[0]?.attributes; - expect(logAttributes).toEqual({ + expect(logAttributes).toStrictEqual({ userId: { value: '123', type: 'string', @@ -211,22 +212,13 @@ describe('_INTERNAL_captureLog', () => { }, scope_2: { type: 'integer', - unit: 'gigabytes', + unit: 'gigabyte', value: 38, }, scope_3: { type: 'boolean', value: true, }, - scope_4: { - type: 'integer[]', - value: [1, 2, 3], - }, - scope_5: { - type: 'boolean[]', - value: [true, false, true], - unit: 's', - }, }); }); }); From ab276bdb7b1ac6460e0a95bc4a582b10d8941fd6 Mon Sep 17 00:00:00 2001 From: Lukas Stracke Date: Tue, 16 Dec 2025 12:00:26 +0100 Subject: [PATCH 10/19] fallback logic only in log attributes --- packages/core/src/logs/internal.ts | 63 +++++++++++++++++++++++------- 1 file changed, 48 insertions(+), 15 deletions(-) diff --git a/packages/core/src/logs/internal.ts b/packages/core/src/logs/internal.ts index 7bf74407725d..61ea1a014cd9 100644 --- a/packages/core/src/logs/internal.ts +++ b/packages/core/src/logs/internal.ts @@ -1,5 +1,4 @@ -import type { TypedAttributeValue } from '../attributes'; -import { attributeValueToTypedAttributeValue } from '../attributes'; +import { attributeValueToTypedAttributeValue, TypedAttributeValue } from '../attributes'; import { getGlobalSingleton } from '../carrier'; import type { Client } from '../client'; import { getClient, getCurrentScope, getGlobalScope, getIsolationScope } from '../currentScopes'; @@ -18,6 +17,31 @@ 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 attributeValueToTypedAttributeValueWithFallback(value: unknown): TypedAttributeValue { + const typedAttributeValue = attributeValueToTypedAttributeValue(value); + if (typedAttributeValue) { + return typedAttributeValue; + } + + 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. * @@ -98,7 +122,7 @@ export function _INTERNAL_captureLog( const { user: { id, email, username }, - attributes: scopeAttributes, + attributes: scopeAttributes = {}, } = getMergedScopeData(currentScope); setLogAttribute(processedLogAttributes, 'user.id', id, false); setLogAttribute(processedLogAttributes, 'user.email', email, false); @@ -141,7 +165,7 @@ export function _INTERNAL_captureLog( // Add the parent span ID to the log attributes for trace context setLogAttribute(processedLogAttributes, 'sentry.trace.parent_span_id', span?.spanContext().spanId); - const processedLog = { ...beforeLog, attributes: { ...scopeAttributes, ...processedLogAttributes } }; + const processedLog = { ...beforeLog, attributes: processedLogAttributes }; client.emit('beforeCaptureLog', processedLog); @@ -153,7 +177,7 @@ export function _INTERNAL_captureLog( return; } - const { level, message, attributes = {}, severityNumber } = log; + const { level, message, attributes: logAttributes = {}, severityNumber } = log; const serializedLog: SerializedLog = { timestamp: timestampInSeconds(), @@ -161,16 +185,25 @@ 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) => { - const typedAttributeValue = attributeValueToTypedAttributeValue(attributes[key]); - if (typedAttributeValue) { - acc[key] = typedAttributeValue; - } - return acc; - }, - {} as Record, - ), + attributes: { + ...Object.keys(scopeAttributes).reduce( + (acc, key) => { + const attributeValue = attributeValueToTypedAttributeValue(scopeAttributes[key]); + if (attributeValue) { + acc[key] = attributeValue; + } + return acc; + }, + {} as Record, + ), + ...Object.keys(logAttributes).reduce( + (acc, key) => { + acc[key] = attributeValueToTypedAttributeValueWithFallback(logAttributes[key]); + return acc; + }, + {} as Record, + ), + }, }; captureSerializedLog(client, serializedLog); From e21944b73838b8d957da081cb8e1dd49494f77cf Mon Sep 17 00:00:00 2001 From: Lukas Stracke Date: Tue, 16 Dec 2025 12:24:08 +0100 Subject: [PATCH 11/19] unify `attributeValueToTypedAttributeValue` with optional fallback logic --- packages/core/src/attributes.ts | 30 ++++++++++++++++++-- packages/core/src/logs/internal.ts | 27 +----------------- packages/core/src/types-hoist/log.ts | 4 +-- packages/core/test/lib/logs/internal.test.ts | 4 +-- 4 files changed, 32 insertions(+), 33 deletions(-) diff --git a/packages/core/src/attributes.ts b/packages/core/src/attributes.ts index 285a08c51fb2..c3099d0096ab 100644 --- a/packages/core/src/attributes.ts +++ b/packages/core/src/attributes.ts @@ -61,6 +61,7 @@ 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. * @@ -68,14 +69,39 @@ export function isAttributeObject(maybeObj: unknown): maybeObj is AttributeObjec * All values besides the supported attribute types (see {@link AttributeTypeMap}) are stringified to a string attribute value. * * @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 | void { +export function attributeValueToTypedAttributeValue( + rawValue: unknown, + useFallback = false, +): TypedAttributeValue | void { const { value, unit } = isAttributeObject(rawValue) ? rawValue : { value: rawValue, unit: undefined }; const attributeValue = getTypedAttributeValue(value); + const checkedUnit = unit && typeof unit === 'string' ? { unit } : {}; if (attributeValue) { - return { ...attributeValue, ...(unit && typeof unit === 'string' ? { unit } : {}) }; + return { ...attributeValue, ...checkedUnit }; } + + if (!useFallback) { + return; + } + + // 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, + }; } /** diff --git a/packages/core/src/logs/internal.ts b/packages/core/src/logs/internal.ts index 61ea1a014cd9..1d5ae9a55cdf 100644 --- a/packages/core/src/logs/internal.ts +++ b/packages/core/src/logs/internal.ts @@ -17,31 +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 attributeValueToTypedAttributeValueWithFallback(value: unknown): TypedAttributeValue { - const typedAttributeValue = attributeValueToTypedAttributeValue(value); - if (typedAttributeValue) { - return typedAttributeValue; - } - - 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. * @@ -198,7 +173,7 @@ export function _INTERNAL_captureLog( ), ...Object.keys(logAttributes).reduce( (acc, key) => { - acc[key] = attributeValueToTypedAttributeValueWithFallback(logAttributes[key]); + acc[key] = attributeValueToTypedAttributeValue(logAttributes[key], true); return acc; }, {} as Record, diff --git a/packages/core/src/types-hoist/log.ts b/packages/core/src/types-hoist/log.ts index 04dbb4f3a149..7c704d3caf77 100644 --- a/packages/core/src/types-hoist/log.ts +++ b/packages/core/src/types-hoist/log.ts @@ -1,4 +1,4 @@ -import type { TypedAttributes } from '../attributes'; +import type { Attributes } from '../attributes'; import type { ParameterizedString } from './parameterize'; export type LogSeverityLevel = 'trace' | 'debug' | 'info' | 'warn' | 'error' | 'fatal'; @@ -55,7 +55,7 @@ export interface SerializedLog { /** * Arbitrary structured data that stores information about the log - e.g., userId: 100. */ - attributes?: TypedAttributes; + attributes?: Attributes; /** * The severity number. diff --git a/packages/core/test/lib/logs/internal.test.ts b/packages/core/test/lib/logs/internal.test.ts index 2a1055c95843..2eec7c64dcbc 100644 --- a/packages/core/test/lib/logs/internal.test.ts +++ b/packages/core/test/lib/logs/internal.test.ts @@ -326,9 +326,7 @@ describe('_INTERNAL_captureLog', () => { message: 'original message', attributes: { original: true, - // attributes here still have the same form as originally set on the scope or log - scope_1: 'attribute_value', - scope_2: { value: 38, unit: 'gigabytes' }, + // scope attributes are not included in beforeSendLog - they're only added during serialization }, }); From cea78baedb282d22461776790e7b4f511d16e831 Mon Sep 17 00:00:00 2001 From: Lukas Stracke Date: Tue, 16 Dec 2025 12:35:24 +0100 Subject: [PATCH 12/19] adjust tests --- packages/core/src/scope.ts | 9 +- packages/core/test/lib/attributes.test.ts | 311 ++++++++++++++++------ 2 files changed, 235 insertions(+), 85 deletions(-) diff --git a/packages/core/src/scope.ts b/packages/core/src/scope.ts index 2ec1f6480788..e715ba178e1e 100644 --- a/packages/core/src/scope.ts +++ b/packages/core/src/scope.ts @@ -335,8 +335,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 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 +349,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/test/lib/attributes.test.ts b/packages/core/test/lib/attributes.test.ts index 7cfd5f04c6c4..47e062d54fdc 100644 --- a/packages/core/test/lib/attributes.test.ts +++ b/packages/core/test/lib/attributes.test.ts @@ -2,111 +2,258 @@ 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('converts an integer number value to a typed attribute value', () => { + const result = attributeValueToTypedAttributeValue(42); + expect(result).toStrictEqual({ + value: 42, + 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.each([ - ['foo', 'bar'], - [1, 2, 3], - [true, false, true], - [1, 'foo', true], - { foo: 'bar' }, - () => 'test', - Symbol('test'), - ])('returns undefined for none-primitive values (%s)', value => { - const result = attributeValueToTypedAttributeValue(value); - expect(result).toBeUndefined(); - }); - }); + 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 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', + }); + }); - describe('attribute objects without units', () => { - 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('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.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.each([ - ['foo', 'bar'], - [1, 2, 3], - [true, false, true], - [1, 'foo', true], - { foo: 'bar' }, - () => 'test', - Symbol('test'), - ])('returns undefined for none-primitive values (%s)', value => { - const result = attributeValueToTypedAttributeValue({ value }); - expect(result).toBeUndefined(); + 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.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 integer number value to a typed attribute value', () => { + const result = attributeValueToTypedAttributeValue(42, true); + expect(result).toStrictEqual({ + value: 42, + type: 'integer', + }); + }); + + 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('converts a boolean value to a typed attribute value', () => { + const result = attributeValueToTypedAttributeValue(true, true); + expect(result).toStrictEqual({ + value: true, + type: 'boolean', + }); }); }); - 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', + 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('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('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.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('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.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('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 number arrays', () => { + const result = attributeValueToTypedAttributeValue([1, 2, 3], true); + expect(result).toStrictEqual({ + value: '[1,2,3]', + type: 'string', + }); + }); + + it('stringifies boolean arrays', () => { + const result = attributeValueToTypedAttributeValue([true, false, true], true); + expect(result).toStrictEqual({ + value: '[true,false,true]', + type: 'string', + }); + }); + + it('stringifies mixed arrays', () => { + const result = attributeValueToTypedAttributeValue([1, 'foo', true], true); + expect(result).toStrictEqual({ + value: '[1,"foo",true]', + type: 'string', + }); + }); + + it('stringifies objects', () => { + const result = attributeValueToTypedAttributeValue({ foo: 'bar' }, true); + expect(result).toStrictEqual({ + value: '{"foo":"bar"}', + type: 'string', + }); + }); + + it('returns empty string for non-stringifiable values (functions)', () => { + const result = attributeValueToTypedAttributeValue(() => 'test', true); + expect(result).toStrictEqual({ + value: '', + type: 'string', + }); + }); + + it('returns empty string for non-stringifiable values (symbols)', () => { + const result = attributeValueToTypedAttributeValue(Symbol('test'), true); + expect(result).toStrictEqual({ + value: '', + type: 'string', + }); + }); + + it('stringifies non-primitive attribute object values', () => { + const result = attributeValueToTypedAttributeValue({ value: { nested: 'object' } }, true); + expect(result).toStrictEqual({ + value: '{"nested":"object"}', + type: 'string', + }); + }); + }); + }); }); describe('isAttributeObject', () => { From e54035055689b4a14ce6f42c2525fade84445261 Mon Sep 17 00:00:00 2001 From: Lukas Stracke Date: Tue, 16 Dec 2025 12:41:39 +0100 Subject: [PATCH 13/19] more tests --- packages/core/src/scope.ts | 2 +- packages/core/test/lib/attributes.test.ts | 6 +++--- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/packages/core/src/scope.ts b/packages/core/src/scope.ts index e715ba178e1e..67416b2d696e 100644 --- a/packages/core/src/scope.ts +++ b/packages/core/src/scope.ts @@ -336,7 +336,7 @@ export class Scope { * Sets an attribute onto the scope. * * These attributes are currently only applied to logs. - * In the future, they will be applied to metrics and spans. + * 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 diff --git a/packages/core/test/lib/attributes.test.ts b/packages/core/test/lib/attributes.test.ts index 47e062d54fdc..8177bf6afddc 100644 --- a/packages/core/test/lib/attributes.test.ts +++ b/packages/core/test/lib/attributes.test.ts @@ -12,10 +12,10 @@ describe('attributeValueToTypedAttributeValue', () => { }); }); - it('converts an integer number value to a typed attribute value', () => { - const result = attributeValueToTypedAttributeValue(42); + 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: 42, + value: value, type: 'integer', }); }); From 8bf89f7e875094f5c516d7f39ccb88af5a6a77af Mon Sep 17 00:00:00 2001 From: Lukas Stracke Date: Tue, 16 Dec 2025 13:32:51 +0100 Subject: [PATCH 14/19] 100% coverage <3 --- packages/core/test/lib/attributes.test.ts | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/packages/core/test/lib/attributes.test.ts b/packages/core/test/lib/attributes.test.ts index 8177bf6afddc..9d9b2d5c1e9a 100644 --- a/packages/core/test/lib/attributes.test.ts +++ b/packages/core/test/lib/attributes.test.ts @@ -245,6 +245,21 @@ describe('attributeValueToTypedAttributeValue', () => { }); }); + it('returns empty string if JSON.stringify fails', () => { + const result = attributeValueToTypedAttributeValue( + { + toJSON: () => { + throw new Error('test'); + }, + }, + true, + ); + expect(result).toStrictEqual({ + value: '', + type: 'string', + }); + }); + it('stringifies non-primitive attribute object values', () => { const result = attributeValueToTypedAttributeValue({ value: { nested: 'object' } }, true); expect(result).toStrictEqual({ From 9a02e953312f55bde6187e0ef19ff2e9a8b6784c Mon Sep 17 00:00:00 2001 From: Lukas Stracke Date: Tue, 16 Dec 2025 13:57:28 +0100 Subject: [PATCH 15/19] fix tests, adjust jsdoc --- .../suites/public-api/logger/integration/test.ts | 4 ++-- .../suites/public-api/logger/scopeAttributes/subject.js | 3 +++ .../suites/public-api/logger/scenario.ts | 3 +++ .../node-integration-tests/suites/public-api/logger/test.ts | 4 ++-- packages/core/src/attributes.ts | 6 ++++-- 5 files changed, 14 insertions(+), 6 deletions(-) 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 cbb21e9214d0..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 @@ -167,7 +167,7 @@ sentryTest('should capture console object calls', async ({ getLocalTestUrl, page 'sentry.message.template': { value: 'Mixed: {} {} {} {}', type: 'string' }, 'sentry.message.parameter.0': { value: 'prefix', type: 'string' }, 'sentry.message.parameter.1': { value: '{"obj":true}', type: 'string' }, - 'sentry.message.parameter.2': { value: [4, 5, 6], type: 'integer[]' }, + 'sentry.message.parameter.2': { value: '[4,5,6]', type: 'string' }, 'sentry.message.parameter.3': { value: 'suffix', type: 'string' }, }, }, @@ -236,7 +236,7 @@ sentryTest('should capture console object calls', async ({ getLocalTestUrl, page 'sentry.message.template': { value: 'hello {} {} {}', type: 'string' }, 'sentry.message.parameter.0': { value: true, type: 'boolean' }, 'sentry.message.parameter.1': { value: 'null', type: 'string' }, - 'sentry.message.parameter.2': { value: 'undefined', type: 'string' }, + 'sentry.message.parameter.2': { value: '', type: 'string' }, }, }, ], 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 index f7696b0ff458..9bba2c222bdc 100644 --- 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 @@ -3,6 +3,9 @@ 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' }); 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 index c5ba3ed29eb7..a3e4b4c7b8e1 100644 --- a/dev-packages/node-integration-tests/suites/public-api/logger/scenario.ts +++ b/dev-packages/node-integration-tests/suites/public-api/logger/scenario.ts @@ -15,6 +15,9 @@ async function run(): Promise { 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' }); 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 index 661ea0436acc..c507d51d8ce4 100644 --- a/dev-packages/node-integration-tests/suites/public-api/logger/test.ts +++ b/dev-packages/node-integration-tests/suites/public-api/logger/test.ts @@ -25,12 +25,12 @@ const commonAttributes: SerializedLog['attributes'] = { }, }; -describe('metrics', () => { +describe('logs', () => { afterAll(() => { cleanupChildProcesses(); }); - test('should capture all metric types', async () => { + test('captures logs with scope and log attributes', async () => { const runner = createRunner(__dirname, 'scenario.ts') .expect({ log: { diff --git a/packages/core/src/attributes.ts b/packages/core/src/attributes.ts index c3099d0096ab..b31e264a59f2 100644 --- a/packages/core/src/attributes.ts +++ b/packages/core/src/attributes.ts @@ -62,11 +62,13 @@ 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. From cd8f34620292c2c78cb665e8ade96eb21533581f Mon Sep 17 00:00:00 2001 From: Lukas Stracke Date: Tue, 16 Dec 2025 13:58:30 +0100 Subject: [PATCH 16/19] further jsdoc adjustments --- packages/core/src/scope.ts | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/packages/core/src/scope.ts b/packages/core/src/scope.ts index 67416b2d696e..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' }, * }); * ``` From 9813fa2b2949ad2af4ebc01e6ca95ed25cd3c7ad Mon Sep 17 00:00:00 2001 From: Lukas Stracke Date: Tue, 16 Dec 2025 14:31:33 +0100 Subject: [PATCH 17/19] lint --- packages/core/src/logs/internal.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/packages/core/src/logs/internal.ts b/packages/core/src/logs/internal.ts index 1d5ae9a55cdf..8ad6676b3880 100644 --- a/packages/core/src/logs/internal.ts +++ b/packages/core/src/logs/internal.ts @@ -1,4 +1,5 @@ -import { attributeValueToTypedAttributeValue, TypedAttributeValue } from '../attributes'; +import type { TypedAttributeValue } from '../attributes'; +import { attributeValueToTypedAttributeValue } from '../attributes'; import { getGlobalSingleton } from '../carrier'; import type { Client } from '../client'; import { getClient, getCurrentScope, getGlobalScope, getIsolationScope } from '../currentScopes'; From 18e52c60e1692d4bc3c39d0ddc8582469956dc51 Mon Sep 17 00:00:00 2001 From: Lukas Stracke Date: Tue, 16 Dec 2025 17:07:35 +0100 Subject: [PATCH 18/19] use Object.fromEntries instead of reduce --- packages/core/src/logs/internal.ts | 29 ++++++++++++----------------- 1 file changed, 12 insertions(+), 17 deletions(-) diff --git a/packages/core/src/logs/internal.ts b/packages/core/src/logs/internal.ts index 8ad6676b3880..da9766822b1a 100644 --- a/packages/core/src/logs/internal.ts +++ b/packages/core/src/logs/internal.ts @@ -155,6 +155,16 @@ export function _INTERNAL_captureLog( 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(), level, @@ -162,23 +172,8 @@ export function _INTERNAL_captureLog( trace_id: traceContext?.trace_id, severity_number: severityNumber ?? SEVERITY_TEXT_TO_SEVERITY_NUMBER[level], attributes: { - ...Object.keys(scopeAttributes).reduce( - (acc, key) => { - const attributeValue = attributeValueToTypedAttributeValue(scopeAttributes[key]); - if (attributeValue) { - acc[key] = attributeValue; - } - return acc; - }, - {} as Record, - ), - ...Object.keys(logAttributes).reduce( - (acc, key) => { - acc[key] = attributeValueToTypedAttributeValue(logAttributes[key], true); - return acc; - }, - {} as Record, - ), + ...serializedScopeAttributes, + ...serializedLogAttributes, }, }; From 9aba62af9077038d5e6d6cb67af9cf358d63df5b Mon Sep 17 00:00:00 2001 From: Lukas Stracke Date: Tue, 16 Dec 2025 17:19:21 +0100 Subject: [PATCH 19/19] of course, lint complains --- packages/core/src/logs/internal.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/core/src/logs/internal.ts b/packages/core/src/logs/internal.ts index da9766822b1a..f2dc407dd001 100644 --- a/packages/core/src/logs/internal.ts +++ b/packages/core/src/logs/internal.ts @@ -1,4 +1,3 @@ -import type { TypedAttributeValue } from '../attributes'; import { attributeValueToTypedAttributeValue } from '../attributes'; import { getGlobalSingleton } from '../carrier'; import type { Client } from '../client'; @@ -100,6 +99,7 @@ export function _INTERNAL_captureLog( 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); @@ -158,7 +158,7 @@ export function _INTERNAL_captureLog( const serializedScopeAttributes = Object.fromEntries( Object.entries(scopeAttributes) .map(([key, value]) => [key, attributeValueToTypedAttributeValue(value)]) - .filter(([_, value]) => value != null), + .filter(([, value]) => value != null), ); const serializedLogAttributes = Object.fromEntries(