Skip to content

Commit 74ee2fe

Browse files
nicohrubecclaude
andcommitted
feat(core): Support homogeneous primitive arrays as span attributes
Relay's wire contract (AttributeType enum in relay-event-schema) defines exactly five `type:` tags: boolean, integer, double, string, array. The SDK's AttributeTypeMap previously declared typed array variants (`string[]`, `integer[]`, etc.) that Relay does not recognize - these were never actually emitted because the runtime serializer only handled primitives, so array-valued attributes silently dropped. This change: - Collapses the four `x[]` variants in AttributeTypeMap into a single `array` variant whose value is `Array<string> | Array<number> | Array<boolean>`. - Extends getTypedAttributeValue to auto-detect homogeneous primitive arrays and emit `{type: 'array', value: [...]}`. - Adds an isHomogeneousPrimitiveArray guard so mixed-type and nested arrays remain unsupported (dropped by default, stringified under the fallback path). - Updates tests to cover the new typed-array path (including empty arrays, unit preservation, and mixed-type drop/stringify). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent a87183e commit 74ee2fe

5 files changed

Lines changed: 76 additions & 67 deletions

File tree

packages/core/src/attributes.ts

Lines changed: 19 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -15,10 +15,7 @@ type AttributeTypeMap = {
1515
integer: number;
1616
double: number;
1717
boolean: boolean;
18-
'string[]': Array<string>;
19-
'integer[]': Array<number>;
20-
'double[]': Array<number>;
21-
'boolean[]': Array<boolean>;
18+
array: Array<string> | Array<number> | Array<boolean>;
2219
};
2320

2421
/* Generates a type from the AttributeTypeMap like:
@@ -66,9 +63,9 @@ export function isAttributeObject(maybeObj: unknown): maybeObj is AttributeObjec
6663
/**
6764
* Converts an attribute value to a typed attribute value.
6865
*
69-
* For now, we intentionally only support primitive values and attribute objects with primitive values.
70-
* If @param useFallback is true, we stringify non-primitive values to a string attribute value. Otherwise
71-
* we return `undefined` for unsupported values.
66+
* For now, we support primitive values and homogeneous arrays of primitives, either raw or
67+
* inside attribute objects. If @param useFallback is true, we stringify other non-primitive values
68+
* to a string attribute value. Otherwise we return `undefined` for unsupported values.
7269
*
7370
* @param value - The value of the passed attribute.
7471
* @param useFallback - If true, unsupported values will be stringified to a string attribute value.
@@ -170,17 +167,18 @@ function estimatePrimitiveSizeInBytes(value: Primitive): number {
170167
}
171168

172169
/**
173-
* NOTE: We intentionally do not return anything for non-primitive values:
174-
* - array support will come in the future but if we stringify arrays now,
175-
* sending arrays (unstringified) later will be a subtle breaking change.
170+
* NOTE: We return typed attributes for primitives and homogeneous arrays of primitives:
171+
* - Homogeneous primitive arrays ship with `type: 'array'` (Relay's wire tag for arrays).
172+
* - Mixed-type and nested arrays are not supported and return undefined.
176173
* - Objects are not supported yet and product support is still TBD.
177174
* - We still keep the type signature for TypedAttributeValue wider to avoid a
178-
* breaking change once we add support for non-primitive values.
179-
* - Once we go back to supporting arrays and stringifying all other values,
180-
* we already implemented the serialization logic here:
181-
* https://github.com/getsentry/sentry-javascript/pull/18165
175+
* breaking change once we add support for other non-primitive values.
182176
*/
183177
function getTypedAttributeValue(value: unknown): TypedAttributeValue | void {
178+
if (Array.isArray(value) && isHomogeneousPrimitiveArray(value)) {
179+
return { value, type: 'array' };
180+
}
181+
184182
const primitiveType =
185183
typeof value === 'string'
186184
? 'string'
@@ -201,3 +199,10 @@ function getTypedAttributeValue(value: unknown): TypedAttributeValue | void {
201199
return { value, type: primitiveType };
202200
}
203201
}
202+
203+
function isHomogeneousPrimitiveArray(arr: unknown[]): boolean {
204+
if (arr.length === 0) return true;
205+
const t = typeof arr[0];
206+
if (t !== 'string' && t !== 'number' && t !== 'boolean') return false;
207+
return arr.every(v => typeof v === t);
208+
}

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

