From 640f65777006511f36ca52892949d97041ab1f30 Mon Sep 17 00:00:00 2001 From: Lukas Stracke Date: Wed, 17 Jun 2026 12:18:15 +0200 Subject: [PATCH 1/2] ref(core/opentelemetry): Set low cardinality span names when span streaming is enabled --- packages/core/src/shared-exports.ts | 1 + packages/core/src/utils/spanNames.ts | 39 ++++++++ .../core/test/lib/utils/spanNames.test.ts | 95 +++++++++++++++++++ packages/opentelemetry/src/sampler.ts | 9 +- packages/opentelemetry/src/spanExporter.ts | 10 +- ...enhanceDscWithOpenTelemetryRootSpanName.ts | 6 +- .../src/utils/parseSpanDescription.ts | 55 +++++++++-- .../test/utils/parseSpanDescription.test.ts | 41 ++++++++ 8 files changed, 244 insertions(+), 12 deletions(-) create mode 100644 packages/core/src/utils/spanNames.ts create mode 100644 packages/core/test/lib/utils/spanNames.test.ts diff --git a/packages/core/src/shared-exports.ts b/packages/core/src/shared-exports.ts index 87908812ca85..1bb94d3755ef 100644 --- a/packages/core/src/shared-exports.ts +++ b/packages/core/src/shared-exports.ts @@ -101,6 +101,7 @@ export { spanTimeInputToSeconds, updateSpanName, } from './utils/spanUtils'; +export { buildSpanName } from './utils/spanNames'; export { _setSpanForScope as _INTERNAL_setSpanForScope } from './utils/spanOnScope'; export { parseSampleRate } from './utils/parseSampleRate'; export { applySdkMetadata } from './utils/sdkMetadata'; diff --git a/packages/core/src/utils/spanNames.ts b/packages/core/src/utils/spanNames.ts new file mode 100644 index 000000000000..a63aa8aaf698 --- /dev/null +++ b/packages/core/src/utils/spanNames.ts @@ -0,0 +1,39 @@ +/** + * Given an array of template strings by the format `static string {key0} string... {key1}`, + * replace the keys with the values of the data object's matching keys. + * + * Goes through the array by original order and returns the first matching template + * candidate that includes all non-empty keys. + * + * @param templates - An array of template strings by the format + * `static string {key0} string... {key1}` + * @param data - An object with the keys to be replaced in the templates. Non-string values are ignored. + * @returns The populated span name, or an empty string if no matching template is found + */ +export function buildSpanName(templates: string[], data: Record) { + const sanitizedDataKeys: string[] = []; + const sanitizedData: Record = Object.keys(data).reduce((acc: Record, key) => { + if (typeof data[key] === 'string') { + acc[key] = data[key]; + sanitizedDataKeys.push(key); + } + return acc; + }, {}); + + for (const template of templates) { + const keysInTemplate = template.match(/{([^}]+)}/g)?.map(k => k.slice(1, -1)) || []; + + if (!keysInTemplate.every(k => sanitizedDataKeys.includes(k) && sanitizedData[k]?.trim().length)) { + continue; + } + + let spanName = template; + keysInTemplate.forEach(k => { + const value = sanitizedData[k]; + // replace all matching keys (could use replaceAll once we bump language level support) + spanName = spanName.split(`{${k}}`).join(value); + }); + return spanName; + } + return ''; +} diff --git a/packages/core/test/lib/utils/spanNames.test.ts b/packages/core/test/lib/utils/spanNames.test.ts new file mode 100644 index 000000000000..656f17b906df --- /dev/null +++ b/packages/core/test/lib/utils/spanNames.test.ts @@ -0,0 +1,95 @@ +import { describe, expect, it } from 'vitest'; + +import { buildSpanName } from '../../../src/utils/spanNames'; + +describe('buildSpanName', () => { + it('returns first matching template populated with data', () => { + expect( + buildSpanName(['{method} {url}', '{method} {host}', '{method} {path}'], { + method: 'GET', + host: 'example.com', + }), + ).toBe('GET example.com'); + }); + + it('ignores matching keys with empty values', () => { + expect( + buildSpanName(['{method} {url}', '{method} {host}', '{method} {path}', 'fallback'], { + method: '', + host: 'example.com', + }), + ).toBe('fallback'); + }); + + it('returns an empty string if no templates are provided', () => { + expect(buildSpanName([], { method: 'GET', host: 'example.com' })).toBe(''); + }); + + it('returns a static template with no placeholders', () => { + expect(buildSpanName(['static name'], {})).toBe('static name'); + }); + + it('ignores keys whose values are only whitespace', () => { + expect(buildSpanName(['{method} {host}', 'fallback'], { method: ' ', host: 'example.com' })).toBe('fallback'); + }); + + it('returns an empty string when no template can be fully populated', () => { + expect(buildSpanName(['{method} {url}'], { host: 'example.com' })).toBe(''); + }); + + it('replaces repeated placeholders', () => { + expect(buildSpanName(['{method} {method}'], { method: 'GET' })).toBe('GET GET'); + }); + + it('skips templates with missing keys and uses the next viable one', () => { + expect(buildSpanName(['{method} {url}', '{method} {host}'], { method: 'GET', host: 'example.com' })).toBe( + 'GET example.com', + ); + }); + + it('ignores extra keys in data not referenced by the template', () => { + expect(buildSpanName(['{method}'], { method: 'GET', unused: 'x' })).toBe('GET'); + }); + + it('ignores non-string values', () => { + // For the moment, I think this behaviour is fine, since I'm not aware of a use case where + // we'd want create a span name from a non-string value (e.g. span attribute). If we do, + // we can easily address this by changing the behavior to stringify data values. + expect( + buildSpanName( + [ + '{number}', + '{boolean}', + '{object}', + '{array}', + '{function}', + '{symbol}', + '{bigint}', + '{throw}', + '{proxy}', + 'fallback', + ], + { + number: 123, + boolean: true, + object: { a: 1 }, + array: [1, 2, 3], + function: () => {}, + symbol: Symbol('test'), + bigint: BigInt(123), + throw: () => { + throw new Error('test'); + }, + proxy: new Proxy( + {}, + { + get: () => { + throw new Error('test'); + }, + }, + ), + }, + ), + ).toBe('fallback'); + }); +}); diff --git a/packages/opentelemetry/src/sampler.ts b/packages/opentelemetry/src/sampler.ts index be873aca3d51..b66e9ca65a87 100644 --- a/packages/opentelemetry/src/sampler.ts +++ b/packages/opentelemetry/src/sampler.ts @@ -96,7 +96,12 @@ export class SentrySampler implements Sampler { // Likewise, we only record client outcomes for child spans when streaming if (parentSampled) { if (ignoreSpans?.length) { - const { description: inferredChildName, op: childOp } = inferSpanData(spanName, spanAttributes, spanKind); + const { description: inferredChildName, op: childOp } = inferSpanData( + spanName, + spanAttributes, + spanKind, + this._isSpanStreaming, + ); if ( shouldIgnoreSpan( { @@ -136,7 +141,7 @@ export class SentrySampler implements Sampler { description: inferredSpanName, data: inferredAttributes, op, - } = inferSpanData(spanName, spanAttributes, spanKind); + } = inferSpanData(spanName, spanAttributes, spanKind, this._isSpanStreaming); const mergedAttributes = { ...inferredAttributes, diff --git a/packages/opentelemetry/src/spanExporter.ts b/packages/opentelemetry/src/spanExporter.ts index b050c92a583d..35629ea4eded 100644 --- a/packages/opentelemetry/src/spanExporter.ts +++ b/packages/opentelemetry/src/spanExporter.ts @@ -401,7 +401,15 @@ function getSpanData(span: ReadableSpan): { origin?: SpanOrigin; } { const { op: definedOp, source: definedSource, origin } = parseSpan(span); - const { op: inferredOp, description, source: inferredSource, data: inferredData } = parseSpanDescription(span); + const { + op: inferredOp, + description, + source: inferredSource, + data: inferredData, + } = parseSpanDescription( + span, + false, // the SpanExporter only exports in transaction mode, so we don't need to prefer low cardinality span names + ); const op = definedOp || inferredOp; const source = definedSource || inferredSource; diff --git a/packages/opentelemetry/src/utils/enhanceDscWithOpenTelemetryRootSpanName.ts b/packages/opentelemetry/src/utils/enhanceDscWithOpenTelemetryRootSpanName.ts index 7fb080119d3b..cf8f6efd2551 100644 --- a/packages/opentelemetry/src/utils/enhanceDscWithOpenTelemetryRootSpanName.ts +++ b/packages/opentelemetry/src/utils/enhanceDscWithOpenTelemetryRootSpanName.ts @@ -1,5 +1,5 @@ import type { Client } from '@sentry/core'; -import { hasSpansEnabled, SEMANTIC_ATTRIBUTE_SENTRY_SOURCE, spanToJSON } from '@sentry/core'; +import { hasSpansEnabled, hasSpanStreamingEnabled, SEMANTIC_ATTRIBUTE_SENTRY_SOURCE, spanToJSON } from '@sentry/core'; import { getSamplingDecision } from './getSamplingDecision'; import { parseSpanDescription } from './parseSpanDescription'; import { spanHasName } from './spanTypes'; @@ -24,7 +24,9 @@ export function enhanceDscWithOpenTelemetryRootSpanName(client: Client): void { const attributes = jsonSpan.data; const source = attributes[SEMANTIC_ATTRIBUTE_SENTRY_SOURCE]; - const { description } = spanHasName(rootSpan) ? parseSpanDescription(rootSpan) : { description: undefined }; + const { description } = spanHasName(rootSpan) + ? parseSpanDescription(rootSpan, hasSpanStreamingEnabled(client)) + : { description: undefined }; if (source !== 'url' && description) { dsc.transaction = description; } diff --git a/packages/opentelemetry/src/utils/parseSpanDescription.ts b/packages/opentelemetry/src/utils/parseSpanDescription.ts index fc0f92143516..d206deac0766 100644 --- a/packages/opentelemetry/src/utils/parseSpanDescription.ts +++ b/packages/opentelemetry/src/utils/parseSpanDescription.ts @@ -1,6 +1,7 @@ import type { Attributes, AttributeValue } from '@opentelemetry/api'; import { SpanKind } from '@opentelemetry/api'; import { + ATTR_DB_QUERY_TEXT, ATTR_DB_SYSTEM_NAME, ATTR_HTTP_REQUEST_METHOD, ATTR_HTTP_ROUTE, @@ -16,6 +17,7 @@ import { } from '@opentelemetry/semantic-conventions'; import type { SpanAttributes, TransactionSource } from '@sentry/core'; import { + buildSpanName, getSanitizedUrlString, parseUrl, SEMANTIC_ATTRIBUTE_SENTRY_CUSTOM_SPAN_NAME, @@ -39,7 +41,12 @@ interface SpanDescription { /** * Infer the op & description for a set of name, attributes and kind of a span. */ -export function inferSpanData(spanName: string, attributes: SpanAttributes, kind: SpanKind): SpanDescription { +export function inferSpanData( + spanName: string, + attributes: SpanAttributes, + kind: SpanKind, + preferLowCardinalityName?: boolean, +): SpanDescription { // if http.method exists, this is an http request span // eslint-disable-next-line deprecation/deprecation const httpMethod = attributes[ATTR_HTTP_REQUEST_METHOD] || attributes[SEMATTRS_HTTP_METHOD]; @@ -56,7 +63,11 @@ export function inferSpanData(spanName: string, attributes: SpanAttributes, kind // If db.type exists then this is a database call span // If the Redis DB is used as a cache, the span description should not be changed if (dbSystem && !opIsCache) { - return descriptionForDbSystem({ attributes, name: spanName }); + return _descriptionForDbSystem({ + attributes, + name: spanName, + lowCardinalityName: preferLowCardinalityName ?? false, + }); } const customSourceOrRoute = attributes[SEMANTIC_ATTRIBUTE_SENTRY_SOURCE] === 'custom' ? 'custom' : 'route'; @@ -103,15 +114,26 @@ export function inferSpanData(spanName: string, attributes: SpanAttributes, kind * * Based on https://github.com/open-telemetry/opentelemetry-collector-contrib/blob/7422ce2a06337f68a59b552b8c5a2ac125d6bae5/exporter/sentryexporter/sentry_exporter.go#L306 */ -export function parseSpanDescription(span: AbstractSpan): SpanDescription { +export function parseSpanDescription(span: AbstractSpan, preferLowCardinalitySpanName?: boolean): SpanDescription { const attributes = spanHasAttributes(span) ? span.attributes : {}; const name = spanHasName(span) ? span.name : ''; const kind = getSpanKind(span); - return inferSpanData(name, attributes, kind); + return inferSpanData(name, attributes, kind, preferLowCardinalitySpanName); } -function descriptionForDbSystem({ attributes, name }: { attributes: Attributes; name: string }): SpanDescription { +/** + * Only exported for tests. + */ +export function _descriptionForDbSystem({ + attributes, + name, + lowCardinalityName, +}: { + attributes: Attributes; + name: string; + lowCardinalityName: boolean; +}): SpanDescription { // if we already have a custom name, we don't overwrite it but only set the op const userDefinedName = attributes[SEMANTIC_ATTRIBUTE_SENTRY_CUSTOM_SPAN_NAME]; if (typeof userDefinedName === 'string') { @@ -127,9 +149,28 @@ function descriptionForDbSystem({ attributes, name }: { attributes: Attributes; return { op: 'db', description: name, source: 'custom' }; } - // Use DB statement (Ex "SELECT * FROM table") if possible as description. + if (lowCardinalityName) { + const lowCardSpanName = buildSpanName( + [ + '{db.query.summary}', + '{db.operation.name} {db.collection.name}', + '{db.operation.name} {db.collection.name}', + '{db.operation.name} {db.stored_procedure.name}', + '{db.operation.name} {db.namespace}', + '{db.collection.name}', + '{db.stored_procedure.name}', + '{db.namespace}', + '{db.system.name}', + 'Database operation', + ], + attributes, + ); + + return { op: 'db', description: lowCardSpanName, source: 'task' }; + } + // Use DB statement (Ex "SELECT * FROM table WHERE id = ?") if possible as description. // eslint-disable-next-line deprecation/deprecation - const statement = attributes[SEMATTRS_DB_STATEMENT]; + const statement = attributes[SEMATTRS_DB_STATEMENT] ?? attributes[ATTR_DB_QUERY_TEXT]; const description = statement ? statement.toString() : name; diff --git a/packages/opentelemetry/test/utils/parseSpanDescription.test.ts b/packages/opentelemetry/test/utils/parseSpanDescription.test.ts index 529866b8a2ac..07be31fac4ba 100644 --- a/packages/opentelemetry/test/utils/parseSpanDescription.test.ts +++ b/packages/opentelemetry/test/utils/parseSpanDescription.test.ts @@ -18,6 +18,7 @@ import { import { SEMANTIC_ATTRIBUTE_SENTRY_CUSTOM_SPAN_NAME, SEMANTIC_ATTRIBUTE_SENTRY_SOURCE } from '@sentry/core'; import { describe, expect, it } from 'vitest'; import { + _descriptionForDbSystem, descriptionForHttpMethod, getSanitizedUrl, getUserUpdatedNameAndSource, @@ -549,6 +550,46 @@ describe('descriptionForHttpMethod', () => { }); }); +describe('descriptionForDbSystem', () => { + it('returns parameterized query by default', () => { + const actual = _descriptionForDbSystem({ + attributes: { + [SEMATTRS_DB_SYSTEM]: 'mysql', + [SEMATTRS_DB_STATEMENT]: 'SELECT * from users', + }, + name: 'test name', + lowCardinalityName: false, + }); + expect(actual).toEqual({ + description: 'SELECT * from users', + op: 'db', + source: 'task', + }); + }); + + it.each([ + [{ 'db.query.summary': 'SELECT users' }, 'SELECT users'], + [{ 'db.operation.name': 'SELECT', 'db.collection.name': 'users' }, 'SELECT users'], + [{ 'db.operation.name': 'EXEC', 'db.stored_procedure.name': 'delete_alerts' }, 'EXEC delete_alerts'], + [{ 'db.operation.name': 'SELECT', 'db.namespace': 'sentry' }, 'SELECT sentry'], + [{ 'db.collection.name': 'users' }, 'users'], + [{ 'db.stored_procedure.name': 'delete_alerts' }, 'delete_alerts'], + [{ 'db.namespace': 'sentry' }, 'sentry'], + [{ 'db.system.name': 'postgres' }, 'postgres'], + ])('returns low cardinality name if lowCardinalityName is true based on attributes', (attributes, expectedName) => { + const actual = _descriptionForDbSystem({ + attributes, + name: 'test name', + lowCardinalityName: true, + }); + expect(actual).toEqual({ + description: expectedName, + op: 'db', + source: 'task', + }); + }); +}); + describe('getSanitizedUrl', () => { it.each([ [ From f37a58cceb116f3a53c2d0099d59265697c7477a Mon Sep 17 00:00:00 2001 From: Lukas Stracke Date: Wed, 17 Jun 2026 15:01:01 +0200 Subject: [PATCH 2/2] streamling and cleanup --- packages/core/src/utils/spanNames.ts | 8 ++--- .../core/test/lib/utils/spanNames.test.ts | 12 +++++++ .../src/utils/parseSpanDescription.ts | 24 ++++++++------ .../test/utils/parseSpanDescription.test.ts | 31 ++++++++++++++++++- 4 files changed, 60 insertions(+), 15 deletions(-) diff --git a/packages/core/src/utils/spanNames.ts b/packages/core/src/utils/spanNames.ts index a63aa8aaf698..e067ba668a6e 100644 --- a/packages/core/src/utils/spanNames.ts +++ b/packages/core/src/utils/spanNames.ts @@ -11,26 +11,24 @@ * @returns The populated span name, or an empty string if no matching template is found */ export function buildSpanName(templates: string[], data: Record) { - const sanitizedDataKeys: string[] = []; const sanitizedData: Record = Object.keys(data).reduce((acc: Record, key) => { if (typeof data[key] === 'string') { acc[key] = data[key]; - sanitizedDataKeys.push(key); } return acc; }, {}); for (const template of templates) { - const keysInTemplate = template.match(/{([^}]+)}/g)?.map(k => k.slice(1, -1)) || []; + const keysInTemplate = template.match(/{([^{}]+)}/g)?.map(k => k.slice(1, -1)) || []; - if (!keysInTemplate.every(k => sanitizedDataKeys.includes(k) && sanitizedData[k]?.trim().length)) { + if (!keysInTemplate.every(k => sanitizedData[k]?.trim().length)) { continue; } let spanName = template; keysInTemplate.forEach(k => { const value = sanitizedData[k]; - // replace all matching keys (could use replaceAll once we bump language level support) + // replace all matching keys (could use `.replaceAll` once we bump language level support) spanName = spanName.split(`{${k}}`).join(value); }); return spanName; diff --git a/packages/core/test/lib/utils/spanNames.test.ts b/packages/core/test/lib/utils/spanNames.test.ts index 656f17b906df..43ebd82c93c4 100644 --- a/packages/core/test/lib/utils/spanNames.test.ts +++ b/packages/core/test/lib/utils/spanNames.test.ts @@ -92,4 +92,16 @@ describe('buildSpanName', () => { ), ).toBe('fallback'); }); + + it('handles keys containing other keys', () => { + expect( + buildSpanName(['{url} {url.full} {url}'], { url: 'example.com', 'url.full': 'https://example.com/api/users' }), + ).toBe('example.com https://example.com/api/users example.com'); + expect( + buildSpanName(['{url.full} {url} {url.full}'], { + url: 'example.com', + 'url.full': 'https://example.com/api/users', + }), + ).toBe('https://example.com/api/users example.com https://example.com/api/users'); + }); }); diff --git a/packages/opentelemetry/src/utils/parseSpanDescription.ts b/packages/opentelemetry/src/utils/parseSpanDescription.ts index d206deac0766..9802fd150e20 100644 --- a/packages/opentelemetry/src/utils/parseSpanDescription.ts +++ b/packages/opentelemetry/src/utils/parseSpanDescription.ts @@ -1,7 +1,12 @@ import type { Attributes, AttributeValue } from '@opentelemetry/api'; import { SpanKind } from '@opentelemetry/api'; import { + ATTR_DB_COLLECTION_NAME, + ATTR_DB_NAMESPACE, + ATTR_DB_OPERATION_NAME, + ATTR_DB_QUERY_SUMMARY, ATTR_DB_QUERY_TEXT, + ATTR_DB_STORED_PROCEDURE_NAME, ATTR_DB_SYSTEM_NAME, ATTR_HTTP_REQUEST_METHOD, ATTR_HTTP_ROUTE, @@ -152,15 +157,16 @@ export function _descriptionForDbSystem({ if (lowCardinalityName) { const lowCardSpanName = buildSpanName( [ - '{db.query.summary}', - '{db.operation.name} {db.collection.name}', - '{db.operation.name} {db.collection.name}', - '{db.operation.name} {db.stored_procedure.name}', - '{db.operation.name} {db.namespace}', - '{db.collection.name}', - '{db.stored_procedure.name}', - '{db.namespace}', - '{db.system.name}', + `{${ATTR_DB_QUERY_SUMMARY}}`, + `{${ATTR_DB_OPERATION_NAME}} {${ATTR_DB_COLLECTION_NAME}}`, + `{${ATTR_DB_OPERATION_NAME}} {${ATTR_DB_STORED_PROCEDURE_NAME}}`, + `{${ATTR_DB_OPERATION_NAME}} {${ATTR_DB_NAMESPACE}}`, + `{${ATTR_DB_COLLECTION_NAME}}`, + `{${ATTR_DB_STORED_PROCEDURE_NAME}}`, + `{${ATTR_DB_NAMESPACE}}`, + `{${ATTR_DB_SYSTEM_NAME}}`, + // eslint-disable-next-line deprecation/deprecation deprecated alias for ATTR_DB_SYSTEM_NAME + `{${SEMATTRS_DB_SYSTEM}}`, 'Database operation', ], attributes, diff --git a/packages/opentelemetry/test/utils/parseSpanDescription.test.ts b/packages/opentelemetry/test/utils/parseSpanDescription.test.ts index 07be31fac4ba..ad48387d25d9 100644 --- a/packages/opentelemetry/test/utils/parseSpanDescription.test.ts +++ b/packages/opentelemetry/test/utils/parseSpanDescription.test.ts @@ -2,6 +2,7 @@ import type { Span } from '@opentelemetry/api'; import { SpanKind } from '@opentelemetry/api'; import { + ATTR_DB_QUERY_TEXT, ATTR_DB_SYSTEM_NAME, ATTR_HTTP_ROUTE, SEMATTRS_DB_STATEMENT, @@ -550,7 +551,7 @@ describe('descriptionForHttpMethod', () => { }); }); -describe('descriptionForDbSystem', () => { +describe('_descriptionForDbSystem', () => { it('returns parameterized query by default', () => { const actual = _descriptionForDbSystem({ attributes: { @@ -567,6 +568,21 @@ describe('descriptionForDbSystem', () => { }); }); + it('falls back to db.query.text if no attributes are present to match low cardinality name', () => { + const actual = _descriptionForDbSystem({ + attributes: { + [ATTR_DB_QUERY_TEXT]: 'SELECT * from users', + }, + name: 'test name', + lowCardinalityName: false, + }); + expect(actual).toEqual({ + description: 'SELECT * from users', + op: 'db', + source: 'task', + }); + }); + it.each([ [{ 'db.query.summary': 'SELECT users' }, 'SELECT users'], [{ 'db.operation.name': 'SELECT', 'db.collection.name': 'users' }, 'SELECT users'], @@ -588,6 +604,19 @@ describe('descriptionForDbSystem', () => { source: 'task', }); }); + + it('falls back to static string if no attributes are present to match low cardinality name', () => { + const actual = _descriptionForDbSystem({ + attributes: {}, + name: 'test name', + lowCardinalityName: true, + }); + expect(actual).toEqual({ + description: 'Database operation', + op: 'db', + source: 'task', + }); + }); }); describe('getSanitizedUrl', () => {