Skip to content

Commit 1535106

Browse files
authored
ref(core): Use serializeAttributes for metric attribute serialization (#18582)
pre-work for #18160 Use the same attibute serialization logic we already use in logs. Bundle size impact: - size decreases for users with logs and metrics - size increases for metrics-only users. I think this is because previously the separate serialization logic could be inlined, while now it has to inline the slightly larger logic. Since it's not used in any non-treeshakeable default behavior (yet), this results in higher bundle size. Given we'll use this in our span serialization logic in v11, it will become part of the minimum SDK anyway (without metrics), so it _should_ be fine to do this now. - Because we include metrics (but not logs) in all our CDN bundles by accident, this now also increases CDN bundle size 😬. We're tracking removal of this for most bundles in v11 (#18583) I think the largest positive long-term aspect of this refactor is that we'll re-use this logic more and more going forward (scope attributes on metrics, spansv2, and other telemetry items in the future), so I'd like to have it unified.
1 parent 29ed4da commit 1535106

File tree

6 files changed

+169
-134
lines changed

6 files changed

+169
-134
lines changed

packages/core/src/attributes.ts

Lines changed: 7 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -75,7 +75,7 @@ export function isAttributeObject(maybeObj: unknown): maybeObj is AttributeObjec
7575
*/
7676
export function attributeValueToTypedAttributeValue(
7777
rawValue: unknown,
78-
useFallback?: boolean,
78+
useFallback?: boolean | 'skip-undefined',
7979
): TypedAttributeValue | void {
8080
const { value, unit } = isAttributeObject(rawValue) ? rawValue : { value: rawValue, unit: undefined };
8181
const attributeValue = getTypedAttributeValue(value);
@@ -84,7 +84,7 @@ export function attributeValueToTypedAttributeValue(
8484
return { ...attributeValue, ...checkedUnit };
8585
}
8686

87-
if (!useFallback) {
87+
if (!useFallback || (useFallback === 'skip-undefined' && value === undefined)) {
8888
return;
8989
}
9090

@@ -113,9 +113,12 @@ export function attributeValueToTypedAttributeValue(
113113
*
114114
* @returns The serialized attributes.
115115
*/
116-
export function serializeAttributes<T>(attributes: RawAttributes<T>, fallback: boolean = false): Attributes {
116+
export function serializeAttributes<T>(
117+
attributes: RawAttributes<T> | undefined,
118+
fallback: boolean | 'skip-undefined' = false,
119+
): Attributes {
117120
const serializedAttributes: Attributes = {};
118-
for (const [key, value] of Object.entries(attributes)) {
121+
for (const [key, value] of Object.entries(attributes ?? {})) {
119122
const typedValue = attributeValueToTypedAttributeValue(value, fallback);
120123
if (typedValue) {
121124
serializedAttributes[key] = typedValue;

packages/core/src/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -449,6 +449,7 @@ export type {
449449
MetricType,
450450
SerializedMetric,
451451
SerializedMetricContainer,
452+
// eslint-disable-next-line deprecation/deprecation
452453
SerializedMetricAttributeValue,
453454
} from './types-hoist/metric';
454455
export type { TimedEvent } from './types-hoist/timedEvent';

packages/core/src/metrics/internal.ts

Lines changed: 3 additions & 54 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,11 @@
1+
import { serializeAttributes } from '../attributes';
12
import { getGlobalSingleton } from '../carrier';
23
import type { Client } from '../client';
34
import { getClient, getCurrentScope, getIsolationScope } from '../currentScopes';
45
import { DEBUG_BUILD } from '../debug-build';
56
import type { Scope } from '../scope';
67
import type { Integration } from '../types-hoist/integration';
7-
import type { Metric, SerializedMetric, SerializedMetricAttributeValue } from '../types-hoist/metric';
8+
import type { Metric, SerializedMetric } from '../types-hoist/metric';
89
import { debug } from '../utils/debug-logger';
910
import { getCombinedScopeData } from '../utils/scopeData';
1011
import { _getSpanForScope } from '../utils/spanOnScope';
@@ -14,50 +15,6 @@ import { createMetricEnvelope } from './envelope';
1415

1516
const MAX_METRIC_BUFFER_SIZE = 1000;
1617

17-
/**
18-
* Converts a metric attribute to a serialized metric attribute.
19-
*
20-
* @param value - The value of the metric attribute.
21-
* @returns The serialized metric attribute.
22-
*/
23-
export function metricAttributeToSerializedMetricAttribute(value: unknown): SerializedMetricAttributeValue {
24-
switch (typeof value) {
25-
case 'number':
26-
if (Number.isInteger(value)) {
27-
return {
28-
value,
29-
type: 'integer',
30-
};
31-
}
32-
return {
33-
value,
34-
type: 'double',
35-
};
36-
case 'boolean':
37-
return {
38-
value,
39-
type: 'boolean',
40-
};
41-
case 'string':
42-
return {
43-
value,
44-
type: 'string',
45-
};
46-
default: {
47-
let stringValue = '';
48-
try {
49-
stringValue = JSON.stringify(value) ?? '';
50-
} catch {
51-
// Do nothing
52-
}
53-
return {
54-
value: stringValue,
55-
type: 'string',
56-
};
57-
}
58-
}
59-
}
60-
6118
/**
6219
* Sets a metric attribute if the value exists and the attribute key is not already present.
6320
*
@@ -169,14 +126,6 @@ function _enrichMetricAttributes(beforeMetric: Metric, client: Client, currentSc
169126
* Creates a serialized metric ready to be sent to Sentry.
170127
*/
171128
function _buildSerializedMetric(metric: Metric, client: Client, currentScope: Scope): SerializedMetric {
172-
// Serialize attributes
173-
const serializedAttributes: Record<string, SerializedMetricAttributeValue> = {};
174-
for (const key in metric.attributes) {
175-
if (metric.attributes[key] !== undefined) {
176-
serializedAttributes[key] = metricAttributeToSerializedMetricAttribute(metric.attributes[key]);
177-
}
178-
}
179-
180129
// Get trace context
181130
const [, traceContext] = _getTraceInfoFromScope(client, currentScope);
182131
const span = _getSpanForScope(currentScope);
@@ -191,7 +140,7 @@ function _buildSerializedMetric(metric: Metric, client: Client, currentScope: Sc
191140
type: metric.type,
192141
unit: metric.unit,
193142
value: metric.value,
194-
attributes: serializedAttributes,
143+
attributes: serializeAttributes(metric.attributes, 'skip-undefined'),
195144
};
196145
}
197146

packages/core/src/types-hoist/metric.ts

Lines changed: 7 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,5 @@
1+
import type { Attributes, TypedAttributeValue } from '../attributes';
2+
13
export type MetricType = 'counter' | 'gauge' | 'distribution';
24

35
export interface Metric {
@@ -27,11 +29,10 @@ export interface Metric {
2729
attributes?: Record<string, unknown>;
2830
}
2931

30-
export type SerializedMetricAttributeValue =
31-
| { value: string; type: 'string' }
32-
| { value: number; type: 'integer' }
33-
| { value: number; type: 'double' }
34-
| { value: boolean; type: 'boolean' };
32+
/**
33+
* @deprecated this was not intended for public consumption
34+
*/
35+
export type SerializedMetricAttributeValue = TypedAttributeValue;
3536

3637
export interface SerializedMetric {
3738
/**
@@ -72,7 +73,7 @@ export interface SerializedMetric {
7273
/**
7374
* Arbitrary structured data that stores information about the metric.
7475
*/
75-
attributes?: Record<string, SerializedMetricAttributeValue>;
76+
attributes?: Attributes;
7677
}
7778

7879
export type SerializedMetricContainer = {

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

Lines changed: 151 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import { describe, expect, it } from 'vitest';
2-
import { attributeValueToTypedAttributeValue, isAttributeObject } from '../../src/attributes';
2+
import { attributeValueToTypedAttributeValue, isAttributeObject, serializeAttributes } from '../../src/attributes';
33

44
describe('attributeValueToTypedAttributeValue', () => {
55
describe('without fallback (default behavior)', () => {
@@ -267,8 +267,43 @@ describe('attributeValueToTypedAttributeValue', () => {
267267
type: 'string',
268268
});
269269
});
270+
271+
it.each([null, { value: null }, { value: null, unit: 'byte' }])('stringifies %s values', value => {
272+
const result = attributeValueToTypedAttributeValue(value, true);
273+
expect(result).toMatchObject({
274+
value: 'null',
275+
type: 'string',
276+
});
277+
});
278+
279+
it.each([undefined, { value: undefined }])('stringifies %s values to ""', value => {
280+
const result = attributeValueToTypedAttributeValue(value, true);
281+
expect(result).toEqual({
282+
value: '',
283+
type: 'string',
284+
});
285+
});
286+
287+
it('stringifies undefined values with unit to ""', () => {
288+
const result = attributeValueToTypedAttributeValue({ value: undefined, unit: 'byte' }, true);
289+
expect(result).toEqual({
290+
value: '',
291+
unit: 'byte',
292+
type: 'string',
293+
});
294+
});
270295
});
271296
});
297+
298+
describe('with fallback="skip-undefined"', () => {
299+
it.each([undefined, { value: undefined }, { value: undefined, unit: 'byte' }])(
300+
'ignores undefined values (%s)',
301+
value => {
302+
const result = attributeValueToTypedAttributeValue(value, 'skip-undefined');
303+
expect(result).toBeUndefined();
304+
},
305+
);
306+
});
272307
});
273308

274309
describe('isAttributeObject', () => {
@@ -297,3 +332,118 @@ describe('isAttributeObject', () => {
297332
},
298333
);
299334
});
335+
336+
describe('serializeAttributes', () => {
337+
it('returns an empty object for undefined attributes', () => {
338+
const result = serializeAttributes(undefined);
339+
expect(result).toStrictEqual({});
340+
});
341+
342+
it('returns an empty object for an empty object', () => {
343+
const result = serializeAttributes({});
344+
expect(result).toStrictEqual({});
345+
});
346+
347+
it('serializes valid, non-primitive values', () => {
348+
const result = serializeAttributes({ foo: 'bar', bar: { value: 123 }, baz: { value: 456, unit: 'byte' } });
349+
expect(result).toStrictEqual({
350+
bar: {
351+
type: 'integer',
352+
value: 123,
353+
},
354+
baz: {
355+
type: 'integer',
356+
unit: 'byte',
357+
value: 456,
358+
},
359+
foo: {
360+
type: 'string',
361+
value: 'bar',
362+
},
363+
});
364+
});
365+
366+
it('ignores undefined values if fallback is false', () => {
367+
const result = serializeAttributes(
368+
{ foo: undefined, bar: { value: undefined }, baz: { value: undefined, unit: 'byte' } },
369+
false,
370+
);
371+
expect(result).toStrictEqual({});
372+
});
373+
374+
it('ignores undefined values if fallback is "skip-undefined"', () => {
375+
const result = serializeAttributes(
376+
{ foo: undefined, bar: { value: undefined }, baz: { value: undefined, unit: 'byte' } },
377+
'skip-undefined',
378+
);
379+
expect(result).toStrictEqual({});
380+
});
381+
382+
it('stringifies undefined values to "" if fallback is true', () => {
383+
const result = serializeAttributes(
384+
{ foo: undefined, bar: { value: undefined }, baz: { value: undefined, unit: 'byte' } },
385+
true,
386+
);
387+
expect(result).toStrictEqual({
388+
bar: {
389+
type: 'string',
390+
value: '',
391+
},
392+
baz: {
393+
type: 'string',
394+
unit: 'byte',
395+
value: '',
396+
},
397+
foo: { type: 'string', value: '' },
398+
});
399+
});
400+
401+
it('ignores null values by default', () => {
402+
const result = serializeAttributes({ foo: null, bar: { value: null }, baz: { value: null, unit: 'byte' } });
403+
expect(result).toStrictEqual({});
404+
});
405+
406+
it('stringifies to `"null"` if fallback is true', () => {
407+
const result = serializeAttributes({ foo: null, bar: { value: null }, baz: { value: null, unit: 'byte' } }, true);
408+
expect(result).toStrictEqual({
409+
foo: {
410+
type: 'string',
411+
value: 'null',
412+
},
413+
bar: {
414+
type: 'string',
415+
value: 'null',
416+
},
417+
baz: {
418+
type: 'string',
419+
unit: 'byte',
420+
value: 'null',
421+
},
422+
});
423+
});
424+
425+
describe('invalid (non-primitive) values', () => {
426+
it("doesn't fall back to stringification by default", () => {
427+
const result = serializeAttributes({ foo: { some: 'object' }, bar: [1, 2, 3], baz: () => {} });
428+
expect(result).toStrictEqual({});
429+
});
430+
431+
it('falls back to stringification of unsupported non-primitive values if fallback is true', () => {
432+
const result = serializeAttributes({ foo: { some: 'object' }, bar: [1, 2, 3], baz: () => {} }, true);
433+
expect(result).toStrictEqual({
434+
bar: {
435+
type: 'string',
436+
value: '[1,2,3]',
437+
},
438+
baz: {
439+
type: 'string',
440+
value: '',
441+
},
442+
foo: {
443+
type: 'string',
444+
value: '{"some":"object"}',
445+
},
446+
});
447+
});
448+
});
449+
});

0 commit comments

Comments
 (0)