Compare commits

...

1 Commits

Author SHA1 Message Date
srikanthccv
04021842f0 feat(semconv): add migration product surfaces
Product constants and defaults take the current semantic-convention
names. Span-metrics label keys keep the collector's spelling until the
metrics rename ships. The related-values table holds no metrics rows, so
the migration report covers traces and logs.

Assisted-by: Claude Fable 5.1
2026-10-02 02:34:45 +05:30
46 changed files with 1076 additions and 34 deletions

View File

@@ -7836,6 +7836,10 @@ components:
rowsScanned:
minimum: 0
type: integer
semconvResolutions:
items:
$ref: '#/components/schemas/Querybuildertypesv5SemconvResolution'
type: array
stepIntervals:
additionalProperties:
minimum: 0
@@ -8565,6 +8569,20 @@ components:
stepInterval:
$ref: '#/components/schemas/Querybuildertypesv5Step'
type: object
Querybuildertypesv5SemconvResolution:
properties:
current:
type: string
kind:
type: string
members:
items:
type: string
nullable: true
type: array
requested:
type: string
type: object
Querybuildertypesv5Step:
description: Step interval. Accepts a Go duration string (e.g., "60s", "1m",
"1h") or a number representing seconds (e.g., 60).
@@ -10372,6 +10390,42 @@ components:
- values
- complete
type: object
TelemetrytypesGettableSemconvMigrationReport:
properties:
endUnixMilli:
format: int64
type: integer
entries:
items:
$ref: '#/components/schemas/TelemetrytypesSemconvMigrationReportEntry'
nullable: true
type: array
startUnixMilli:
format: int64
type: integer
required:
- entries
type: object
TelemetrytypesSemconvMigrationReportEntry:
properties:
current:
type: string
lastSeenUnixMilli:
format: int64
type: integer
old:
type: string
resourceSets:
minimum: 0
type: integer
services:
items:
type: string
nullable: true
type: array
signal:
type: string
type: object
TelemetrytypesSignal:
enum:
- traces
@@ -12739,6 +12793,65 @@ paths:
tags:
- fields
x-signoz-stability: alpha
/api/v1/fields/semconv-migration:
get:
deprecated: false
description: Returns services that still emit old semantic-convention names
without the current family name
operationId: GetSemconvMigrationReport
parameters:
- in: query
name: startUnixMilli
schema:
format: int64
type: integer
- in: query
name: endUnixMilli
schema:
format: int64
type: integer
responses:
"200":
content:
application/json:
schema:
properties:
data:
$ref: '#/components/schemas/TelemetrytypesGettableSemconvMigrationReport'
status:
type: string
required:
- status
- data
type: object
description: OK
"401":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Unauthorized
"403":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Forbidden
"500":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Internal Server Error
security:
- api_key:
- VIEWER
- tokenizer:
- VIEWER
summary: Get semantic-convention migration report
tags:
- fields
x-signoz-stability: alpha
/api/v1/fields/values:
get:
deprecated: false

View File

@@ -0,0 +1,9 @@
import axios from 'api';
import { SemconvMigrationReport } from 'types/api/semconvMigration';
async function getSemconvMigrationReport(): Promise<SemconvMigrationReport> {
const response = await axios.get('/fields/semconv-migration');
return response.data.data;
}
export default getSemconvMigrationReport;

View File

