Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
24 commits
Select commit Hold shift + click to select a range
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,10 @@

- "You miss 100 percent of the chances you don't take. — Wayne Gretzky" — Michael Scott

- **feat(core): Support array attributes for spans, logs, and metrics ([#20427](https://github.com/getsentry/sentry-javascript/pull/20427))**

Arrays of primitive values (`string`, `number`, `boolean`) are now accepted as attribute values. Arrays containing non-primitive elements will be dropped and won't show up in Sentry. Note that array attributes on logs and metrics were previously stringified in certain cases and will now be sent as arrays instead.

- **feat(browser): Add `ingest_settings` to v2 log envelope payload ([#20453](https://github.com/getsentry/sentry-javascript/pull/20453))**

Inference of user data (e.g. IP address, browser name/version) on log events is now gated behind the `sendDefaultPii` option. Previously, this data was always inferred by default.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -162,7 +162,7 @@ sentryTest('should capture console object calls', async ({ getLocalTestUrl, page
'sentry.sdk.version': { value: expect.any(String), type: 'string' },
'sentry.timestamp.sequence': { value: expect.any(Number), type: 'integer' },
'sentry.message.template': { value: 'Array: {}', type: 'string' },
'sentry.message.parameter.0': { value: '[1,2,3,"string"]', type: 'string' },
'sentry.message.parameter.0': { value: [1, 2, 3, 'string'], type: 'array' },
},
},
{
Expand All @@ -179,7 +179,7 @@ sentryTest('should capture console object calls', async ({ getLocalTestUrl, page
'sentry.message.template': { value: 'Mixed: {} {} {} {}', type: 'string' },
'sentry.message.parameter.0': { value: 'prefix', type: 'string' },
'sentry.message.parameter.1': { value: '{"obj":true}', type: 'string' },
'sentry.message.parameter.2': { value: '[4,5,6]', type: 'string' },
'sentry.message.parameter.2': { value: [4, 5, 6], type: 'array' },
'sentry.message.parameter.3': { value: 'suffix', type: 'string' },
},
},
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,6 @@ Sentry.logger.info('log_before_any_scope', { log_attr: 'log_attr_1' });

Sentry.getGlobalScope().setAttributes({ global_scope_attr: true });

// this attribute will not be sent for now
Sentry.getGlobalScope().setAttribute('array_attr', [1, 2, 3]);

// global scope, log attribute
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,7 @@ sentryTest('captures logs with scope attributes', async ({ getLocalTestUrl, page
'sentry.sdk.version': { value: expect.any(String), type: 'string' },
'sentry.timestamp.sequence': { value: expect.any(Number), type: 'integer' },
global_scope_attr: { value: true, type: 'boolean' },
array_attr: { value: [1, 2, 3], type: 'array' },
log_attr: { value: 'log_attr_2', type: 'string' },
},
},
Expand All @@ -63,6 +64,7 @@ sentryTest('captures logs with scope attributes', async ({ getLocalTestUrl, page
'sentry.sdk.version': { value: expect.any(String), type: 'string' },
'sentry.timestamp.sequence': { value: expect.any(Number), type: 'integer' },
global_scope_attr: { value: true, type: 'boolean' },
array_attr: { value: [1, 2, 3], type: 'array' },
isolation_scope_1_attr: { value: 100, unit: 'millisecond', type: 'integer' },
log_attr: { value: 'log_attr_3', type: 'string' },
},
Expand All @@ -78,6 +80,7 @@ sentryTest('captures logs with scope attributes', async ({ getLocalTestUrl, page
'sentry.sdk.version': { value: expect.any(String), type: 'string' },
'sentry.timestamp.sequence': { value: expect.any(Number), type: 'integer' },
global_scope_attr: { value: true, type: 'boolean' },
array_attr: { value: [1, 2, 3], type: 'array' },
isolation_scope_1_attr: { value: 100, unit: 'millisecond', type: 'integer' },
scope_attr: { value: 200, unit: 'millisecond', type: 'integer' },
log_attr: { value: 'log_attr_4', type: 'string' },
Expand All @@ -94,6 +97,7 @@ sentryTest('captures logs with scope attributes', async ({ getLocalTestUrl, page
'sentry.sdk.version': { value: expect.any(String), type: 'string' },
'sentry.timestamp.sequence': { value: expect.any(Number), type: 'integer' },
global_scope_attr: { value: true, type: 'boolean' },
array_attr: { value: [1, 2, 3], type: 'array' },
isolation_scope_1_attr: { value: 100, unit: 'millisecond', type: 'integer' },
scope_2_attr: { value: 300, unit: 'millisecond', type: 'integer' },
log_attr: { value: 'log_attr_5', type: 'string' },
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,10 @@ const SEGMENT_SPAN = {
type: 'integer',
value: expect.any(Number),
},
// TODO: 'device.archs' is set but arrays are not yet serialized in span attributes
'device.archs': {
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for pulling this one in.

type: 'array',
value: expect.any(Array),
},
'device.processor_count': {
type: 'integer',
value: expect.any(Number),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,6 @@ async function run(): Promise<void> {

Sentry.getGlobalScope().setAttribute('global_scope_attr', true);

// this attribute will not be sent for now
Sentry.getGlobalScope().setAttributes({ array_attr: [1, 2, 3] });

// global scope, log attribute
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,7 @@ describe('logs', () => {
attributes: {
...commonAttributes,
global_scope_attr: { value: true, type: 'boolean' },
array_attr: { value: [1, 2, 3], type: 'array' },
log_attr: { value: 'log_attr_2', type: 'string' },
},
},
Expand All @@ -73,6 +74,7 @@ describe('logs', () => {
attributes: {
...commonAttributes,
global_scope_attr: { value: true, type: 'boolean' },
array_attr: { value: [1, 2, 3], type: 'array' },
isolation_scope_1_attr: { value: 100, unit: 'millisecond', type: 'integer' },
log_attr: { value: 'log_attr_3', type: 'string' },
},
Expand All @@ -86,6 +88,7 @@ describe('logs', () => {
attributes: {
...commonAttributes,
global_scope_attr: { value: true, type: 'boolean' },
array_attr: { value: [1, 2, 3], type: 'array' },
isolation_scope_1_attr: { value: 100, unit: 'millisecond', type: 'integer' },
scope_attr: { value: 200, unit: 'millisecond', type: 'integer' },
log_attr: { value: 'log_attr_4', type: 'string' },
Expand All @@ -100,6 +103,7 @@ describe('logs', () => {
attributes: {
...commonAttributes,
global_scope_attr: { value: true, type: 'boolean' },
array_attr: { value: [1, 2, 3], type: 'array' },
isolation_scope_1_attr: { value: 100, unit: 'millisecond', type: 'integer' },
scope_2_attr: { value: 300, unit: 'millisecond', type: 'integer' },
log_attr: { value: 'log_attr_5', type: 'string' },
Expand Down
25 changes: 10 additions & 15 deletions packages/core/src/attributes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,10 +15,7 @@ type AttributeTypeMap = {
integer: number;
double: number;
boolean: boolean;
'string[]': Array<string>;
'integer[]': Array<number>;
'double[]': Array<number>;
'boolean[]': Array<boolean>;
array: Array<string> | Array<number> | Array<boolean>;
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

q: Would be BigInt supported as well? I think this is a broader question actually

};

/* Generates a type from the AttributeTypeMap like:
Expand Down Expand Up @@ -66,9 +63,9 @@ export function isAttributeObject(maybeObj: unknown): maybeObj is AttributeObjec
/**
* Converts an attribute value to a typed attribute value.
*
* For now, we intentionally only support primitive values and attribute objects with primitive values.
* If @param useFallback is true, we stringify non-primitive values to a string attribute value. Otherwise
* we return `undefined` for unsupported values.
* For now, we support primitive values and arrays, either raw or inside attribute objects.
* If @param useFallback is true, we stringify other non-primitive values to a string attribute
* value. Otherwise we return `undefined` for unsupported values.
*
* @param value - The value of the passed attribute.
* @param useFallback - If true, unsupported values will be stringified to a string attribute value.
Expand Down Expand Up @@ -170,17 +167,15 @@ function estimatePrimitiveSizeInBytes(value: Primitive): number {
}

/**
* NOTE: We intentionally do not return anything for non-primitive values:
* - array support will come in the future but if we stringify arrays now,
* sending arrays (unstringified) later will be a subtle breaking change.
* NOTE: We return typed attributes for primitives and arrays:
* - Relay currently only supports arrays consisting of primitive values. Attributes with non-conforming arrays are dropped by Relay, so runtime type validation in the SDK is unnecessary.
* - Objects are not supported yet and product support is still TBD.
* - We still keep the type signature for TypedAttributeValue wider to avoid a
* breaking change once we add support for non-primitive values.
* - Once we go back to supporting arrays and stringifying all other values,
* we already implemented the serialization logic here:
* https://github.com/getsentry/sentry-javascript/pull/18165
*/
function getTypedAttributeValue(value: unknown): TypedAttributeValue | void {
if (Array.isArray(value) && value.length !== 0) {
return { value, type: 'array' };
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Mixed-type array console parameters silently dropped by Relay

Medium Severity

The getTypedAttributeValue function now converts all non-empty arrays to { type: 'array' } before the stringify fallback path is reached. This means SDK-internal sentry.message.parameter.X attributes created by createConsoleTemplateAttributes from mixed-type console args (e.g. console.log('Array:', [1, 2, 3, 'string'])) are now sent as array attributes instead of being stringified. Since Relay drops non-homogeneous arrays, these template parameters will be silently lost — a regression from the previous behavior where they were preserved as stringified values. The body text is unaffected, but structured template parameter data is lost for mixed-type arrays.

Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit f896f3a. Configure here.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

this is basically what I described in the PR description, we can discuss it but I think this is a tradeoff we have to make. don't think we can really solve this unless we do runtime inspection of the types of elements in arrays

}

const primitiveType =
typeof value === 'string'
? 'string'
Expand Down
99 changes: 43 additions & 56 deletions packages/core/test/lib/attributes.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -76,33 +76,43 @@ describe('attributeValueToTypedAttributeValue', () => {
);
});

describe('invalid values (non-primitives)', () => {
// Element types are not validated at runtime by the SDK (Relay drops non-conforming arrays).
describe('arrays', () => {
it.each([
['foo', 'bar'],
[1, 2, 3],
[true, false, true],
[1, 'foo', true],
{ foo: 'bar' },
() => 'test',
Symbol('test'),
])('returns undefined for non-primitive raw values (%s)', value => {
[['foo', 'bar']],
[[1, 2, 3]],
[[true, false, true]],
[[1, 'foo', true]],
[[NaN, 1, 2]],
[{ value: ['foo', 'bar'] }],
])('emits a typed array attribute for value %j', value => {
const result = attributeValueToTypedAttributeValue(value);
expect(result).toBeUndefined();
const expected = Array.isArray(value) ? value : (value as { value: unknown[] }).value;
expect(result).toStrictEqual({ value: expected, type: 'array' });
});

it.each([
['foo', 'bar'],
[1, 2, 3],
[true, false, true],
[1, 'foo', true],
{ foo: 'bar' },
() => 'test',
Symbol('test'),
])('returns undefined for non-primitive attribute object values (%s)', value => {
const result = attributeValueToTypedAttributeValue({ value });
expect(result).toBeUndefined();
it('returns undefined for empty arrays', () => {
expect(attributeValueToTypedAttributeValue([])).toBeUndefined();
});
});

describe('invalid values (non-primitives)', () => {
it.each([[{ foo: 'bar' }], [() => 'test'], [Symbol('test')]])(
'returns undefined for non-primitive raw values (%s)',
value => {
const result = attributeValueToTypedAttributeValue(value);
expect(result).toBeUndefined();
},
);

it.each([[{ foo: 'bar' }], [() => 'test'], [Symbol('test')]])(
'returns undefined for non-primitive attribute object values (%s)',
value => {
const result = attributeValueToTypedAttributeValue({ value });
expect(result).toBeUndefined();
},
);
});
});

describe('with fallback=true', () => {
Expand Down Expand Up @@ -189,38 +199,6 @@ describe('attributeValueToTypedAttributeValue', () => {
});

describe('invalid values (non-primitives) - stringified fallback', () => {
it('stringifies string arrays', () => {
const result = attributeValueToTypedAttributeValue(['foo', 'bar'], true);
expect(result).toStrictEqual({
value: '["foo","bar"]',
type: 'string',
});
});

it('stringifies number arrays', () => {
const result = attributeValueToTypedAttributeValue([1, 2, 3], true);
expect(result).toStrictEqual({
value: '[1,2,3]',
type: 'string',
});
});

it('stringifies boolean arrays', () => {
const result = attributeValueToTypedAttributeValue([true, false, true], true);
expect(result).toStrictEqual({
value: '[true,false,true]',
type: 'string',
});
});

it('stringifies mixed arrays', () => {
const result = attributeValueToTypedAttributeValue([1, 'foo', true], true);
expect(result).toStrictEqual({
value: '[1,"foo",true]',
type: 'string',
});
});

it('stringifies objects', () => {
const result = attributeValueToTypedAttributeValue({ foo: 'bar' }, true);
expect(result).toStrictEqual({
Expand Down Expand Up @@ -425,15 +403,17 @@ describe('serializeAttributes', () => {
describe('invalid (non-primitive) values', () => {
it("doesn't fall back to stringification by default", () => {
const result = serializeAttributes({ foo: { some: 'object' }, bar: [1, 2, 3], baz: () => {} });
expect(result).toStrictEqual({});
expect(result).toStrictEqual({
bar: { type: 'array', value: [1, 2, 3] },
});
});

it('falls back to stringification of unsupported non-primitive values if fallback is true', () => {
const result = serializeAttributes({ foo: { some: 'object' }, bar: [1, 2, 3], baz: () => {} }, true);
expect(result).toStrictEqual({
bar: {
type: 'string',
value: '[1,2,3]',
type: 'array',
value: [1, 2, 3],
},
baz: {
type: 'string',
Expand All @@ -445,5 +425,12 @@ describe('serializeAttributes', () => {
},
});
});

// Element types are not validated at runtime by the SDK (Relay drops non-conforming arrays).
it('accepts mixed-type arrays', () => {
expect(serializeAttributes({ mixed: ['a', 1] })).toStrictEqual({
mixed: { type: 'array', value: ['a', 1] },
});
});
});
});
10 changes: 9 additions & 1 deletion packages/core/test/lib/logs/internal.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -191,7 +191,6 @@ describe('_INTERNAL_captureLog', () => {
scope.setAttribute('scope_2', { value: 38, unit: 'gigabyte' });
scope.setAttributes({
scope_3: true,
// these are invalid since for now we don't support arrays
scope_4: [1, 2, 3],
scope_5: { value: [true, false, true], unit: 'second' },
});
Expand Down Expand Up @@ -229,6 +228,15 @@ describe('_INTERNAL_captureLog', () => {
type: 'boolean',
value: true,
},
scope_4: {
type: 'array',
value: [1, 2, 3],
},
scope_5: {
type: 'array',
value: [true, false, true],
unit: 'second',
},
'sentry.timestamp.sequence': { value: expect.any(Number), type: 'integer' },
});
});
Expand Down
6 changes: 3 additions & 3 deletions packages/core/test/lib/tracing/spans/estimateSize.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -130,9 +130,9 @@ describe('estimateSerializedSpanSizeInBytes', () => {
status: 'ok',
is_segment: false,
attributes: {
'item.ids': { type: 'string[]', value: ['id-001', 'id-002', 'id-003', 'id-004', 'id-005'] },
scores: { type: 'double[]', value: [1.1, 2.2, 3.3, 4.4] },
flags: { type: 'boolean[]', value: [true, false, true] },
'item.ids': { type: 'array', value: ['id-001', 'id-002', 'id-003', 'id-004', 'id-005'] },
scores: { type: 'array', value: [1.1, 2.2, 3.3, 4.4] },
flags: { type: 'array', value: [true, false, true] },
},
};

Expand Down
4 changes: 1 addition & 3 deletions packages/core/test/lib/utils/spanUtils.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -622,11 +622,9 @@ describe('spanToJSON', () => {
attr1: { type: 'string', value: 'value1' },
attr2: { type: 'integer', value: 2 },
attr3: { type: 'boolean', value: true },
attr4: { type: 'array', value: [1, 2, 3] },
[SEMANTIC_ATTRIBUTE_SENTRY_OP]: { type: 'string', value: 'test op' },
[SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: { type: 'string', value: 'auto' },
// notice the absence of `attr4`!
// for now, we don't yet serialize array attributes. This test will fail
// once we allow serializing them.
},
links: [
{
Expand Down
Loading