From 0609305218697a34afad6273ea59d52126ada8f6 Mon Sep 17 00:00:00 2001 From: Nicolas Hrubec Date: Tue, 21 Apr 2026 13:58:29 +0200 Subject: [PATCH 1/5] implement --- packages/core/src/semanticAttributes.ts | 4 + .../core/src/tracing/spans/captureSpan.ts | 15 +++ .../lib/tracing/spans/captureSpan.test.ts | 98 +++++++++++++++++++ 3 files changed, 117 insertions(+) diff --git a/packages/core/src/semanticAttributes.ts b/packages/core/src/semanticAttributes.ts index 02b6a4ec08a6..a62c09ca7b53 100644 --- a/packages/core/src/semanticAttributes.ts +++ b/packages/core/src/semanticAttributes.ts @@ -52,6 +52,10 @@ export const SEMANTIC_ATTRIBUTE_SENTRY_SEGMENT_ID = 'sentry.segment.id'; export const SEMANTIC_ATTRIBUTE_SENTRY_SDK_NAME = 'sentry.sdk.name'; /** The version of the Sentry SDK */ export const SEMANTIC_ATTRIBUTE_SENTRY_SDK_VERSION = 'sentry.sdk.version'; +/** The list of integrations enabled in the Sentry SDK (e.g., ["InboundFilters", "BrowserTracing"]) */ +export const SEMANTIC_ATTRIBUTE_SENTRY_SDK_INTEGRATIONS = 'sentry.sdk.integrations'; +/** The list of SDK packages loaded by the application (e.g., ["npm:@sentry/browser@9.0.0"]) */ +export const SEMANTIC_ATTRIBUTE_SENTRY_SDK_PACKAGES = 'sentry.sdk.packages'; /** The user ID (gated by sendDefaultPii) */ export const SEMANTIC_ATTRIBUTE_USER_ID = 'user.id'; diff --git a/packages/core/src/tracing/spans/captureSpan.ts b/packages/core/src/tracing/spans/captureSpan.ts index fe8bc31fcae7..3a792b342357 100644 --- a/packages/core/src/tracing/spans/captureSpan.ts +++ b/packages/core/src/tracing/spans/captureSpan.ts @@ -4,7 +4,9 @@ import type { ScopeData } from '../../scope'; import { SEMANTIC_ATTRIBUTE_SENTRY_ENVIRONMENT, SEMANTIC_ATTRIBUTE_SENTRY_RELEASE, + SEMANTIC_ATTRIBUTE_SENTRY_SDK_INTEGRATIONS, SEMANTIC_ATTRIBUTE_SENTRY_SDK_NAME, + SEMANTIC_ATTRIBUTE_SENTRY_SDK_PACKAGES, SEMANTIC_ATTRIBUTE_SENTRY_SDK_VERSION, SEMANTIC_ATTRIBUTE_SENTRY_SEGMENT_ID, SEMANTIC_ATTRIBUTE_SENTRY_SEGMENT_NAME, @@ -53,6 +55,7 @@ export function captureSpan(span: Span, client: Client): SerializedStreamedSpanW if (spanJSON.is_segment) { applyScopeToSegmentSpan(spanJSON, finalScopeData); + applySdkMetadataToSegmentSpan(spanJSON, client); // Allow hook subscribers to mutate the segment span JSON // This also invokes the `processSegmentSpan` hook of all integrations client.emit('processSegmentSpan', spanJSON); @@ -90,6 +93,18 @@ function applyScopeToSegmentSpan(_segmentSpanJSON: StreamedSpanJSON, _scopeData: // This will follow in a separate PR } +function applySdkMetadataToSegmentSpan(segmentSpanJSON: StreamedSpanJSON, client: Client): void { + const integrationNames = client.getOptions().integrations.map(i => i.name); + const packages = client.getSdkMetadata()?.sdk?.packages?.map(p => `${p.name}@${p.version}`); + + safeSetSpanJSONAttributes(segmentSpanJSON, { + [SEMANTIC_ATTRIBUTE_SENTRY_SDK_INTEGRATIONS]: integrationNames.length + ? JSON.stringify(integrationNames) + : undefined, + [SEMANTIC_ATTRIBUTE_SENTRY_SDK_PACKAGES]: packages?.length ? JSON.stringify(packages) : undefined, + }); +} + function applyCommonSpanAttributes( spanJSON: StreamedSpanJSON, serializedSegmentSpan: StreamedSpanJSON, diff --git a/packages/core/test/lib/tracing/spans/captureSpan.test.ts b/packages/core/test/lib/tracing/spans/captureSpan.test.ts index d429d50714a2..fe449b2fb0b4 100644 --- a/packages/core/test/lib/tracing/spans/captureSpan.test.ts +++ b/packages/core/test/lib/tracing/spans/captureSpan.test.ts @@ -7,7 +7,9 @@ import { SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN, SEMANTIC_ATTRIBUTE_SENTRY_RELEASE, SEMANTIC_ATTRIBUTE_SENTRY_SAMPLE_RATE, + SEMANTIC_ATTRIBUTE_SENTRY_SDK_INTEGRATIONS, SEMANTIC_ATTRIBUTE_SENTRY_SDK_NAME, + SEMANTIC_ATTRIBUTE_SENTRY_SDK_PACKAGES, SEMANTIC_ATTRIBUTE_SENTRY_SDK_VERSION, SEMANTIC_ATTRIBUTE_SENTRY_SEGMENT_ID, SEMANTIC_ATTRIBUTE_SENTRY_SEGMENT_NAME, @@ -291,6 +293,102 @@ describe('captureSpan', () => { }); }); + it('adds sentry.sdk.integrations and sentry.sdk.packages to segment spans as JSON-stringified strings', () => { + const client = new TestClient( + getDefaultTestClientOptions({ + dsn: 'https://dsn@ingest.f00.f00/1', + tracesSampleRate: 1, + release: '1.0.0', + environment: 'staging', + integrations: [ + { name: 'InboundFilters', setupOnce: () => {} }, + { name: 'BrowserTracing', setupOnce: () => {} }, + ], + _metadata: { + sdk: { + name: 'sentry.javascript.browser', + version: '9.0.0', + packages: [ + { name: 'npm:@sentry/browser', version: '9.0.0' }, + { name: 'npm:@sentry/core', version: '9.0.0' }, + ], + }, + }, + }), + ); + + const span = withScope(scope => { + scope.setClient(client); + const span = startInactiveSpan({ name: 'my-span', attributes: { 'sentry.op': 'http.client' } }); + span.end(); + return span; + }); + + expect(captureSpan(span, client)).toStrictEqual({ + span_id: expect.stringMatching(/^[\da-f]{16}$/), + trace_id: expect.stringMatching(/^[\da-f]{32}$/), + parent_span_id: undefined, + links: undefined, + start_timestamp: expect.any(Number), + name: 'my-span', + end_timestamp: expect.any(Number), + status: 'ok', + is_segment: true, + attributes: { + [SEMANTIC_ATTRIBUTE_SENTRY_OP]: { type: 'string', value: 'http.client' }, + [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: { type: 'string', value: 'manual' }, + [SEMANTIC_ATTRIBUTE_SENTRY_SAMPLE_RATE]: { type: 'integer', value: 1 }, + [SEMANTIC_ATTRIBUTE_SENTRY_SEGMENT_NAME]: { value: 'my-span', type: 'string' }, + [SEMANTIC_ATTRIBUTE_SENTRY_SEGMENT_ID]: { value: span.spanContext().spanId, type: 'string' }, + 'sentry.span.source': { value: 'custom', type: 'string' }, + [SEMANTIC_ATTRIBUTE_SENTRY_SOURCE]: { value: 'custom', type: 'string' }, + [SEMANTIC_ATTRIBUTE_SENTRY_RELEASE]: { value: '1.0.0', type: 'string' }, + [SEMANTIC_ATTRIBUTE_SENTRY_ENVIRONMENT]: { value: 'staging', type: 'string' }, + [SEMANTIC_ATTRIBUTE_SENTRY_SDK_NAME]: { value: 'sentry.javascript.browser', type: 'string' }, + [SEMANTIC_ATTRIBUTE_SENTRY_SDK_VERSION]: { value: '9.0.0', type: 'string' }, + [SEMANTIC_ATTRIBUTE_SENTRY_SDK_INTEGRATIONS]: { + type: 'string', + value: '["InboundFilters","BrowserTracing"]', + }, + [SEMANTIC_ATTRIBUTE_SENTRY_SDK_PACKAGES]: { + type: 'string', + value: '["npm:@sentry/browser@9.0.0","npm:@sentry/core@9.0.0"]', + }, + }, + _segmentSpan: span, + }); + }); + + it('does not add sentry.sdk.integrations or sentry.sdk.packages to non-segment child spans', () => { + const client = new TestClient( + getDefaultTestClientOptions({ + dsn: 'https://dsn@ingest.f00.f00/1', + tracesSampleRate: 1, + integrations: [{ name: 'InboundFilters', setupOnce: () => {} }], + _metadata: { + sdk: { + name: 'sentry.javascript.browser', + version: '9.0.0', + packages: [{ name: 'npm:@sentry/browser', version: '9.0.0' }], + }, + }, + }), + ); + + const serializedChild = withScope(scope => { + scope.setClient(client); + return startSpan({ name: 'segment' }, () => { + const childSpan = startInactiveSpan({ name: 'child' }); + childSpan.end(); + return captureSpan(childSpan, client); + }); + }); + + expect(serializedChild.is_segment).toBe(false); + expect(serializedChild.attributes?.[SEMANTIC_ATTRIBUTE_SENTRY_SDK_INTEGRATIONS]).toBeUndefined(); + expect(serializedChild.attributes?.[SEMANTIC_ATTRIBUTE_SENTRY_SDK_PACKAGES]).toBeUndefined(); + }); + describe('client hooks', () => { it('calls processSpan and processSegmentSpan hooks for a segment span', () => { const client = new TestClient( From 9d7dfd562d1ebfce9ab0deaf02cabeb575a7137d Mon Sep 17 00:00:00 2001 From: Nicolas Hrubec Date: Tue, 21 Apr 2026 16:09:31 +0200 Subject: [PATCH 2/5] only send integrations --- packages/core/src/semanticAttributes.ts | 2 -- .../core/src/tracing/spans/captureSpan.ts | 8 ++----- .../lib/tracing/spans/captureSpan.test.ts | 21 ++----------------- 3 files changed, 4 insertions(+), 27 deletions(-) diff --git a/packages/core/src/semanticAttributes.ts b/packages/core/src/semanticAttributes.ts index a62c09ca7b53..fff57045b65e 100644 --- a/packages/core/src/semanticAttributes.ts +++ b/packages/core/src/semanticAttributes.ts @@ -54,8 +54,6 @@ export const SEMANTIC_ATTRIBUTE_SENTRY_SDK_NAME = 'sentry.sdk.name'; export const SEMANTIC_ATTRIBUTE_SENTRY_SDK_VERSION = 'sentry.sdk.version'; /** The list of integrations enabled in the Sentry SDK (e.g., ["InboundFilters", "BrowserTracing"]) */ export const SEMANTIC_ATTRIBUTE_SENTRY_SDK_INTEGRATIONS = 'sentry.sdk.integrations'; -/** The list of SDK packages loaded by the application (e.g., ["npm:@sentry/browser@9.0.0"]) */ -export const SEMANTIC_ATTRIBUTE_SENTRY_SDK_PACKAGES = 'sentry.sdk.packages'; /** The user ID (gated by sendDefaultPii) */ export const SEMANTIC_ATTRIBUTE_USER_ID = 'user.id'; diff --git a/packages/core/src/tracing/spans/captureSpan.ts b/packages/core/src/tracing/spans/captureSpan.ts index 3a792b342357..81348f10afd4 100644 --- a/packages/core/src/tracing/spans/captureSpan.ts +++ b/packages/core/src/tracing/spans/captureSpan.ts @@ -6,7 +6,6 @@ import { SEMANTIC_ATTRIBUTE_SENTRY_RELEASE, SEMANTIC_ATTRIBUTE_SENTRY_SDK_INTEGRATIONS, SEMANTIC_ATTRIBUTE_SENTRY_SDK_NAME, - SEMANTIC_ATTRIBUTE_SENTRY_SDK_PACKAGES, SEMANTIC_ATTRIBUTE_SENTRY_SDK_VERSION, SEMANTIC_ATTRIBUTE_SENTRY_SEGMENT_ID, SEMANTIC_ATTRIBUTE_SENTRY_SEGMENT_NAME, @@ -95,13 +94,10 @@ function applyScopeToSegmentSpan(_segmentSpanJSON: StreamedSpanJSON, _scopeData: function applySdkMetadataToSegmentSpan(segmentSpanJSON: StreamedSpanJSON, client: Client): void { const integrationNames = client.getOptions().integrations.map(i => i.name); - const packages = client.getSdkMetadata()?.sdk?.packages?.map(p => `${p.name}@${p.version}`); + if (!integrationNames.length) return; safeSetSpanJSONAttributes(segmentSpanJSON, { - [SEMANTIC_ATTRIBUTE_SENTRY_SDK_INTEGRATIONS]: integrationNames.length - ? JSON.stringify(integrationNames) - : undefined, - [SEMANTIC_ATTRIBUTE_SENTRY_SDK_PACKAGES]: packages?.length ? JSON.stringify(packages) : undefined, + [SEMANTIC_ATTRIBUTE_SENTRY_SDK_INTEGRATIONS]: JSON.stringify(integrationNames), }); } diff --git a/packages/core/test/lib/tracing/spans/captureSpan.test.ts b/packages/core/test/lib/tracing/spans/captureSpan.test.ts index fe449b2fb0b4..ed3990a8703e 100644 --- a/packages/core/test/lib/tracing/spans/captureSpan.test.ts +++ b/packages/core/test/lib/tracing/spans/captureSpan.test.ts @@ -9,7 +9,6 @@ import { SEMANTIC_ATTRIBUTE_SENTRY_SAMPLE_RATE, SEMANTIC_ATTRIBUTE_SENTRY_SDK_INTEGRATIONS, SEMANTIC_ATTRIBUTE_SENTRY_SDK_NAME, - SEMANTIC_ATTRIBUTE_SENTRY_SDK_PACKAGES, SEMANTIC_ATTRIBUTE_SENTRY_SDK_VERSION, SEMANTIC_ATTRIBUTE_SENTRY_SEGMENT_ID, SEMANTIC_ATTRIBUTE_SENTRY_SEGMENT_NAME, @@ -293,7 +292,7 @@ describe('captureSpan', () => { }); }); - it('adds sentry.sdk.integrations and sentry.sdk.packages to segment spans as JSON-stringified strings', () => { + it('adds sentry.sdk.integrations to segment spans as a JSON-stringified string', () => { const client = new TestClient( getDefaultTestClientOptions({ dsn: 'https://dsn@ingest.f00.f00/1', @@ -308,10 +307,6 @@ describe('captureSpan', () => { sdk: { name: 'sentry.javascript.browser', version: '9.0.0', - packages: [ - { name: 'npm:@sentry/browser', version: '9.0.0' }, - { name: 'npm:@sentry/core', version: '9.0.0' }, - ], }, }, }), @@ -350,28 +345,17 @@ describe('captureSpan', () => { type: 'string', value: '["InboundFilters","BrowserTracing"]', }, - [SEMANTIC_ATTRIBUTE_SENTRY_SDK_PACKAGES]: { - type: 'string', - value: '["npm:@sentry/browser@9.0.0","npm:@sentry/core@9.0.0"]', - }, }, _segmentSpan: span, }); }); - it('does not add sentry.sdk.integrations or sentry.sdk.packages to non-segment child spans', () => { + it('does not add sentry.sdk.integrations to non-segment child spans', () => { const client = new TestClient( getDefaultTestClientOptions({ dsn: 'https://dsn@ingest.f00.f00/1', tracesSampleRate: 1, integrations: [{ name: 'InboundFilters', setupOnce: () => {} }], - _metadata: { - sdk: { - name: 'sentry.javascript.browser', - version: '9.0.0', - packages: [{ name: 'npm:@sentry/browser', version: '9.0.0' }], - }, - }, }), ); @@ -386,7 +370,6 @@ describe('captureSpan', () => { expect(serializedChild.is_segment).toBe(false); expect(serializedChild.attributes?.[SEMANTIC_ATTRIBUTE_SENTRY_SDK_INTEGRATIONS]).toBeUndefined(); - expect(serializedChild.attributes?.[SEMANTIC_ATTRIBUTE_SENTRY_SDK_PACKAGES]).toBeUndefined(); }); describe('client hooks', () => { From b92ee6dd58660a90d982ee3b564e0c66cafd3c7c Mon Sep 17 00:00:00 2001 From: Nicolas Hrubec Date: Tue, 21 Apr 2026 17:17:58 +0200 Subject: [PATCH 3/5] arrays are now supported in relay? --- packages/core/src/attributes.ts | 33 ++++---- .../core/src/tracing/spans/captureSpan.ts | 2 +- packages/core/test/lib/attributes.test.ts | 76 ++++++++++--------- packages/core/test/lib/logs/internal.test.ts | 10 ++- .../lib/tracing/spans/captureSpan.test.ts | 6 +- .../lib/tracing/spans/estimateSize.test.ts | 6 +- .../core/test/lib/utils/spanUtils.test.ts | 4 +- 7 files changed, 76 insertions(+), 61 deletions(-) diff --git a/packages/core/src/attributes.ts b/packages/core/src/attributes.ts index 1f4a6638f577..5a2cec8f697b 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, homogeneous arrays of primitives, and attribute objects + * wrapping either. 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,18 @@ 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 homogeneous arrays of primitives: + * - Homogeneous primitive arrays ship with `type: 'array'` (Relay's wire tag for arrays). + * - Mixed-type and nested arrays are not supported and return undefined. * - 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 + * breaking change once we add support for other non-primitive values. */ function getTypedAttributeValue(value: unknown): TypedAttributeValue | void { + if (Array.isArray(value) && isHomogeneousPrimitiveArray(value)) { + return { value, type: 'array' }; + } + const primitiveType = typeof value === 'string' ? 'string' @@ -201,3 +199,10 @@ function getTypedAttributeValue(value: unknown): TypedAttributeValue | void { return { value, type: primitiveType }; } } + +function isHomogeneousPrimitiveArray(arr: unknown[]): boolean { + if (arr.length === 0) return true; + const t = typeof arr[0]; + if (t !== 'string' && t !== 'number' && t !== 'boolean') return false; + return arr.every(v => typeof v === t); +} diff --git a/packages/core/src/tracing/spans/captureSpan.ts b/packages/core/src/tracing/spans/captureSpan.ts index 81348f10afd4..1042afdac731 100644 --- a/packages/core/src/tracing/spans/captureSpan.ts +++ b/packages/core/src/tracing/spans/captureSpan.ts @@ -97,7 +97,7 @@ function applySdkMetadataToSegmentSpan(segmentSpanJSON: StreamedSpanJSON, client if (!integrationNames.length) return; safeSetSpanJSONAttributes(segmentSpanJSON, { - [SEMANTIC_ATTRIBUTE_SENTRY_SDK_INTEGRATIONS]: JSON.stringify(integrationNames), + [SEMANTIC_ATTRIBUTE_SENTRY_SDK_INTEGRATIONS]: integrationNames, }); } diff --git a/packages/core/test/lib/attributes.test.ts b/packages/core/test/lib/attributes.test.ts index 13b9e026e6e9..aa7343b94883 100644 --- a/packages/core/test/lib/attributes.test.ts +++ b/packages/core/test/lib/attributes.test.ts @@ -76,28 +76,39 @@ describe('attributeValueToTypedAttributeValue', () => { ); }); + describe('homogeneous primitive arrays', () => { + it.each([ + [['foo', 'bar']], + [[1, 2, 3]], + [[true, false, true]], + [[] as unknown[]], + ])('emits a typed array attribute for raw value %j', value => { + const result = attributeValueToTypedAttributeValue(value); + expect(result).toStrictEqual({ value, type: 'array' }); + }); + + it('emits a typed array attribute for attribute object values', () => { + const result = attributeValueToTypedAttributeValue({ value: ['foo', 'bar'] }); + expect(result).toStrictEqual({ value: ['foo', 'bar'], type: 'array' }); + }); + }); + describe('invalid values (non-primitives)', () => { it.each([ - ['foo', 'bar'], - [1, 2, 3], - [true, false, true], - [1, 'foo', true], - { foo: 'bar' }, - () => 'test', - Symbol('test'), + [[1, 'foo', true]], + [{ 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'], - [1, 2, 3], - [true, false, true], - [1, 'foo', true], - { foo: 'bar' }, - () => 'test', - Symbol('test'), + [[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(); @@ -189,26 +200,10 @@ 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); + it('stringifies mixed-type arrays (not homogeneous)', () => { + const result = attributeValueToTypedAttributeValue(['foo', 1, true], 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]', + value: '["foo",1,true]', type: 'string', }); }); @@ -425,15 +420,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 +442,12 @@ describe('serializeAttributes', () => { }, }); }); + + it('drops mixed-type arrays by default and stringifies them with fallback', () => { + expect(serializeAttributes({ mixed: ['a', 1] })).toStrictEqual({}); + expect(serializeAttributes({ mixed: ['a', 1] }, true)).toStrictEqual({ + mixed: { type: 'string', 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/captureSpan.test.ts b/packages/core/test/lib/tracing/spans/captureSpan.test.ts index ed3990a8703e..2cc91c761cf3 100644 --- a/packages/core/test/lib/tracing/spans/captureSpan.test.ts +++ b/packages/core/test/lib/tracing/spans/captureSpan.test.ts @@ -292,7 +292,7 @@ describe('captureSpan', () => { }); }); - it('adds sentry.sdk.integrations to segment spans as a JSON-stringified string', () => { + it('adds sentry.sdk.integrations to segment spans as an array attribute', () => { const client = new TestClient( getDefaultTestClientOptions({ dsn: 'https://dsn@ingest.f00.f00/1', @@ -342,8 +342,8 @@ describe('captureSpan', () => { [SEMANTIC_ATTRIBUTE_SENTRY_SDK_NAME]: { value: 'sentry.javascript.browser', type: 'string' }, [SEMANTIC_ATTRIBUTE_SENTRY_SDK_VERSION]: { value: '9.0.0', type: 'string' }, [SEMANTIC_ATTRIBUTE_SENTRY_SDK_INTEGRATIONS]: { - type: 'string', - value: '["InboundFilters","BrowserTracing"]', + type: 'array', + value: ['InboundFilters', 'BrowserTracing'], }, }, _segmentSpan: span, 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: [ { From b5f6b76ba37346c37ce1616dab860f7c0cec22ab Mon Sep 17 00:00:00 2001 From: Nicolas Hrubec Date: Tue, 21 Apr 2026 17:19:50 +0200 Subject: [PATCH 4/5] update comment --- packages/core/src/attributes.ts | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/packages/core/src/attributes.ts b/packages/core/src/attributes.ts index 5a2cec8f697b..dd76e2a8dbb9 100644 --- a/packages/core/src/attributes.ts +++ b/packages/core/src/attributes.ts @@ -64,8 +64,9 @@ export function isAttributeObject(maybeObj: unknown): maybeObj is AttributeObjec * Converts an attribute value to a typed attribute value. * * For now, we support primitive values, homogeneous arrays of primitives, and attribute objects - * wrapping either. If @param useFallback is true, we stringify other non-primitive values to a - * string attribute value. Otherwise we return `undefined` for unsupported values. + * with primitive or primitive-array values. 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. From 4ce3c11cebe32034b3d23afab3a776aaed52ba1a Mon Sep 17 00:00:00 2001 From: Nicolas Hrubec Date: Tue, 21 Apr 2026 17:27:42 +0200 Subject: [PATCH 5/5] lint --- packages/core/src/attributes.ts | 7 ++-- packages/core/test/lib/attributes.test.ts | 48 ++++++++++------------- 2 files changed, 24 insertions(+), 31 deletions(-) diff --git a/packages/core/src/attributes.ts b/packages/core/src/attributes.ts index dd76e2a8dbb9..c8681fc6e757 100644 --- a/packages/core/src/attributes.ts +++ b/packages/core/src/attributes.ts @@ -63,10 +63,9 @@ export function isAttributeObject(maybeObj: unknown): maybeObj is AttributeObjec /** * Converts an attribute value to a typed attribute value. * - * For now, we support primitive values, homogeneous arrays of primitives, and attribute objects - * with primitive or primitive-array values. If @param useFallback is true, we stringify other - * non-primitive values to a string attribute value. Otherwise we return `undefined` for unsupported - * values. + * For now, we support primitive values and homogeneous arrays of primitives, 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. diff --git a/packages/core/test/lib/attributes.test.ts b/packages/core/test/lib/attributes.test.ts index aa7343b94883..060fbe1f618b 100644 --- a/packages/core/test/lib/attributes.test.ts +++ b/packages/core/test/lib/attributes.test.ts @@ -77,15 +77,13 @@ describe('attributeValueToTypedAttributeValue', () => { }); describe('homogeneous primitive arrays', () => { - it.each([ - [['foo', 'bar']], - [[1, 2, 3]], - [[true, false, true]], - [[] as unknown[]], - ])('emits a typed array attribute for raw value %j', value => { - const result = attributeValueToTypedAttributeValue(value); - expect(result).toStrictEqual({ value, type: 'array' }); - }); + it.each([[['foo', 'bar']], [[1, 2, 3]], [[true, false, true]], [[] as unknown[]]])( + 'emits a typed array attribute for raw value %j', + value => { + const result = attributeValueToTypedAttributeValue(value); + expect(result).toStrictEqual({ value, type: 'array' }); + }, + ); it('emits a typed array attribute for attribute object values', () => { const result = attributeValueToTypedAttributeValue({ value: ['foo', 'bar'] }); @@ -94,25 +92,21 @@ describe('attributeValueToTypedAttributeValue', () => { }); describe('invalid values (non-primitives)', () => { - it.each([ - [[1, 'foo', true]], - [{ foo: 'bar' }], - [() => 'test'], - [Symbol('test')], - ])('returns undefined for non-primitive raw values (%s)', value => { - const result = attributeValueToTypedAttributeValue(value); - expect(result).toBeUndefined(); - }); + it.each([[[1, 'foo', true]], [{ foo: 'bar' }], [() => 'test'], [Symbol('test')]])( + 'returns undefined for non-primitive raw values (%s)', + value => { + const result = attributeValueToTypedAttributeValue(value); + expect(result).toBeUndefined(); + }, + ); - it.each([ - [[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.each([[[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(); + }, + ); }); });