Skip to content
Merged
Show file tree
Hide file tree
Changes from all 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
14 changes: 7 additions & 7 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.5 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.5 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.3 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.1 KB',
limit: '42.5 KB',
},
{
name: 'CDN Bundle (incl. Tracing, Replay)',
Expand All @@ -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',
Expand Down Expand Up @@ -231,7 +231,7 @@ module.exports = [
import: createImport('init'),
ignore: [...builtinModules, ...nodePrefixedBuiltinModules],
gzip: true,
limit: '51.1 KB',
limit: '52 KB',
},
// Node SDK (ESM)
{
Expand Down
141 changes: 141 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 as well as attributes with units. 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 unknown for attribute values.

Original file line number Diff line number Diff line change
@@ -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> = T & ValidatedAttributes<T>;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
export type RawAttribute<T> = T extends { value: any } | { unit: any } ? AttributeObject : T;

export type Attributes = Record<string, TypedAttributeValue>;

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

type AttributeTypeMap = {
string: string;
integer: number;
double: number;
boolean: boolean;
'string[]': Array<string>;
'integer[]': Array<number>;
'double[]': Array<number>;
'boolean[]': Array<boolean>;
};

/* 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<MeasurementUnit, string>
// 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<T> = {
// 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<AttributeTypeMap, 'string' | 'integer' | 'double' | 'boolean'> | 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',
};
}
96 changes: 95 additions & 1 deletion packages/core/src/scope.ts
Original file line number Diff line number Diff line change
@@ -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';
Expand Down Expand Up @@ -46,6 +47,7 @@ export interface ScopeContext {
extra: Extras;
contexts: Contexts;
tags: { [key: string]: Primitive };
attributes?: RawAttributes<Record<string, unknown>>;
fingerprint: string[];
propagationContext: PropagationContext;
}
Expand All @@ -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<Record<string, unknown>>;
extra: Extras;
contexts: Contexts;
attachments: Attachment[];
Expand Down Expand Up @@ -104,6 +108,9 @@ export class Scope {
/** Tags */
protected _tags: { [key: string]: Primitive };

/** Attributes */
protected _attributes: RawAttributes<Record<string, unknown>>;

/** Extra */
protected _extra: Extras;

Expand Down Expand Up @@ -155,6 +162,7 @@ export class Scope {
this._attachments = [];
this._user = {};
this._tags = {};
this._attributes = {};
this._extra = {};
this._contexts = {};
this._sdkProcessingMetadata = {};
Expand All @@ -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) {
Expand Down Expand Up @@ -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<T extends Record<string, unknown>>(newAttributes: RawAttributes<T>): 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<T extends RawAttribute<T> extends { value: any } | { unit: any } ? AttributeObject : unknown>(
key: string,
value: RawAttribute<T>,
): 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 @@ -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 };

Expand Down Expand Up @@ -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 = {};
Expand Down Expand Up @@ -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,
Expand Down
Loading