From 0d59febe673f0d0f57d8f7ff6978ae5628630031 Mon Sep 17 00:00:00 2001 From: Lukas Stracke Date: Tue, 11 Nov 2025 13:25:00 +0100 Subject: [PATCH 1/7] feat(core): Add `Scope::SetAttribute(s)` APIs --- packages/core/src/scope.ts | 76 ++++++++++++- packages/core/test/lib/attributes.test.ts | 100 +++++++++++++++++ packages/core/test/lib/scope.test.ts | 127 +++++++++++++++++++++- 3 files changed, 301 insertions(+), 2 deletions(-) create mode 100644 packages/core/test/lib/attributes.test.ts diff --git a/packages/core/src/scope.ts b/packages/core/src/scope.ts index 8b1e21acfb4a..1196d9d46add 100644 --- a/packages/core/src/scope.ts +++ b/packages/core/src/scope.ts @@ -1,4 +1,5 @@ /* eslint-disable max-lines */ +import { Attributes, attributeValueToTypedAttributeValue, AttributeValueType, TypedAttributeValue } from './attributes'; import type { Client } from './client'; import { DEBUG_BUILD } from './debug-build'; import { updateSession } from './session'; @@ -46,6 +47,7 @@ export interface ScopeContext { extra: Extras; contexts: Contexts; tags: { [key: string]: Primitive }; + attributes?: Attributes; fingerprint: string[]; propagationContext: PropagationContext; } @@ -71,6 +73,8 @@ export interface ScopeData { breadcrumbs: Breadcrumb[]; user: User; tags: { [key: string]: Primitive }; + // TODO(v11): Make this a required field (could be subtly breaking if we did it today) + attributes?: Attributes; extra: Extras; contexts: Contexts; attachments: Attachment[]; @@ -104,6 +108,9 @@ export class Scope { /** Tags */ protected _tags: { [key: string]: Primitive }; + /** Attributes */ + protected _attributes: Attributes; + /** Extra */ protected _extra: Extras; @@ -155,6 +162,7 @@ export class Scope { this._attachments = []; this._user = {}; this._tags = {}; + this._attributes = {}; this._extra = {}; this._contexts = {}; this._sdkProcessingMetadata = {}; @@ -171,6 +179,7 @@ export class Scope { const newScope = new Scope(); newScope._breadcrumbs = [...this._breadcrumbs]; newScope._tags = { ...this._tags }; + newScope._attributes = { ...this._attributes }; newScope._extra = { ...this._extra }; newScope._contexts = { ...this._contexts }; if (this._contexts.flags) { @@ -296,6 +305,59 @@ export class Scope { return this; } + /** + * Sets attributes onto the scope. + * + * TODO: + * Currently, these attributes are not applied to any telemetry data but they will be 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 concrete type declaration and an optional unit (if applicable to your attribute). + * You can only pass in primitive values or arrays of primitive values. + * + * @example + * ```typescript + * scope.setAttributes({ + * is_admin: true, + * payment_selection: 'credit_card', + * clicked_products: [130, 554, 292], + * render_duration: { value: 'render_duration', type: 'float', unit: 'ms' }, + * }); + * ``` + */ + public setAttributes(newAttributes: Record): this { + Object.entries(newAttributes).forEach(([key, value]) => { + if (typeof value === 'object' && !Array.isArray(value)) { + this._attributes[key] = value; + } else { + this._attributes[key] = attributeValueToTypedAttributeValue(value); + } + }); + this._notifyScopeListeners(); + return this; + } + + /** + * Sets an attribute onto the scope. + * + * TODO: + * Currently, these attributes are not applied to any telemetry data but they will be in the future. + * + * @param key - The attribute key. + * @param value - the attribute value. You can either pass in a raw value (primitive or array of primitives), or + * a typed attribute value object with a concrete type declaration and an optional unit (if applicable to your attribute). + * + * @example + * ```typescript + * scope.setAttribute('is_admin', true); + * scope.setAttribute('clicked_products', [130, 554, 292]); + * scope.setAttribute('render_duration', { value: 'render_duration', type: 'float', unit: 'ms' }); + * ``` + */ + public setAttribute(key: string, value: AttributeValueType | TypedAttributeValue): this { + return this.setAttributes({ [key]: value }); + } + /** * Set an object that will be merged into existing extra on the scope, * and will be sent as extra data with the event. @@ -411,9 +473,19 @@ export class Scope { ? (captureContext as ScopeContext) : undefined; - const { tags, extra, user, contexts, level, fingerprint = [], propagationContext } = scopeInstance || {}; + const { + tags, + attributes, + extra, + user, + contexts, + level, + fingerprint = [], + propagationContext, + } = scopeInstance || {}; this._tags = { ...this._tags, ...tags }; + this._attributes = { ...this._attributes, ...attributes }; this._extra = { ...this._extra, ...extra }; this._contexts = { ...this._contexts, ...contexts }; @@ -444,6 +516,7 @@ export class Scope { // client is not cleared here on purpose! this._breadcrumbs = []; this._tags = {}; + this._attributes = {}; this._extra = {}; this._user = {}; this._contexts = {}; @@ -530,6 +603,7 @@ export class Scope { attachments: this._attachments, contexts: this._contexts, tags: this._tags, + attributes: this._attributes, extra: this._extra, user: this._user, level: this._level, diff --git a/packages/core/test/lib/attributes.test.ts b/packages/core/test/lib/attributes.test.ts new file mode 100644 index 000000000000..a137229c9118 --- /dev/null +++ b/packages/core/test/lib/attributes.test.ts @@ -0,0 +1,100 @@ +import { describe, expect, it } from 'vitest'; +import { attributeValueToTypedAttributeValue } from '../../src/attributes'; + +describe('attributeValueToTypedAttributeValue', () => { + describe('primitive values', () => { + it('converts a string value to a typed attribute value', () => { + const result = attributeValueToTypedAttributeValue('test'); + expect(result).toEqual({ + value: 'test', + type: 'string', + }); + }); + + it('converts an interger number value to a typed attribute value', () => { + const result = attributeValueToTypedAttributeValue(42); + expect(result).toEqual({ + value: 42, + type: 'integer', + }); + }); + + it('converts a double number value to a typed attribute value', () => { + const result = attributeValueToTypedAttributeValue(42.34); + expect(result).toEqual({ + value: 42.34, + type: 'double', + }); + }); + + it('converts a boolean value to a typed attribute value', () => { + const result = attributeValueToTypedAttributeValue(true); + expect(result).toEqual({ + value: true, + type: 'boolean', + }); + }); + }); + + describe('arrays', () => { + it('converts an array of strings to a typed attribute value', () => { + const result = attributeValueToTypedAttributeValue(['foo', 'bar']); + expect(result).toEqual({ + 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).toEqual({ + 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).toEqual({ + 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).toEqual({ + value: [true, false, true], + type: 'boolean[]', + }); + }); + }); + + describe('disallowed value types', () => { + it('stringifies mixed float and integer numbers to a string attribute value', () => { + const result = attributeValueToTypedAttributeValue([1, 2.2, 3]); + expect(result).toEqual({ + value: '[1,2.2,3]', + type: 'string', + }); + }); + + it('stringifies an array of mixed types to a string attribute value', () => { + // @ts-expect-error - this is not allowed by types but we still test fallback behaviour + const result = attributeValueToTypedAttributeValue([1, 'foo', true]); + expect(result).toEqual({ + value: '[1,"foo",true]', + type: 'string', + }); + }); + + it('stringifies an object value to a string attribute value', () => { + // @ts-expect-error - this is not allowed by types but we still test fallback behaviour + const result = attributeValueToTypedAttributeValue({ foo: 'bar' }); + expect(result).toEqual({ + value: '{"foo":"bar"}', + type: 'string', + }); + }); + }); +}); diff --git a/packages/core/test/lib/scope.test.ts b/packages/core/test/lib/scope.test.ts index 280ba4c651ff..00a02e6367e5 100644 --- a/packages/core/test/lib/scope.test.ts +++ b/packages/core/test/lib/scope.test.ts @@ -27,6 +27,7 @@ describe('Scope', () => { attachments: [], contexts: {}, tags: {}, + attributes: {}, extra: {}, user: {}, level: undefined, @@ -42,6 +43,7 @@ describe('Scope', () => { scope.update({ tags: { foo: 'bar' }, extra: { foo2: 'bar2' }, + attributes: { attr1: { value: 'value1', type: 'string' } }, }); expect(scope.getScopeData()).toEqual({ @@ -51,6 +53,7 @@ describe('Scope', () => { tags: { foo: 'bar', }, + attributes: { attr1: { value: 'value1', type: 'string' } }, extra: { foo2: 'bar2', }, @@ -71,6 +74,7 @@ describe('Scope', () => { scope.update({ tags: { foo: 'bar' }, + attributes: { attr1: { value: 'value1', type: 'string' } }, extra: { foo2: 'bar2' }, }); @@ -85,6 +89,7 @@ describe('Scope', () => { tags: { foo: 'bar', }, + attributes: { attr1: { value: 'value1', type: 'string' } }, extra: { foo2: 'bar2', }, @@ -114,7 +119,7 @@ describe('Scope', () => { }); }); - describe('attributes modification', () => { + describe('scope data modification', () => { test('setFingerprint', () => { const scope = new Scope(); scope.setFingerprint(['abcd']); @@ -152,6 +157,119 @@ describe('Scope', () => { expect(scope['_tags']).toEqual({ a: 'b' }); }); + describe('setAttribute', () => { + it('accepts a key-value pair', () => { + const scope = new Scope(); + + scope.setAttribute('str', 'b'); + scope.setAttribute('int', 1); + scope.setAttribute('double', 1.1); + scope.setAttribute('bool', true); + + expect(scope['_attributes']).toEqual({ + str: { + type: 'string', + value: 'b', + }, + bool: { + type: 'boolean', + value: true, + }, + double: { + type: 'double', + value: 1.1, + }, + int: { + type: 'integer', + value: 1, + }, + }); + }); + + it('accepts a typed attribute value', () => { + const scope = new Scope(); + scope.setAttribute('str', { type: 'string', value: 'b' }); + expect(scope['_attributes']).toEqual({ + str: { type: 'string', value: 'b' }, + }); + }); + + it('accepts a unit', () => { + const scope = new Scope(); + scope.setAttribute('str', { type: 'string', value: 'b', unit: 'ms' }); + expect(scope['_attributes']).toEqual({ + str: { type: 'string', value: 'b', unit: 'ms' }, + }); + }); + + it('accepts an array', () => { + const scope = new Scope(); + + scope.setAttribute('strArray', ['a', 'b', 'c']); + scope.setAttribute('intArray', { value: [1, 2, 3], type: 'integer[]', unit: 'ms' }); + + expect(scope['_attributes']).toEqual({ + strArray: { type: 'string[]', value: ['a', 'b', 'c'] }, + intArray: { value: [1, 2, 3], type: 'integer[]', unit: 'ms' }, + }); + }); + }); + + describe('setAttributes', () => { + it('accepts key-value pairs', () => { + const scope = new Scope(); + scope.setAttributes({ str: 'b', int: 1, double: 1.1, bool: true }); + expect(scope['_attributes']).toEqual({ + str: { + type: 'string', + value: 'b', + }, + bool: { + type: 'boolean', + value: true, + }, + double: { + type: 'double', + value: 1.1, + }, + int: { + type: 'integer', + value: 1, + }, + }); + }); + + it('accepts typed attribute values', () => { + const scope = new Scope(); + scope.setAttributes({ str: { type: 'string', value: 'b' }, int: { type: 'integer', value: 1 } }); + expect(scope['_attributes']).toEqual({ + str: { type: 'string', value: 'b' }, + int: { type: 'integer', value: 1 }, + }); + }); + + it('accepts units', () => { + const scope = new Scope(); + scope.setAttributes({ str: { type: 'string', value: 'b', unit: 'ms' } }); + expect(scope['_attributes']).toEqual({ + str: { type: 'string', value: 'b', unit: 'ms' }, + }); + }); + + it('accepts arrays', () => { + const scope = new Scope(); + scope.setAttributes({ + strArray: ['a', 'b', 'c'], + intArray: { value: [1, 2, 3], type: 'integer[]', unit: 'ms' }, + }); + + expect(scope['_attributes']).toEqual({ + strArray: { type: 'string[]', value: ['a', 'b', 'c'] }, + intArray: { type: 'integer[]', value: [1, 2, 3], unit: 'ms' }, + }); + }); + }); + test('setUser', () => { const scope = new Scope(); scope.setUser({ id: '1' }); @@ -298,12 +416,18 @@ describe('Scope', () => { const oldPropagationContext = scope.getScopeData().propagationContext; scope.setExtra('a', 2); scope.setTag('a', 'b'); + scope.setAttribute('c', 'd'); scope.setUser({ id: '1' }); scope.setFingerprint(['abcd']); scope.addBreadcrumb({ message: 'test' }); + + expect(scope['_attributes']).toEqual({ c: { type: 'string', value: 'd' } }); expect(scope['_extra']).toEqual({ a: 2 }); + scope.clear(); + expect(scope['_extra']).toEqual({}); + expect(scope['_attributes']).toEqual({}); expect(scope['_propagationContext']).toEqual({ traceId: expect.any(String), sampled: undefined, @@ -326,6 +450,7 @@ describe('Scope', () => { beforeEach(() => { scope = new Scope(); scope.setTags({ foo: '1', bar: '2' }); + scope.setAttribute('attr1', 'value1'); scope.setExtras({ foo: '1', bar: '2' }); scope.setContext('foo', { id: '1' }); scope.setContext('bar', { id: '2' }); From c94bcd6b07286ea66e3a8c04cdf0c9754baab7bf Mon Sep 17 00:00:00 2001 From: Lukas Stracke Date: Tue, 11 Nov 2025 13:26:37 +0100 Subject: [PATCH 2/7] add attributes.ts --- packages/core/src/attributes.ts | 108 ++++++++++++++++++++++++++++++++ 1 file changed, 108 insertions(+) create mode 100644 packages/core/src/attributes.ts diff --git a/packages/core/src/attributes.ts b/packages/core/src/attributes.ts new file mode 100644 index 000000000000..529a89bce05f --- /dev/null +++ b/packages/core/src/attributes.ts @@ -0,0 +1,108 @@ +export type Attributes = Record; + +export type AttributeValueType = string | number | boolean | Array | Array | Array; + +export type TypedAttributeValue = ( + | { + value: string; + type: 'string'; + } + | { + value: number; + type: 'integer'; + } + | { + value: number; + type: 'double'; + } + | { + value: boolean; + type: 'boolean'; + } + | { + value: Array; + type: 'string[]'; + } + | { + value: Array; + type: 'integer[]'; + } + | { + value: Array; + type: 'double[]'; + } + | { + value: Array; + type: 'boolean[]'; + } +) & { unit?: Units }; + +type Units = 'ms' | 's' | 'bytes' | 'count' | 'percent'; + +/** + * 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'. + * + * @param value - The value of the passed attribute. + * @returns The typed attribute. + */ +export function attributeValueToTypedAttributeValue(value: AttributeValueType): TypedAttributeValue { + 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', + }; + } + + if (Array.isArray(value)) { + if (value.every(item => typeof item === 'string')) { + return { + value, + type: 'string[]', + }; + } + if (value.every(item => typeof item === 'number')) { + if (value.every(item => Number.isInteger(item))) { + return { + value, + type: 'integer[]', + }; + } else if (value.every(item => !Number.isInteger(item))) { + return { + value, + type: 'double[]', + }; + } + } + if (value.every(item => typeof item === 'boolean')) { + return { + value, + type: 'boolean[]', + }; + } + } + + // Fallback: stringify the passed value + return { + value: JSON.stringify(value), + type: 'string', + }; +} From 6fe4b81490d4cd34012a3231608a817640b7f1eb Mon Sep 17 00:00:00 2001 From: Lukas Stracke Date: Tue, 11 Nov 2025 14:38:23 +0100 Subject: [PATCH 3/7] add removeAttribute method --- packages/core/src/scope.ts | 19 ++++++++++++ packages/core/test/lib/scope.test.ts | 45 ++++++++++++++++++++++++++++ 2 files changed, 64 insertions(+) diff --git a/packages/core/src/scope.ts b/packages/core/src/scope.ts index 1196d9d46add..457bb1fba8f4 100644 --- a/packages/core/src/scope.ts +++ b/packages/core/src/scope.ts @@ -358,6 +358,25 @@ export class Scope { return this.setAttributes({ [key]: value }); } + /** + * Removes the attribute with the given key from the scope. + * + * @param key - The attribute key. + * + * @example + * ```typescript + * scope.removeAttribute('is_admin'); + * ``` + */ + public removeAttribute(key: string): this { + if (key in this._attributes) { + // eslint-disable-next-line @typescript-eslint/no-dynamic-delete + delete this._attributes[key]; + this._notifyScopeListeners(); + } + return this; + } + /** * Set an object that will be merged into existing extra on the scope, * and will be sent as extra data with the event. diff --git a/packages/core/test/lib/scope.test.ts b/packages/core/test/lib/scope.test.ts index 00a02e6367e5..844839b9507c 100644 --- a/packages/core/test/lib/scope.test.ts +++ b/packages/core/test/lib/scope.test.ts @@ -213,6 +213,15 @@ describe('Scope', () => { intArray: { value: [1, 2, 3], type: 'integer[]', unit: 'ms' }, }); }); + + it('notifies scope listeners once per call', () => { + const scope = new Scope(); + const listener = vi.fn(); + scope.addScopeListener(listener); + scope.setAttribute('str', 'b'); + scope.setAttribute('int', 1); + expect(listener).toHaveBeenCalledTimes(2); + }); }); describe('setAttributes', () => { @@ -268,6 +277,42 @@ describe('Scope', () => { intArray: { type: 'integer[]', value: [1, 2, 3], unit: 'ms' }, }); }); + + it('notifies scope listeners once per call', () => { + const scope = new Scope(); + const listener = vi.fn(); + scope.addScopeListener(listener); + scope.setAttributes({ str: 'b', int: 1 }); + scope.setAttributes({ bool: true }); + expect(listener).toHaveBeenCalledTimes(2); + }); + }); + + describe('removeAttribute', () => { + it('removes an attribute', () => { + const scope = new Scope(); + scope.setAttribute('str', 'b'); + scope.setAttribute('int', 1); + scope.removeAttribute('str'); + expect(scope['_attributes']).toEqual({ int: { type: 'integer', value: 1 } }); + }); + + it('notifies scope listeners after deletion', () => { + const scope = new Scope(); + const listener = vi.fn(); + scope.addScopeListener(listener); + }); + + it('does nothing if the attribute does not exist', () => { + const scope = new Scope(); + const listener = vi.fn(); + + scope.addScopeListener(listener); + scope.removeAttribute('str'); + + expect(scope['_attributes']).toEqual({}); + expect(listener).not.toHaveBeenCalled(); + }); }); test('setUser', () => { From db448cc9d79d6ae9c0c4bb99d8e3592e7de69865 Mon Sep 17 00:00:00 2001 From: Lukas Stracke Date: Tue, 11 Nov 2025 16:27:04 +0100 Subject: [PATCH 4/7] fix lint --- packages/core/src/scope.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/packages/core/src/scope.ts b/packages/core/src/scope.ts index 457bb1fba8f4..1331c93a2232 100644 --- a/packages/core/src/scope.ts +++ b/packages/core/src/scope.ts @@ -1,5 +1,6 @@ /* eslint-disable max-lines */ -import { Attributes, attributeValueToTypedAttributeValue, AttributeValueType, TypedAttributeValue } from './attributes'; +import type { Attributes, AttributeValueType, TypedAttributeValue } from './attributes'; +import { attributeValueToTypedAttributeValue } from './attributes'; import type { Client } from './client'; import { DEBUG_BUILD } from './debug-build'; import { updateSession } from './session'; From 1bde9563469234acc97292831b88c2f584eacc82 Mon Sep 17 00:00:00 2001 From: Lukas Stracke Date: Tue, 11 Nov 2025 16:27:08 +0100 Subject: [PATCH 5/7] fix size limit --- .size-limit.js | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/.size-limit.js b/.size-limit.js index 2d07afde52ab..3ea397444a0a 100644 --- a/.size-limit.js +++ b/.size-limit.js @@ -38,7 +38,7 @@ module.exports = [ path: 'packages/browser/build/npm/esm/prod/index.js', import: createImport('init', 'browserTracingIntegration'), gzip: true, - limit: '41.3 KB', + limit: '42 KB', }, { name: '@sentry/browser (incl. Tracing, Profiling)', @@ -127,7 +127,7 @@ module.exports = [ import: createImport('init', 'ErrorBoundary', 'reactRouterV6BrowserTracingIntegration'), ignore: ['react/jsx-runtime'], gzip: true, - limit: '43.3 KB', + limit: '44 KB', }, // Vue SDK (ESM) { @@ -142,7 +142,7 @@ module.exports = [ path: 'packages/vue/build/esm/index.js', import: createImport('init', 'browserTracingIntegration'), gzip: true, - limit: '43.1 KB', + limit: '44 KB', }, // Svelte SDK (ESM) { @@ -163,7 +163,7 @@ module.exports = [ name: 'CDN Bundle (incl. Tracing)', path: createCDNPath('bundle.tracing.min.js'), gzip: true, - limit: '42 KB', + limit: '42.5 KB', }, { name: 'CDN Bundle (incl. Tracing, Replay)', @@ -231,7 +231,7 @@ module.exports = [ import: createImport('init'), ignore: [...builtinModules, ...nodePrefixedBuiltinModules], gzip: true, - limit: '51 KB', + limit: '52 KB', }, // Node SDK (ESM) { @@ -240,7 +240,7 @@ module.exports = [ import: createImport('init'), ignore: [...builtinModules, ...nodePrefixedBuiltinModules], gzip: true, - limit: '158 KB', + limit: '159 KB', }, { name: '@sentry/node - without tracing', From 49182ebab164f19eb15ea50377b3513c1dbea1db Mon Sep 17 00:00:00 2001 From: Lukas Stracke Date: Tue, 11 Nov 2025 17:22:59 +0100 Subject: [PATCH 6/7] fix remove scope listener test --- packages/core/test/lib/scope.test.ts | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/packages/core/test/lib/scope.test.ts b/packages/core/test/lib/scope.test.ts index 844839b9507c..d9ab421b9455 100644 --- a/packages/core/test/lib/scope.test.ts +++ b/packages/core/test/lib/scope.test.ts @@ -300,7 +300,15 @@ describe('Scope', () => { it('notifies scope listeners after deletion', () => { const scope = new Scope(); const listener = vi.fn(); + scope.addScopeListener(listener); + scope.setAttribute('str', { type: 'string', value: 'b' }); + expect(listener).toHaveBeenCalledTimes(1); + + listener.mockClear(); + + scope.removeAttribute('str'); + expect(listener).toHaveBeenCalledTimes(1); }); it('does nothing if the attribute does not exist', () => { From b360e4b694a89cf1f701a15ce618dadcba9d7964 Mon Sep 17 00:00:00 2001 From: Lukas Stracke Date: Tue, 11 Nov 2025 17:54:38 +0100 Subject: [PATCH 7/7] be extra careful with typed attribute values --- packages/core/src/scope.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/core/src/scope.ts b/packages/core/src/scope.ts index 1331c93a2232..be35f7469a55 100644 --- a/packages/core/src/scope.ts +++ b/packages/core/src/scope.ts @@ -328,7 +328,7 @@ export class Scope { */ public setAttributes(newAttributes: Record): this { Object.entries(newAttributes).forEach(([key, value]) => { - if (typeof value === 'object' && !Array.isArray(value)) { + if (typeof value === 'object' && value !== null && !Array.isArray(value)) { this._attributes[key] = value; } else { this._attributes[key] = attributeValueToTypedAttributeValue(value);