diff --git a/.size-limit.js b/.size-limit.js index 100444907e06..6e6ee0f68303 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)', @@ -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', @@ -231,7 +231,7 @@ module.exports = [ import: createImport('init'), ignore: [...builtinModules, ...nodePrefixedBuiltinModules], gzip: true, - limit: '51.1 KB', + limit: '52 KB', }, // Node SDK (ESM) { diff --git a/packages/core/src/attributes.ts b/packages/core/src/attributes.ts new file mode 100644 index 000000000000..d979d5c4350f --- /dev/null +++ b/packages/core/src/attributes.ts @@ -0,0 +1,141 @@ +import { DEBUG_BUILD } from './debug-build'; +import type { DurationUnit, FractionUnit, InformationUnit } from './types-hoist/measurement'; +import { debug } from './utils/debug-logger'; + +export type RawAttributes = T & ValidatedAttributes; +// eslint-disable-next-line @typescript-eslint/no-explicit-any +export type RawAttribute = T extends { value: any } | { unit: any } ? AttributeObject : T; + +export type Attributes = Record; + +export type AttributeValueType = string | number | boolean | Array | Array | Array; + +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?: AttributeUnit }; + +export type AttributeObject = { + value: unknown; + 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 + [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 AttributeObject { + return ( + typeof maybeObj === 'object' && + maybeObj != null && + !Array.isArray(maybeObj) && + Object.keys(maybeObj).includes('value') + ); +} + +/** + * 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 { value, unit } = isAttributeObject(rawValue) ? rawValue : { value: rawValue, unit: undefined }; + return { ...getTypedAttributeValue(value), ...(unit && typeof unit === 'string' ? { unit } : {}) }; +} + +// Only allow string, boolean, or number types +const getPrimitiveType: ( + item: unknown, +) => keyof Pick | null = item => + typeof item === 'string' + ? 'string' + : typeof item === 'boolean' + ? 'boolean' + : typeof item === 'number' && !Number.isNaN(item) + ? Number.isInteger(item) + ? 'integer' + : 'double' + : null; + +function getTypedAttributeValue(value: unknown): TypedAttributeValue { + const primitiveType = getPrimitiveType(value); + 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(value)) { + const coherentArrayType = value.reduce((acc: 'string' | 'boolean' | 'integer' | 'double' | null, item) => { + if (!acc || getPrimitiveType(item) !== acc) { + return null; + } + return acc; + }, getPrimitiveType(value[0])); + + if (coherentArrayType) { + return { value, type: `${coherentArrayType}[]` }; + } + } + + // Fallback: stringify the passed value + let fallbackValue = ''; + try { + fallbackValue = JSON.stringify(value) ?? String(value); + } catch { + try { + fallbackValue = String(value); + } catch { + DEBUG_BUILD && debug.warn('Failed to stringify attribute value', value); + // ignore + } + } + + // This is quite a low-quality message but we cannot safely log the original `value` + // here due to String() or JSON.stringify() potentially throwing. + DEBUG_BUILD && + debug.log(`Stringified attribute value to ${fallbackValue} because it's not a supported attribute value type`); + + return { + value: fallbackValue, + type: 'string', + }; +} diff --git a/packages/core/src/scope.ts b/packages/core/src/scope.ts index b23b01664431..2ec1f6480788 100644 --- a/packages/core/src/scope.ts +++ b/packages/core/src/scope.ts @@ -1,4 +1,5 @@ /* eslint-disable max-lines */ +import type { AttributeObject, RawAttribute, RawAttributes } 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?: RawAttributes>; 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?: RawAttributes>; extra: Extras; contexts: Contexts; attachments: Attachment[]; @@ -104,6 +108,9 @@ export class Scope { /** Tags */ protected _tags: { [key: string]: Primitive }; + /** Attributes */ + protected _attributes: RawAttributes>; + /** 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,79 @@ 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 `value` and an optional `unit` (if applicable to your attribute). + * + * @example + * ```typescript + * scope.setAttributes({ + * is_admin: true, + * payment_selection: 'credit_card', + * clicked_products: [130, 554, 292], + * render_duration: { value: 'render_duration', unit: 'ms' }, + * }); + * ``` + */ + public setAttributes>(newAttributes: RawAttributes): this { + this._attributes = { + ...this._attributes, + ...newAttributes, + }; + + 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, 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', unit: 'ms' }); + * ``` + */ + // eslint-disable-next-line @typescript-eslint/no-explicit-any + public setAttribute extends { value: any } | { unit: any } ? AttributeObject : unknown>( + key: string, + value: RawAttribute, + ): this { + 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. @@ -409,9 +491,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 +534,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 +621,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..99aa20d07c85 --- /dev/null +++ b/packages/core/test/lib/attributes.test.ts @@ -0,0 +1,286 @@ +import { describe, expect, it } from 'vitest'; +import { attributeValueToTypedAttributeValue, isAttributeObject } from '../../src/attributes'; + +describe('attributeValueToTypedAttributeValue', () => { + describe('primitive values', () => { + it('converts a string value to a typed attribute value', () => { + const result = attributeValueToTypedAttributeValue('test'); + expect(result).toStrictEqual({ + value: 'test', + type: 'string', + }); + }); + + it('converts an interger number value to a typed attribute value', () => { + const result = attributeValueToTypedAttributeValue(42); + expect(result).toStrictEqual({ + value: 42, + type: 'integer', + }); + }); + + it('converts a double number value to a typed attribute value', () => { + const result = attributeValueToTypedAttributeValue(42.34); + expect(result).toStrictEqual({ + value: 42.34, + type: 'double', + }); + }); + + it('converts a boolean value to a typed attribute value', () => { + const result = attributeValueToTypedAttributeValue(true); + expect(result).toStrictEqual({ + value: true, + type: 'boolean', + }); + }); + }); + + describe('arrays', () => { + it('converts an array of strings to a typed attribute value', () => { + const result = attributeValueToTypedAttributeValue(['foo', 'bar']); + expect(result).toStrictEqual({ + value: ['foo', 'bar'], + type: 'string[]', + }); + }); + + it('converts an array of integer numbers to a typed attribute value', () => { + const result = attributeValueToTypedAttributeValue([1, 2, 3]); + expect(result).toStrictEqual({ + value: [1, 2, 3], + type: 'integer[]', + }); + }); + + it('converts an array of double numbers to a typed attribute value', () => { + const result = attributeValueToTypedAttributeValue([1.1, 2.2, 3.3]); + expect(result).toStrictEqual({ + value: [1.1, 2.2, 3.3], + type: 'double[]', + }); + }); + + it('converts an array of booleans to a typed attribute value', () => { + const result = attributeValueToTypedAttributeValue([true, false, true]); + expect(result).toStrictEqual({ + value: [true, false, true], + type: 'boolean[]', + }); + }); + }); + + describe('attribute objects without units', () => { + // Note: These tests only test exemplar type and fallback behaviour (see above for more cases) + it('converts a primitive value to a typed attribute value', () => { + const result = attributeValueToTypedAttributeValue({ value: 123.45 }); + expect(result).toStrictEqual({ + value: 123.45, + type: 'double', + }); + }); + + it('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('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', + }); + }); + + 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', () => { + it('stringifies mixed float and integer numbers to a string attribute value', () => { + const result = attributeValueToTypedAttributeValue([1, 2.2, 3]); + expect(result).toStrictEqual({ + value: '[1,2.2,3]', + type: 'string', + }); + }); + + it('stringifies an array of allowed but incoherent types to a string attribute value', () => { + const result = attributeValueToTypedAttributeValue([1, 'foo', true]); + expect(result).toStrictEqual({ + value: '[1,"foo",true]', + type: 'string', + }); + }); + + it('stringifies an array of disallowed and incoherent types to a string attribute value', () => { + const result = attributeValueToTypedAttributeValue([null, undefined, NaN]); + expect(result).toStrictEqual({ + value: '[null,null,null]', + type: 'string', + }); + }); + + it('stringifies an object value to a string attribute value', () => { + const result = attributeValueToTypedAttributeValue({ foo: 'bar' }); + expect(result).toStrictEqual({ + value: '{"foo":"bar"}', + type: 'string', + }); + }); + + it('stringifies a null value to a string attribute value', () => { + const result = attributeValueToTypedAttributeValue(null); + expect(result).toStrictEqual({ + value: 'null', + type: 'string', + }); + }); + + it('stringifies an undefined value to a string attribute value', () => { + const result = attributeValueToTypedAttributeValue(undefined); + expect(result).toStrictEqual({ + value: 'undefined', + type: 'string', + }); + }); + + it('stringifies an NaN number value to a string attribute value', () => { + const result = attributeValueToTypedAttributeValue(NaN); + expect(result).toStrictEqual({ + value: 'null', + type: 'string', + }); + }); + + it('converts an object toString if stringification fails', () => { + const result = attributeValueToTypedAttributeValue({ + value: { + toJson: () => { + throw new Error('test'); + }, + }, + }); + expect(result).toStrictEqual({ + value: '{}', + type: 'string', + }); + }); + + it('falls back to an empty string if stringification and toString fails', () => { + const result = attributeValueToTypedAttributeValue({ + value: { + toJSON: () => { + throw new Error('test'); + }, + toString: () => { + throw new Error('test'); + }, + }, + }); + expect(result).toStrictEqual({ + value: '', + type: 'string', + }); + }); + + it('converts a function toString ', () => { + const result = attributeValueToTypedAttributeValue(() => { + return 'test'; + }); + + expect(result).toStrictEqual({ + value: '() => {\n return "test";\n }', + type: 'string', + }); + }); + + it('converts a symbol toString', () => { + const result = attributeValueToTypedAttributeValue(Symbol('test')); + expect(result).toStrictEqual({ + value: 'Symbol(test)', + type: 'string', + }); + }); + }); + + it.each([1, true, null, undefined, NaN, Symbol('test'), { foo: 'bar' }])( + 'ignores invalid (non-string) units (%s)', + unit => { + const result = attributeValueToTypedAttributeValue({ value: 'foo', unit }); + expect(result).toStrictEqual({ + value: 'foo', + 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('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); + }, + ); +}); diff --git a/packages/core/test/lib/scope.test.ts b/packages/core/test/lib/scope.test.ts index 221ac14a6fa2..339a57828e5b 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' } }, }); expect(scope.getScopeData()).toEqual({ @@ -51,6 +53,7 @@ describe('Scope', () => { tags: { foo: 'bar', }, + attributes: { attr1: { value: 'value1' } }, 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,159 @@ 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: 'b', + bool: true, + double: 1.1, + int: 1, + }); + }); + + it('accepts an attribute value object', () => { + const scope = new Scope(); + scope.setAttribute('str', { value: 'b' }); + expect(scope['_attributes']).toEqual({ + str: { value: 'b' }, + }); + }); + + it('accepts an attribute value object with a unit', () => { + const scope = new Scope(); + scope.setAttribute('str', { value: 1, unit: 'millisecond' }); + expect(scope['_attributes']).toEqual({ + str: { value: 1, unit: 'millisecond' }, + }); + }); + + 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' }, + }); + }); + + it('accepts an array', () => { + const scope = new Scope(); + + scope.setAttribute('strArray', ['a', 'b', 'c']); + scope.setAttribute('intArray', { value: [1, 2, 3], unit: 'millisecond' }); + + expect(scope['_attributes']).toEqual({ + strArray: ['a', 'b', 'c'], + intArray: { value: [1, 2, 3], unit: 'millisecond' }, + }); + }); + + 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', () => { + 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: 'b', + int: 1, + double: 1.1, + bool: true, + }); + }); + + it('accepts attribute value objects', () => { + const scope = new Scope(); + scope.setAttributes({ str: { value: 'b' }, int: { value: 1 } }); + expect(scope['_attributes']).toEqual({ + str: { value: 'b' }, + int: { value: 1 }, + }); + }); + + it('accepts attribute value objects with units', () => { + const scope = new Scope(); + scope.setAttributes({ str: { value: 'b', unit: 'millisecond' }, int: { value: 12, unit: 'second' } }); + expect(scope['_attributes']).toEqual({ + str: { value: 'b', unit: 'millisecond' }, + int: { value: 12, unit: 'second' }, + }); + }); + + it('accepts arrays', () => { + const scope = new Scope(); + scope.setAttributes({ + strArray: ['a', 'b', 'c'], + intArray: { value: [1, 2, 3], unit: 'millisecond' }, + }); + + expect(scope['_attributes']).toEqual({ + strArray: ['a', 'b', 'c'], + intArray: { value: [1, 2, 3], unit: 'millisecond' }, + }); + }); + + 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: 1 }); + }); + + it('notifies scope listeners after deletion', () => { + const scope = new Scope(); + const listener = vi.fn(); + + scope.addScopeListener(listener); + scope.setAttribute('str', { value: 'b' }); + expect(listener).toHaveBeenCalledTimes(1); + + listener.mockClear(); + + scope.removeAttribute('str'); + expect(listener).toHaveBeenCalledTimes(1); + }); + + 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', () => { const scope = new Scope(); scope.setUser({ id: '1' }); @@ -329,12 +487,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: '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 +521,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' });