Lines changed: 44 additions & 46 deletions
Original file line numberDiff line numberDiff line change
@@ -76,33 +76,38 @@ describe('attributeValueToTypedAttributeValue', () => {
7676
);
7777
});
7878

79-
describe('invalid values (non-primitives)', () => {
80-
it.each([
81-
['foo', 'bar'],
82-
[1, 2, 3],
83-
[true, false, true],
84-
[1, 'foo', true],
85-
{ foo: 'bar' },
86-
() => 'test',
87-
Symbol('test'),
88-
])('returns undefined for non-primitive raw values (%s)', value => {
89-
const result = attributeValueToTypedAttributeValue(value);
90-
expect(result).toBeUndefined();
91-
});
79+
describe('homogeneous primitive arrays', () => {
80+
it.each([[['foo', 'bar']], [[1, 2, 3]], [[true, false, true]], [[] as unknown[]]])(
81+
'emits a typed array attribute for raw value %j',
82+
value => {
83+
const result = attributeValueToTypedAttributeValue(value);
84+
expect(result).toStrictEqual({ value, type: 'array' });
85+
},
86+
);
9287

93-
it.each([
94-
['foo', 'bar'],
95-
[1, 2, 3],
96-
[true, false, true],
97-
[1, 'foo', true],
98-
{ foo: 'bar' },
99-
() => 'test',
100-
Symbol('test'),
101-
])('returns undefined for non-primitive attribute object values (%s)', value => {
102-
const result = attributeValueToTypedAttributeValue({ value });
103-
expect(result).toBeUndefined();
88+
it('emits a typed array attribute for attribute object values', () => {
89+
const result = attributeValueToTypedAttributeValue({ value: ['foo', 'bar'] });
90+
expect(result).toStrictEqual({ value: ['foo', 'bar'], type: 'array' });
10491
});
10592
});
93+
94+
describe('invalid values (non-primitives)', () => {
95+
it.each([[[1, 'foo', true]], [{ foo: 'bar' }], [() => 'test'], [Symbol('test')]])(
96+
'returns undefined for non-primitive raw values (%s)',
97+
value => {
98+
const result = attributeValueToTypedAttributeValue(value);
99+
expect(result).toBeUndefined();
100+
},
101+
);
102+
103+
it.each([[[1, 'foo', true]], [{ foo: 'bar' }], [() => 'test'], [Symbol('test')]])(
104+
'returns undefined for non-primitive attribute object values (%s)',
105+
value => {
106+
const result = attributeValueToTypedAttributeValue({ value });
107+
expect(result).toBeUndefined();
108+
},
109+
);
110+
});
106111
});
107112

108113
describe('with fallback=true', () => {
@@ -189,26 +194,10 @@ describe('attributeValueToTypedAttributeValue', () => {
189194
});
190195

191196
describe('invalid values (non-primitives) - stringified fallback', () => {
192-
it('stringifies string arrays', () => {
193-
const result = attributeValueToTypedAttributeValue(['foo', 'bar'], true);
197+
it('stringifies mixed-type arrays (not homogeneous)', () => {
198+
const result = attributeValueToTypedAttributeValue(['foo', 1, true], true);
194199
expect(result).toStrictEqual({
195-
value: '["foo","bar"]',
196-
type: 'string',
197-
});
198-
});
199-
200-
it('stringifies number arrays', () => {
201-
const result = attributeValueToTypedAttributeValue([1, 2, 3], true);
202-
expect(result).toStrictEqual({
203-
value: '[1,2,3]',
204-
type: 'string',
205-
});
206-
});
207-
208-
it('stringifies boolean arrays', () => {
209-
const result = attributeValueToTypedAttributeValue([true, false, true], true);
210-
expect(result).toStrictEqual({
211-
value: '[true,false,true]',
200+
value: '["foo",1,true]',
212201
type: 'string',
213202
});
214203
});
@@ -425,15 +414,17 @@ describe('serializeAttributes', () => {
425414
describe('invalid (non-primitive) values', () => {
426415
it("doesn't fall back to stringification by default", () => {
427416
const result = serializeAttributes({ foo: { some: 'object' }, bar: [1, 2, 3], baz: () => {} });
428-
expect(result).toStrictEqual({});
417+
expect(result).toStrictEqual({
418+
bar: { type: 'array', value: [1, 2, 3] },
419+
});
429420
});
430421

431422
it('falls back to stringification of unsupported non-primitive values if fallback is true', () => {
432423
const result = serializeAttributes({ foo: { some: 'object' }, bar: [1, 2, 3], baz: () => {} }, true);
433424
expect(result).toStrictEqual({
434425
bar: {
435-
type: 'string',
436-
value: '[1,2,3]',
426+
type: 'array',
427+
value: [1, 2, 3],
437428
},
438429
baz: {
439430
type: 'string',
@@ -445,5 +436,12 @@ describe('serializeAttributes', () => {
445436
},
446437
});
447438
});
439+
440+
it('drops mixed-type arrays by default and stringifies them with fallback', () => {
441+
expect(serializeAttributes({ mixed: ['a', 1] })).toStrictEqual({});
442+
expect(serializeAttributes({ mixed: ['a', 1] }, true)).toStrictEqual({
443+
mixed: { type: 'string', value: '["a",1]' },
444+
});
445+
});
448446
});
449447
});

packages/core/test/lib/logs/internal.test.ts

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -191,7 +191,6 @@ describe('_INTERNAL_captureLog', () => {
191191
scope.setAttribute('scope_2', { value: 38, unit: 'gigabyte' });
192192
scope.setAttributes({
193193
scope_3: true,
194-
// these are invalid since for now we don't support arrays
195194
scope_4: [1, 2, 3],
196195
scope_5: { value: [true, false, true], unit: 'second' },
197196
});
@@ -229,6 +228,15 @@ describe('_INTERNAL_captureLog', () => {
229228
type: 'boolean',
230229
value: true,
231230
},
231+
scope_4: {
232+
type: 'array',
233+
value: [1, 2, 3],
234+
},
235+
scope_5: {
236+
type: 'array',
237+
value: [true, false, true],
238+
unit: 'second',
239+
},
232240
'sentry.timestamp.sequence': { value: expect.any(Number), type: 'integer' },
233241
});
234242
});

packages/core/test/lib/tracing/spans/estimateSize.test.ts

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -130,9 +130,9 @@ describe('estimateSerializedSpanSizeInBytes', () => {
130130
status: 'ok',
131131
is_segment: false,
132132
attributes: {
133-
'item.ids': { type: 'string[]', value: ['id-001', 'id-002', 'id-003', 'id-004', 'id-005'] },
134-
scores: { type: 'double[]', value: [1.1, 2.2, 3.3, 4.4] },
135-
flags: { type: 'boolean[]', value: [true, false, true] },
133+
'item.ids': { type: 'array', value: ['id-001', 'id-002', 'id-003', 'id-004', 'id-005'] },
134+
scores: { type: 'array', value: [1.1, 2.2, 3.3, 4.4] },
135+
flags: { type: 'array', value: [true, false, true] },
136136
},
137137
};
138138

packages/core/test/lib/utils/spanUtils.test.ts

Lines changed: 1 addition & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -622,11 +622,9 @@ describe('spanToJSON', () => {
622622
attr1: { type: 'string', value: 'value1' },
623623
attr2: { type: 'integer', value: 2 },
624624
attr3: { type: 'boolean', value: true },
625+
attr4: { type: 'array', value: [1, 2, 3] },
625626
[SEMANTIC_ATTRIBUTE_SENTRY_OP]: { type: 'string', value: 'test op' },
626627
[SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: { type: 'string', value: 'auto' },
627-
// notice the absence of `attr4`!
628-
// for now, we don't yet serialize array attributes. This test will fail
629-
// once we allow serializing them.
630628
},
631629
links: [
632630
{

0 commit comments

Comments
 (0)