From d47204c598c1f8535d709935e2ebacc8275b2c15 Mon Sep 17 00:00:00 2001 From: Lukas Stracke Date: Tue, 11 Nov 2025 13:25:00 +0100 Subject: [PATCH 01/17] 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 b23b01664431..992e95ab97d6 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) { @@ -294,6 +303,59 @@ export class Scope { return this.setTags({ [key]: value }); } + /** + * 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. @@ -409,9 +471,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 }; @@ -442,6 +514,7 @@ export class Scope { // client is not cleared here on purpose! this._breadcrumbs = []; this._tags = {}; + this._attributes = {}; this._extra = {}; this._user = {}; this._contexts = {}; @@ -528,6 +601,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 221ac14a6fa2..5b93a283c870 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']); @@ -183,6 +188,119 @@ describe('Scope', () => { }); }); + 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' }); @@ -329,12 +447,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, @@ -357,6 +481,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 227ebc79209667b30f0bf1501094c510cea127a4 Mon Sep 17 00:00:00 2001 From: Lukas Stracke Date: Tue, 11 Nov 2025 13:26:37 +0100 Subject: [PATCH 02/17] 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 a1e1fcc1174d5e4c072e2a733766fdd9779989ab Mon Sep 17 00:00:00 2001 From: Lukas Stracke Date: Tue, 11 Nov 2025 14:38:23 +0100 Subject: [PATCH 03/17] 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 992e95ab97d6..5335e534edcd 100644 --- a/packages/core/src/scope.ts +++ b/packages/core/src/scope.ts @@ -356,6 +356,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 5b93a283c870..a7be864772d9 100644 --- a/packages/core/test/lib/scope.test.ts +++ b/packages/core/test/lib/scope.test.ts @@ -244,6 +244,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', () => { @@ -299,6 +308,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 d72787ca20bbbc1d4c626bf25a0c0de0c595c87c Mon Sep 17 00:00:00 2001 From: Lukas Stracke Date: Tue, 11 Nov 2025 16:27:04 +0100 Subject: [PATCH 04/17] 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 5335e534edcd..8d837bda2b1c 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 9b4e65c9f42b92bb8823ce39beb196d6bc90731c Mon Sep 17 00:00:00 2001 From: Lukas Stracke Date: Tue, 11 Nov 2025 16:27:08 +0100 Subject: [PATCH 05/17] fix size limit --- .size-limit.js | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/.size-limit.js b/.size-limit.js index 100444907e06..87f3f2cce5c2 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.5 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.5 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.3 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.1 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.1 KB', + limit: '52 KB', }, // Node SDK (ESM) { From cadc629e1038f95cbf77fa37d82767dc98d98e73 Mon Sep 17 00:00:00 2001 From: Lukas Stracke Date: Tue, 11 Nov 2025 17:22:59 +0100 Subject: [PATCH 06/17] 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 a7be864772d9..bd8cda5548fd 100644 --- a/packages/core/test/lib/scope.test.ts +++ b/packages/core/test/lib/scope.test.ts @@ -331,7 +331,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 c4e9005bdfcba1cffae57efddaaf6485d9ea3785 Mon Sep 17 00:00:00 2001 From: Lukas Stracke Date: Tue, 11 Nov 2025 17:54:38 +0100 Subject: [PATCH 07/17] 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 8d837bda2b1c..009bd468080e 100644 --- a/packages/core/src/scope.ts +++ b/packages/core/src/scope.ts @@ -326,7 +326,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); From 29e0b2fcdc26a96ede196df00460f85988617e8d Mon Sep 17 00:00:00 2001 From: Sigrid <32902192+s1gr1d@users.noreply.github.com> Date: Fri, 14 Nov 2025 12:39:56 +0100 Subject: [PATCH 08/17] ref(attribute): Add type to accept `unknown` and official attribute type (#18201) Uses a conditional type to infer whether an attribute object has the type of an official attribute by checking if it has a `value` or a `unit`: ```ts export type ValidatedAttributes = { [K in keyof T]: T[K] extends { value: any } | { unit: any } ? ValidAttributeObject : unknown; }; ``` That way, TS can show an error when people attempt to use the attribute type while still allowing non-official types. image --- I also added a small helper type to generate the very long attribute type object, so it can generate a type like this: ```ts /* | { value: string; type: 'string' } | { value: number; type: 'integer' } | { value: number; type: 'double' } */ ``` --- .size-limit.js | 4 +- packages/core/src/attributes.ts | 92 ++++++++++++++--------- packages/core/src/scope.ts | 23 ++++-- packages/core/test/lib/attributes.test.ts | 2 - 4 files changed, 77 insertions(+), 44 deletions(-) diff --git a/.size-limit.js b/.size-limit.js index 87f3f2cce5c2..6e6ee0f68303 100644 --- a/.size-limit.js +++ b/.size-limit.js @@ -183,14 +183,14 @@ module.exports = [ path: createCDNPath('bundle.min.js'), gzip: false, brotli: false, - limit: '80 KB', + limit: '82 KB', }, { name: 'CDN Bundle (incl. Tracing) - uncompressed', path: createCDNPath('bundle.tracing.min.js'), gzip: false, brotli: false, - limit: '125 KB', + limit: '127 KB', }, { name: 'CDN Bundle (incl. Tracing, Replay) - uncompressed', diff --git a/packages/core/src/attributes.ts b/packages/core/src/attributes.ts index 529a89bce05f..29c2019cdfec 100644 --- a/packages/core/src/attributes.ts +++ b/packages/core/src/attributes.ts @@ -2,43 +2,65 @@ 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 AttributeTypeMap = { + string: string; + integer: number; + double: number; + boolean: boolean; + 'string[]': Array; + 'integer[]': Array; + 'double[]': Array; + 'boolean[]': Array; +}; + +/* Generates a type from the AttributeTypeMap like: + | { value: string; type: 'string' } + | { value: number; type: 'integer' } + | { value: number; type: 'double' } + */ +type AttributeUnion = { + [K in keyof AttributeTypeMap]: { + value: AttributeTypeMap[K]; + type: K; + }; +}[keyof AttributeTypeMap]; + +export type TypedAttributeValue = AttributeUnion & { unit?: Units }; + +type AttributeWithUnit = { + value: unknown; + unit: Units; +}; type Units = 'ms' | 's' | 'bytes' | 'count' | 'percent'; +type ValidAttributeObject = AttributeWithUnit | TypedAttributeValue; + +/* If an attribute has either a 'value' or 'unit' property, we use the ValidAttributeObject type. */ +export type ValidatedAttributes = { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + [K in keyof T]: T[K] extends { value: any } | { unit: any } ? ValidAttributeObject : unknown; +}; + +/** + * Type-guard: The attribute object has the shape the official attribute object (value, type, unit). + * https://develop.sentry.dev/sdk/telemetry/scopes/#setting-attributes + */ +export function isAttributeObject(value: unknown): value is ValidAttributeObject { + if (typeof value !== 'object' || value === null || Array.isArray(value)) { + return false; + } + // MUST have a 'value' property + if (!Object.prototype.hasOwnProperty.call(value, 'value')) { + return false; + } + // And it MUST have 'unit' OR 'type' + const hasUnit = Object.prototype.hasOwnProperty.call(value, 'unit'); + const hasType = Object.prototype.hasOwnProperty.call(value, 'type'); + + return hasUnit || hasType; +} + /** * Converts an attribute value to a typed attribute value. * @@ -47,7 +69,7 @@ type Units = 'ms' | 's' | 'bytes' | 'count' | 'percent'; * @param value - The value of the passed attribute. * @returns The typed attribute. */ -export function attributeValueToTypedAttributeValue(value: AttributeValueType): TypedAttributeValue { +export function attributeValueToTypedAttributeValue(value: unknown): TypedAttributeValue { switch (typeof value) { case 'number': if (Number.isInteger(value)) { diff --git a/packages/core/src/scope.ts b/packages/core/src/scope.ts index 009bd468080e..6765d96148ea 100644 --- a/packages/core/src/scope.ts +++ b/packages/core/src/scope.ts @@ -1,6 +1,6 @@ /* eslint-disable max-lines */ -import type { Attributes, AttributeValueType, TypedAttributeValue } from './attributes'; -import { attributeValueToTypedAttributeValue } from './attributes'; +import type { Attributes, AttributeValueType, TypedAttributeValue, ValidatedAttributes } from './attributes'; +import { attributeValueToTypedAttributeValue, isAttributeObject } from './attributes'; import type { Client } from './client'; import { DEBUG_BUILD } from './debug-build'; import { updateSession } from './session'; @@ -324,14 +324,27 @@ export class Scope { * }); * ``` */ - public setAttributes(newAttributes: Record): this { + public setAttributes>(newAttributes: T & ValidatedAttributes): this { Object.entries(newAttributes).forEach(([key, value]) => { - if (typeof value === 'object' && value !== null && !Array.isArray(value)) { - this._attributes[key] = value; + if (isAttributeObject(value)) { + // Case 1: ({ value, unit }) + if ('unit' in value && !('type' in value)) { + // Infer type from the inner value + this._attributes[key] = { + ...attributeValueToTypedAttributeValue(value.value), + unit: value.unit, + }; + } + // Case 2: ({ value, type, unit? }) + else { + this._attributes[key] = value; + } } else { + // Else: (string, number, etc.) or a random object (will stringify random values). this._attributes[key] = attributeValueToTypedAttributeValue(value); } }); + this._notifyScopeListeners(); return this; } diff --git a/packages/core/test/lib/attributes.test.ts b/packages/core/test/lib/attributes.test.ts index a137229c9118..a86d49950e43 100644 --- a/packages/core/test/lib/attributes.test.ts +++ b/packages/core/test/lib/attributes.test.ts @@ -80,7 +80,6 @@ describe('attributeValueToTypedAttributeValue', () => { }); 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]', @@ -89,7 +88,6 @@ describe('attributeValueToTypedAttributeValue', () => { }); 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"}', From 8d04d23aa2aebad9cc31130904171a7776770d16 Mon Sep 17 00:00:00 2001 From: Lukas Stracke Date: Sat, 15 Nov 2025 20:07:27 +0100 Subject: [PATCH 09/17] attribute objects w/o type, keep attributes as-is on scope --- packages/core/src/attributes.ts | 109 ++++++++++++---------- packages/core/src/scope.ts | 51 ++++------ packages/core/test/lib/attributes.test.ts | 44 ++++++++- packages/core/test/lib/scope.test.ts | 89 +++++++----------- 4 files changed, 157 insertions(+), 136 deletions(-) diff --git a/packages/core/src/attributes.ts b/packages/core/src/attributes.ts index 29c2019cdfec..3f369b67c2c6 100644 --- a/packages/core/src/attributes.ts +++ b/packages/core/src/attributes.ts @@ -1,3 +1,7 @@ +export type RawAttributes = T & ValidatedAttributes; +// eslint-disable-next-line @typescript-eslint/no-explicit-any +export type RawAttribute = T extends { value: any } | { unit: any } ? AttributeWithUnit : T; + export type Attributes = Record; export type AttributeValueType = string | number | boolean | Array | Array | Array; @@ -27,38 +31,33 @@ type AttributeUnion = { export type TypedAttributeValue = AttributeUnion & { unit?: Units }; -type AttributeWithUnit = { +export type AttributeWithUnit = { value: unknown; - unit: Units; + unit?: Units; }; +/** + * Unit of measurement that can be added to an attribute. + */ type Units = 'ms' | 's' | 'bytes' | 'count' | 'percent'; -type ValidAttributeObject = AttributeWithUnit | TypedAttributeValue; - /* If an attribute has either a 'value' or 'unit' property, we use the ValidAttributeObject type. */ export type ValidatedAttributes = { // eslint-disable-next-line @typescript-eslint/no-explicit-any - [K in keyof T]: T[K] extends { value: any } | { unit: any } ? ValidAttributeObject : unknown; + [K in keyof T]: T[K] extends { value: any } | { unit: any } ? AttributeWithUnit : unknown; }; /** * Type-guard: The attribute object has the shape the official attribute object (value, type, unit). * https://develop.sentry.dev/sdk/telemetry/scopes/#setting-attributes */ -export function isAttributeObject(value: unknown): value is ValidAttributeObject { - if (typeof value !== 'object' || value === null || Array.isArray(value)) { - return false; - } - // MUST have a 'value' property - if (!Object.prototype.hasOwnProperty.call(value, 'value')) { +export function isAttributeObject(value: unknown): value is AttributeWithUnit { + if (typeof value !== 'object' || value == null || Array.isArray(value)) { return false; } - // And it MUST have 'unit' OR 'type' - const hasUnit = Object.prototype.hasOwnProperty.call(value, 'unit'); - const hasType = Object.prototype.hasOwnProperty.call(value, 'type'); - return hasUnit || hasType; + // MUST have 'value' and 'unit' property + return Object.prototype.hasOwnProperty.call(value, 'value') && Object.prototype.hasOwnProperty.call(value, 'unit'); } /** @@ -69,62 +68,78 @@ export function isAttributeObject(value: unknown): value is ValidAttributeObject * @param value - The value of the passed attribute. * @returns The typed attribute. */ -export function attributeValueToTypedAttributeValue(value: unknown): TypedAttributeValue { +export function attributeValueToTypedAttributeValue(rawValue: unknown): TypedAttributeValue { + const unit = isAttributeObject(rawValue) ? rawValue.unit : undefined; + const value = isAttributeObject(rawValue) ? rawValue.value : rawValue; + switch (typeof value) { - case 'number': - if (Number.isInteger(value)) { - return { - value, - type: 'integer', - }; + case 'number': { + const numberType = getNumberType(value); + if (!numberType) { + break; } return { value, - type: 'double', + type: numberType, + unit, }; + } case 'boolean': return { value, type: 'boolean', + unit, }; case 'string': return { value, type: 'string', + unit, }; } 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[]', - }; + const coherentType = value.reduce((acc: 'string' | 'boolean' | 'integer' | 'double' | null, item) => { + if (!acc || getPrimitiveType(item) !== acc) { + return null; } - } - if (value.every(item => typeof item === 'boolean')) { - return { - value, - type: 'boolean[]', - }; + return acc; + }, getPrimitiveType(value[0])); + + if (coherentType) { + return { value, type: `${coherentType}[]`, unit }; } } // Fallback: stringify the passed value + let fallbackValue = ''; + try { + fallbackValue = JSON.stringify(value) ?? String(value); + } catch { + try { + fallbackValue = String(value); + } catch { + // ignore + } + } + return { - value: JSON.stringify(value), + value: fallbackValue, type: 'string', + unit, }; } + +// Disallow NaN, differentiate between integer and double +const getNumberType: (num: number) => 'integer' | 'double' | null = item => + Number.isNaN(item) ? null : Number.isInteger(item) ? 'integer' : 'double'; + +// Only allow string, boolean, or number types +const getPrimitiveType: (item: unknown) => 'string' | 'boolean' | 'integer' | 'double' | null = item => + typeof item === 'string' + ? 'string' + : typeof item === 'boolean' + ? 'boolean' + : typeof item === 'number' + ? getNumberType(item) + : null; diff --git a/packages/core/src/scope.ts b/packages/core/src/scope.ts index 6765d96148ea..0247d721bc6f 100644 --- a/packages/core/src/scope.ts +++ b/packages/core/src/scope.ts @@ -1,6 +1,5 @@ /* eslint-disable max-lines */ -import type { Attributes, AttributeValueType, TypedAttributeValue, ValidatedAttributes } from './attributes'; -import { attributeValueToTypedAttributeValue, isAttributeObject } from './attributes'; +import type { AttributeWithUnit, RawAttribute, RawAttributes } from './attributes'; import type { Client } from './client'; import { DEBUG_BUILD } from './debug-build'; import { updateSession } from './session'; @@ -48,7 +47,7 @@ export interface ScopeContext { extra: Extras; contexts: Contexts; tags: { [key: string]: Primitive }; - attributes?: Attributes; + attributes?: RawAttributes>; fingerprint: string[]; propagationContext: PropagationContext; } @@ -75,7 +74,7 @@ export interface ScopeData { user: User; tags: { [key: string]: Primitive }; // TODO(v11): Make this a required field (could be subtly breaking if we did it today) - attributes?: Attributes; + attributes?: RawAttributes>; extra: Extras; contexts: Contexts; attachments: Attachment[]; @@ -110,7 +109,7 @@ export class Scope { protected _tags: { [key: string]: Primitive }; /** Attributes */ - protected _attributes: Attributes; + protected _attributes: RawAttributes>; /** Extra */ protected _extra: Extras; @@ -311,8 +310,7 @@ export class Scope { * 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. + * an object with a `value` and an optional `unit` (if applicable to your attribute). * * @example * ```typescript @@ -320,30 +318,15 @@ export class Scope { * is_admin: true, * payment_selection: 'credit_card', * clicked_products: [130, 554, 292], - * render_duration: { value: 'render_duration', type: 'float', unit: 'ms' }, + * render_duration: { value: 'render_duration', unit: 'ms' }, * }); * ``` */ - public setAttributes>(newAttributes: T & ValidatedAttributes): this { - Object.entries(newAttributes).forEach(([key, value]) => { - if (isAttributeObject(value)) { - // Case 1: ({ value, unit }) - if ('unit' in value && !('type' in value)) { - // Infer type from the inner value - this._attributes[key] = { - ...attributeValueToTypedAttributeValue(value.value), - unit: value.unit, - }; - } - // Case 2: ({ value, type, unit? }) - else { - this._attributes[key] = value; - } - } else { - // Else: (string, number, etc.) or a random object (will stringify random values). - this._attributes[key] = attributeValueToTypedAttributeValue(value); - } - }); + public setAttributes>(newAttributes: RawAttributes): this { + this._attributes = { + ...this._attributes, + ...newAttributes, + }; this._notifyScopeListeners(); return this; @@ -356,17 +339,21 @@ export class Scope { * 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). + * @param value - the attribute value. You can either pass in a raw value, or an attribute + * object with a `value` 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' }); + * scope.setAttribute('render_duration', { value: 'render_duration', unit: 'ms' }); * ``` */ - public setAttribute(key: string, value: AttributeValueType | TypedAttributeValue): this { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + public setAttribute extends { value: any } | { unit: any } ? AttributeWithUnit : unknown>( + key: string, + value: RawAttribute, + ): this { return this.setAttributes({ [key]: value }); } diff --git a/packages/core/test/lib/attributes.test.ts b/packages/core/test/lib/attributes.test.ts index a86d49950e43..74ad12275bb4 100644 --- a/packages/core/test/lib/attributes.test.ts +++ b/packages/core/test/lib/attributes.test.ts @@ -70,6 +70,16 @@ describe('attributeValueToTypedAttributeValue', () => { }); }); + describe('attribute objects without units', () => { + it('converts a number value to a typed attribute value', () => { + const result = attributeValueToTypedAttributeValue({ value: 123 }); + expect(result).toEqual({ + value: 123, + type: 'integer', + }); + }); + }); + describe('disallowed value types', () => { it('stringifies mixed float and integer numbers to a string attribute value', () => { const result = attributeValueToTypedAttributeValue([1, 2.2, 3]); @@ -79,7 +89,7 @@ describe('attributeValueToTypedAttributeValue', () => { }); }); - it('stringifies an array of mixed types to a string attribute value', () => { + it('stringifies an array of allowed but incoherent types to a string attribute value', () => { const result = attributeValueToTypedAttributeValue([1, 'foo', true]); expect(result).toEqual({ value: '[1,"foo",true]', @@ -87,6 +97,14 @@ describe('attributeValueToTypedAttributeValue', () => { }); }); + it('stringifies an array of disallowed and incoherent types to a string attribute value', () => { + const result = attributeValueToTypedAttributeValue([null, undefined, NaN]); + expect(result).toEqual({ + value: '[null,null,null]', + type: 'string', + }); + }); + it('stringifies an object value to a string attribute value', () => { const result = attributeValueToTypedAttributeValue({ foo: 'bar' }); expect(result).toEqual({ @@ -94,5 +112,29 @@ describe('attributeValueToTypedAttributeValue', () => { type: 'string', }); }); + + it('stringifies a null value to a string attribute value', () => { + const result = attributeValueToTypedAttributeValue(null); + expect(result).toEqual({ + value: 'null', + type: 'string', + }); + }); + + it('stringifies an undefined value to a string attribute value', () => { + const result = attributeValueToTypedAttributeValue(undefined); + expect(result).toEqual({ + value: 'undefined', + type: 'string', + }); + }); + + it('stringifies an NaN number value to a string attribute value', () => { + const result = attributeValueToTypedAttributeValue(NaN); + expect(result).toEqual({ + value: 'null', + type: 'string', + }); + }); }); }); diff --git a/packages/core/test/lib/scope.test.ts b/packages/core/test/lib/scope.test.ts index bd8cda5548fd..c1cb78de6d8d 100644 --- a/packages/core/test/lib/scope.test.ts +++ b/packages/core/test/lib/scope.test.ts @@ -43,7 +43,7 @@ describe('Scope', () => { scope.update({ tags: { foo: 'bar' }, extra: { foo2: 'bar2' }, - attributes: { attr1: { value: 'value1', type: 'string' } }, + attributes: { attr1: { value: 'value1' } }, }); expect(scope.getScopeData()).toEqual({ @@ -53,7 +53,7 @@ describe('Scope', () => { tags: { foo: 'bar', }, - attributes: { attr1: { value: 'value1', type: 'string' } }, + attributes: { attr1: { value: 'value1' } }, extra: { foo2: 'bar2', }, @@ -198,38 +198,26 @@ describe('Scope', () => { 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, - }, + str: 'b', + bool: true, + double: 1.1, + int: 1, }); }); - it('accepts a typed attribute value', () => { + it('accepts an attribute value object', () => { const scope = new Scope(); - scope.setAttribute('str', { type: 'string', value: 'b' }); + scope.setAttribute('str', { value: 'b' }); expect(scope['_attributes']).toEqual({ - str: { type: 'string', value: 'b' }, + str: { value: 'b' }, }); }); - it('accepts a unit', () => { + it('accepts an attribute value object with a unit', () => { const scope = new Scope(); - scope.setAttribute('str', { type: 'string', value: 'b', unit: 'ms' }); + scope.setAttribute('str', { value: 1, unit: 'ms' }); expect(scope['_attributes']).toEqual({ - str: { type: 'string', value: 'b', unit: 'ms' }, + str: { value: 1, unit: 'ms' }, }); }); @@ -237,11 +225,11 @@ describe('Scope', () => { const scope = new Scope(); scope.setAttribute('strArray', ['a', 'b', 'c']); - scope.setAttribute('intArray', { value: [1, 2, 3], type: 'integer[]', unit: 'ms' }); + scope.setAttribute('intArray', { value: [1, 2, 3], unit: 'ms' }); expect(scope['_attributes']).toEqual({ - strArray: { type: 'string[]', value: ['a', 'b', 'c'] }, - intArray: { value: [1, 2, 3], type: 'integer[]', unit: 'ms' }, + strArray: ['a', 'b', 'c'], + intArray: { value: [1, 2, 3], unit: 'ms' }, }); }); @@ -260,39 +248,28 @@ describe('Scope', () => { 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, - }, + str: 'b', + int: 1, + double: 1.1, + bool: true, }); }); - it('accepts typed attribute values', () => { + it('accepts attribute value objects', () => { const scope = new Scope(); - scope.setAttributes({ str: { type: 'string', value: 'b' }, int: { type: 'integer', value: 1 } }); + scope.setAttributes({ str: { value: 'b' }, int: { value: 1 } }); expect(scope['_attributes']).toEqual({ - str: { type: 'string', value: 'b' }, - int: { type: 'integer', value: 1 }, + str: { value: 'b' }, + int: { value: 1 }, }); }); - it('accepts units', () => { + it('accepts attribute value objects with units', () => { const scope = new Scope(); - scope.setAttributes({ str: { type: 'string', value: 'b', unit: 'ms' } }); + scope.setAttributes({ str: { value: 'b', unit: 'ms' }, int: { value: 12, unit: 's' } }); expect(scope['_attributes']).toEqual({ - str: { type: 'string', value: 'b', unit: 'ms' }, + str: { value: 'b', unit: 'ms' }, + int: { value: 12, unit: 's' }, }); }); @@ -300,12 +277,12 @@ describe('Scope', () => { const scope = new Scope(); scope.setAttributes({ strArray: ['a', 'b', 'c'], - intArray: { value: [1, 2, 3], type: 'integer[]', unit: 'ms' }, + intArray: { value: [1, 2, 3], unit: 'ms' }, }); expect(scope['_attributes']).toEqual({ - strArray: { type: 'string[]', value: ['a', 'b', 'c'] }, - intArray: { type: 'integer[]', value: [1, 2, 3], unit: 'ms' }, + strArray: ['a', 'b', 'c'], + intArray: { value: [1, 2, 3], unit: 'ms' }, }); }); @@ -325,7 +302,7 @@ describe('Scope', () => { scope.setAttribute('str', 'b'); scope.setAttribute('int', 1); scope.removeAttribute('str'); - expect(scope['_attributes']).toEqual({ int: { type: 'integer', value: 1 } }); + expect(scope['_attributes']).toEqual({ int: 1 }); }); it('notifies scope listeners after deletion', () => { @@ -333,7 +310,7 @@ describe('Scope', () => { const listener = vi.fn(); scope.addScopeListener(listener); - scope.setAttribute('str', { type: 'string', value: 'b' }); + scope.setAttribute('str', { value: 'b' }); expect(listener).toHaveBeenCalledTimes(1); listener.mockClear(); @@ -505,7 +482,7 @@ describe('Scope', () => { scope.setFingerprint(['abcd']); scope.addBreadcrumb({ message: 'test' }); - expect(scope['_attributes']).toEqual({ c: { type: 'string', value: 'd' } }); + expect(scope['_attributes']).toEqual({ c: 'd' }); expect(scope['_extra']).toEqual({ a: 2 }); scope.clear(); From 4540a35de8672db5242885a189e3327f02504fb6 Mon Sep 17 00:00:00 2001 From: Lukas Stracke Date: Mon, 17 Nov 2025 12:32:22 +0100 Subject: [PATCH 10/17] adapt `attributeValueToTypedAttributeValue` --- packages/core/src/attributes.ts | 84 ++++++---- packages/core/test/lib/attributes.test.ts | 185 +++++++++++++++++++--- 2 files changed, 212 insertions(+), 57 deletions(-) diff --git a/packages/core/src/attributes.ts b/packages/core/src/attributes.ts index 3f369b67c2c6..06598d78aa60 100644 --- a/packages/core/src/attributes.ts +++ b/packages/core/src/attributes.ts @@ -51,73 +51,102 @@ export type ValidatedAttributes = { * Type-guard: The attribute object has the shape the official attribute object (value, type, unit). * https://develop.sentry.dev/sdk/telemetry/scopes/#setting-attributes */ -export function isAttributeObject(value: unknown): value is AttributeWithUnit { - if (typeof value !== 'object' || value == null || Array.isArray(value)) { +export function isAttributeObject(maybeObj: unknown): maybeObj is AttributeWithUnit { + if (typeof maybeObj !== 'object' || maybeObj == null || Array.isArray(maybeObj)) { return false; } - // MUST have 'value' and 'unit' property - return Object.prototype.hasOwnProperty.call(value, 'value') && Object.prototype.hasOwnProperty.call(value, 'unit'); + // MUST have 'value' property + // MAY have 'unit' property + // MUST NOT have other properties + const keys = Object.keys(maybeObj); + + // MUST have 'value' + if (!keys.includes('value')) { + return false; + } + + // ALLOWED keys: 'value', optionally 'unit' + if (keys.some(k => k !== 'value' && k !== 'unit')) { + return false; + } + + // All checks passed + return true; } /** * 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. * * @param value - The value of the passed attribute. * @returns The typed attribute. */ export function attributeValueToTypedAttributeValue(rawValue: unknown): TypedAttributeValue { - const unit = isAttributeObject(rawValue) ? rawValue.unit : undefined; - const value = isAttributeObject(rawValue) ? rawValue.value : rawValue; + const { value, unit } = isAttributeObject(rawValue) ? rawValue : { value: rawValue, unit: undefined }; + return { ...getTypedAttributeValue(value), ...(unit && { unit }) }; +} + +// Disallow NaN, differentiate between integer and double +const getNumberType: (num: number) => 'integer' | 'double' | null = item => + Number.isNaN(item) ? null : Number.isInteger(item) ? 'integer' : 'double'; + +// Only allow string, boolean, or number types +const getPrimitiveType: (item: unknown) => 'string' | 'boolean' | 'integer' | 'double' | null = item => + typeof item === 'string' + ? 'string' + : typeof item === 'boolean' + ? 'boolean' + : typeof item === 'number' + ? getNumberType(item) + : null; - switch (typeof value) { +function getTypedAttributeValue(val: unknown): TypedAttributeValue { + switch (typeof val) { case 'number': { - const numberType = getNumberType(value); + const numberType = getNumberType(val); if (!numberType) { break; } return { - value, + value: val, type: numberType, - unit, }; } case 'boolean': return { - value, + value: val, type: 'boolean', - unit, }; case 'string': return { - value, + value: val, type: 'string', - unit, }; } - if (Array.isArray(value)) { - const coherentType = value.reduce((acc: 'string' | 'boolean' | 'integer' | 'double' | null, item) => { + if (Array.isArray(val)) { + const coherentType = val.reduce((acc: 'string' | 'boolean' | 'integer' | 'double' | null, item) => { if (!acc || getPrimitiveType(item) !== acc) { return null; } return acc; - }, getPrimitiveType(value[0])); + }, getPrimitiveType(val[0])); if (coherentType) { - return { value, type: `${coherentType}[]`, unit }; + return { value: val, type: `${coherentType}[]` }; } } // Fallback: stringify the passed value let fallbackValue = ''; try { - fallbackValue = JSON.stringify(value) ?? String(value); + fallbackValue = JSON.stringify(val) ?? String(val); } catch { try { - fallbackValue = String(value); + fallbackValue = String(val); } catch { // ignore } @@ -126,20 +155,5 @@ export function attributeValueToTypedAttributeValue(rawValue: unknown): TypedAtt return { value: fallbackValue, type: 'string', - unit, }; } - -// Disallow NaN, differentiate between integer and double -const getNumberType: (num: number) => 'integer' | 'double' | null = item => - Number.isNaN(item) ? null : Number.isInteger(item) ? 'integer' : 'double'; - -// Only allow string, boolean, or number types -const getPrimitiveType: (item: unknown) => 'string' | 'boolean' | 'integer' | 'double' | null = item => - typeof item === 'string' - ? 'string' - : typeof item === 'boolean' - ? 'boolean' - : typeof item === 'number' - ? getNumberType(item) - : null; diff --git a/packages/core/test/lib/attributes.test.ts b/packages/core/test/lib/attributes.test.ts index 74ad12275bb4..a27ee0bd71cb 100644 --- a/packages/core/test/lib/attributes.test.ts +++ b/packages/core/test/lib/attributes.test.ts @@ -1,11 +1,11 @@ import { describe, expect, it } from 'vitest'; -import { attributeValueToTypedAttributeValue } from '../../src/attributes'; +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).toEqual({ + expect(result).toStrictEqual({ value: 'test', type: 'string', }); @@ -13,7 +13,7 @@ describe('attributeValueToTypedAttributeValue', () => { it('converts an interger number value to a typed attribute value', () => { const result = attributeValueToTypedAttributeValue(42); - expect(result).toEqual({ + expect(result).toStrictEqual({ value: 42, type: 'integer', }); @@ -21,7 +21,7 @@ describe('attributeValueToTypedAttributeValue', () => { it('converts a double number value to a typed attribute value', () => { const result = attributeValueToTypedAttributeValue(42.34); - expect(result).toEqual({ + expect(result).toStrictEqual({ value: 42.34, type: 'double', }); @@ -29,7 +29,7 @@ describe('attributeValueToTypedAttributeValue', () => { it('converts a boolean value to a typed attribute value', () => { const result = attributeValueToTypedAttributeValue(true); - expect(result).toEqual({ + expect(result).toStrictEqual({ value: true, type: 'boolean', }); @@ -39,7 +39,7 @@ describe('attributeValueToTypedAttributeValue', () => { describe('arrays', () => { it('converts an array of strings to a typed attribute value', () => { const result = attributeValueToTypedAttributeValue(['foo', 'bar']); - expect(result).toEqual({ + expect(result).toStrictEqual({ value: ['foo', 'bar'], type: 'string[]', }); @@ -47,7 +47,7 @@ describe('attributeValueToTypedAttributeValue', () => { it('converts an array of integer numbers to a typed attribute value', () => { const result = attributeValueToTypedAttributeValue([1, 2, 3]); - expect(result).toEqual({ + expect(result).toStrictEqual({ value: [1, 2, 3], type: 'integer[]', }); @@ -55,7 +55,7 @@ describe('attributeValueToTypedAttributeValue', () => { it('converts an array of double numbers to a typed attribute value', () => { const result = attributeValueToTypedAttributeValue([1.1, 2.2, 3.3]); - expect(result).toEqual({ + expect(result).toStrictEqual({ value: [1.1, 2.2, 3.3], type: 'double[]', }); @@ -63,7 +63,7 @@ describe('attributeValueToTypedAttributeValue', () => { it('converts an array of booleans to a typed attribute value', () => { const result = attributeValueToTypedAttributeValue([true, false, true]); - expect(result).toEqual({ + expect(result).toStrictEqual({ value: [true, false, true], type: 'boolean[]', }); @@ -71,19 +71,66 @@ describe('attributeValueToTypedAttributeValue', () => { }); describe('attribute objects without units', () => { - it('converts a number value to a typed attribute value', () => { - const result = attributeValueToTypedAttributeValue({ value: 123 }); - expect(result).toEqual({ - value: 123, - type: 'integer', + // Note: These tests only test exemplar type and fallback behaviour (see above for more cases) + it('converts a primitive value to a typed attribute value', () => { + const result = attributeValueToTypedAttributeValue({ value: 123.45 }); + expect(result).toStrictEqual({ + value: 123.45, + type: 'double', + }); + }); + + it('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', }); }); }); - describe('disallowed value types', () => { + 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', + }); + }); + + 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', + }); + }); + }); + + 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).toEqual({ + expect(result).toStrictEqual({ value: '[1,2.2,3]', type: 'string', }); @@ -91,7 +138,7 @@ describe('attributeValueToTypedAttributeValue', () => { it('stringifies an array of allowed but incoherent types to a string attribute value', () => { const result = attributeValueToTypedAttributeValue([1, 'foo', true]); - expect(result).toEqual({ + expect(result).toStrictEqual({ value: '[1,"foo",true]', type: 'string', }); @@ -99,7 +146,7 @@ describe('attributeValueToTypedAttributeValue', () => { it('stringifies an array of disallowed and incoherent types to a string attribute value', () => { const result = attributeValueToTypedAttributeValue([null, undefined, NaN]); - expect(result).toEqual({ + expect(result).toStrictEqual({ value: '[null,null,null]', type: 'string', }); @@ -107,7 +154,7 @@ describe('attributeValueToTypedAttributeValue', () => { it('stringifies an object value to a string attribute value', () => { const result = attributeValueToTypedAttributeValue({ foo: 'bar' }); - expect(result).toEqual({ + expect(result).toStrictEqual({ value: '{"foo":"bar"}', type: 'string', }); @@ -115,7 +162,7 @@ describe('attributeValueToTypedAttributeValue', () => { it('stringifies a null value to a string attribute value', () => { const result = attributeValueToTypedAttributeValue(null); - expect(result).toEqual({ + expect(result).toStrictEqual({ value: 'null', type: 'string', }); @@ -123,7 +170,7 @@ describe('attributeValueToTypedAttributeValue', () => { it('stringifies an undefined value to a string attribute value', () => { const result = attributeValueToTypedAttributeValue(undefined); - expect(result).toEqual({ + expect(result).toStrictEqual({ value: 'undefined', type: 'string', }); @@ -131,10 +178,104 @@ describe('attributeValueToTypedAttributeValue', () => { it('stringifies an NaN number value to a string attribute value', () => { const result = attributeValueToTypedAttributeValue(NaN); - expect(result).toEqual({ + 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('stringifies an attribute-object-like object with additional properties to a string attribute value', () => { + const result = attributeValueToTypedAttributeValue({ value: 'foo', bar: 'baz' }); + expect(result).toStrictEqual({ + value: '{"value":"foo","bar":"baz"}', + type: 'string', + }); + }); + + it('stringifies an attribute-object-like object with a unit property to a string attribute value', () => { + const result = attributeValueToTypedAttributeValue({ value: 'foo', unit: 'ms', bar: 'baz' }); + expect(result).toStrictEqual({ + value: '{"value":"foo","unit":"ms","bar":"baz"}', + type: 'string', + }); + }); + }); +}); + +describe('isAttributeObject', () => { + it.each([ + { value: 123.45, unit: 'ms' }, + { value: [true, false], unit: 'count' }, + { value: { foo: 'bar' }, unit: 'bytes' }, + { value: { value: 123.45, unit: 'ms' }, unit: 'ms' }, + { value: 1 }, + ])('returns true for a valid attribute object (%s)', obj => { + const result = isAttributeObject(obj); + expect(result).toBe(true); + }); + + it.each([ + 1, + true, + 'test', + null, + undefined, + NaN, + Symbol('test'), + { value: { foo: 'bar' }, bar: 'baz' }, + { value: 1, unit: 'ms', anotherProperty: 'test' }, + ])('returns false for an invalid attribute object (%s)', obj => { + const result = isAttributeObject(obj); + expect(result).toBe(false); }); }); From 994f487df02fcc3138997b4d70dd40f228aa63af Mon Sep 17 00:00:00 2001 From: Lukas Stracke Date: Mon, 17 Nov 2025 13:12:06 +0100 Subject: [PATCH 11/17] ignore invalid units --- packages/core/src/attributes.ts | 4 ++-- packages/core/test/lib/attributes.test.ts | 11 +++++++++++ 2 files changed, 13 insertions(+), 2 deletions(-) diff --git a/packages/core/src/attributes.ts b/packages/core/src/attributes.ts index 06598d78aa60..93ac9bdacc0b 100644 --- a/packages/core/src/attributes.ts +++ b/packages/core/src/attributes.ts @@ -39,7 +39,7 @@ export type AttributeWithUnit = { /** * Unit of measurement that can be added to an attribute. */ -type Units = 'ms' | 's' | 'bytes' | 'count' | 'percent'; +type Units = 'ms' | 's' | 'bytes' | 'count' | 'percent' | string; /* If an attribute has either a 'value' or 'unit' property, we use the ValidAttributeObject type. */ export type ValidatedAttributes = { @@ -86,7 +86,7 @@ export function isAttributeObject(maybeObj: unknown): maybeObj is AttributeWithU */ export function attributeValueToTypedAttributeValue(rawValue: unknown): TypedAttributeValue { const { value, unit } = isAttributeObject(rawValue) ? rawValue : { value: rawValue, unit: undefined }; - return { ...getTypedAttributeValue(value), ...(unit && { unit }) }; + return { ...getTypedAttributeValue(value), ...(unit && typeof unit === 'string' ? { unit } : {}) }; } // Disallow NaN, differentiate between integer and double diff --git a/packages/core/test/lib/attributes.test.ts b/packages/core/test/lib/attributes.test.ts index a27ee0bd71cb..d6a32f889168 100644 --- a/packages/core/test/lib/attributes.test.ts +++ b/packages/core/test/lib/attributes.test.ts @@ -250,6 +250,17 @@ describe('attributeValueToTypedAttributeValue', () => { }); }); }); + + it.each([1, true, null, undefined, NaN, Symbol('test'), { foo: 'bar' }])( + 'ignores invalid (non-string) units (%s)', + unit => { + const result = attributeValueToTypedAttributeValue({ value: 'foo', unit }); + expect(result).toStrictEqual({ + value: 'foo', + type: 'string', + }); + }, + ); }); describe('isAttributeObject', () => { From 8cc1c2a62e108bb2455391c117757279c30386e4 Mon Sep 17 00:00:00 2001 From: Lukas Stracke Date: Tue, 18 Nov 2025 10:36:49 +0100 Subject: [PATCH 12/17] streamline isAttributeObject --- packages/core/src/attributes.ts | 35 +++++---------- packages/core/src/scope.ts | 4 +- packages/core/test/lib/attributes.test.ts | 52 ++++++++++------------- 3 files changed, 35 insertions(+), 56 deletions(-) diff --git a/packages/core/src/attributes.ts b/packages/core/src/attributes.ts index 93ac9bdacc0b..25a735baa0bd 100644 --- a/packages/core/src/attributes.ts +++ b/packages/core/src/attributes.ts @@ -1,6 +1,6 @@ export type RawAttributes = T & ValidatedAttributes; // eslint-disable-next-line @typescript-eslint/no-explicit-any -export type RawAttribute = T extends { value: any } | { unit: any } ? AttributeWithUnit : T; +export type RawAttribute = T extends { value: any } | { unit: any } ? AttributeObject : T; export type Attributes = Record; @@ -31,7 +31,7 @@ type AttributeUnion = { export type TypedAttributeValue = AttributeUnion & { unit?: Units }; -export type AttributeWithUnit = { +export type AttributeObject = { value: unknown; unit?: Units; }; @@ -44,35 +44,20 @@ type Units = 'ms' | 's' | 'bytes' | 'count' | 'percent' | string; /* If an attribute has either a 'value' or 'unit' property, we use the ValidAttributeObject type. */ export type ValidatedAttributes = { // eslint-disable-next-line @typescript-eslint/no-explicit-any - [K in keyof T]: T[K] extends { value: any } | { unit: any } ? AttributeWithUnit : unknown; + [K in keyof T]: T[K] extends { value: any } | { unit: any } ? AttributeObject : unknown; }; /** * Type-guard: The attribute object has the shape the official attribute object (value, type, unit). * https://develop.sentry.dev/sdk/telemetry/scopes/#setting-attributes */ -export function isAttributeObject(maybeObj: unknown): maybeObj is AttributeWithUnit { - if (typeof maybeObj !== 'object' || maybeObj == null || Array.isArray(maybeObj)) { - return false; - } - - // MUST have 'value' property - // MAY have 'unit' property - // MUST NOT have other properties - const keys = Object.keys(maybeObj); - - // MUST have 'value' - if (!keys.includes('value')) { - return false; - } - - // ALLOWED keys: 'value', optionally 'unit' - if (keys.some(k => k !== 'value' && k !== 'unit')) { - return false; - } - - // All checks passed - return true; +export function isAttributeObject(maybeObj: unknown): maybeObj is AttributeObject { + return ( + typeof maybeObj === 'object' && + maybeObj != null && + !Array.isArray(maybeObj) && + Object.keys(maybeObj).includes('value') + ); } /** diff --git a/packages/core/src/scope.ts b/packages/core/src/scope.ts index 0247d721bc6f..2ec1f6480788 100644 --- a/packages/core/src/scope.ts +++ b/packages/core/src/scope.ts @@ -1,5 +1,5 @@ /* eslint-disable max-lines */ -import type { AttributeWithUnit, RawAttribute, RawAttributes } from './attributes'; +import type { AttributeObject, RawAttribute, RawAttributes } from './attributes'; import type { Client } from './client'; import { DEBUG_BUILD } from './debug-build'; import { updateSession } from './session'; @@ -350,7 +350,7 @@ export class Scope { * ``` */ // eslint-disable-next-line @typescript-eslint/no-explicit-any - public setAttribute extends { value: any } | { unit: any } ? AttributeWithUnit : unknown>( + public setAttribute extends { value: any } | { unit: any } ? AttributeObject : unknown>( key: string, value: RawAttribute, ): this { diff --git a/packages/core/test/lib/attributes.test.ts b/packages/core/test/lib/attributes.test.ts index d6a32f889168..99aa20d07c85 100644 --- a/packages/core/test/lib/attributes.test.ts +++ b/packages/core/test/lib/attributes.test.ts @@ -125,6 +125,17 @@ describe('attributeValueToTypedAttributeValue', () => { 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. + const result = attributeValueToTypedAttributeValue({ value: 'foo', unit: 'ms', bar: 'baz' }); + expect(result).toStrictEqual({ + value: 'foo', + unit: 'ms', + type: 'string', + }); + }); }); describe('unsupported value types', () => { @@ -233,22 +244,6 @@ describe('attributeValueToTypedAttributeValue', () => { type: 'string', }); }); - - it('stringifies an attribute-object-like object with additional properties to a string attribute value', () => { - const result = attributeValueToTypedAttributeValue({ value: 'foo', bar: 'baz' }); - expect(result).toStrictEqual({ - value: '{"value":"foo","bar":"baz"}', - type: 'string', - }); - }); - - it('stringifies an attribute-object-like object with a unit property to a string attribute value', () => { - const result = attributeValueToTypedAttributeValue({ value: 'foo', unit: 'ms', bar: 'baz' }); - expect(result).toStrictEqual({ - value: '{"value":"foo","unit":"ms","bar":"baz"}', - type: 'string', - }); - }); }); it.each([1, true, null, undefined, NaN, Symbol('test'), { foo: 'bar' }])( @@ -275,18 +270,17 @@ describe('isAttributeObject', () => { expect(result).toBe(true); }); - it.each([ - 1, - true, - 'test', - null, - undefined, - NaN, - Symbol('test'), - { value: { foo: 'bar' }, bar: 'baz' }, - { value: 1, unit: 'ms', anotherProperty: 'test' }, - ])('returns false for an invalid attribute object (%s)', obj => { - const result = isAttributeObject(obj); - expect(result).toBe(false); + it('returns true for an object with a value property', () => { + // Explicitly demonstrate this behaviour which for now we're fine with. + // We may reconsider in the future. + expect(isAttributeObject({ value: 123.45, some: 'other property' })).toBe(true); }); + + it.each([1, true, 'test', null, undefined, NaN, Symbol('test')])( + 'returns false for an invalid attribute object (%s)', + obj => { + const result = isAttributeObject(obj); + expect(result).toBe(false); + }, + ); }); From 779e053534c7827243c9c44be9a748de781ef58b Mon Sep 17 00:00:00 2001 From: Lukas Stracke Date: Tue, 18 Nov 2025 11:01:49 +0100 Subject: [PATCH 13/17] streamline primitive attribute value conversion --- packages/core/src/attributes.ts | 69 ++++++++++++++++----------------- 1 file changed, 34 insertions(+), 35 deletions(-) diff --git a/packages/core/src/attributes.ts b/packages/core/src/attributes.ts index 25a735baa0bd..1c8ddd9593bd 100644 --- a/packages/core/src/attributes.ts +++ b/packages/core/src/attributes.ts @@ -1,3 +1,6 @@ +import { DEBUG_BUILD } from './debug-build'; +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; @@ -74,69 +77,65 @@ export function attributeValueToTypedAttributeValue(rawValue: unknown): TypedAtt return { ...getTypedAttributeValue(value), ...(unit && typeof unit === 'string' ? { unit } : {}) }; } -// Disallow NaN, differentiate between integer and double -const getNumberType: (num: number) => 'integer' | 'double' | null = item => - Number.isNaN(item) ? null : Number.isInteger(item) ? 'integer' : 'double'; - // Only allow string, boolean, or number types -const getPrimitiveType: (item: unknown) => 'string' | 'boolean' | 'integer' | 'double' | null = item => +const getPrimitiveType: ( + item: unknown, +) => keyof Pick | null = item => typeof item === 'string' ? 'string' : typeof item === 'boolean' ? 'boolean' : typeof item === 'number' - ? getNumberType(item) + ? Number.isNaN(item) + ? null + : Number.isInteger(item) + ? 'integer' + : 'double' : null; -function getTypedAttributeValue(val: unknown): TypedAttributeValue { - switch (typeof val) { - case 'number': { - const numberType = getNumberType(val); - if (!numberType) { - break; - } - return { - value: val, - type: numberType, - }; - } - case 'boolean': - return { - value: val, - type: 'boolean', - }; - case 'string': - return { - value: val, - type: 'string', - }; +function getTypedAttributeValue(value: unknown): TypedAttributeValue { + const primitiveType = getPrimitiveType(value); + if (primitiveType) { + // @ts-expect-error - TS complains because {@link TypedAttributeValue} is strictly typed to + // avoid setting the wrong `type` on the attribute value. + // In this case, getPrimitiveType already does the check but TS doesn't know that. + // The "clean" alternative is to return an object per `typeof value` case + // but that would require more bundle size + // Therefore, we ignore it. + return { value, type: primitiveType }; } - if (Array.isArray(val)) { - const coherentType = val.reduce((acc: 'string' | 'boolean' | 'integer' | 'double' | null, item) => { + 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(val[0])); + }, getPrimitiveType(value[0])); - if (coherentType) { - return { value: val, type: `${coherentType}[]` }; + if (coherentArrayType) { + return { value, type: `${coherentArrayType}[]` }; } } // Fallback: stringify the passed value let fallbackValue = ''; try { - fallbackValue = JSON.stringify(val) ?? String(val); + fallbackValue = JSON.stringify(value) ?? String(value); } catch { try { - fallbackValue = String(val); + 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', From 7d69b12e7cc4d055be459838b7b35a400f8aeeda Mon Sep 17 00:00:00 2001 From: Lukas Stracke Date: Tue, 18 Nov 2025 13:11:51 +0100 Subject: [PATCH 14/17] use MeasurementUnit (aka Relay's `MetricUnit`) --- packages/core/src/attributes.ts | 10 +++------- packages/core/test/lib/scope.test.ts | 27 ++++++++++++++++++--------- 2 files changed, 21 insertions(+), 16 deletions(-) diff --git a/packages/core/src/attributes.ts b/packages/core/src/attributes.ts index 1c8ddd9593bd..75c48f3ccecc 100644 --- a/packages/core/src/attributes.ts +++ b/packages/core/src/attributes.ts @@ -1,4 +1,5 @@ import { DEBUG_BUILD } from './debug-build'; +import type { MeasurementUnit } from './types-hoist/measurement'; import { debug } from './utils/debug-logger'; export type RawAttributes = T & ValidatedAttributes; @@ -32,18 +33,13 @@ type AttributeUnion = { }; }[keyof AttributeTypeMap]; -export type TypedAttributeValue = AttributeUnion & { unit?: Units }; +export type TypedAttributeValue = AttributeUnion & { unit?: MeasurementUnit }; export type AttributeObject = { value: unknown; - unit?: Units; + unit?: MeasurementUnit; }; -/** - * Unit of measurement that can be added to an attribute. - */ -type Units = 'ms' | 's' | 'bytes' | 'count' | 'percent' | string; - /* If an attribute has either a 'value' or 'unit' property, we use the ValidAttributeObject type. */ export type ValidatedAttributes = { // eslint-disable-next-line @typescript-eslint/no-explicit-any diff --git a/packages/core/test/lib/scope.test.ts b/packages/core/test/lib/scope.test.ts index c1cb78de6d8d..6877541f15c2 100644 --- a/packages/core/test/lib/scope.test.ts +++ b/packages/core/test/lib/scope.test.ts @@ -215,9 +215,18 @@ describe('Scope', () => { it('accepts an attribute value object with a unit', () => { const scope = new Scope(); - scope.setAttribute('str', { value: 1, unit: 'ms' }); + scope.setAttribute('str', { value: 1, unit: 'millisecond' }); expect(scope['_attributes']).toEqual({ - str: { value: 1, unit: 'ms' }, + str: { value: 1, unit: 'millisecond' }, + }); + }); + + it('accepts a custom unit', () => { + // mostly there for type checking purposes. + const scope = new Scope(); + scope.setAttribute('str', { value: 3, unit: 'inch' }); + expect(scope['_attributes']).toEqual({ + str: { value: 3, unit: 'inch' }, }); }); @@ -225,11 +234,11 @@ describe('Scope', () => { const scope = new Scope(); scope.setAttribute('strArray', ['a', 'b', 'c']); - scope.setAttribute('intArray', { value: [1, 2, 3], unit: 'ms' }); + scope.setAttribute('intArray', { value: [1, 2, 3], unit: 'millisecond' }); expect(scope['_attributes']).toEqual({ strArray: ['a', 'b', 'c'], - intArray: { value: [1, 2, 3], unit: 'ms' }, + intArray: { value: [1, 2, 3], unit: 'millisecond' }, }); }); @@ -266,10 +275,10 @@ describe('Scope', () => { it('accepts attribute value objects with units', () => { const scope = new Scope(); - scope.setAttributes({ str: { value: 'b', unit: 'ms' }, int: { value: 12, unit: 's' } }); + scope.setAttributes({ str: { value: 'b', unit: 'millisecond' }, int: { value: 12, unit: 'second' } }); expect(scope['_attributes']).toEqual({ - str: { value: 'b', unit: 'ms' }, - int: { value: 12, unit: 's' }, + str: { value: 'b', unit: 'millisecond' }, + int: { value: 12, unit: 'second' }, }); }); @@ -277,12 +286,12 @@ describe('Scope', () => { const scope = new Scope(); scope.setAttributes({ strArray: ['a', 'b', 'c'], - intArray: { value: [1, 2, 3], unit: 'ms' }, + intArray: { value: [1, 2, 3], unit: 'millisecond' }, }); expect(scope['_attributes']).toEqual({ strArray: ['a', 'b', 'c'], - intArray: { value: [1, 2, 3], unit: 'ms' }, + intArray: { value: [1, 2, 3], unit: 'millisecond' }, }); }); From ab75d4a81378501b9f773e79c61d1244a68e1ce6 Mon Sep 17 00:00:00 2001 From: Lukas Stracke Date: Tue, 18 Nov 2025 17:05:29 +0100 Subject: [PATCH 15/17] use metric units without custom and none units --- packages/core/src/attributes.ts | 10 +++++++--- packages/core/test/lib/scope.test.ts | 3 ++- 2 files changed, 9 insertions(+), 4 deletions(-) diff --git a/packages/core/src/attributes.ts b/packages/core/src/attributes.ts index 75c48f3ccecc..c749a4b73a82 100644 --- a/packages/core/src/attributes.ts +++ b/packages/core/src/attributes.ts @@ -1,5 +1,5 @@ import { DEBUG_BUILD } from './debug-build'; -import type { MeasurementUnit } from './types-hoist/measurement'; +import type { DurationUnit, FractionUnit, InformationUnit, MeasurementUnit, NoneUnit } from './types-hoist/measurement'; import { debug } from './utils/debug-logger'; export type RawAttributes = T & ValidatedAttributes; @@ -33,13 +33,17 @@ type AttributeUnion = { }; }[keyof AttributeTypeMap]; -export type TypedAttributeValue = AttributeUnion & { unit?: MeasurementUnit }; +export type TypedAttributeValue = AttributeUnion & { unit?: AttributeUnit }; export type AttributeObject = { value: unknown; - unit?: MeasurementUnit; + unit?: AttributeUnit; }; +// Unfortunately, we loose type safety if we did something like Exclude +// so therefore we unionize between the three supported unit categories. +type AttributeUnit = DurationUnit | InformationUnit | FractionUnit; + /* If an attribute has either a 'value' or 'unit' property, we use the ValidAttributeObject type. */ export type ValidatedAttributes = { // eslint-disable-next-line @typescript-eslint/no-explicit-any diff --git a/packages/core/test/lib/scope.test.ts b/packages/core/test/lib/scope.test.ts index 6877541f15c2..339a57828e5b 100644 --- a/packages/core/test/lib/scope.test.ts +++ b/packages/core/test/lib/scope.test.ts @@ -221,9 +221,10 @@ describe('Scope', () => { }); }); - it('accepts a custom unit', () => { + it('still accepts a custom unit but TS-errors on it', () => { // mostly there for type checking purposes. const scope = new Scope(); + /** @ts-expect-error we don't support custom units type-wise but we don't actively block them */ scope.setAttribute('str', { value: 3, unit: 'inch' }); expect(scope['_attributes']).toEqual({ str: { value: 3, unit: 'inch' }, From bcb59255771d58108f4e5339f53df426d37eb0b3 Mon Sep 17 00:00:00 2001 From: Lukas Stracke Date: Tue, 18 Nov 2025 17:08:10 +0100 Subject: [PATCH 16/17] lint --- packages/core/src/attributes.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/core/src/attributes.ts b/packages/core/src/attributes.ts index c749a4b73a82..56eee8b4462b 100644 --- a/packages/core/src/attributes.ts +++ b/packages/core/src/attributes.ts @@ -1,5 +1,5 @@ import { DEBUG_BUILD } from './debug-build'; -import type { DurationUnit, FractionUnit, InformationUnit, MeasurementUnit, NoneUnit } from './types-hoist/measurement'; +import type { DurationUnit, FractionUnit, InformationUnit } from './types-hoist/measurement'; import { debug } from './utils/debug-logger'; export type RawAttributes = T & ValidatedAttributes; From 04d75b13bf8a79bb93a249fea0d374a2864a2e77 Mon Sep 17 00:00:00 2001 From: Lukas Stracke Date: Tue, 18 Nov 2025 17:09:40 +0100 Subject: [PATCH 17/17] save a few more bytes in `getPrimitiveType` --- packages/core/src/attributes.ts | 10 ++++------ 1 file changed, 4 insertions(+), 6 deletions(-) diff --git a/packages/core/src/attributes.ts b/packages/core/src/attributes.ts index 56eee8b4462b..d979d5c4350f 100644 --- a/packages/core/src/attributes.ts +++ b/packages/core/src/attributes.ts @@ -85,12 +85,10 @@ const getPrimitiveType: ( ? 'string' : typeof item === 'boolean' ? 'boolean' - : typeof item === 'number' - ? Number.isNaN(item) - ? null - : Number.isInteger(item) - ? 'integer' - : 'double' + : typeof item === 'number' && !Number.isNaN(item) + ? Number.isInteger(item) + ? 'integer' + : 'double' : null; function getTypedAttributeValue(value: unknown): TypedAttributeValue {