Compare commits

...

7 Commits

Author SHA1 Message Date
SagarRajput-7
3d75731a39 feat: addressed comments 2026-04-15 16:16:19 +05:30
SagarRajput-7
43b6a14a1e Merge branch 'main' into dismissble-workspace-setting-callout 2026-04-15 15:39:48 +05:30
SagarRajput-7
09d2db297b feat: added info dismissible callout for the license row in workspace settings 2026-04-15 14:50:57 +05:30
primus-bot[bot]
be1a0fa3a5 chore(release): bump to v0.119.0 (#10936)
Some checks are pending
build-staging / js-build (push) Blocked by required conditions
build-staging / prepare (push) Waiting to run
build-staging / go-build (push) Blocked by required conditions
build-staging / staging (push) Blocked by required conditions
Release Drafter / update_release_draft (push) Waiting to run
Co-authored-by: primus-bot[bot] <171087277+primus-bot[bot]@users.noreply.github.com>
Co-authored-by: Priyanshu Shrivastava <priyanshu@signoz.io>
2026-04-15 08:35:52 +00:00
Nikhil Mantri
6ad2711c7a feat(/fields/*) : introduce a new param metricNamespace in the APIs for prefix match (#10779)
* chore: initial commit

* chore: added metricNamespace as a new param

* chore: go generate openapi, update spec

* chore: frontend yarn generate:api

* chore: added metricnamespace support in /fields/values as well as added integration tests

* chore: corrected comment

* chore: added unit tests for getMetricsKeys and getMeterSourceMetricKeys

---------

Co-authored-by: Srikanth Chekuri <srikanth.chekuri92@gmail.com>
2026-04-15 07:26:42 +00:00
swapnil-signoz
4f59cb0de3 chore: updating cloud integration agent version (#10933)
Some checks failed
build-staging / prepare (push) Has been cancelled
build-staging / js-build (push) Has been cancelled
build-staging / go-build (push) Has been cancelled
build-staging / staging (push) Has been cancelled
Release Drafter / update_release_draft (push) Has been cancelled
2026-04-15 05:01:26 +00:00
Srikanth Chekuri
304c39e08c fix(route-policy): allow undefined variables for expression (#10934)
* fix(route-policy): allow undefined variables for expression

* chore: fix lint
2026-04-15 04:55:21 +00:00
20 changed files with 936 additions and 24 deletions

View File

@@ -190,7 +190,7 @@ services:
# - ../common/clickhouse/storage.xml:/etc/clickhouse-server/config.d/storage.xml
signoz:
!!merge <<: *db-depend
image: signoz/signoz:v0.118.0
image: signoz/signoz:v0.119.0
ports:
- "8080:8080" # signoz port
# - "6060:6060" # pprof port

View File

@@ -117,7 +117,7 @@ services:
# - ../common/clickhouse/storage.xml:/etc/clickhouse-server/config.d/storage.xml
signoz:
!!merge <<: *db-depend
image: signoz/signoz:v0.118.0
image: signoz/signoz:v0.119.0
ports:
- "8080:8080" # signoz port
volumes:

View File

@@ -181,7 +181,7 @@ services:
# - ../common/clickhouse/storage.xml:/etc/clickhouse-server/config.d/storage.xml
signoz:
!!merge <<: *db-depend
image: signoz/signoz:${VERSION:-v0.118.0}
image: signoz/signoz:${VERSION:-v0.119.0}
container_name: signoz
ports:
- "8080:8080" # signoz port

View File

@@ -109,7 +109,7 @@ services:
# - ../common/clickhouse/storage.xml:/etc/clickhouse-server/config.d/storage.xml
signoz:
!!merge <<: *db-depend
image: signoz/signoz:${VERSION:-v0.118.0}
image: signoz/signoz:${VERSION:-v0.119.0}
container_name: signoz
ports:
- "8080:8080" # signoz port

View File

@@ -4465,6 +4465,10 @@ paths:
name: metricName
schema:
type: string
- in: query
name: metricNamespace
schema:
type: string
- in: query
name: searchText
schema:
@@ -4550,6 +4554,10 @@ paths:
name: metricName
schema:
type: string
- in: query
name: metricNamespace
schema:
type: string
- in: query
name: searchText
schema:
@@ -8312,6 +8320,10 @@ paths:
name: metricName
schema:
type: string
- in: query
name: metricNamespace
schema:
type: string
- in: query
name: searchText
schema:
@@ -8409,6 +8421,10 @@ paths:
name: metricName
schema:
type: string
- in: query
name: metricNamespace
schema:
type: string
- in: query
name: searchText
schema:

View File

@@ -3836,6 +3836,11 @@ export type GetFieldsKeysParams = {
* @description undefined
*/
metricName?: string;
/**
* @type string
* @description undefined
*/
metricNamespace?: string;
/**
* @type string
* @description undefined
@@ -3890,6 +3895,11 @@ export type GetFieldsValuesParams = {
* @description undefined
*/
metricName?: string;
/**
* @type string
* @description undefined
*/
metricNamespace?: string;
/**
* @type string
* @description undefined
@@ -4569,6 +4579,11 @@ export type GetRuleHistoryFilterKeysParams = {
* @description undefined
*/
metricName?: string;
/**
* @type string
* @description undefined
*/
metricNamespace?: string;
/**
* @type string
* @description undefined
@@ -4626,6 +4641,11 @@ export type GetRuleHistoryFilterValuesParams = {
* @description undefined
*/
metricName?: string;
/**
* @type string
* @description undefined
*/
metricNamespace?: string;
/**
* @type string
* @description undefined

View File

@@ -37,4 +37,5 @@ export enum LOCALSTORAGE {
SHOW_FREQUENCY_CHART = 'SHOW_FREQUENCY_CHART',
DISSMISSED_COST_METER_INFO = 'DISMISSED_COST_METER_INFO',
DISMISSED_API_KEYS_DEPRECATION_BANNER = 'DISMISSED_API_KEYS_DEPRECATION_BANNER',
LICENSE_KEY_CALLOUT_DISMISSED = 'LICENSE_KEY_CALLOUT_DISMISSED',
}

View File

@@ -38,6 +38,7 @@ import {
} from 'types/api/settings/getRetention';
import { USER_ROLES } from 'types/roles';
import LicenseRowDismissibleCallout from './LicenseKeyRow/LicenseRowDismissibleCallout/LicenseRowDismissibleCallout';
import Retention from './Retention';
import StatusMessage from './StatusMessage';
import { ActionItemsContainer, ErrorText, ErrorTextContainer } from './styles';
@@ -683,7 +684,12 @@ function GeneralSettings({
{showCustomDomainSettings && activeLicense?.key && (
<div className="custom-domain-card-divider" />
)}
{activeLicense?.key && <LicenseKeyRow />}
{activeLicense?.key && (
<>
<LicenseKeyRow />
<LicenseRowDismissibleCallout />
</>
)}
</div>
)}

View File

@@ -0,0 +1,31 @@
.license-key-callout {
margin: var(--spacing-4) var(--spacing-6);
width: auto;
.license-key-callout__description {
display: flex;
align-items: baseline;
gap: var(--spacing-2);
min-width: 0;
flex-wrap: wrap;
font-size: 13px;
}
.license-key-callout__link {
display: inline-flex;
align-items: center;
padding: 1px 5px;
border-radius: 2px;
background: var(--callout-primary-background);
color: var(--callout-primary-description);
font-family: 'SF Mono', 'Fira Code', 'Fira Mono', monospace;
font-size: var(--paragraph-base-400-font-size);
text-decoration: none;
&:hover {
background: var(--callout-primary-border);
color: var(--callout-primary-icon);
text-decoration: none;
}
}
}

View File

@@ -0,0 +1,87 @@
import { useState } from 'react';
import { Link } from 'react-router-dom';
import { Callout } from '@signozhq/callout';
import getLocalStorageApi from 'api/browser/localstorage/get';
import setLocalStorageApi from 'api/browser/localstorage/set';
import { FeatureKeys } from 'constants/features';
import { LOCALSTORAGE } from 'constants/localStorage';
import ROUTES from 'constants/routes';
import { useGetTenantLicense } from 'hooks/useGetTenantLicense';
import { useAppContext } from 'providers/App/App';
import { USER_ROLES } from 'types/roles';
import './LicenseRowDismissible.styles.scss';
function LicenseRowDismissibleCallout(): JSX.Element | null {
const [isCalloutDismissed, setIsCalloutDismissed] = useState<boolean>(
() =>
getLocalStorageApi(LOCALSTORAGE.LICENSE_KEY_CALLOUT_DISMISSED) === 'true',
);
const { user, featureFlags } = useAppContext();
const { isCloudUser } = useGetTenantLicense();
const isAdmin = user.role === USER_ROLES.ADMIN;
const isEditor = user.role === USER_ROLES.EDITOR;
const isGatewayEnabled =
featureFlags?.find((feature) => feature.name === FeatureKeys.GATEWAY)
?.active || false;
// Service accounts are only accessible to admins
const hasServiceAccountsAccess = isAdmin;
// Ingestion settings are accessible to:
// - Cloud users when gateway is not enabled
// - Admin/Editor when gateway is enabled
const hasIngestionAccess =
(isCloudUser && !isGatewayEnabled) ||
(isGatewayEnabled && (isAdmin || isEditor));
const handleDismissCallout = (): void => {
setLocalStorageApi(LOCALSTORAGE.LICENSE_KEY_CALLOUT_DISMISSED, 'true');
setIsCalloutDismissed(true);
};
return !isCalloutDismissed ? (
<Callout
type="info"
size="small"
showIcon
dismissable
onClose={handleDismissCallout}
className="license-key-callout"
description={
<div className="license-key-callout__description">
This is <strong>NOT</strong> your ingestion or Service account key.
{(hasServiceAccountsAccess || hasIngestionAccess) && (
<>
{' '}
Find your{' '}
{hasServiceAccountsAccess && (
<Link
to={ROUTES.SERVICE_ACCOUNTS_SETTINGS}
className="license-key-callout__link"
>
Service account here
</Link>
)}
{hasServiceAccountsAccess && hasIngestionAccess && ' and '}
{hasIngestionAccess && (
<Link
to={ROUTES.INGESTION_SETTINGS}
className="license-key-callout__link"
>
Ingestion key here
</Link>
)}
.
</>
)}
</div>
}
/>
) : null;
}
export default LicenseRowDismissibleCallout;

View File

@@ -0,0 +1,219 @@
import { FeatureKeys } from 'constants/features';
import { LOCALSTORAGE } from 'constants/localStorage';
import ROUTES from 'constants/routes';
import { useGetTenantLicense } from 'hooks/useGetTenantLicense';
import { render, screen, userEvent } from 'tests/test-utils';
import { USER_ROLES } from 'types/roles';
import LicenseRowDismissibleCallout from '../LicenseRowDismissibleCallout';
const getDescription = (): HTMLElement =>
screen.getByText(
(_, el) =>
el?.classList?.contains('license-key-callout__description') ?? false,
);
const queryDescription = (): HTMLElement | null =>
screen.queryByText(
(_, el) =>
el?.classList?.contains('license-key-callout__description') ?? false,
);
jest.mock('hooks/useGetTenantLicense', () => ({
useGetTenantLicense: jest.fn(),
}));
const mockLicense = (isCloudUser: boolean): void => {
(useGetTenantLicense as jest.Mock).mockReturnValue({
isCloudUser,
isEnterpriseSelfHostedUser: !isCloudUser,
isCommunityUser: false,
isCommunityEnterpriseUser: false,
});
};
const renderCallout = (
role: string,
isCloudUser: boolean,
gatewayActive: boolean,
): void => {
mockLicense(isCloudUser);
render(
<LicenseRowDismissibleCallout />,
{},
{
role,
appContextOverrides: {
featureFlags: [
{
name: FeatureKeys.GATEWAY,
active: gatewayActive,
usage: 0,
usage_limit: -1,
route: '',
},
],
},
},
);
};
describe('LicenseRowDismissibleCallout', () => {
beforeEach(() => {
localStorage.clear();
jest.clearAllMocks();
});
describe('callout content per access level', () => {
it.each([
{
scenario: 'viewer, non-cloud, gateway off — base text only, no links',
role: USER_ROLES.VIEWER,
isCloudUser: false,
gatewayActive: false,
serviceAccountLink: false,
ingestionLink: false,
expectedText: 'This is NOT your ingestion or Service account key.',
},
{
scenario: 'admin, non-cloud, gateway off — service accounts link only',
role: USER_ROLES.ADMIN,
isCloudUser: false,
gatewayActive: false,
serviceAccountLink: true,
ingestionLink: false,
expectedText:
'This is NOT your ingestion or Service account key. Find your Service account here.',
},
{
scenario: 'viewer, cloud, gateway off — ingestion link only',
role: USER_ROLES.VIEWER,
isCloudUser: true,
gatewayActive: false,
serviceAccountLink: false,
ingestionLink: true,
expectedText:
'This is NOT your ingestion or Service account key. Find your Ingestion key here.',
},
{
scenario: 'admin, cloud, gateway off — both links',
role: USER_ROLES.ADMIN,
isCloudUser: true,
gatewayActive: false,
serviceAccountLink: true,
ingestionLink: true,
expectedText:
'This is NOT your ingestion or Service account key. Find your Service account here and Ingestion key here.',
},
{
scenario: 'admin, non-cloud, gateway on — both links',
role: USER_ROLES.ADMIN,
isCloudUser: false,
gatewayActive: true,
serviceAccountLink: true,
ingestionLink: true,
expectedText:
'This is NOT your ingestion or Service account key. Find your Service account here and Ingestion key here.',
},
{
scenario: 'editor, non-cloud, gateway on — ingestion link only',
role: USER_ROLES.EDITOR,
isCloudUser: false,
gatewayActive: true,
serviceAccountLink: false,
ingestionLink: true,
expectedText:
'This is NOT your ingestion or Service account key. Find your Ingestion key here.',
},
])(
'$scenario',
({
role,
isCloudUser,
gatewayActive,
serviceAccountLink,
ingestionLink,
expectedText,
}) => {
renderCallout(role, isCloudUser, gatewayActive);
const description = getDescription();
expect(description).toBeInTheDocument();
expect(description).toHaveTextContent(expectedText);
if (serviceAccountLink) {
expect(
screen.getByRole('link', { name: /Service account here/ }),
).toBeInTheDocument();
} else {
expect(
screen.queryByRole('link', { name: /Service account here/ }),
).not.toBeInTheDocument();
}
if (ingestionLink) {
expect(
screen.getByRole('link', { name: /Ingestion key here/ }),
).toBeInTheDocument();
} else {
expect(
screen.queryByRole('link', { name: /Ingestion key here/ }),
).not.toBeInTheDocument();
}
},
);
});
describe('Link routing', () => {
it('should link to service accounts settings', () => {
renderCallout(USER_ROLES.ADMIN, false, false);
const link = screen.getByRole('link', {
name: /Service account here/,
}) as HTMLAnchorElement;
expect(link.getAttribute('href')).toBe(ROUTES.SERVICE_ACCOUNTS_SETTINGS);
});
it('should link to ingestion settings', () => {
renderCallout(USER_ROLES.VIEWER, true, false);
const link = screen.getByRole('link', {
name: /Ingestion key here/,
}) as HTMLAnchorElement;
expect(link.getAttribute('href')).toBe(ROUTES.INGESTION_SETTINGS);
});
});
describe('Dismissal functionality', () => {
it('should hide callout when dismiss button is clicked', async () => {
const user = userEvent.setup();
renderCallout(USER_ROLES.ADMIN, false, false);
expect(getDescription()).toBeInTheDocument();
await user.click(screen.getByRole('button'));
expect(queryDescription()).not.toBeInTheDocument();
});
it('should persist dismissal in localStorage', async () => {
const user = userEvent.setup();
renderCallout(USER_ROLES.ADMIN, false, false);
await user.click(screen.getByRole('button'));
expect(
localStorage.getItem(LOCALSTORAGE.LICENSE_KEY_CALLOUT_DISMISSED),
).toBe('true');
});
it('should not render when localStorage dismissal is set', () => {
localStorage.setItem(LOCALSTORAGE.LICENSE_KEY_CALLOUT_DISMISSED, 'true');
renderCallout(USER_ROLES.ADMIN, false, false);
expect(queryDescription()).not.toBeInTheDocument();
});
});
});

View File

@@ -223,6 +223,8 @@ func (r *provider) Match(ctx context.Context, orgID string, ruleID string, set m
for _, route := range expressionRoutes {
evaluateExpr, err := r.evaluateExpr(ctx, route.Expression, set)
if err != nil {
//nolint:sloglint
r.settings.Logger().WarnContext(ctx, "failed to evaluate route policy expression", errors.Attr(err), slog.String("rule.id", ruleID))
continue
}
if evaluateExpr {
@@ -298,7 +300,7 @@ func (r *provider) convertLabelSetToEnv(ctx context.Context, labelSet model.Labe
func (r *provider) evaluateExpr(ctx context.Context, expression string, labelSet model.LabelSet) (bool, error) {
env := r.convertLabelSetToEnv(ctx, labelSet)
program, err := expr.Compile(expression, expr.Env(env))
program, err := expr.Compile(expression, expr.Env(env), expr.AllowUndefinedVariables())
if err != nil {
return false, errors.NewInternalf(errors.CodeInternal, "error compiling route policy %s: %v", expression, err)
}

View File

@@ -644,6 +644,22 @@ func TestProvider_EvaluateExpression(t *testing.T) {
},
expected: true,
},
{
name: "nonexistent key OR check",
expression: `threshold.name = 'warning' OR ruleId = 'rule1'`,
labelSet: model.LabelSet{
"threshold.name": "warning",
},
expected: true,
},
{
name: "nonexistent key && check",
expression: `threshold.name = 'warning' && nonexistent = 'auth'`,
labelSet: model.LabelSet{
"threshold.name": "warning",
},
expected: false,
},
}
for _, tt := range tests {

View File

@@ -22,7 +22,7 @@ func newConfig() factory.Config {
Agent: AgentConfig{
// we will maintain the latest version of cloud integration agent from here,
// till we automate it externally or figure out a way to validate it.
Version: "v0.0.8",
Version: "v0.0.9",
},
}
}

View File

@@ -889,7 +889,12 @@ func (t *telemetryMetaStore) getMetricsKeys(ctx context.Context, fieldKeySelecto
// }
if fieldKeySelector.MetricContext != nil {
fieldConds = append(fieldConds, sb.E("metric_name", fieldKeySelector.MetricContext.MetricName))
if fieldKeySelector.MetricContext.MetricName != "" {
fieldConds = append(fieldConds, sb.E("metric_name", fieldKeySelector.MetricContext.MetricName))
}
if fieldKeySelector.MetricContext.MetricNamespace != "" {
fieldConds = append(fieldConds, sb.Like("metric_name", escapeForLike(fieldKeySelector.MetricContext.MetricNamespace)+"%"))
}
}
conds = append(conds, sb.And(fieldConds...))
@@ -977,7 +982,12 @@ func (t *telemetryMetaStore) getMeterSourceMetricKeys(ctx context.Context, field
fieldConds = append(fieldConds, sb.NotLike("attr_name", "\\_\\_%"))
if fieldKeySelector.MetricContext != nil {
fieldConds = append(fieldConds, sb.E("metric_name", fieldKeySelector.MetricContext.MetricName))
if fieldKeySelector.MetricContext.MetricName != "" {
fieldConds = append(fieldConds, sb.E("metric_name", fieldKeySelector.MetricContext.MetricName))
}
if fieldKeySelector.MetricContext.MetricNamespace != "" {
fieldConds = append(fieldConds, sb.Like("metric_name", escapeForLike(fieldKeySelector.MetricContext.MetricNamespace)+"%"))
}
}
conds = append(conds, sb.And(fieldConds...))
@@ -1071,8 +1081,8 @@ func enrichWithIntrinsicMetricKeys(keys map[string][]*telemetrytypes.TelemetryFi
if selector.Signal != telemetrytypes.SignalMetrics && selector.Signal != telemetrytypes.SignalUnspecified {
continue
}
// If a metricName is provided, dont surface intrinsic metric keys
if selector.MetricContext != nil && selector.MetricContext.MetricName != "" {
// If metric filters are provided, do not surface intrinsic metric keys.
if selector.MetricContext != nil && (selector.MetricContext.MetricName != "" || selector.MetricContext.MetricNamespace != "") {
continue
}
@@ -1728,9 +1738,12 @@ func (t *telemetryMetaStore) getMetricFieldValues(ctx context.Context, fieldValu
sb.Where(sb.E("attr_datatype", fieldValueSelector.FieldDataType.TagDataType()))
}
if fieldValueSelector.MetricContext != nil {
if fieldValueSelector.MetricContext != nil && fieldValueSelector.MetricContext.MetricName != "" {
sb.Where(sb.E("metric_name", fieldValueSelector.MetricContext.MetricName))
}
if fieldValueSelector.MetricContext != nil && fieldValueSelector.MetricContext.MetricNamespace != "" {
sb.Where(sb.Like("metric_name", escapeForLike(fieldValueSelector.MetricContext.MetricNamespace)+"%"))
}
if fieldValueSelector.StartUnixMilli > 0 {
sb.Where(sb.GE("last_reported_unix_milli", fieldValueSelector.StartUnixMilli))
@@ -1812,6 +1825,9 @@ func (t *telemetryMetaStore) getIntrinsicMetricFieldValues(ctx context.Context,
if fieldValueSelector.MetricContext != nil && fieldValueSelector.MetricContext.MetricName != "" {
sb.Where(sb.E("metric_name", fieldValueSelector.MetricContext.MetricName))
}
if fieldValueSelector.MetricContext != nil && fieldValueSelector.MetricContext.MetricNamespace != "" {
sb.Where(sb.Like("metric_name", escapeForLike(fieldValueSelector.MetricContext.MetricNamespace)+"%"))
}
if fieldValueSelector.StartUnixMilli > 0 {
sb.Where(sb.GE("unix_milli", fieldValueSelector.StartUnixMilli))
@@ -1869,6 +1885,13 @@ func (t *telemetryMetaStore) getMeterSourceMetricFieldValues(ctx context.Context
}
sb.Where(sb.NotLike("attr.1", "\\_\\_%"))
if fieldValueSelector.MetricContext != nil && fieldValueSelector.MetricContext.MetricName != "" {
sb.Where(sb.E("metric_name", fieldValueSelector.MetricContext.MetricName))
}
if fieldValueSelector.MetricContext != nil && fieldValueSelector.MetricContext.MetricNamespace != "" {
sb.Where(sb.Like("metric_name", escapeForLike(fieldValueSelector.MetricContext.MetricNamespace)+"%"))
}
if fieldValueSelector.Value != "" {
if fieldValueSelector.SelectorMatchType == telemetrytypes.FieldSelectorMatchTypeExact {
sb.Where(sb.E("attr.2", fieldValueSelector.Value))

View File

@@ -320,6 +320,20 @@ func TestEnrichWithIntrinsicMetricKeys(t *testing.T) {
},
)
assert.NotContains(t, result, "metric_name")
result = enrichWithIntrinsicMetricKeys(
map[string][]*telemetrytypes.TelemetryFieldKey{},
[]*telemetrytypes.FieldKeySelector{
{
Signal: telemetrytypes.SignalMetrics,
MetricContext: &telemetrytypes.MetricContext{
MetricNamespace: "system.cpu",
},
SelectorMatchType: telemetrytypes.FieldSelectorMatchTypeFuzzy,
},
},
)
assert.NotContains(t, result, "metric_name")
}
func TestGetMetricFieldValuesIntrinsicMetricName(t *testing.T) {
@@ -392,3 +406,174 @@ func TestGetMetricFieldValuesIntrinsicBoolReturnsEmpty(t *testing.T) {
assert.Empty(t, values.BoolValues)
require.NoError(t, mock.ExpectationsWereMet())
}
func TestGetMetricFieldValuesAppliesMetricNamespace(t *testing.T) {
mockTelemetryStore := telemetrystoretest.New(telemetrystore.Config{}, &regexMatcher{})
mock := mockTelemetryStore.Mock()
metadata := newTestTelemetryMetaStoreTestHelper(mockTelemetryStore)
valueRows := cmock.NewRows([]cmock.ColumnType{
{Name: "attr_string_value", Type: "String"},
}, [][]any{{"value.a"}})
mock.ExpectQuery(regexp.QuoteMeta("SELECT DISTINCT attr_string_value FROM signoz_metrics.distributed_metadata WHERE attr_name = ? AND metric_name LIKE ? LIMIT ?")).
WithArgs("custom_key", "system.cpu%", 11).
WillReturnRows(valueRows)
values, complete, err := metadata.(*telemetryMetaStore).getMetricFieldValues(context.Background(), &telemetrytypes.FieldValueSelector{
FieldKeySelector: &telemetrytypes.FieldKeySelector{
Signal: telemetrytypes.SignalMetrics,
Name: "custom_key",
Limit: 10,
SelectorMatchType: telemetrytypes.FieldSelectorMatchTypeFuzzy,
MetricContext: &telemetrytypes.MetricContext{
MetricNamespace: "system.cpu",
},
},
Limit: 10,
})
require.NoError(t, err)
assert.True(t, complete)
assert.ElementsMatch(t, []string{"value.a"}, values.StringValues)
require.NoError(t, mock.ExpectationsWereMet())
}
func TestGetMetricFieldValuesIntrinsicMetricNameAppliesMetricNamespace(t *testing.T) {
mockTelemetryStore := telemetrystoretest.New(telemetrystore.Config{}, &regexMatcher{})
mock := mockTelemetryStore.Mock()
metadata := newTestTelemetryMetaStoreTestHelper(mockTelemetryStore)
valueRows := cmock.NewRows([]cmock.ColumnType{
{Name: "metric_name", Type: "String"},
}, [][]any{{"system.cpu.utilization"}})
mock.ExpectQuery(regexp.QuoteMeta("SELECT metric_name FROM signoz_metrics.distributed_time_series_v4_1week WHERE metric_name LIKE ? GROUP BY metric_name LIMIT ?")).
WithArgs("system.cpu%", 51).
WillReturnRows(valueRows)
metadataRows := cmock.NewRows([]cmock.ColumnType{
{Name: "attr_string_value", Type: "String"},
}, [][]any{})
mock.ExpectQuery(regexp.QuoteMeta("SELECT DISTINCT attr_string_value FROM signoz_metrics.distributed_metadata WHERE attr_name = ? AND metric_name LIKE ? LIMIT ?")).
WithArgs("metric_name", "system.cpu%", 50).
WillReturnRows(metadataRows)
values, complete, err := metadata.(*telemetryMetaStore).getMetricFieldValues(context.Background(), &telemetrytypes.FieldValueSelector{
FieldKeySelector: &telemetrytypes.FieldKeySelector{
Signal: telemetrytypes.SignalMetrics,
Name: "metric_name",
Limit: 50,
SelectorMatchType: telemetrytypes.FieldSelectorMatchTypeFuzzy,
MetricContext: &telemetrytypes.MetricContext{
MetricNamespace: "system.cpu",
},
},
Limit: 50,
})
require.NoError(t, err)
assert.True(t, complete)
assert.ElementsMatch(t, []string{"system.cpu.utilization"}, values.StringValues)
require.NoError(t, mock.ExpectationsWereMet())
}
func TestGetMeterSourceMetricFieldValuesAppliesMetricNamespace(t *testing.T) {
mockTelemetryStore := telemetrystoretest.New(telemetrystore.Config{}, &regexMatcher{})
mock := mockTelemetryStore.Mock()
metadata := newTestTelemetryMetaStoreTestHelper(mockTelemetryStore)
rows := cmock.NewRows([]cmock.ColumnType{
{Name: "attr", Type: "Array(String)"},
}, [][]any{{[]string{"service.name", "frontend"}}})
mock.ExpectQuery(`SELECT .*distributed_samples_agg_1d.*metric_name LIKE .*`).
WithArgs("service.name", "\\_\\_%", "system.cpu%", "", 11).
WillReturnRows(rows)
values, complete, err := metadata.(*telemetryMetaStore).getMeterSourceMetricFieldValues(context.Background(), &telemetrytypes.FieldValueSelector{
FieldKeySelector: &telemetrytypes.FieldKeySelector{
Signal: telemetrytypes.SignalMetrics,
Source: telemetrytypes.SourceMeter,
Name: "service.name",
MetricContext: &telemetrytypes.MetricContext{
MetricNamespace: "system.cpu",
},
},
Limit: 10,
})
require.NoError(t, err)
assert.True(t, complete)
assert.ElementsMatch(t, []string{"frontend"}, values.StringValues)
require.NoError(t, mock.ExpectationsWereMet())
}
func TestGetMetricsKeysAppliesMetricNamespace(t *testing.T) {
mockTelemetryStore := telemetrystoretest.New(telemetrystore.Config{}, &regexMatcher{})
mock := mockTelemetryStore.Mock()
metadata := newTestTelemetryMetaStoreTestHelper(mockTelemetryStore)
rows := cmock.NewRows([]cmock.ColumnType{
{Name: "name", Type: "String"},
{Name: "field_context", Type: "String"},
{Name: "field_data_type", Type: "String"},
{Name: "priority", Type: "UInt8"},
}, [][]any{{"service.name", "resource", "String", 1}})
mock.ExpectQuery(`(?s)SELECT.*distributed_metadata.*metric_name LIKE.*`).
WithArgs("%service%", "\\_\\_%", "system.cpu%", 11).
WillReturnRows(rows)
keys, complete, err := metadata.(*telemetryMetaStore).getMetricsKeys(context.Background(), []*telemetrytypes.FieldKeySelector{
{
Signal: telemetrytypes.SignalMetrics,
Name: "service",
Limit: 10,
SelectorMatchType: telemetrytypes.FieldSelectorMatchTypeFuzzy,
MetricContext: &telemetrytypes.MetricContext{
MetricNamespace: "system.cpu",
},
},
})
require.NoError(t, err)
assert.True(t, complete)
assert.Len(t, keys, 1)
assert.Equal(t, "service.name", keys[0].Name)
require.NoError(t, mock.ExpectationsWereMet())
}
func TestGetMeterSourceMetricKeysAppliesMetricNamespace(t *testing.T) {
mockTelemetryStore := telemetrystoretest.New(telemetrystore.Config{}, &regexMatcher{})
mock := mockTelemetryStore.Mock()
metadata := newTestTelemetryMetaStoreTestHelper(mockTelemetryStore)
rows := cmock.NewRows([]cmock.ColumnType{
{Name: "attr_name", Type: "String"},
}, [][]any{{"service.name"}})
mock.ExpectQuery(`SELECT.*distributed_samples_agg_1d.*metric_name LIKE.*`).
WithArgs("%service%", "\\_\\_%", "system.cpu%", 10).
WillReturnRows(rows)
keys, complete, err := metadata.(*telemetryMetaStore).getMeterSourceMetricKeys(context.Background(), []*telemetrytypes.FieldKeySelector{
{
Signal: telemetrytypes.SignalMetrics,
Source: telemetrytypes.SourceMeter,
Name: "service",
Limit: 10,
SelectorMatchType: telemetrytypes.FieldSelectorMatchTypeFuzzy,
MetricContext: &telemetrytypes.MetricContext{
MetricNamespace: "system.cpu",
},
},
})
require.NoError(t, err)
assert.True(t, complete)
assert.Len(t, keys, 1)
assert.Equal(t, "service.name", keys[0].Name)
require.NoError(t, mock.ExpectationsWereMet())
}

View File

@@ -268,7 +268,8 @@ func (t *TelemetryFieldValues) NumValues() int {
}
type MetricContext struct {
MetricName string `json:"metricName"`
MetricName string `json:"metricName"`
MetricNamespace string `json:"metricNamespace,omitempty"`
}
type FieldKeySelector struct {
@@ -297,15 +298,16 @@ type GettableFieldKeys struct {
}
type PostableFieldKeysParams struct {
Signal Signal `query:"signal"`
Source Source `query:"source"`
Limit int `query:"limit"`
StartUnixMilli int64 `query:"startUnixMilli"`
EndUnixMilli int64 `query:"endUnixMilli"`
FieldContext FieldContext `query:"fieldContext"`
FieldDataType FieldDataType `query:"fieldDataType"`
MetricName string `query:"metricName"`
SearchText string `query:"searchText"`
Signal Signal `query:"signal"`
Source Source `query:"source"`
Limit int `query:"limit"`
StartUnixMilli int64 `query:"startUnixMilli"`
EndUnixMilli int64 `query:"endUnixMilli"`
FieldContext FieldContext `query:"fieldContext"`
FieldDataType FieldDataType `query:"fieldDataType"`
MetricName string `query:"metricName"`
MetricNamespace string `query:"metricNamespace"`
SearchText string `query:"searchText"`
}
type GettableFieldValues struct {
@@ -344,9 +346,10 @@ func NewFieldKeySelectorFromPostableFieldKeysParams(params PostableFieldKeysPara
req.Limit = 1000
}
if params.MetricName != "" {
if params.MetricName != "" || params.MetricNamespace != "" {
req.MetricContext = &MetricContext{
MetricName: params.MetricName,
MetricName: params.MetricName,
MetricNamespace: params.MetricNamespace,
}
}

View File

@@ -394,3 +394,20 @@ func TestNormalize(t *testing.T) {
})
}
}
func TestNewFieldKeySelectorFromPostableFieldKeysParamsMetricNamespace(t *testing.T) {
selector := NewFieldKeySelectorFromPostableFieldKeysParams(PostableFieldKeysParams{
Signal: SignalMetrics,
MetricNamespace: "system.cpu",
})
if selector.MetricContext == nil {
t.Fatalf("expected metric context to be set")
}
if selector.MetricContext.MetricNamespace != "system.cpu" {
t.Fatalf("expected metric namespace to be propagated, got %q", selector.MetricContext.MetricNamespace)
}
if selector.MetricContext.MetricName != "" {
t.Fatalf("expected metric name to remain empty, got %q", selector.MetricContext.MetricName)
}
}

View File

@@ -658,3 +658,211 @@ def test_non_existent_metrics_returns_404(
get_error_message(response.json())
== "could not find the metric whatevergoennnsgoeshere"
)
# Verify /api/v1/fields/values filters label values by metricNamespace prefix.
# Inserts metrics under ns.a and ns.b, then asserts a specific prefix returns
# only matching values while a common prefix returns both.
def test_metric_namespace_values_filtering(
signoz: types.SigNoz,
create_user_admin: None, # pylint: disable=unused-argument
get_token: Callable[[str, str], str],
insert_metrics: Callable[[List[Metrics]], None],
) -> None:
now = datetime.now(tz=timezone.utc).replace(second=0, microsecond=0)
metrics: List[Metrics] = [
Metrics(
metric_name="ns.a.requests_total",
labels={"service": "svc-a"},
timestamp=now - timedelta(minutes=2),
value=10.0,
),
Metrics(
metric_name="ns.b.requests_total",
labels={"service": "svc-b"},
timestamp=now - timedelta(minutes=2),
value=20.0,
),
]
insert_metrics(metrics)
token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
# Specific prefix: metricNamespace=ns.a should return only svc-a
response = requests.get(
signoz.self.host_configs["8080"].get("/api/v1/fields/values"),
timeout=5,
headers={"authorization": f"Bearer {token}"},
params={
"signal": "metrics",
"name": "service",
"searchText": "",
"metricNamespace": "ns.a",
},
)
assert response.status_code == HTTPStatus.OK
assert response.json()["status"] == "success"
values = response.json()["data"]["values"]["stringValues"]
assert "svc-a" in values
assert "svc-b" not in values
# Common prefix: metricNamespace=ns should return both
response = requests.get(
signoz.self.host_configs["8080"].get("/api/v1/fields/values"),
timeout=5,
headers={"authorization": f"Bearer {token}"},
params={
"signal": "metrics",
"name": "service",
"searchText": "",
"metricNamespace": "ns",
},
)
assert response.status_code == HTTPStatus.OK
assert response.json()["status"] == "success"
values = response.json()["data"]["values"]["stringValues"]
assert "svc-a" in values
assert "svc-b" in values
# Verify /api/v1/fields/values with name=metric_name filters metric names by
# metricNamespace prefix. A specific prefix returns only its metric names;
# a common prefix returns metric names from all matching namespaces.
def test_metric_namespace_metric_name_values_filtering(
signoz: types.SigNoz,
create_user_admin: None, # pylint: disable=unused-argument
get_token: Callable[[str, str], str],
insert_metrics: Callable[[List[Metrics]], None],
) -> None:
now = datetime.now(tz=timezone.utc).replace(second=0, microsecond=0)
metrics: List[Metrics] = [
Metrics(
metric_name="ns.a.cpu.utilization",
labels={"host": "host-a"},
timestamp=now - timedelta(minutes=2),
value=50.0,
),
Metrics(
metric_name="ns.b.cpu.utilization",
labels={"host": "host-b"},
timestamp=now - timedelta(minutes=2),
value=60.0,
),
]
insert_metrics(metrics)
token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
# Specific prefix: metricNamespace=ns.a should return only ns.a.* metric names
response = requests.get(
signoz.self.host_configs["8080"].get("/api/v1/fields/values"),
timeout=5,
headers={"authorization": f"Bearer {token}"},
params={
"signal": "metrics",
"name": "metric_name",
"searchText": "",
"metricNamespace": "ns.a",
},
)
assert response.status_code == HTTPStatus.OK
assert response.json()["status"] == "success"
values = response.json()["data"]["values"]["stringValues"]
assert "ns.a.cpu.utilization" in values
assert "ns.b.cpu.utilization" not in values
# Common prefix: metricNamespace=ns should return both
response = requests.get(
signoz.self.host_configs["8080"].get("/api/v1/fields/values"),
timeout=5,
headers={"authorization": f"Bearer {token}"},
params={
"signal": "metrics",
"name": "metric_name",
"searchText": "",
"metricNamespace": "ns",
},
)
assert response.status_code == HTTPStatus.OK
assert response.json()["status"] == "success"
values = response.json()["data"]["values"]["stringValues"]
assert "ns.a.cpu.utilization" in values
assert "ns.b.cpu.utilization" in values
# Verify /api/v1/fields/keys filters attribute keys by metricNamespace prefix.
# Metrics under ns.a and ns.b carry distinct labels; a specific prefix returns
# only its keys while a common prefix returns keys from both namespaces.
def test_metric_namespace_keys_filtering(
signoz: types.SigNoz,
create_user_admin: None, # pylint: disable=unused-argument
get_token: Callable[[str, str], str],
insert_metrics: Callable[[List[Metrics]], None],
) -> None:
now = datetime.now(tz=timezone.utc).replace(second=0, microsecond=0)
metrics: List[Metrics] = [
Metrics(
metric_name="ns.a.cpu.utilization",
labels={"a_only_label": "val-a"},
timestamp=now - timedelta(minutes=2),
value=10.0,
),
Metrics(
metric_name="ns.b.cpu.utilization",
labels={"b_only_label": "val-b"},
timestamp=now - timedelta(minutes=2),
value=20.0,
),
]
insert_metrics(metrics)
token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
# Specific prefix: metricNamespace=ns.a should return only a_only_label
response = requests.get(
signoz.self.host_configs["8080"].get("/api/v1/fields/keys"),
timeout=5,
headers={"authorization": f"Bearer {token}"},
params={
"signal": "metrics",
"searchText": "label",
"metricNamespace": "ns.a",
},
)
assert response.status_code == HTTPStatus.OK
assert response.json()["status"] == "success"
keys = response.json()["data"]["keys"]
assert "a_only_label" in keys
assert "b_only_label" not in keys
# Common prefix: metricNamespace=ns should return both keys
response = requests.get(
signoz.self.host_configs["8080"].get("/api/v1/fields/keys"),
timeout=5,
headers={"authorization": f"Bearer {token}"},
params={
"signal": "metrics",
"searchText": "label",
"metricNamespace": "ns",
},
)
assert response.status_code == HTTPStatus.OK
assert response.json()["status"] == "success"
keys = response.json()["data"]["keys"]
assert "a_only_label" in keys
assert "b_only_label" in keys

View File

@@ -108,3 +108,81 @@ def test_list_meter_metric_names(
assert (
metric_name in metric_names
), f"Expected {metric_name} in metric names, got: {metric_names}"
# Verify /api/v1/fields/values with source=meter filters label values by metricNamespace
# prefix. Inserts meter-source metrics under ns.a and ns.b, then asserts a specific
# prefix returns only matching values while a common prefix returns both.
def test_metric_namespace_meter_values_filtering(
signoz: types.SigNoz,
create_user_admin: None, # pylint: disable=unused-argument
get_token: Callable[[str, str], str],
insert_meter_samples: Callable[[List[MeterSample]], None],
) -> None:
now = datetime.now(tz=timezone.utc).replace(second=0, microsecond=0)
samples_a = make_meter_samples(
"meter.ns.a.cost",
{"service": "billing-a"},
now,
count=5,
base_value=10.0,
temporality="Delta",
type_="Sum",
is_monotonic=True,
)
samples_b = make_meter_samples(
"meter.ns.b.cost",
{"service": "billing-b"},
now,
count=5,
base_value=20.0,
temporality="Delta",
type_="Sum",
is_monotonic=True,
)
insert_meter_samples(samples_a + samples_b)
token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
# Specific prefix: metricNamespace=meter.ns.a should return only billing-a
response = requests.get(
signoz.self.host_configs["8080"].get("/api/v1/fields/values"),
timeout=5,
headers={"authorization": f"Bearer {token}"},
params={
"signal": "metrics",
"source": "meter",
"name": "service",
"searchText": "",
"metricNamespace": "meter.ns.a",
},
)
assert response.status_code == HTTPStatus.OK
assert response.json()["status"] == "success"
values = response.json()["data"]["values"]["stringValues"]
assert "billing-a" in values
assert "billing-b" not in values
# Common prefix: metricNamespace=meter.ns should return both
response = requests.get(
signoz.self.host_configs["8080"].get("/api/v1/fields/values"),
timeout=5,
headers={"authorization": f"Bearer {token}"},
params={
"signal": "metrics",
"source": "meter",
"name": "service",
"searchText": "",
"metricNamespace": "meter.ns",
},
)
assert response.status_code == HTTPStatus.OK
assert response.json()["status"] == "success"
values = response.json()["data"]["values"]["stringValues"]
assert "billing-a" in values
assert "billing-b" in values