@@ -5,7 +5,7 @@ import { ArrowUpRight } from '@signozhq/icons';
const QUICK_FILTER_DOC_PATHS: Record<string, string> = {
severity_text: 'severity-text',
'deployment.environment': 'environment',
'deployment.environment.name': 'environment',
'service.name': 'service-name',
'host.name': 'hostname',
'k8s.cluster.name': 'k8s-cluster-name',

View File

@@ -0,0 +1,32 @@
import { Alert } from 'antd';
import { findOldSemconvNames } from 'utils/semconv';
interface SemconvEditorWarningProps {
value: unknown;
editor: string;
}
function SemconvEditorWarning({
value,
editor,
}: SemconvEditorWarningProps): JSX.Element | null {
const text = typeof value === 'string' ? value : JSON.stringify(value ?? '');
const renames = findOldSemconvNames(text);
if (renames.length === 0) {
return null;
}
return (
<Alert
type="warning"
showIcon
data-testid="semconv-editor-warning"
message={`${editor} contains renamed OpenTelemetry fields`}
description={renames
.map(({ old, current }) => `${old} → ${current}`)
.join(', ')}
/>
);
}
export default SemconvEditorWarning;

View File

@@ -0,0 +1,23 @@
import { Badge } from '@signozhq/ui/badge';
import { getSemconvRename } from 'utils/semconv';
interface SemconvOldNameBadgeProps {
name: string;
}
function SemconvOldNameBadge({
name,
}: SemconvOldNameBadgeProps): JSX.Element | null {
const rename = getSemconvRename(name);
if (!rename || rename.family.kind !== 'attribute') {
return null;
}
return (
<Badge color="amber" variant="outline" data-testid="semconv-old-name-badge">
old name, renamed to {rename.current}
</Badge>
);
}
export default SemconvOldNameBadge;

View File

@@ -0,0 +1,39 @@
import { render, screen } from '@testing-library/react';
import SemconvEditorWarning from '../SemconvEditorWarning';
import SemconvOldNameBadge from '../SemconvOldNameBadge';
describe('semantic convention product hints', () => {
it('badges an old raw attribute with its current name', () => {
render(<SemconvOldNameBadge name="deployment.environment" />);
expect(screen.getByTestId('semconv-old-name-badge')).toHaveTextContent(
'old name, renamed to deployment.environment.name',
);
});
it('does not badge a current raw attribute', () => {
render(<SemconvOldNameBadge name="deployment.environment.name" />);
expect(
screen.queryByTestId('semconv-old-name-badge'),
).not.toBeInTheDocument();
});
it('shows an informational editor warning without disabling the editor', () => {
render(
<>
<input aria-label="query" defaultValue="deployment.environment = 'prod'" />
<SemconvEditorWarning
value="deployment.environment = 'prod'"
editor="ClickHouse SQL"
/>
</>,
);
expect(screen.getByLabelText('query')).not.toBeDisabled();
expect(screen.getByTestId('semconv-editor-warning')).toHaveTextContent(
'deployment.environment → deployment.environment.name',
);
});
});

View File

@@ -0,0 +1,2 @@
export { default as SemconvEditorWarning } from './SemconvEditorWarning';
export { default as SemconvOldNameBadge } from './SemconvOldNameBadge';

View File

@@ -10,7 +10,7 @@ export const AllTraceFilterKeyValue: Record<string, string> = {
durationNano: 'Duration',
duration_nano: 'Duration',
durationNanoMax: 'Duration',
'deployment.environment': 'Environment',
'deployment.environment.name': 'Environment',
hasError: 'Status',
has_error: 'Status',
serviceName: 'Service Name',
@@ -55,11 +55,11 @@ export const traceFilterKeys: Record<AllTraceFilterKeys, BaseAutocompleteData> =
id: 'serviceName--string--tag--true',
},
'deployment.environment': {
key: 'deployment.environment',
'deployment.environment.name': {
key: 'deployment.environment.name',
dataType: DataTypes.String,
type: 'resource',
id: 'deployment.environment--string--resource--false',
id: 'deployment.environment.name--string--resource--false',
},
name: {
key: 'name',

View File

@@ -155,7 +155,7 @@ function DomainList(): JSX.Element {
dataSource={DataSource.TRACES}
queryData={query}
onChange={handleSearchChange}
placeholder="Enter your filter query (e.g., deployment.environment = 'otel-demo' AND service.name = 'frontend')"
placeholder="Enter your filter query (e.g., deployment.environment.name = 'otel-demo' AND service.name = 'frontend')"
hardcodedAttributeKeys={ApiMonitoringHardcodedAttributeKeys}
/>
</div>

View File

@@ -6,9 +6,9 @@ import { SPAN_ATTRIBUTES } from './Explorer/Domains/DomainDetails/constants';
export const ApiMonitoringHardcodedAttributeKeys: QueryKeyDataSuggestionsProps[] =
[
{
label: 'deployment.environment',
label: 'deployment.environment.name',
type: 'resource',
name: 'deployment.environment',
name: 'deployment.environment.name',
signal: 'traces',
fieldDataType: QUERY_BUILDER_KEY_TYPES.STRING,
},

View File

@@ -87,7 +87,7 @@ export const ApiMonitoringQuickFiltersConfig: IQuickFiltersConfig[] = [
title: 'Environment',
attributeKey: {
key: 'deployment.environment',
key: 'deployment.environment.name',
dataType: DataTypes.String,
type: 'resource',
},

View File

@@ -128,7 +128,7 @@ export const INFRA_MONITORING_ATTR_KEYS = {
K8S_OBJECT_NAME: 'k8s.object.name',
// Environment
DEPLOYMENT_ENVIRONMENT: 'deployment.environment',
DEPLOYMENT_ENVIRONMENT: 'deployment.environment.name',
// Host System
OS_TYPE: 'os.type',
@@ -907,7 +907,7 @@ export const ENTITY_FILTER_PLACEHOLDERS: Record<InfraMonitoringEntity, string> =
[InfraMonitoringEntity.NAMESPACES]:
"Enter your filter query (e.g., k8s.namespace.name = 'production' AND k8s.cluster.name = 'prod-cluster')",
[InfraMonitoringEntity.CLUSTERS]:
"Enter your filter query (e.g., k8s.cluster.name = 'prod-cluster' AND deployment.environment = 'production')",
"Enter your filter query (e.g., k8s.cluster.name = 'prod-cluster' AND deployment.environment.name = 'production')",
[InfraMonitoringEntity.DEPLOYMENTS]:
"Enter your filter query (e.g., k8s.deployment.name = 'api-server' AND k8s.namespace.name = 'production')",
[InfraMonitoringEntity.STATEFULSETS]:

View File

@@ -2,6 +2,17 @@
color: white;
}
.semconv-migration-report {
margin-top: 32px;
display: flex;
flex-direction: column;
gap: 12px;
.ant-table-wrapper {
margin-top: 4px;
}
}
.ingestion-key-container {
margin-top: 24px;
display: flex;

View File

@@ -5,6 +5,8 @@ import getIngestionData from 'api/settings/getIngestionData';
import { useAppContext } from 'providers/App/App';
import { IngestionDataType } from 'types/api/settings/ingestion';
import SemconvMigrationReport from './SemconvMigrationReport';
import './IngestionSettings.styles.scss';
export default function IngestionSettings(): JSX.Element {
@@ -84,6 +86,7 @@ export default function IngestionSettings(): JSX.Element {
dataSource={data}
bordered
/>
<SemconvMigrationReport />
</div>
);
}

View File

@@ -83,6 +83,8 @@ import { MeterAggregateOperator } from 'types/common/queryBuilder';
import { USER_ROLES } from 'types/roles';
import { getDaysUntilExpiry } from 'utils/timeUtils';
import SemconvMigrationReport from './SemconvMigrationReport';
import './IngestionSettings.styles.scss';
const { Option } = Select;
@@ -1705,6 +1707,7 @@ function MultiIngestionSettings(): JSX.Element {
}}
className="ingestion-keys-table"
/>
<SemconvMigrationReport />
</div>
{/* Delete Key Modal */}

View File

@@ -0,0 +1,72 @@
import { useQuery } from 'react-query';
import { Alert, Table, TableColumnsType } from 'antd';
import { Typography } from '@signozhq/ui/typography';
import getSemconvMigrationReport from 'api/semconv/getMigrationReport';
import dayjs from 'dayjs';
import { SemconvMigrationReportEntry } from 'types/api/semconvMigration';
function SemconvMigrationReport(): JSX.Element {
const { data, isLoading, isError } = useQuery({
queryKey: ['semconv-migration-report'],
queryFn: getSemconvMigrationReport,
});
const columns: TableColumnsType<SemconvMigrationReportEntry> = [
{
title: 'Old name',
dataIndex: 'old',
key: 'old',
},
{
title: 'Current name',
dataIndex: 'current',
key: 'current',
},
{
title: 'Signal',
dataIndex: 'signal',
key: 'signal',
},
{
title: 'Services still sending only the old name',
dataIndex: 'services',
key: 'services',
render: (services: string[]): string => services.join(', '),
},
{
title: 'Last seen',
dataIndex: 'lastSeenUnixMilli',
key: 'lastSeenUnixMilli',
render: (value: number): string =>
dayjs(value).format('YYYY-MM-DD HH:mm:ss'),
},
];
return (
<section className="semconv-migration-report">
<Typography.Title level={4}>Semantic convention migration</Typography.Title>
<Typography.Text>
Services in this report sent an old OpenTelemetry field during the last 24
hours without sending its current replacement. Update their SDK or
instrumentation when practical; SigNoz queries remain backward compatible.
</Typography.Text>
{isError && (
<Alert
type="error"
showIcon
message="Could not load the semantic convention migration report"
/>
)}
<Table
loading={isLoading}
columns={columns}
dataSource={data?.entries ?? []}
rowKey={(entry): string => `${entry.current}-${entry.old}-${entry.signal}`}
pagination={false}
locale={{ emptyText: 'No old-only services found in the last 24 hours' }}
/>
</section>
);
}
export default SemconvMigrationReport;

View File

@@ -18,7 +18,7 @@ export const SAMPLE_SPAN_JSON = `{
},
"resource": {
"service.name": "llm-gateway",
"deployment.environment": "production"
"deployment.environment.name": "production"
}
}`;

View File

@@ -1,6 +1,7 @@
import { Divider } from '@signozhq/ui/divider';
import { TooltipSimple } from '@signozhq/ui/tooltip';
import { Typography } from '@signozhq/ui/typography';
import { SemconvOldNameBadge } from 'components/Semconv';
import { TagContainer, TagLabel, TagValue } from './FieldRenderer.styles';
import { FieldRendererProps } from './LogDetailedView.types';
@@ -28,6 +29,7 @@ function FieldRenderer({ field }: FieldRendererProps): JSX.Element {
<Typography.Text truncate={1} className="label">
{newField}{' '}
</Typography.Text>
<SemconvOldNameBadge name={newField} />
</TooltipSimple>
<div className="tags">
@@ -47,7 +49,10 @@ function FieldRenderer({ field }: FieldRendererProps): JSX.Element {
</div>
</>
) : (
<span className="label">{field}</span>
<>
<span className="label">{field}</span>
<SemconvOldNameBadge name={field} />
</>
)}
</span>
);

View File

@@ -164,7 +164,9 @@ describe('useInitialQuery - Priority-Based Resource Filtering', () => {
value: 'frontend-service',
}),
expect.objectContaining({
key: expect.objectContaining({ key: 'deployment.environment' }),
key: expect.objectContaining({
key: 'deployment.environment.name',
}),
value: 'production',
}),
expect.objectContaining({
@@ -286,7 +288,9 @@ describe('useInitialQuery - Priority-Based Resource Filtering', () => {
value: 'legacy-app',
}),
expect.objectContaining({
key: expect.objectContaining({ key: 'deployment.environment' }),
key: expect.objectContaining({
key: 'deployment.environment.name',
}),
value: 'production',
}),
expect.objectContaining({

View File

@@ -6,13 +6,14 @@ import {
TagFilterItem,
} from 'types/api/queryBuilder/queryBuilderData';
import { v4 as uuid } from 'uuid';
import { getSemconvRename } from 'utils/semconv';
const FALLBACK_STARTS_WITH_REGEX = /^(k8s|cloud|host|deployment)/; // regex to filter out resources that start with the specified keywords
const FALLBACK_CONTAINS_REGEX = /(env|service|file|container|tenant)/; // regex to filter out resources that contains the specified keywords
// Priority categories for filter selection
// Strategy:
// - Always include: service.name, deployment.environment, env, environment
// - Always include: service.name, deployment.environment.name, env, environment
// - Select ONE category only: stops at the first category with a matching attribute
// - Within category: picks the first available attribute by order
// - Order (highest to lowest priority): Kubernetes > Cloud > Host > Container
@@ -26,27 +27,36 @@ const PRIORITY_CATEGORIES = [
const SERVICE_AND_ENVIRONMENT_KEYS = [
'service.name',
'deployment.environment',
'deployment.environment.name',
'env',
'environment',
];
export const getFiltersFromResources = (
resources: ILog['resources_string'],
): TagFilterItem[] =>
Object.keys(resources).map((key: string) => {
): TagFilterItem[] => {
const items = new Map<string, TagFilterItem>();
Object.keys(resources).forEach((key: string) => {
const currentKey = getSemconvRename(key)?.current ?? key;
const resourceValue = resources[key] as string;
return {
const item = {
id: uuid(),
key: {
key,
key: currentKey,
dataType: DataTypes.String,
type: 'resource',
},
op: OPERATORS['='],
value: resourceValue,
};
// If raw data contains both names, retain the current value just like the
// backend's current-first resolver.
if (!items.has(currentKey) || key === currentKey) {
items.set(currentKey, item);
}
});
return Array.from(items.values());
};
export const isServiceOrEnvironmentAttribute = (key: string): boolean =>
SERVICE_AND_ENVIRONMENT_KEYS.includes(key);

View File

@@ -28,7 +28,7 @@ import { v4 as uuid } from 'uuid';
export const dbSystemTags: Tags[] = [
{
Key: 'db.system.(string)',
Key: 'db.system.name.(string)',
StringValues: [''],
NumberValues: [],
BoolValues: [],

View File

@@ -1,6 +1,7 @@
import { useTranslation } from 'react-i18next';
import { Form } from 'antd';
import { initialQueryBuilderFormValuesMap } from 'constants/queryBuilder';
import { SemconvEditorWarning } from 'components/Semconv';
import QueryBuilderSearchV2 from 'container/QueryBuilder/filters/QueryBuilderSearchV2/QueryBuilderSearchV2';
import isEqual from 'lodash-es/isEqual';
import { TagFilter } from 'types/api/queryBuilder/queryBuilderData';
@@ -55,6 +56,7 @@ function TagFilterInputWithLogsResultPreview({
value={value}
onChange={onChange}
/>
<SemconvEditorWarning value={value} editor="Pipeline filter" />
<div className="pipeline-filter-input-preview-container">
<LogsFilterPreview filter={value} />
</div>

View File

@@ -2,6 +2,7 @@ import { ChangeEvent, useCallback } from 'react';
import MEditor, { Monaco } from '@monaco-editor/react';
import { Color } from '@signozhq/design-tokens';
import { Input } from 'antd';
import { SemconvEditorWarning } from 'components/Semconv';
import { LEGEND } from 'constants/global';
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
import { useIsDarkMode } from 'hooks/useDarkMode';
@@ -118,6 +119,7 @@ function ClickHouseQueryBuilder({
theme={isDarkMode ? 'my-theme' : 'light'}
beforeMount={setEditorTheme}
/>
<SemconvEditorWarning value={queryData?.query} editor="ClickHouse SQL" />
<Input
onChange={handleUpdateInput}
name="legend"

View File

@@ -1,6 +1,7 @@
import { ChangeEvent, useCallback } from 'react';
import { Input } from 'antd';
import { LEGEND } from 'constants/global';
import { SemconvEditorWarning } from 'components/Semconv';
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
import { IPromQLQuery } from 'types/api/queryBuilder/queryBuilderData';
import { EQueryType } from 'types/common/dashboard';
@@ -66,6 +67,7 @@ function PromQLQueryBuilder({
style={{ marginBottom: '0.5rem' }}
data-testid="promql-query-input"
/>
<SemconvEditorWarning value={queryData?.query} editor="PromQL" />
<Input
onChange={handleUpdateQuery}

View File

@@ -40,7 +40,7 @@ export const LogsQuickFiltersConfig: IQuickFiltersConfig[] = [
type: FiltersType.CHECKBOX,
title: 'Environment',
attributeKey: {
key: 'deployment.environment',
key: 'deployment.environment.name',
dataType: DataTypes.String,
type: 'resource',
},

View File

@@ -11,6 +11,7 @@ import { Skeleton } from 'antd';
import { DetailsHeader, DetailsPanelDrawer } from 'components/DetailsPanel';
import { HeaderAction } from 'components/DetailsPanel/DetailsHeader/DetailsHeader';
import { DetailsPanelState } from 'components/DetailsPanel/types';
import { SemconvOldNameBadge } from 'components/Semconv';
import { QueryParams } from 'constants/query';
import {
initialQueryBuilderFormValuesMap,
@@ -108,6 +109,12 @@ function SpanDetailsContent({
() => getSpanDisplayData(selectedSpan),
[selectedSpan],
);
const semconvLabelSuffix = useCallback(
(fieldKey: string): React.ReactNode => (
<SemconvOldNameBadge name={fieldKey} />
),
[],
);
// Map span attribute actions to PrettyView actions format.
// Use the last key in fieldKeyPath (the actual attribute key), not the full display path.
@@ -329,6 +336,7 @@ function SpanDetailsContent({
visibleActions: VISIBLE_ACTIONS,
pinnedFieldsValue,
onPinnedFieldsChange,
labelSuffixRenderer: semconvLabelSuffix,
}}
/>
</TabsContent>

View File

@@ -29,7 +29,7 @@ export const KEY_ATTRIBUTE_KEYS: Record<string, string[]> = {
traces: [
'service.name',
'service.namespace',
'deployment.environment',
'deployment.environment.name',
'timestamp',
'duration_nano',
'kind_string',

View File

@@ -22,7 +22,7 @@ export const SPAN_CATEGORIES: readonly SpanCategory[] = [
// Map each category to the attribute key it filters on
const CATEGORY_KEYS: Record<Exclude<SpanCategory, 'All'>, string> = {
Database: 'db.system',
Database: 'db.system.name',
HTTP: 'http.method',
Functions: 'kind_string',
Jobs: 'messaging.system',
@@ -34,7 +34,7 @@ const ALL_CATEGORY_KEYS = Object.values(CATEGORY_KEYS);
// The expression clause to add for each category
const CATEGORY_EXPRESSIONS: Record<Exclude<SpanCategory, 'All'>, string> = {
Database: 'db.system exists',
Database: 'db.system.name exists',
HTTP: 'http.method exists',
Functions: "kind_string = 'Internal'",
Jobs: 'messaging.system exists',

View File

@@ -246,6 +246,12 @@
padding-left: 6px !important;
}
&__label {
display: inline-flex;
align-items: baseline;
gap: 6px;
}
&__pinned-icon {
flex-shrink: 0;
color: var(--text-robin-400);

View File

@@ -76,6 +76,7 @@ export interface PrettyViewProps {
value: unknown,
keyPath: readonly (string | number)[],
) => React.ReactNode | undefined;
labelSuffixRenderer?: (fieldKey: string) => React.ReactNode;
}
function PrettyView({
@@ -88,6 +89,7 @@ function PrettyView({
pinnedFieldsValue,
onPinnedFieldsChange,
renderLeafValue,
labelSuffixRenderer,
}: PrettyViewProps): JSX.Element {
const isDarkMode = useIsDarkMode();
const [, setCopy] = useCopyToClipboard();
@@ -318,10 +320,24 @@ function PrettyView({
}}
/>
<span>{displayKey}</span>
{labelSuffixRenderer?.(displayKey)}
</span>
);
},
[togglePin, pinnedEntries],
[togglePin, pinnedEntries, labelSuffixRenderer],
);
const labelRenderer = useCallback(
(keyPath: KeyPath): React.ReactNode => {
const displayKey = String(keyPath[0]);
return (
<span className="pretty-view__label">
<span>{displayKey}</span>
{labelSuffixRenderer?.(displayKey)}
</span>
);
},
[labelSuffixRenderer],
);
return (
@@ -364,6 +380,7 @@ function PrettyView({
shouldExpandNodeInitially={shouldExpandNodeInitially}
valueRenderer={valueRenderer}
getItemString={getItemString}
labelRenderer={labelRenderer}
/>
</div>
);

View File

@@ -0,0 +1,14 @@
export interface SemconvMigrationReportEntry {
current: string;
old: string;
signal: string;
services: string[];
resourceSets: number;
lastSeenUnixMilli: number;
}
export interface SemconvMigrationReport {
startUnixMilli: number;
endUnixMilli: number;
entries: SemconvMigrationReportEntry[];
}

View File

@@ -0,0 +1,29 @@
import { findOldSemconvNames, getSemconvRename } from 'utils/semconv';
describe('semantic convention helpers', () => {
it('returns the current name for an old attribute', () => {
expect(getSemconvRename('deployment.environment')).toMatchObject({
old: 'deployment.environment',
current: 'deployment.environment.name',
});
});
it('finds old names in editor text without matching larger custom names', () => {
expect(
findOldSemconvNames(
"deployment.environment = 'prod' AND custom.db.system.value = 'x'",
),
).toStrictEqual([
expect.objectContaining({
old: 'deployment.environment',
current: 'deployment.environment.name',
}),
]);
});
it('does not warn for current names', () => {
expect(
findOldSemconvNames('deployment.environment.name = prod'),
).toStrictEqual([]);
});
});

View File

@@ -0,0 +1,54 @@
import {
SEMCONV_FAMILIES,
SemconvFamily,
} from 'constants/generated/semconvFamilies.gen';
export type SemconvRename = {
old: string;
current: string;
family: SemconvFamily;
};
const OLD_NAMES = SEMCONV_FAMILIES.flatMap((family) =>
family.members.map((member) => ({
old: member.name,
current: family.current,
family,
})),
);
const OLD_NAME_INDEX = new Map(OLD_NAMES.map((rename) => [rename.old, rename]));
export function getSemconvRename(name: string): SemconvRename | undefined {
return OLD_NAME_INDEX.get(name);
}
export function findOldSemconvNames(text: string): SemconvRename[] {
if (!text) {
return [];
}
return OLD_NAMES.filter(({ old }) => containsSemconvName(text, old));
}
function containsSemconvName(text: string, name: string): boolean {
let offset = 0;
while (offset < text.length) {
const index = text.indexOf(name, offset);
if (index === -1) {
return false;
}
const before = index === 0 ? '' : text[index - 1];
const afterIndex = index + name.length;
const after = afterIndex === text.length ? '' : text[afterIndex];
if (!isSemconvNameCharacter(before) && !isSemconvNameCharacter(after)) {
return true;
}
offset = index + 1;
}
return false;
}
function isSemconvNameCharacter(value: string): boolean {
return /[A-Za-z0-9_.-]/.test(value);
}

View File

@@ -46,5 +46,23 @@ func (provider *provider) addFieldsRoutes(router *mux.Router) error {
return err
}
if err := router.Handle("/api/v1/fields/semconv-migration", handler.New(provider.authzMiddleware.ViewAccess(provider.fieldsHandler.GetSemconvMigrationReport), handler.OpenAPIDef{
ID: "GetSemconvMigrationReport",
Tags: []string{"fields"},
Summary: "Get semantic-convention migration report",
Description: "Returns services that still emit old semantic-convention names without the current family name",
Request: nil,
RequestQuery: new(telemetrytypes.PostableSemconvMigrationReportParams),
RequestContentType: "",
Response: new(telemetrytypes.GettableSemconvMigrationReport),
ResponseContentType: "application/json",
SuccessStatusCode: http.StatusOK,
ErrorStatusCodes: []int{},
Deprecated: false,
SecuritySchemes: newSecuritySchemes(types.RoleViewer),
})).Methods(http.MethodGet).GetError(); err != nil {
return err
}
return nil
}

View File

@@ -8,4 +8,7 @@ type Handler interface {
// Gets the fields values for the given field value selector
GetFieldsValues(http.ResponseWriter, *http.Request)
// Gets services that still emit only historical semantic-convention names.
GetSemconvMigrationReport(http.ResponseWriter, *http.Request)
}

View File

@@ -2,6 +2,7 @@ package implfields
import (
"net/http"
"time"
"github.com/SigNoz/signoz/pkg/factory"
"github.com/SigNoz/signoz/pkg/http/binding"
@@ -16,6 +17,43 @@ type handler struct {
telemetryMetadataStore telemetrytypes.MetadataStore
}
func (handler *handler) GetSemconvMigrationReport(rw http.ResponseWriter, req *http.Request) {
ctx := req.Context()
var params telemetrytypes.PostableSemconvMigrationReportParams
if err := binding.Query.BindQuery(req.URL.Query(), &params); err != nil {
render.Error(rw, err)
return
}
now := time.Now()
if params.EndUnixMilli == 0 {
params.EndUnixMilli = now.UnixMilli()
}
if params.StartUnixMilli == 0 {
params.StartUnixMilli = now.Add(-24 * time.Hour).UnixMilli()
}
claims, err := authtypes.ClaimsFromContext(ctx)
if err != nil {
render.Error(rw, err)
return
}
report, err := handler.telemetryMetadataStore.GetSemconvMigrationReport(
ctx,
valuer.MustNewUUID(claims.OrgID),
params.StartUnixMilli,
params.EndUnixMilli,
)
if err != nil {
render.Error(rw, err)
return
}
render.Success(rw, http.StatusOK, report)
}
func NewHandler(settings factory.ProviderSettings, telemetryMetadataStore telemetrytypes.MetadataStore) fields.Handler {
return &handler{
telemetryMetadataStore: telemetryMetadataStore,

View File

@@ -116,6 +116,9 @@ func (q *querier) QueryRange(ctx context.Context, orgID valuer.UUID, req *qbtype
// safely assume ms regardless of the resolution the caller sent.
req.Start = querybuilder.ToMilliSecs(req.Start)
req.End = querybuilder.ToMilliSecs(req.End)
// Key resolution rewrites parts of the builder queries, so the caller's
// spelling is read before that.
semconvResolutions := semconvResolutionsForRequest(req)
event := &qbtypes.QBEvent{
Version: "v5",
@@ -164,6 +167,7 @@ func (q *querier) QueryRange(ctx context.Context, orgID valuer.UUID, req *qbtype
qbResp, qbErr := q.run(ctx, orgID, queries, req, steps, event, preseededResults)
if qbResp != nil {
qbResp.QBEvent = event
qbResp.Meta.SemconvResolutions = semconvResolutions
if len(intervalWarnings) != 0 && req.RequestType == qbtypes.RequestTypeTimeSeries {
if qbResp.Warning == nil {
qbResp.Warning = &qbtypes.QueryWarnData{

View File

@@ -0,0 +1,131 @@
package querier
import (
"encoding/json"
"strings"
"unicode"
"github.com/SigNoz/signoz/pkg/querybuilder"
"github.com/SigNoz/signoz/pkg/semconv"
qbtypes "github.com/SigNoz/signoz/pkg/types/querybuildertypes/querybuildertypesv5"
"github.com/SigNoz/signoz/pkg/types/telemetrytypes"
)
// semconvResolutionsForRequest reports the family spellings a query builder
// request names. Raw ClickHouse SQL and PromQL are not rewritten, so they
// report nothing.
func semconvResolutionsForRequest(req *qbtypes.QueryRangeRequest) []qbtypes.SemconvResolution {
if req == nil {
return nil
}
resolutions := make([]qbtypes.SemconvResolution, 0)
seen := make(map[string]struct{})
for _, envelope := range req.CompositeQuery.Queries {
signal, metric, applies := semconvResolutionSignal(envelope.Spec)
if !applies {
continue
}
payload, err := json.Marshal(envelope.Spec)
if err != nil {
continue
}
text := string(payload)
for family := range semconv.All() {
if family.Kind() == semconv.KindMetric && signal != telemetrytypes.SignalMetrics {
continue
}
selector := telemetrytypes.FieldKeySelector{Name: family.Current(), Signal: signal, MetricContext: metric}
spellings := semconv.Members(family.Kind(), selector)
if family.Kind() == semconv.KindAttribute && signal == telemetrytypes.SignalMetrics {
spellings = querybuilder.MetricLabelSpellings(selector)
}
if len(spellings) <= 1 {
continue
}
for _, requested := range spellings {
if !containsSemconvName(text, requested) {
continue
}
identity := family.Kind().StringValue() + "\x00" + requested + "\x00" + family.Current()
if _, ok := seen[identity]; ok {
continue
}
seen[identity] = struct{}{}
resolutions = append(resolutions, qbtypes.SemconvResolution{
Requested: requested,
Current: family.Current(),
Members: append([]string{family.Current()}, family.Old()...),
Kind: family.Kind().StringValue(),
})
}
}
}
if len(resolutions) == 0 {
return nil
}
return resolutions
}
func semconvResolutionSignal(spec any) (telemetrytypes.Signal, *telemetrytypes.MetricContext, bool) {
switch query := spec.(type) {
case qbtypes.QueryBuilderQuery[qbtypes.TraceAggregation], qbtypes.QueryBuilderTraceOperator:
return telemetrytypes.SignalTraces, nil, true
case qbtypes.QueryBuilderQuery[qbtypes.LogAggregation]:
return telemetrytypes.SignalLogs, nil, true
case qbtypes.QueryBuilderQuery[qbtypes.MetricAggregation]:
var metric *telemetrytypes.MetricContext
if len(query.Aggregations) > 0 {
metric = &telemetrytypes.MetricContext{MetricName: query.Aggregations[0].MetricName}
}
return telemetrytypes.SignalMetrics, metric, true
case qbtypes.QueryBuilderJoin:
// A join can hold several signals. The unspecified signal keeps the
// family signal gates in force and admits every applicable family.
return telemetrytypes.SignalUnspecified, nil, true
default:
return telemetrytypes.SignalUnspecified, nil, false
}
}
// containsSemconvName reports whether name occurs in text as a whole key. A
// field context prefix counts as a boundary, so `resource.x` names `x`.
func containsSemconvName(text, name string) bool {
for offset := 0; offset < len(text); {
index := strings.Index(text[offset:], name)
if index < 0 {
return false
}
start := offset + index
end := start + len(name)
if hasSemconvNameStartBoundary(text, start) &&
(end == len(text) || !isSemconvNameRune(rune(text[end]))) {
return true
}
offset = start + 1
}
return false
}
func hasSemconvNameStartBoundary(text string, start int) bool {
if start == 0 || !isSemconvNameRune(rune(text[start-1])) {
return true
}
for _, qualifier := range []string{"resource.", "attribute.", "tag.", "point."} {
qualifierStart := start - len(qualifier)
if qualifierStart >= 0 && text[qualifierStart:start] == qualifier &&
(qualifierStart == 0 || !isSemconvNameRune(rune(text[qualifierStart-1]))) {
return true
}
}
return false
}
func isSemconvNameRune(r rune) bool {
return unicode.IsLetter(r) || unicode.IsDigit(r) || r == '_' || r == '.' || r == '-'
}

View File

@@ -0,0 +1,118 @@
package querier
import (
"encoding/json"
"testing"
qbtypes "github.com/SigNoz/signoz/pkg/types/querybuildertypes/querybuildertypesv5"
"github.com/SigNoz/signoz/pkg/types/telemetrytypes"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestSemconvResolutionsReportsOldTraceAttribute(t *testing.T) {
req := &qbtypes.QueryRangeRequest{
CompositeQuery: qbtypes.CompositeQuery{Queries: []qbtypes.QueryEnvelope{{
Spec: qbtypes.QueryBuilderQuery[qbtypes.TraceAggregation]{
Signal: telemetrytypes.SignalTraces,
Filter: &qbtypes.Filter{Expression: "deployment.environment = 'prod'"},
},
}}},
}
assert.Equal(t, []qbtypes.SemconvResolution{{
Requested: "deployment.environment",
Current: "deployment.environment.name",
Members: []string{"deployment.environment.name", "deployment.environment"},
Kind: "attribute",
}}, semconvResolutionsForRequest(req), "old trace attribute should be reported as a family resolution")
}
func TestSemconvResolutionsReportsContextQualifiedOldAttribute(t *testing.T) {
var req qbtypes.QueryRangeRequest
err := json.Unmarshal([]byte(`{
"start": 1,
"end": 2,
"requestType": "raw",
"compositeQuery": {
"queries": [{
"type": "builder_query",
"spec": {
"signal": "traces",
"name": "A",
"filter": {"expression": "resource.deployment.environment EXISTS"}
}
}]
}
}`), &req)
require.NoError(t, err)
assert.Equal(t, []qbtypes.SemconvResolution{{
Requested: "deployment.environment",
Current: "deployment.environment.name",
Members: []string{"deployment.environment.name", "deployment.environment"},
Kind: "attribute",
}}, semconvResolutionsForRequest(&req), "qualified old trace attribute should be reported after request decoding")
}
func TestSemconvResolutionsReportsCurrentLogAttribute(t *testing.T) {
req := &qbtypes.QueryRangeRequest{
CompositeQuery: qbtypes.CompositeQuery{Queries: []qbtypes.QueryEnvelope{{
Spec: qbtypes.QueryBuilderQuery[qbtypes.LogAggregation]{
Signal: telemetrytypes.SignalLogs,
SelectFields: []telemetrytypes.TelemetryFieldKey{{
Name: "deployment.environment.name",
}},
},
}}},
}
assert.Equal(t, []qbtypes.SemconvResolution{{
Requested: "deployment.environment.name",
Current: "deployment.environment.name",
Members: []string{"deployment.environment.name", "deployment.environment"},
Kind: "attribute",
}}, semconvResolutionsForRequest(req), "current log attribute should identify its complete family")
}
func TestSemconvResolutionsReportsSpanMetricResourceLabel(t *testing.T) {
req := &qbtypes.QueryRangeRequest{
CompositeQuery: qbtypes.CompositeQuery{Queries: []qbtypes.QueryEnvelope{{
Spec: qbtypes.QueryBuilderQuery[qbtypes.MetricAggregation]{
Signal: telemetrytypes.SignalMetrics,
Aggregations: []qbtypes.MetricAggregation{{MetricName: "signoz_calls_total"}},
Filter: &qbtypes.Filter{Expression: "resource_deployment.environment = 'prod'"},
},
}}},
}
assert.Equal(t, []qbtypes.SemconvResolution{{
Requested: "resource_deployment.environment",
Current: "deployment.environment.name",
Members: []string{"deployment.environment.name", "deployment.environment"},
Kind: "attribute",
}}, semconvResolutionsForRequest(req), "a span-metrics resource label is reported under its stored spelling")
}
func TestSemconvResolutionsIgnoresRawSQL(t *testing.T) {
req := &qbtypes.QueryRangeRequest{
CompositeQuery: qbtypes.CompositeQuery{Queries: []qbtypes.QueryEnvelope{{
Spec: qbtypes.ClickHouseQuery{Query: "SELECT attributes_string['deployment.environment']"},
}}},
}
assert.Empty(t, semconvResolutionsForRequest(req), "raw SQL is not rewritten and should not report a resolution")
}
func TestSemconvResolutionsRequiresNameBoundary(t *testing.T) {
req := &qbtypes.QueryRangeRequest{
CompositeQuery: qbtypes.CompositeQuery{Queries: []qbtypes.QueryEnvelope{{
Spec: qbtypes.QueryBuilderQuery[qbtypes.TraceAggregation]{
Signal: telemetrytypes.SignalTraces,
Filter: &qbtypes.Filter{Expression: "custom.deployment.environment = 'prod'"},
},
}}},
}
assert.Empty(t, semconvResolutionsForRequest(req), "a family name embedded in a larger custom key must not match")
}

View File

@@ -0,0 +1,148 @@
package telemetrymetadata
import (
"context"
"fmt"
"slices"
"strings"
"github.com/huandu/go-sqlbuilder"
"github.com/SigNoz/signoz/pkg/errors"
"github.com/SigNoz/signoz/pkg/semconv"
"github.com/SigNoz/signoz/pkg/types/telemetrytypes"
"github.com/SigNoz/signoz/pkg/valuer"
)
type semconvMigrationRow struct {
current string
old string
signal string
service string
resourceSets uint64
lastSeenUnixMilli int64
}
// GetSemconvMigrationReport lists the services whose metadata carries an old
// family spelling and no current one. It reads attributes_metadata, a
// deduplicated set of resource and attribute fingerprints, not the raw
// telemetry tables.
func (t *telemetryMetaStore) GetSemconvMigrationReport(
ctx context.Context,
_ valuer.UUID,
startUnixMilli, endUnixMilli int64,
) (*telemetrytypes.GettableSemconvMigrationReport, error) {
query, args := t.semconvMigrationReportQuery(startUnixMilli, endUnixMilli)
report := &telemetrytypes.GettableSemconvMigrationReport{
StartUnixMilli: startUnixMilli,
EndUnixMilli: endUnixMilli,
Entries: []*telemetrytypes.SemconvMigrationReportEntry{},
}
if query == "" {
return report, nil
}
rows, err := t.telemetrystore.ClickhouseDB().Query(ctx, query, args...)
if err != nil {
return nil, errors.Wrap(err, errors.TypeInternal, errors.CodeInternal, "failed to build semantic-convention migration report")
}
defer rows.Close()
grouped := make(map[string]*telemetrytypes.SemconvMigrationReportEntry)
serviceSets := make(map[string]map[string]struct{})
for rows.Next() {
var row semconvMigrationRow
if err := rows.Scan(&row.current, &row.old, &row.signal, &row.service, &row.resourceSets, &row.lastSeenUnixMilli); err != nil {
return nil, errors.Wrap(err, errors.TypeInternal, errors.CodeInternal, "failed to scan semantic-convention migration report")
}
identity := row.current + "\x00" + row.old + "\x00" + row.signal
entry, ok := grouped[identity]
if !ok {
entry = &telemetrytypes.SemconvMigrationReportEntry{
Current: row.current,
Old: row.old,
Signal: row.signal,
Services: []string{},
}
grouped[identity] = entry
serviceSets[identity] = make(map[string]struct{})
report.Entries = append(report.Entries, entry)
}
serviceSets[identity][row.service] = struct{}{}
entry.ResourceSets += row.resourceSets
entry.LastSeenUnixMilli = max(entry.LastSeenUnixMilli, row.lastSeenUnixMilli)
}
if err := rows.Err(); err != nil {
return nil, errors.Wrap(err, errors.TypeInternal, errors.CodeInternal, "failed to read semantic-convention migration report")
}
for identity, entry := range grouped {
for service := range serviceSets[identity] {
entry.Services = append(entry.Services, service)
}
slices.Sort(entry.Services)
}
slices.SortFunc(report.Entries, func(a, b *telemetrytypes.SemconvMigrationReportEntry) int {
return strings.Compare(a.Current+"\x00"+a.Old+"\x00"+a.Signal, b.Current+"\x00"+b.Old+"\x00"+b.Signal)
})
return report, nil
}
func (t *telemetryMetaStore) semconvMigrationReportQuery(startUnixMilli, endUnixMilli int64) (string, []any) {
builders := make([]sqlbuilder.Builder, 0)
for family := range semconv.All() {
if family.Kind() != semconv.KindAttribute {
continue
}
for _, old := range family.Old() {
signals := semconvFamilySignals(family.Current(), old)
if len(signals) == 0 {
continue
}
sb := sqlbuilder.NewSelectBuilder()
sb.Select(
fmt.Sprintf("%s AS current_name", sb.Var(family.Current())),
fmt.Sprintf("%s AS old_name", sb.Var(old)),
"data_source",
"if(empty(resource_attributes['service.name']), '<unknown>', resource_attributes['service.name']) AS service_name",
"uniqExact(tuple(resource_fingerprint, attrs_fingerprint)) AS resource_sets",
"toInt64(max(unix_milli)) AS last_seen_unix_milli",
)
sb.From(t.relatedMetadataDBName + "." + t.relatedMetadataTblName)
sb.Where(sb.GE("unix_milli", startUnixMilli))
sb.Where(sb.LE("unix_milli", endUnixMilli))
sb.Where(sb.In("data_source", signals...))
sb.Where(sb.Or(semconvMetadataPresenceConditions(sb, old)...))
sb.Where(fmt.Sprintf("NOT (%s)", sb.Or(semconvMetadataPresenceConditions(sb, family.Current())...)))
sb.GroupBy("data_source", "service_name")
builders = append(builders, sb)
}
}
if len(builders) == 0 {
return "", nil
}
return sqlbuilder.UnionAll(builders...).BuildWithFlavor(sqlbuilder.ClickHouse)
}
// semconvFamilySignals lists the signals under which old resolves to
// current. The metadata table holds traces and logs rows.
func semconvFamilySignals(current, old string) []any {
signals := make([]any, 0, 2)
for _, signal := range []telemetrytypes.Signal{telemetrytypes.SignalTraces, telemetrytypes.SignalLogs} {
members := semconv.Members(semconv.KindAttribute, telemetrytypes.FieldKeySelector{Name: old, Signal: signal})
if slices.Contains(members, current) {
signals = append(signals, signal.StringValue())
}
}
return signals
}
func semconvMetadataPresenceConditions(sb *sqlbuilder.SelectBuilder, name string) []string {
return []string{
fmt.Sprintf("mapContains(resource_attributes, %s)", sb.Var(name)),
fmt.Sprintf("mapContains(attributes, %s)", sb.Var(name)),
}
}

View File

@@ -25,10 +25,22 @@ type Result struct {
}
type ExecStats struct {
RowsScanned uint64 `json:"rowsScanned"`
BytesScanned uint64 `json:"bytesScanned"`
DurationMS uint64 `json:"durationMs"`
StepIntervals map[string]uint64 `json:"stepIntervals,omitempty"`
RowsScanned uint64 `json:"rowsScanned"`
BytesScanned uint64 `json:"bytesScanned"`
DurationMS uint64 `json:"durationMs"`
StepIntervals map[string]uint64 `json:"stepIntervals,omitempty"`
SemconvResolutions []SemconvResolution `json:"semconvResolutions,omitempty"`
}
// SemconvResolution records a semantic-convention family that the query
// builder resolved. Requested preserves the spelling supplied by the caller so
// agents and editors can update their next query without changing response
// labels in the current response.
type SemconvResolution struct {
Requested string `json:"requested"`
Current string `json:"current"`
Members []string `json:"members"`
Kind string `json:"kind"`
}
var _ jsonschema.Preparer = &ExecStats{}

View File

@@ -304,6 +304,28 @@ type GettableFieldValues struct {
Complete bool `json:"complete" required:"true"`
}
// PostableSemconvMigrationReportParams selects the metadata window used to
// find services that still emit only historical semantic-convention names.
type PostableSemconvMigrationReportParams struct {
StartUnixMilli int64 `query:"startUnixMilli"`
EndUnixMilli int64 `query:"endUnixMilli"`
}
type SemconvMigrationReportEntry struct {
Current string `json:"current"`
Old string `json:"old"`
Signal string `json:"signal"`
Services []string `json:"services"`
ResourceSets uint64 `json:"resourceSets"`
LastSeenUnixMilli int64 `json:"lastSeenUnixMilli"`
}
type GettableSemconvMigrationReport struct {
StartUnixMilli int64 `json:"startUnixMilli"`
EndUnixMilli int64 `json:"endUnixMilli"`
Entries []*SemconvMigrationReportEntry `json:"entries" required:"true"`
}
type PostableFieldValueParams struct {
PostableFieldKeysParams
Name string `query:"name"`

View File

@@ -26,6 +26,10 @@ type MetadataStore interface {
// GetAllValues returns a list of all values.
GetAllValues(ctx context.Context, orgID valuer.UUID, fieldValueSelector *FieldValueSelector) (*TelemetryFieldValues, bool, error)
// GetSemconvMigrationReport returns services whose metadata contains an old
// semantic-convention name but no current member of that family.
GetSemconvMigrationReport(ctx context.Context, orgID valuer.UUID, startUnixMilli, endUnixMilli int64) (*GettableSemconvMigrationReport, error)
// FetchTemporality fetches the temporality for metric
FetchTemporality(ctx context.Context, orgID valuer.UUID, queryTimeRangeStartTs, queryTimeRangeEndTs uint64, metricName string) (metrictypes.Temporality, error)

View File

@@ -23,7 +23,19 @@ type MockMetadataStore struct {
ColumnEvolutionMetadataMap map[string][]*telemetrytypes.EvolutionEntry
LookupKeysMap map[telemetrytypes.MetricMetadataLookupKey]int64
// StaticFields holds signal-specific intrinsic field definitions (e.g. logstelemetryschema.IntrinsicFields).
StaticFields map[string]telemetrytypes.TelemetryFieldKey
StaticFields map[string]telemetrytypes.TelemetryFieldKey
SemconvMigrationReport *telemetrytypes.GettableSemconvMigrationReport
}
func (m *MockMetadataStore) GetSemconvMigrationReport(_ context.Context, _ valuer.UUID, startUnixMilli, endUnixMilli int64) (*telemetrytypes.GettableSemconvMigrationReport, error) {
if m.SemconvMigrationReport != nil {
return m.SemconvMigrationReport, nil
}
return &telemetrytypes.GettableSemconvMigrationReport{
StartUnixMilli: startUnixMilli,
EndUnixMilli: endUnixMilli,
Entries: []*telemetrytypes.SemconvMigrationReportEntry{},
}, nil
}
// NewMockMetadataStore creates a new instance of MockMetadataStore with initialized maps.

View File

@@ -125,6 +125,15 @@ def get_field_values(signoz: types.SigNoz, token: str, params: dict, path: str =
)
def get_semconv_migration_report(signoz: types.SigNoz, token: str, params: dict) -> requests.Response:
return requests.get(
signoz.self.host_configs["8080"].get("/api/v1/fields/semconv-migration"),
timeout=30,
headers={"authorization": f"Bearer {token}"},
params=params,
)
@pytest.fixture(name="insert_attributes_metadata", scope="function")
def insert_attributes_metadata(
clickhouse: types.TestContainerClickhouse,

View File

@@ -1,12 +1,12 @@
from collections.abc import Callable
from datetime import datetime, timedelta
from datetime import UTC, datetime, timedelta
from http import HTTPStatus
import pytest
from fixtures import types
from fixtures.auth import USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD
from fixtures.metadata import AttributesMetadata, get_field_keys, get_field_values
from fixtures.metadata import AttributesMetadata, get_field_keys, get_field_values, get_semconv_migration_report
from fixtures.querier import (
RequestType,
build_aggregation,
@@ -465,3 +465,66 @@ def test_related_values_search_matches_merged_value(
# The BOTH row reads as staging, so its old production value must not
# satisfy the search.
assert set(response.json()["data"]["values"].get("relatedValues") or []) == {"production"}
def test_response_meta_reports_the_resolved_family(
signoz: types.SigNoz,
create_user_admin: None, # pylint: disable=unused-argument
get_token: Callable[[str, str], str],
family_fleet: datetime,
) -> None:
"""Raw SQL and PromQL are not rewritten, so only a builder query reports."""
token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
response = make_query_request(
signoz,
token,
start_ms=int((family_fleet - timedelta(minutes=2)).timestamp() * 1000),
end_ms=int((family_fleet + timedelta(minutes=1)).timestamp() * 1000),
request_type=RequestType.RAW,
queries=[
build_raw_query(
"A",
"traces",
limit=1,
filter_expression=f"resource.{OLD_KEY} EXISTS",
order=[build_order_by("timestamp", "asc")],
select_fields=[{"name": "span.name"}],
)
],
)
assert response.status_code == HTTPStatus.OK, response.text
assert {
"requested": OLD_KEY,
"current": CURRENT_KEY,
"members": [CURRENT_KEY, OLD_KEY],
"kind": "attribute",
} in response.json()["data"]["meta"]["semconvResolutions"]
def test_migration_report_lists_old_only_services(
signoz: types.SigNoz,
create_user_admin: None, # pylint: disable=unused-argument
get_token: Callable[[str, str], str],
insert_attributes_metadata: Callable[[list[AttributesMetadata]], None],
) -> None:
now = datetime.now(tz=UTC).replace(microsecond=0)
insert_attributes_metadata(
[
AttributesMetadata(data_source="traces", resource_attributes={"service.name": OLD, OLD_KEY: "production"}, timestamp=now),
AttributesMetadata(data_source="traces", resource_attributes={"service.name": NEW, CURRENT_KEY: "production"}, timestamp=now),
AttributesMetadata(data_source="traces", resource_attributes={"service.name": BOTH, CURRENT_KEY: "staging", OLD_KEY: "production"}, timestamp=now),
]
)
token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
response = get_semconv_migration_report(
signoz,
token,
{
"startUnixMilli": int((now - timedelta(hours=1)).timestamp() * 1000),
"endUnixMilli": int((now + timedelta(hours=1)).timestamp() * 1000),
},
)
assert response.status_code == HTTPStatus.OK, response.text
entry = next(item for item in response.json()["data"]["entries"] if item["current"] == CURRENT_KEY and item["old"] == OLD_KEY and item["signal"] == "traces")
# NEW carries the current spelling, BOTH carries both. Only OLD is behind.
assert set(entry["services"]) == {OLD}