Skip to content
Open
Show file tree
Hide file tree
Changes from 6 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 6 additions & 6 deletions .size-limit.js
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,7 @@ module.exports = [
path: 'packages/browser/build/npm/esm/prod/index.js',
import: createImport('init', 'browserTracingIntegration'),
gzip: true,
limit: '41.3 KB',
limit: '42 KB',
},
{
name: '@sentry/browser (incl. Tracing, Profiling)',
Expand Down Expand Up @@ -127,7 +127,7 @@ module.exports = [
import: createImport('init', 'ErrorBoundary', 'reactRouterV6BrowserTracingIntegration'),
ignore: ['react/jsx-runtime'],
gzip: true,
limit: '43.3 KB',
limit: '44 KB',
},
// Vue SDK (ESM)
{
Expand All @@ -142,7 +142,7 @@ module.exports = [
path: 'packages/vue/build/esm/index.js',
import: createImport('init', 'browserTracingIntegration'),
gzip: true,
limit: '43.1 KB',
limit: '44 KB',
},
// Svelte SDK (ESM)
{
Expand All @@ -163,7 +163,7 @@ module.exports = [
name: 'CDN Bundle (incl. Tracing)',
path: createCDNPath('bundle.tracing.min.js'),
gzip: true,
limit: '42 KB',
limit: '42.5 KB',
},
{
name: 'CDN Bundle (incl. Tracing, Replay)',
Expand Down Expand Up @@ -231,7 +231,7 @@ module.exports = [
import: createImport('init'),
ignore: [...builtinModules, ...nodePrefixedBuiltinModules],
gzip: true,
limit: '51 KB',
limit: '52 KB',
},
// Node SDK (ESM)
{
Expand All @@ -240,7 +240,7 @@ module.exports = [
import: createImport('init'),
ignore: [...builtinModules, ...nodePrefixedBuiltinModules],
gzip: true,
limit: '158 KB',
limit: '159 KB',
},
{
name: '@sentry/node - without tracing',
Expand Down
108 changes: 108 additions & 0 deletions packages/core/src/attributes.ts
Copy link
Member Author

@Lms24 Lms24 Nov 11, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

In contrast to logs and metrics attribute definitions and helper functions, this one already handles array attributes. My thinking is, we introduce this here then unify logs, metrics (+ spans eventually) to use the types and APIs here. While we'll have to change the used types for logs and metrics, I don't think this is breaking as theoretically logs and metrics support subsets of the attribute value types specified here.

Original file line number Diff line number Diff line change
@@ -0,0 +1,108 @@
export type Attributes = Record<string, TypedAttributeValue>;

export type AttributeValueType = string | number | boolean | Array<string> | Array<number> | Array<boolean>;

export type TypedAttributeValue = (
| {
value: string;
type: 'string';
}
| {
value: number;
type: 'integer';
}
| {
value: number;
type: 'double';
}
| {
value: boolean;
type: 'boolean';
}
| {
value: Array<string>;
type: 'string[]';
}
| {
value: Array<number>;
type: 'integer[]';
}
| {
value: Array<number>;
type: 'double[]';
}
| {
value: Array<boolean>;
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',
};
}
96 changes: 95 additions & 1 deletion packages/core/src/scope.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,6 @@
/* eslint-disable max-lines */
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';
Expand Down Expand Up @@ -46,6 +48,7 @@ export interface ScopeContext {
extra: Extras;
contexts: Contexts;
tags: { [key: string]: Primitive };
attributes?: Attributes;
fingerprint: string[];
propagationContext: PropagationContext;
}
Expand All @@ -71,6 +74,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[];
Expand Down Expand Up @@ -104,6 +109,9 @@ export class Scope {
/** Tags */
protected _tags: { [key: string]: Primitive };

/** Attributes */
protected _attributes: Attributes;

/** Extra */
protected _extra: Extras;

Expand Down Expand Up @@ -155,6 +163,7 @@ export class Scope {
this._attachments = [];
this._user = {};
this._tags = {};
this._attributes = {};
this._extra = {};
this._contexts = {};
this._sdkProcessingMetadata = {};
Expand All @@ -171,6 +180,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) {
Expand Down Expand Up @@ -296,6 +306,78 @@ export class Scope {
return this;
}

/**
* Sets attributes onto the scope.
*
* TODO:
* Currently, these attributes are not applied to any telemetry data but they will be in the future.
*
* @param newAttributes - The attributes to set on the scope. You can either pass in key-value pairs, or
* an object with a concrete type declaration and an optional unit (if applicable to your attribute).
* You can only pass in primitive values or arrays of primitive values.
*
* @example
* ```typescript
* scope.setAttributes({
* is_admin: true,
* payment_selection: 'credit_card',
* clicked_products: [130, 554, 292],
* render_duration: { value: 'render_duration', type: 'float', unit: 'ms' },
* });
* ```
*/
public setAttributes(newAttributes: Record<string, AttributeValueType | TypedAttributeValue>): 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 });
}

/**
* 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.
Expand Down Expand Up @@ -411,9 +493,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 };

Expand Down Expand Up @@ -444,6 +536,7 @@ export class Scope {
// client is not cleared here on purpose!
this._breadcrumbs = [];
this._tags = {};
this._attributes = {};
this._extra = {};
this._user = {};
this._contexts = {};
Expand Down Expand Up @@ -530,6 +623,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,
Expand Down
Loading
Loading