mirror of
https://github.com/SigNoz/signoz.git
synced 2026-10-26 19:11:00 +00:00
Compare commits
1 Commits
test/uplot
...
feat/semco
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
04021842f0 |
@@ -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
|
||||
|
||||
9
frontend/src/api/semconv/getMigrationReport.ts
Normal file
9
frontend/src/api/semconv/getMigrationReport.ts
Normal 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;
|
||||
@@ -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',
|
||||
|
||||
32
frontend/src/components/Semconv/SemconvEditorWarning.tsx
Normal file
32
frontend/src/components/Semconv/SemconvEditorWarning.tsx
Normal 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;
|
||||
23
frontend/src/components/Semconv/SemconvOldNameBadge.tsx
Normal file
23
frontend/src/components/Semconv/SemconvOldNameBadge.tsx
Normal 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;
|
||||
39
frontend/src/components/Semconv/__tests__/Semconv.test.tsx
Normal file
39
frontend/src/components/Semconv/__tests__/Semconv.test.tsx
Normal 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',
|
||||
);
|
||||
});
|
||||
});
|
||||
2
frontend/src/components/Semconv/index.ts
Normal file
2
frontend/src/components/Semconv/index.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export { default as SemconvEditorWarning } from './SemconvEditorWarning';
|
||||
export { default as SemconvOldNameBadge } from './SemconvOldNameBadge';
|
||||
@@ -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',
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
|
||||
@@ -87,7 +87,7 @@ export const ApiMonitoringQuickFiltersConfig: IQuickFiltersConfig[] = [
|
||||
title: 'Environment',
|
||||
|
||||
attributeKey: {
|
||||
key: 'deployment.environment',
|
||||
key: 'deployment.environment.name',
|
||||
dataType: DataTypes.String,
|
||||
type: 'resource',
|
||||
},
|
||||
|
||||
@@ -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]:
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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 */}
|
||||
|
||||
@@ -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;
|
||||
@@ -18,7 +18,7 @@ export const SAMPLE_SPAN_JSON = `{
|
||||
},
|
||||
"resource": {
|
||||
"service.name": "llm-gateway",
|
||||
"deployment.environment": "production"
|
||||
"deployment.environment.name": "production"
|
||||
}
|
||||
}`;
|
||||
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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: [],
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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',
|
||||
},
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
14
frontend/src/types/api/semconvMigration.ts
Normal file
14
frontend/src/types/api/semconvMigration.ts
Normal 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[];
|
||||
}
|
||||
29
frontend/src/utils/__tests__/semconv.test.ts
Normal file
29
frontend/src/utils/__tests__/semconv.test.ts
Normal 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([]);
|
||||
});
|
||||
});
|
||||
54
frontend/src/utils/semconv.ts
Normal file
54
frontend/src/utils/semconv.ts
Normal 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);
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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(), ¶ms); 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,
|
||||
|
||||
@@ -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{
|
||||
|
||||
131
pkg/querier/semconv_resolutions.go
Normal file
131
pkg/querier/semconv_resolutions.go
Normal 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 == '-'
|
||||
}
|
||||
118
pkg/querier/semconv_resolutions_test.go
Normal file
118
pkg/querier/semconv_resolutions_test.go
Normal 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")
|
||||
}
|
||||
148
pkg/telemetrymetadata/semconv_migration.go
Normal file
148
pkg/telemetrymetadata/semconv_migration.go
Normal 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)),
|
||||
}
|
||||
}
|
||||
@@ -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{}
|
||||
|
||||
@@ -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"`
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
9
tests/fixtures/metadata.py
vendored
9
tests/fixtures/metadata.py
vendored
@@ -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,
|
||||
|
||||
@@ -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}
|
||||
|
||||
Reference in New Issue
Block a user