Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
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
1 change: 1 addition & 0 deletions packages/core/src/shared-exports.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down
37 changes: 37 additions & 0 deletions packages/core/src/utils/spanNames.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
/**
* 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<string, unknown>) {
const sanitizedData: Record<string, string> = Object.keys(data).reduce((acc: Record<string, string>, key) => {
if (typeof data[key] === 'string') {
acc[key] = data[key];
}
return acc;
}, {});

for (const template of templates) {
const keysInTemplate = template.match(/{([^{}]+)}/g)?.map(k => k.slice(1, -1)) || [];

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)
spanName = spanName.split(`{${k}}`).join(value);
});
return spanName;
}
return '';
}
107 changes: 107 additions & 0 deletions packages/core/test/lib/utils/spanNames.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,107 @@
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');
});

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');
});
});
9 changes: 7 additions & 2 deletions packages/opentelemetry/src/sampler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(
{
Expand Down Expand Up @@ -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,
Expand Down
10 changes: 9 additions & 1 deletion packages/opentelemetry/src/spanExporter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
Original file line number Diff line number Diff line change
@@ -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';
Expand All @@ -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;
}
Expand Down
61 changes: 54 additions & 7 deletions packages/opentelemetry/src/utils/parseSpanDescription.ts
Original file line number Diff line number Diff line change
@@ -1,6 +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,
Expand All @@ -16,6 +22,7 @@ import {
} from '@opentelemetry/semantic-conventions';
import type { SpanAttributes, TransactionSource } from '@sentry/core';
import {
buildSpanName,
getSanitizedUrlString,
parseUrl,
SEMANTIC_ATTRIBUTE_SENTRY_CUSTOM_SPAN_NAME,
Expand All @@ -39,7 +46,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];
Expand All @@ -56,7 +68,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';
Expand Down Expand Up @@ -103,15 +119,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 : '<unknown>';
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') {
Expand All @@ -127,9 +154,29 @@ 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(
[
`{${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,
);

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;

Expand Down
Loading
Loading