Skip to content

Commit 3375de0

Browse files
Lms24s1gr1d
andauthored
feat(core): Add scope attribute APIs (#18165)
This PR adds `scope.setAttribute`, `scope.setAttributes` and `scope.removeAttribute` methods, as specified in our [develop docs](https://develop.sentry.dev/sdk/telemetry/scopes/#setting-attributes). This intial PR only enables setting the attributes (including attributes with units) as well as the usual scope data operations (clone(), update(), clear(), getSpanData()). These attributes are not yet applied to any of the telemetry we eventually want them to apply to. I'll take care of this in a follow-up PR. closes #18140 ref https://linear.app/getsentry/project/implement-global-attributes-api-javascript-02c3c74184fc/issues --------- Co-authored-by: Sigrid <32902192+s1gr1d@users.noreply.github.com>
1 parent edc1f09 commit 3375de0

File tree

5 files changed

+695
-9
lines changed

5 files changed

+695
-9
lines changed

.size-limit.js

Lines changed: 7 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -38,7 +38,7 @@ module.exports = [
3838
path: 'packages/browser/build/npm/esm/prod/index.js',
3939
import: createImport('init', 'browserTracingIntegration'),
4040
gzip: true,
41-
limit: '41.5 KB',
41+
limit: '42 KB',
4242
},
4343
{
4444
name: '@sentry/browser (incl. Tracing, Profiling)',
@@ -127,7 +127,7 @@ module.exports = [
127127
import: createImport('init', 'ErrorBoundary', 'reactRouterV6BrowserTracingIntegration'),
128128
ignore: ['react/jsx-runtime'],
129129
gzip: true,
130-
limit: '43.5 KB',
130+
limit: '44 KB',
131131
},
132132
// Vue SDK (ESM)
133133
{
@@ -142,7 +142,7 @@ module.exports = [
142142
path: 'packages/vue/build/esm/index.js',
143143
import: createImport('init', 'browserTracingIntegration'),
144144
gzip: true,
145-
limit: '43.3 KB',
145+
limit: '44 KB',
146146
},
147147
// Svelte SDK (ESM)
148148
{
@@ -163,7 +163,7 @@ module.exports = [
163163
name: 'CDN Bundle (incl. Tracing)',
164164
path: createCDNPath('bundle.tracing.min.js'),
165165
gzip: true,
166-
limit: '42.1 KB',
166+
limit: '42.5 KB',
167167
},
168168
{
169169
name: 'CDN Bundle (incl. Tracing, Replay)',
@@ -183,14 +183,14 @@ module.exports = [
183183
path: createCDNPath('bundle.min.js'),
184184
gzip: false,
185185
brotli: false,
186-
limit: '80 KB',
186+
limit: '82 KB',
187187
},
188188
{
189189
name: 'CDN Bundle (incl. Tracing) - uncompressed',
190190
path: createCDNPath('bundle.tracing.min.js'),
191191
gzip: false,
192192
brotli: false,
193-
limit: '125 KB',
193+
limit: '127 KB',
194194
},
195195
{
196196
name: 'CDN Bundle (incl. Tracing, Replay) - uncompressed',
@@ -231,7 +231,7 @@ module.exports = [
231231
import: createImport('init'),
232232
ignore: [...builtinModules, ...nodePrefixedBuiltinModules],
233233
gzip: true,
234-
limit: '51.1 KB',
234+
limit: '52 KB',
235235
},
236236
// Node SDK (ESM)
237237
{

packages/core/src/attributes.ts

Lines changed: 141 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,141 @@
1+
import { DEBUG_BUILD } from './debug-build';
2+
import type { DurationUnit, FractionUnit, InformationUnit } from './types-hoist/measurement';
3+
import { debug } from './utils/debug-logger';
4+
5+
export type RawAttributes<T> = T & ValidatedAttributes<T>;
6+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
7+
export type RawAttribute<T> = T extends { value: any } | { unit: any } ? AttributeObject : T;
8+
9+
export type Attributes = Record<string, TypedAttributeValue>;
10+
11+
export type AttributeValueType = string | number | boolean | Array<string> | Array<number> | Array<boolean>;
12+
13+
type AttributeTypeMap = {
14+
string: string;
15+
integer: number;
16+
double: number;
17+
boolean: boolean;
18+
'string[]': Array<string>;
19+
'integer[]': Array<number>;
20+
'double[]': Array<number>;
21+
'boolean[]': Array<boolean>;
22+
};
23+
24+
/* Generates a type from the AttributeTypeMap like:
25+
| { value: string; type: 'string' }
26+
| { value: number; type: 'integer' }
27+
| { value: number; type: 'double' }
28+
*/
29+
type AttributeUnion = {
30+
[K in keyof AttributeTypeMap]: {
31+
value: AttributeTypeMap[K];
32+
type: K;
33+
};
34+
}[keyof AttributeTypeMap];
35+
36+
export type TypedAttributeValue = AttributeUnion & { unit?: AttributeUnit };
37+
38+
export type AttributeObject = {
39+
value: unknown;
40+
unit?: AttributeUnit;
41+
};
42+
43+
// Unfortunately, we loose type safety if we did something like Exclude<MeasurementUnit, string>
44+
// so therefore we unionize between the three supported unit categories.
45+
type AttributeUnit = DurationUnit | InformationUnit | FractionUnit;
46+
47+
/* If an attribute has either a 'value' or 'unit' property, we use the ValidAttributeObject type. */
48+
export type ValidatedAttributes<T> = {
49+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
50+
[K in keyof T]: T[K] extends { value: any } | { unit: any } ? AttributeObject : unknown;
51+
};
52+
53+
/**
54+
* Type-guard: The attribute object has the shape the official attribute object (value, type, unit).
55+
* https://develop.sentry.dev/sdk/telemetry/scopes/#setting-attributes
56+
*/
57+
export function isAttributeObject(maybeObj: unknown): maybeObj is AttributeObject {
58+
return (
59+
typeof maybeObj === 'object' &&
60+
maybeObj != null &&
61+
!Array.isArray(maybeObj) &&
62+
Object.keys(maybeObj).includes('value')
63+
);
64+
}
65+
66+
/**
67+
* Converts an attribute value to a typed attribute value.
68+
*
69+
* Does not allow mixed arrays. In case of a mixed array, the value is stringified and the type is 'string'.
70+
* All values besides the supported attribute types (see {@link AttributeTypeMap}) are stringified to a string attribute value.
71+
*
72+
* @param value - The value of the passed attribute.
73+
* @returns The typed attribute.
74+
*/
75+
export function attributeValueToTypedAttributeValue(rawValue: unknown): TypedAttributeValue {
76+
const { value, unit } = isAttributeObject(rawValue) ? rawValue : { value: rawValue, unit: undefined };
77+
return { ...getTypedAttributeValue(value), ...(unit && typeof unit === 'string' ? { unit } : {}) };
78+
}
79+
80+
// Only allow string, boolean, or number types
81+
const getPrimitiveType: (
82+
item: unknown,
83+
) => keyof Pick<AttributeTypeMap, 'string' | 'integer' | 'double' | 'boolean'> | null = item =>
84+
typeof item === 'string'
85+
? 'string'
86+
: typeof item === 'boolean'
87+
? 'boolean'
88+
: typeof item === 'number' && !Number.isNaN(item)
89+
? Number.isInteger(item)
90+
? 'integer'
91+
: 'double'
92+
: null;
93+
94+
function getTypedAttributeValue(value: unknown): TypedAttributeValue {
95+
const primitiveType = getPrimitiveType(value);
96+
if (primitiveType) {
97+
// @ts-expect-error - TS complains because {@link TypedAttributeValue} is strictly typed to
98+
// avoid setting the wrong `type` on the attribute value.
99+
// In this case, getPrimitiveType already does the check but TS doesn't know that.
100+
// The "clean" alternative is to return an object per `typeof value` case
101+
// but that would require more bundle size
102+
// Therefore, we ignore it.
103+
return { value, type: primitiveType };
104+
}
105+
106+
if (Array.isArray(value)) {
107+
const coherentArrayType = value.reduce((acc: 'string' | 'boolean' | 'integer' | 'double' | null, item) => {
108+
if (!acc || getPrimitiveType(item) !== acc) {
109+
return null;
110+
}
111+
return acc;
112+
}, getPrimitiveType(value[0]));
113+
114+
if (coherentArrayType) {
115+
return { value, type: `${coherentArrayType}[]` };
116+
}
117+
}
118+
119+
// Fallback: stringify the passed value
120+
let fallbackValue = '';
121+
try {
122+
fallbackValue = JSON.stringify(value) ?? String(value);
123+
} catch {
124+
try {
125+
fallbackValue = String(value);
126+
} catch {
127+
DEBUG_BUILD && debug.warn('Failed to stringify attribute value', value);
128+
// ignore
129+
}
130+
}
131+
132+
// This is quite a low-quality message but we cannot safely log the original `value`
133+
// here due to String() or JSON.stringify() potentially throwing.
134+
DEBUG_BUILD &&
135+
debug.log(`Stringified attribute value to ${fallbackValue} because it's not a supported attribute value type`);
136+
137+
return {
138+
value: fallbackValue,
139+
type: 'string',
140+
};
141+
}

packages/core/src/scope.ts

Lines changed: 95 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
/* eslint-disable max-lines */
2+
import type { AttributeObject, RawAttribute, RawAttributes } from './attributes';
23
import type { Client } from './client';
34
import { DEBUG_BUILD } from './debug-build';
45
import { updateSession } from './session';
@@ -46,6 +47,7 @@ export interface ScopeContext {
4647
extra: Extras;
4748
contexts: Contexts;
4849
tags: { [key: string]: Primitive };
50+
attributes?: RawAttributes<Record<string, unknown>>;
4951
fingerprint: string[];
5052
propagationContext: PropagationContext;
5153
}
@@ -71,6 +73,8 @@ export interface ScopeData {
7173
breadcrumbs: Breadcrumb[];
7274
user: User;
7375
tags: { [key: string]: Primitive };
76+
// TODO(v11): Make this a required field (could be subtly breaking if we did it today)
77+
attributes?: RawAttributes<Record<string, unknown>>;
7478
extra: Extras;
7579
contexts: Contexts;
7680
attachments: Attachment[];
@@ -104,6 +108,9 @@ export class Scope {
104108
/** Tags */
105109
protected _tags: { [key: string]: Primitive };
106110

111+
/** Attributes */
112+
protected _attributes: RawAttributes<Record<string, unknown>>;
113+
107114
/** Extra */
108115
protected _extra: Extras;
109116

@@ -155,6 +162,7 @@ export class Scope {
155162
this._attachments = [];
156163
this._user = {};
157164
this._tags = {};
165+
this._attributes = {};
158166
this._extra = {};
159167
this._contexts = {};
160168
this._sdkProcessingMetadata = {};
@@ -171,6 +179,7 @@ export class Scope {
171179
const newScope = new Scope();
172180
newScope._breadcrumbs = [...this._breadcrumbs];
173181
newScope._tags = { ...this._tags };
182+
newScope._attributes = { ...this._attributes };
174183
newScope._extra = { ...this._extra };
175184
newScope._contexts = { ...this._contexts };
176185
if (this._contexts.flags) {
@@ -294,6 +303,79 @@ export class Scope {
294303
return this.setTags({ [key]: value });
295304
}
296305

306+
/**
307+
* Sets attributes onto the scope.
308+
*
309+
* TODO:
310+
* Currently, these attributes are not applied to any telemetry data but they will be in the future.
311+
*
312+
* @param newAttributes - The attributes to set on the scope. You can either pass in key-value pairs, or
313+
* an object with a `value` and an optional `unit` (if applicable to your attribute).
314+
*
315+
* @example
316+
* ```typescript
317+
* scope.setAttributes({
318+
* is_admin: true,
319+
* payment_selection: 'credit_card',
320+
* clicked_products: [130, 554, 292],
321+
* render_duration: { value: 'render_duration', unit: 'ms' },
322+
* });
323+
* ```
324+
*/
325+
public setAttributes<T extends Record<string, unknown>>(newAttributes: RawAttributes<T>): this {
326+
this._attributes = {
327+
...this._attributes,
328+
...newAttributes,
329+
};
330+
331+
this._notifyScopeListeners();
332+
return this;
333+
}
334+
335+
/**
336+
* Sets an attribute onto the scope.
337+
*
338+
* TODO:
339+
* Currently, these attributes are not applied to any telemetry data but they will be in the future.
340+
*
341+
* @param key - The attribute key.
342+
* @param value - the attribute value. You can either pass in a raw value, or an attribute
343+
* object with a `value` and an optional `unit` (if applicable to your attribute).
344+
*
345+
* @example
346+
* ```typescript
347+
* scope.setAttribute('is_admin', true);
348+
* scope.setAttribute('clicked_products', [130, 554, 292]);
349+
* scope.setAttribute('render_duration', { value: 'render_duration', unit: 'ms' });
350+
* ```
351+
*/
352+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
353+
public setAttribute<T extends RawAttribute<T> extends { value: any } | { unit: any } ? AttributeObject : unknown>(
354+
key: string,
355+
value: RawAttribute<T>,
356+
): this {
357+
return this.setAttributes({ [key]: value });
358+
}
359+
360+
/**
361+
* Removes the attribute with the given key from the scope.
362+
*
363+
* @param key - The attribute key.
364+
*
365+
* @example
366+
* ```typescript
367+
* scope.removeAttribute('is_admin');
368+
* ```
369+
*/
370+
public removeAttribute(key: string): this {
371+
if (key in this._attributes) {
372+
// eslint-disable-next-line @typescript-eslint/no-dynamic-delete
373+
delete this._attributes[key];
374+
this._notifyScopeListeners();
375+
}
376+
return this;
377+
}
378+
297379
/**
298380
* Set an object that will be merged into existing extra on the scope,
299381
* and will be sent as extra data with the event.
@@ -409,9 +491,19 @@ export class Scope {
409491
? (captureContext as ScopeContext)
410492
: undefined;
411493

412-
const { tags, extra, user, contexts, level, fingerprint = [], propagationContext } = scopeInstance || {};
494+
const {
495+
tags,
496+
attributes,
497+
extra,
498+
user,
499+
contexts,
500+
level,
501+
fingerprint = [],
502+
propagationContext,
503+
} = scopeInstance || {};
413504

414505
this._tags = { ...this._tags, ...tags };
506+
this._attributes = { ...this._attributes, ...attributes };
415507
this._extra = { ...this._extra, ...extra };
416508
this._contexts = { ...this._contexts, ...contexts };
417509

@@ -442,6 +534,7 @@ export class Scope {
442534
// client is not cleared here on purpose!
443535
this._breadcrumbs = [];
444536
this._tags = {};
537+
this._attributes = {};
445538
this._extra = {};
446539
this._user = {};
447540
this._contexts = {};
@@ -528,6 +621,7 @@ export class Scope {
528621
attachments: this._attachments,
529622
contexts: this._contexts,
530623
tags: this._tags,
624+
attributes: this._attributes,
531625
extra: this._extra,
532626
user: this._user,
533627
level: this._level,

0 commit comments

Comments
 (0)