Skip to content

Commit 10db70f

Browse files
authored
ref(attribute): Add type to accept unknown and official attribute type (#18201)
Uses a conditional type to infer whether an attribute object has the type of an official attribute by checking if it has a `value` or a `unit`: ```ts export type ValidatedAttributes<T> = { [K in keyof T]: T[K] extends { value: any } | { unit: any } ? ValidAttributeObject : unknown; }; ``` That way, TS can show an error when people attempt to use the attribute type while still allowing non-official types. <img width="673" height="250" alt="image" src="https://github.com/user-attachments/assets/f635f46d-6c3f-49cb-b31f-a02a6ebf91d3" /> --- I also added a small helper type to generate the very long attribute type object, so it can generate a type like this: ```ts /* | { value: string; type: 'string' } | { value: number; type: 'integer' } | { value: number; type: 'double' } */ ```
1 parent b360e4b commit 10db70f

File tree

4 files changed

+77
-44
lines changed

4 files changed

+77
-44
lines changed

.size-limit.js

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -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',

packages/core/src/attributes.ts

Lines changed: 57 additions & 35 deletions
Original file line numberDiff line numberDiff line change
@@ -2,43 +2,65 @@ export type Attributes = Record<string, TypedAttributeValue>;
22

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

5-
export type TypedAttributeValue = (
6-
| {
7-
value: string;
8-
type: 'string';
9-
}
10-
| {
11-
value: number;
12-
type: 'integer';
13-
}
14-
| {
15-
value: number;
16-
type: 'double';
17-
}
18-
| {
19-
value: boolean;
20-
type: 'boolean';
21-
}
22-
| {
23-
value: Array<string>;
24-
type: 'string[]';
25-
}
26-
| {
27-
value: Array<number>;
28-
type: 'integer[]';
29-
}
30-
| {
31-
value: Array<number>;
32-
type: 'double[]';
33-
}
34-
| {
35-
value: Array<boolean>;
36-
type: 'boolean[]';
37-
}
38-
) & { unit?: Units };
5+
type AttributeTypeMap = {
6+
string: string;
7+
integer: number;
8+
double: number;
9+
boolean: boolean;
10+
'string[]': Array<string>;
11+
'integer[]': Array<number>;
12+
'double[]': Array<number>;
13+
'boolean[]': Array<boolean>;
14+
};
15+
16+
/* Generates a type from the AttributeTypeMap like:
17+
| { value: string; type: 'string' }
18+
| { value: number; type: 'integer' }
19+
| { value: number; type: 'double' }
20+
*/
21+
type AttributeUnion = {
22+
[K in keyof AttributeTypeMap]: {
23+
value: AttributeTypeMap[K];
24+
type: K;
25+
};
26+
}[keyof AttributeTypeMap];
27+
28+
export type TypedAttributeValue = AttributeUnion & { unit?: Units };
29+
30+
type AttributeWithUnit = {
31+
value: unknown;
32+
unit: Units;
33+
};
3934

4035
type Units = 'ms' | 's' | 'bytes' | 'count' | 'percent';
4136

37+
type ValidAttributeObject = AttributeWithUnit | TypedAttributeValue;
38+
39+
/* If an attribute has either a 'value' or 'unit' property, we use the ValidAttributeObject type. */
40+
export type ValidatedAttributes<T> = {
41+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
42+
[K in keyof T]: T[K] extends { value: any } | { unit: any } ? ValidAttributeObject : unknown;
43+
};
44+
45+
/**
46+
* Type-guard: The attribute object has the shape the official attribute object (value, type, unit).
47+
* https://develop.sentry.dev/sdk/telemetry/scopes/#setting-attributes
48+
*/
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')) {
55+
return false;
56+
}
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');
60+
61+
return hasUnit || hasType;
62+
}
63+
4264
/**
4365
* Converts an attribute value to a typed attribute value.
4466
*
@@ -47,7 +69,7 @@ type Units = 'ms' | 's' | 'bytes' | 'count' | 'percent';
4769
* @param value - The value of the passed attribute.
4870
* @returns The typed attribute.
4971
*/
50-
export function attributeValueToTypedAttributeValue(value: AttributeValueType): TypedAttributeValue {
72+
export function attributeValueToTypedAttributeValue(value: unknown): TypedAttributeValue {
5173
switch (typeof value) {
5274
case 'number':
5375
if (Number.isInteger(value)) {

packages/core/src/scope.ts

Lines changed: 18 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
/* eslint-disable max-lines */
2-
import type { Attributes, AttributeValueType, TypedAttributeValue } from './attributes';
3-
import { attributeValueToTypedAttributeValue } from './attributes';
2+
import type { Attributes, AttributeValueType, TypedAttributeValue, ValidatedAttributes } from './attributes';
3+
import { attributeValueToTypedAttributeValue, isAttributeObject } from './attributes';
44
import type { Client } from './client';
55
import { DEBUG_BUILD } from './debug-build';
66
import { updateSession } from './session';
@@ -326,14 +326,27 @@ export class Scope {
326326
* });
327327
* ```
328328
*/
329-
public setAttributes(newAttributes: Record<string, AttributeValueType | TypedAttributeValue>): this {
329+
public setAttributes<T extends Record<string, unknown>>(newAttributes: T & ValidatedAttributes<T>): this {
330330
Object.entries(newAttributes).forEach(([key, value]) => {
331-
if (typeof value === 'object' && value !== null && !Array.isArray(value)) {
332-
this._attributes[key] = value;
331+
if (isAttributeObject(value)) {
332+
// Case 1: ({ value, unit })
333+
if ('unit' in value && !('type' in value)) {
334+
// Infer type from the inner value
335+
this._attributes[key] = {
336+
...attributeValueToTypedAttributeValue(value.value),
337+
unit: value.unit,
338+
};
339+
}
340+
// Case 2: ({ value, type, unit? })
341+
else {
342+
this._attributes[key] = value;
343+
}
333344
} else {
345+
// Else: (string, number, etc.) or a random object (will stringify random values).
334346
this._attributes[key] = attributeValueToTypedAttributeValue(value);
335347
}
336348
});
349+
337350
this._notifyScopeListeners();
338351
return this;
339352
}

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

Lines changed: 0 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -80,7 +80,6 @@ describe('attributeValueToTypedAttributeValue', () => {
8080
});
8181

8282
it('stringifies an array of mixed types to a string attribute value', () => {
83-
// @ts-expect-error - this is not allowed by types but we still test fallback behaviour
8483
const result = attributeValueToTypedAttributeValue([1, 'foo', true]);
8584
expect(result).toEqual({
8685
value: '[1,"foo",true]',
@@ -89,7 +88,6 @@ describe('attributeValueToTypedAttributeValue', () => {
8988
});
9089

9190
it('stringifies an object value to a string attribute value', () => {
92-
// @ts-expect-error - this is not allowed by types but we still test fallback behaviour
9391
const result = attributeValueToTypedAttributeValue({ foo: 'bar' });
9492
expect(result).toEqual({
9593
value: '{"foo":"bar"}',

0 commit comments

Comments
 (0)