diff --git a/CHANGELOG.md b/CHANGELOG.md index 0658797b2093..52fec9f57892 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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. diff --git a/dev-packages/browser-integration-tests/suites/public-api/logger/integration/test.ts b/dev-packages/browser-integration-tests/suites/public-api/logger/integration/test.ts index 8312c2a13e4d..f16fc7170120 100644 --- a/dev-packages/browser-integration-tests/suites/public-api/logger/integration/test.ts +++ b/dev-packages/browser-integration-tests/suites/public-api/logger/integration/test.ts @@ -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' }, }, }, { @@ -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' }, }, }, diff --git a/dev-packages/browser-integration-tests/suites/public-api/logger/scopeAttributes/subject.js b/dev-packages/browser-integration-tests/suites/public-api/logger/scopeAttributes/subject.js index 9bba2c222bdc..76377beb82a7 100644 --- a/dev-packages/browser-integration-tests/suites/public-api/logger/scopeAttributes/subject.js +++ b/dev-packages/browser-integration-tests/suites/public-api/logger/scopeAttributes/subject.js @@ -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 diff --git a/dev-packages/browser-integration-tests/suites/public-api/logger/scopeAttributes/test.ts b/dev-packages/browser-integration-tests/suites/public-api/logger/scopeAttributes/test.ts index 07af615712ff..5c3e4c7d2ec8 100644 --- a/dev-packages/browser-integration-tests/suites/public-api/logger/scopeAttributes/test.ts +++ b/dev-packages/browser-integration-tests/suites/public-api/logger/scopeAttributes/test.ts @@ -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' }, }, }, @@ -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' }, }, @@ -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' }, @@ -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' }, diff --git a/dev-packages/e2e-tests/test-applications/deno-streamed/tests/spans.test.ts b/dev-packages/e2e-tests/test-applications/deno-streamed/tests/spans.test.ts index 15d7eaf99d9a..11f87c9229db 100644 --- a/dev-packages/e2e-tests/test-applications/deno-streamed/tests/spans.test.ts +++ b/dev-packages/e2e-tests/test-applications/deno-streamed/tests/spans.test.ts @@ -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': { + type: 'array', + value: expect.any(Array), + }, 'device.processor_count': { type: 'integer', value: expect.any(Number), diff --git a/dev-packages/node-integration-tests/suites/public-api/logger/scenario.ts b/dev-packages/node-integration-tests/suites/public-api/logger/scenario.ts index c3be917706e7..c3ad84c1e146 100644 --- a/dev-packages/node-integration-tests/suites/public-api/logger/scenario.ts +++ b/dev-packages/node-integration-tests/suites/public-api/logger/scenario.ts @@ -15,7 +15,6 @@ async function run(): Promise { 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 diff --git a/dev-packages/node-integration-tests/suites/public-api/logger/test.ts b/dev-packages/node-integration-tests/suites/public-api/logger/test.ts index e992d70c4de3..d81b602e0527 100644 --- a/dev-packages/node-integration-tests/suites/public-api/logger/test.ts +++ b/dev-packages/node-integration-tests/suites/public-api/logger/test.ts @@ -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' }, }, }, @@ -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' }, }, @@ -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' }, @@ -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' }, diff --git a/packages/core/src/attributes.ts b/packages/core/src/attributes.ts index 1f4a6638f577..9473b01df87e 100644 --- a/packages/core/src/attributes.ts +++ b/packages/core/src/attributes.ts @@ -15,10 +15,7 @@ type AttributeTypeMap = { integer: number; double: number; boolean: boolean; - 'string[]': Array; - 'integer[]': Array; - 'double[]': Array; - 'boolean[]': Array; + array: Array | Array | Array; }; /* Generates a type from the AttributeTypeMap like: @@ -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. @@ -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' }; + } + const primitiveType = typeof value === 'string' ? 'string' diff --git a/packages/core/test/lib/attributes.test.ts b/packages/core/test/lib/attributes.test.ts index 13b9e026e6e9..6c936e8679a5 100644 --- a/packages/core/test/lib/attributes.test.ts +++ b/packages/core/test/lib/attributes.test.ts @@ -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', () => { @@ -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({ @@ -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', @@ -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] }, + }); + }); }); }); diff --git a/packages/core/test/lib/logs/internal.test.ts b/packages/core/test/lib/logs/internal.test.ts index 360485f5ca84..48c93c7cf1d1 100644 --- a/packages/core/test/lib/logs/internal.test.ts +++ b/packages/core/test/lib/logs/internal.test.ts @@ -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' }, }); @@ -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' }, }); }); diff --git a/packages/core/test/lib/tracing/spans/estimateSize.test.ts b/packages/core/test/lib/tracing/spans/estimateSize.test.ts index 35d569691dea..e92b260839f2 100644 --- a/packages/core/test/lib/tracing/spans/estimateSize.test.ts +++ b/packages/core/test/lib/tracing/spans/estimateSize.test.ts @@ -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] }, }, }; diff --git a/packages/core/test/lib/utils/spanUtils.test.ts b/packages/core/test/lib/utils/spanUtils.test.ts index e4a0b31990d7..a2f2dbea7aba 100644 --- a/packages/core/test/lib/utils/spanUtils.test.ts +++ b/packages/core/test/lib/utils/spanUtils.test.ts @@ -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: [ {