Skip to content

Commit 4707fe7

Browse files
committed
attribute objects w/o type, keep attributes as-is on scope
1 parent aa50099 commit 4707fe7

File tree

4 files changed

+157
-136
lines changed

4 files changed

+157
-136
lines changed

packages/core/src/attributes.ts

Lines changed: 62 additions & 47 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,7 @@
1+
export type RawAttributes<T> = T & ValidatedAttributes<T>;
2+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
3+
export type RawAttribute<T> = T extends { value: any } | { unit: any } ? AttributeWithUnit : T;
4+
15
export type Attributes = Record<string, TypedAttributeValue>;
26

37
export type AttributeValueType = string | number | boolean | Array<string> | Array<number> | Array<boolean>;
@@ -27,38 +31,33 @@ type AttributeUnion = {
2731

2832
export type TypedAttributeValue = AttributeUnion & { unit?: Units };
2933

30-
type AttributeWithUnit = {
34+
export type AttributeWithUnit = {
3135
value: unknown;
32-
unit: Units;
36+
unit?: Units;
3337
};
3438

39+
/**
40+
* Unit of measurement that can be added to an attribute.
41+
*/
3542
type Units = 'ms' | 's' | 'bytes' | 'count' | 'percent';
3643

37-
type ValidAttributeObject = AttributeWithUnit | TypedAttributeValue;
38-
3944
/* If an attribute has either a 'value' or 'unit' property, we use the ValidAttributeObject type. */
4045
export type ValidatedAttributes<T> = {
4146
// eslint-disable-next-line @typescript-eslint/no-explicit-any
42-
[K in keyof T]: T[K] extends { value: any } | { unit: any } ? ValidAttributeObject : unknown;
47+
[K in keyof T]: T[K] extends { value: any } | { unit: any } ? AttributeWithUnit : unknown;
4348
};
4449

4550
/**
4651
* Type-guard: The attribute object has the shape the official attribute object (value, type, unit).
4752
* https://develop.sentry.dev/sdk/telemetry/scopes/#setting-attributes
4853
*/
49-
export function isAttributeObject(value: unknown): value is ValidAttributeObject {
50-
if (typeof value !== 'object' || value === null || Array.isArray(value)) {
51-
return false;
52-
}
53-
// MUST have a 'value' property
54-
if (!Object.prototype.hasOwnProperty.call(value, 'value')) {
54+
export function isAttributeObject(value: unknown): value is AttributeWithUnit {
55+
if (typeof value !== 'object' || value == null || Array.isArray(value)) {
5556
return false;
5657
}
57-
// And it MUST have 'unit' OR 'type'
58-
const hasUnit = Object.prototype.hasOwnProperty.call(value, 'unit');
59-
const hasType = Object.prototype.hasOwnProperty.call(value, 'type');
6058

61-
return hasUnit || hasType;
59+
// MUST have 'value' and 'unit' property
60+
return Object.prototype.hasOwnProperty.call(value, 'value') && Object.prototype.hasOwnProperty.call(value, 'unit');
6261
}
6362

6463
/**
@@ -69,62 +68,78 @@ export function isAttributeObject(value: unknown): value is ValidAttributeObject
6968
* @param value - The value of the passed attribute.
7069
* @returns The typed attribute.
7170
*/
72-
export function attributeValueToTypedAttributeValue(value: unknown): TypedAttributeValue {
71+
export function attributeValueToTypedAttributeValue(rawValue: unknown): TypedAttributeValue {
72+
const unit = isAttributeObject(rawValue) ? rawValue.unit : undefined;
73+
const value = isAttributeObject(rawValue) ? rawValue.value : rawValue;
74+
7375
switch (typeof value) {
74-
case 'number':
75-
if (Number.isInteger(value)) {
76-
return {
77-
value,
78-
type: 'integer',
79-
};
76+
case 'number': {
77+
const numberType = getNumberType(value);
78+
if (!numberType) {
79+
break;
8080
}
8181
return {
8282
value,
83-
type: 'double',
83+
type: numberType,
84+
unit,
8485
};
86+
}
8587
case 'boolean':
8688
return {
8789
value,
8890
type: 'boolean',
91+
unit,
8992
};
9093
case 'string':
9194
return {
9295
value,
9396
type: 'string',
97+
unit,
9498
};
9599
}
96100

97101
if (Array.isArray(value)) {
98-
if (value.every(item => typeof item === 'string')) {
99-
return {
100-
value,
101-
type: 'string[]',
102-
};
103-
}
104-
if (value.every(item => typeof item === 'number')) {
105-
if (value.every(item => Number.isInteger(item))) {
106-
return {
107-
value,
108-
type: 'integer[]',
109-
};
110-
} else if (value.every(item => !Number.isInteger(item))) {
111-
return {
112-
value,
113-
type: 'double[]',
114-
};
102+
const coherentType = value.reduce((acc: 'string' | 'boolean' | 'integer' | 'double' | null, item) => {
103+
if (!acc || getPrimitiveType(item) !== acc) {
104+
return null;
115105
}
116-
}
117-
if (value.every(item => typeof item === 'boolean')) {
118-
return {
119-
value,
120-
type: 'boolean[]',
121-
};
106+
return acc;
107+
}, getPrimitiveType(value[0]));
108+
109+
if (coherentType) {
110+
return { value, type: `${coherentType}[]`, unit };
122111
}
123112
}
124113

125114
// Fallback: stringify the passed value
115+
let fallbackValue = '';
116+
try {
117+
fallbackValue = JSON.stringify(value) ?? String(value);
118+
} catch {
119+
try {
120+
fallbackValue = String(value);
121+
} catch {
122+
// ignore
123+
}
124+
}
125+
126126
return {
127-
value: JSON.stringify(value),
127+
value: fallbackValue,
128128
type: 'string',
129+
unit,
129130
};
130131
}
132+
133+
// Disallow NaN, differentiate between integer and double
134+
const getNumberType: (num: number) => 'integer' | 'double' | null = item =>
135+
Number.isNaN(item) ? null : Number.isInteger(item) ? 'integer' : 'double';
136+
137+
// Only allow string, boolean, or number types
138+
const getPrimitiveType: (item: unknown) => 'string' | 'boolean' | 'integer' | 'double' | null = item =>
139+
typeof item === 'string'
140+
? 'string'
141+
: typeof item === 'boolean'
142+
? 'boolean'
143+
: typeof item === 'number'
144+
? getNumberType(item)
145+
: null;

packages/core/src/scope.ts

Lines changed: 19 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,5 @@
11
/* eslint-disable max-lines */
2-
import type { Attributes, AttributeValueType, TypedAttributeValue, ValidatedAttributes } from './attributes';
3-
import { attributeValueToTypedAttributeValue, isAttributeObject } from './attributes';
2+
import type { AttributeWithUnit, RawAttribute, RawAttributes } from './attributes';
43
import type { Client } from './client';
54
import { DEBUG_BUILD } from './debug-build';
65
import { updateSession } from './session';
@@ -48,7 +47,7 @@ export interface ScopeContext {
4847
extra: Extras;
4948
contexts: Contexts;
5049
tags: { [key: string]: Primitive };
51-
attributes?: Attributes;
50+
attributes?: RawAttributes<Record<string, unknown>>;
5251
fingerprint: string[];
5352
propagationContext: PropagationContext;
5453
}
@@ -75,7 +74,7 @@ export interface ScopeData {
7574
user: User;
7675
tags: { [key: string]: Primitive };
7776
// TODO(v11): Make this a required field (could be subtly breaking if we did it today)
78-
attributes?: Attributes;
77+
attributes?: RawAttributes<Record<string, unknown>>;
7978
extra: Extras;
8079
contexts: Contexts;
8180
attachments: Attachment[];
@@ -110,7 +109,7 @@ export class Scope {
110109
protected _tags: { [key: string]: Primitive };
111110

112111
/** Attributes */
113-
protected _attributes: Attributes;
112+
protected _attributes: RawAttributes<Record<string, unknown>>;
114113

115114
/** Extra */
116115
protected _extra: Extras;
@@ -311,39 +310,23 @@ export class Scope {
311310
* Currently, these attributes are not applied to any telemetry data but they will be in the future.
312311
*
313312
* @param newAttributes - The attributes to set on the scope. You can either pass in key-value pairs, or
314-
* an object with a concrete type declaration and an optional unit (if applicable to your attribute).
315-
* You can only pass in primitive values or arrays of primitive values.
313+
* an object with a `value` and an optional `unit` (if applicable to your attribute).
316314
*
317315
* @example
318316
* ```typescript
319317
* scope.setAttributes({
320318
* is_admin: true,
321319
* payment_selection: 'credit_card',
322320
* clicked_products: [130, 554, 292],
323-
* render_duration: { value: 'render_duration', type: 'float', unit: 'ms' },
321+
* render_duration: { value: 'render_duration', unit: 'ms' },
324322
* });
325323
* ```
326324
*/
327-
public setAttributes<T extends Record<string, unknown>>(newAttributes: T & ValidatedAttributes<T>): this {
328-
Object.entries(newAttributes).forEach(([key, value]) => {
329-
if (isAttributeObject(value)) {
330-
// Case 1: ({ value, unit })
331-
if ('unit' in value && !('type' in value)) {
332-
// Infer type from the inner value
333-
this._attributes[key] = {
334-
...attributeValueToTypedAttributeValue(value.value),
335-
unit: value.unit,
336-
};
337-
}
338-
// Case 2: ({ value, type, unit? })
339-
else {
340-
this._attributes[key] = value;
341-
}
342-
} else {
343-
// Else: (string, number, etc.) or a random object (will stringify random values).
344-
this._attributes[key] = attributeValueToTypedAttributeValue(value);
345-
}
346-
});
325+
public setAttributes<T extends Record<string, unknown>>(newAttributes: RawAttributes<T>): this {
326+
this._attributes = {
327+
...this._attributes,
328+
...newAttributes,
329+
};
347330

348331
this._notifyScopeListeners();
349332
return this;
@@ -356,17 +339,21 @@ export class Scope {
356339
* Currently, these attributes are not applied to any telemetry data but they will be in the future.
357340
*
358341
* @param key - The attribute key.
359-
* @param value - the attribute value. You can either pass in a raw value (primitive or array of primitives), or
360-
* a typed attribute value object with a concrete type declaration and an optional unit (if applicable to your attribute).
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).
361344
*
362345
* @example
363346
* ```typescript
364347
* scope.setAttribute('is_admin', true);
365348
* scope.setAttribute('clicked_products', [130, 554, 292]);
366-
* scope.setAttribute('render_duration', { value: 'render_duration', type: 'float', unit: 'ms' });
349+
* scope.setAttribute('render_duration', { value: 'render_duration', unit: 'ms' });
367350
* ```
368351
*/
369-
public setAttribute(key: string, value: AttributeValueType | TypedAttributeValue): this {
352+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
353+
public setAttribute<T extends RawAttribute<T> extends { value: any } | { unit: any } ? AttributeWithUnit : unknown>(
354+
key: string,
355+
value: RawAttribute<T>,
356+
): this {
370357
return this.setAttributes({ [key]: value });
371358
}
372359

packages/core/test/lib/attributes.test.ts

Lines changed: 43 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -70,6 +70,16 @@ describe('attributeValueToTypedAttributeValue', () => {
7070
});
7171
});
7272

73+
describe('attribute objects without units', () => {
74+
it('converts a number value to a typed attribute value', () => {
75+
const result = attributeValueToTypedAttributeValue({ value: 123 });
76+
expect(result).toEqual({
77+
value: 123,
78+
type: 'integer',
79+
});
80+
});
81+
});
82+
7383
describe('disallowed value types', () => {
7484
it('stringifies mixed float and integer numbers to a string attribute value', () => {
7585
const result = attributeValueToTypedAttributeValue([1, 2.2, 3]);
@@ -79,20 +89,52 @@ describe('attributeValueToTypedAttributeValue', () => {
7989
});
8090
});
8191

82-
it('stringifies an array of mixed types to a string attribute value', () => {
92+
it('stringifies an array of allowed but incoherent types to a string attribute value', () => {
8393
const result = attributeValueToTypedAttributeValue([1, 'foo', true]);
8494
expect(result).toEqual({
8595
value: '[1,"foo",true]',
8696
type: 'string',
8797
});
8898
});
8999

100+
it('stringifies an array of disallowed and incoherent types to a string attribute value', () => {
101+
const result = attributeValueToTypedAttributeValue([null, undefined, NaN]);
102+
expect(result).toEqual({
103+
value: '[null,null,null]',
104+
type: 'string',
105+
});
106+
});
107+
90108
it('stringifies an object value to a string attribute value', () => {
91109
const result = attributeValueToTypedAttributeValue({ foo: 'bar' });
92110
expect(result).toEqual({
93111
value: '{"foo":"bar"}',
94112
type: 'string',
95113
});
96114
});
115+
116+
it('stringifies a null value to a string attribute value', () => {
117+
const result = attributeValueToTypedAttributeValue(null);
118+
expect(result).toEqual({
119+
value: 'null',
120+
type: 'string',
121+
});
122+
});
123+
124+
it('stringifies an undefined value to a string attribute value', () => {
125+
const result = attributeValueToTypedAttributeValue(undefined);
126+
expect(result).toEqual({
127+
value: 'undefined',
128+
type: 'string',
129+
});
130+
});
131+
132+
it('stringifies an NaN number value to a string attribute value', () => {
133+
const result = attributeValueToTypedAttributeValue(NaN);
134+
expect(result).toEqual({
135+
value: 'null',
136+
type: 'string',
137+
});
138+
});
97139
});
98140
});

0 commit comments

Comments
 (0)