mirror of
https://github.com/SigNoz/signoz.git
synced 2026-08-30 08:10:29 +01:00
Compare commits
4 Commits
worktree-t
...
platform-p
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
83a785af78 | ||
|
|
dbb989b625 | ||
|
|
024f55cb36 | ||
|
|
7df1b2fd85 |
@@ -94,6 +94,7 @@ func runGenerateAuthz(_ context.Context) error {
|
||||
|
||||
allowedResources := map[string]bool{
|
||||
coretypes.NewResourceRef(coretypes.ResourceServiceAccount).String(): true,
|
||||
coretypes.NewResourceRef(coretypes.ResourceMetaResourceAuthDomain).String(): true,
|
||||
coretypes.NewResourceRef(coretypes.ResourceRole).String(): true,
|
||||
coretypes.NewResourceRef(coretypes.ResourceMetaResourceFactorAPIKey).String(): true,
|
||||
coretypes.NewResourceRef(coretypes.ResourceTelemetryResourceLogs).String(): true,
|
||||
|
||||
@@ -2559,7 +2559,6 @@ components:
|
||||
- factor-api-key
|
||||
- license
|
||||
- subscription
|
||||
- deployment-host
|
||||
- logs
|
||||
- traces
|
||||
- metrics
|
||||
@@ -8802,7 +8801,6 @@ components:
|
||||
- span
|
||||
- trace
|
||||
- resource
|
||||
- scope
|
||||
- attribute
|
||||
- body
|
||||
- ""
|
||||
@@ -15470,8 +15468,10 @@ paths:
|
||||
$ref: '#/components/schemas/RenderErrorResponse'
|
||||
description: Internal Server Error
|
||||
security:
|
||||
- api_key: []
|
||||
- tokenizer: []
|
||||
- api_key:
|
||||
- VIEWER
|
||||
- tokenizer:
|
||||
- VIEWER
|
||||
summary: Get features
|
||||
tags:
|
||||
- features
|
||||
@@ -23943,9 +23943,9 @@ paths:
|
||||
description: Internal Server Error
|
||||
security:
|
||||
- api_key:
|
||||
- deployment-host:list
|
||||
- VIEWER
|
||||
- tokenizer:
|
||||
- deployment-host:list
|
||||
- VIEWER
|
||||
summary: Get host info from Zeus.
|
||||
tags:
|
||||
- zeus
|
||||
@@ -23999,9 +23999,9 @@ paths:
|
||||
description: Internal Server Error
|
||||
security:
|
||||
- api_key:
|
||||
- deployment-host:update
|
||||
- ADMIN
|
||||
- tokenizer:
|
||||
- deployment-host:update
|
||||
- ADMIN
|
||||
summary: Put host in Zeus for a deployment.
|
||||
tags:
|
||||
- zeus
|
||||
|
||||
@@ -67,7 +67,7 @@ func (ah *APIHandler) RegisterRoutes(router *mux.Router, am *middleware.AuthZ) {
|
||||
// note: add ee override methods first
|
||||
|
||||
// routes available only in ee version
|
||||
router.HandleFunc("/api/v1/features", am.OpenAccess(ah.getFeatureFlags)).Methods(http.MethodGet)
|
||||
router.HandleFunc("/api/v1/features", am.ViewAccess(ah.getFeatureFlags)).Methods(http.MethodGet)
|
||||
|
||||
// base overrides
|
||||
router.HandleFunc("/api/v1/version", am.OpenAccess(ah.getVersion)).Methods(http.MethodGet)
|
||||
|
||||
@@ -1541,6 +1541,7 @@ describe('PrivateRoute', () => {
|
||||
path: ROUTES.SERVICE_ACCOUNTS_SETTINGS,
|
||||
deniedRoles: DENIED_ROLES,
|
||||
},
|
||||
ORG_SETTINGS: { path: ROUTES.ORG_SETTINGS, deniedRoles: DENIED_ROLES },
|
||||
TRACES_EXPLORER: { path: ROUTES.TRACES_EXPLORER, deniedRoles: DENIED_ROLES },
|
||||
TRACE: { path: ROUTES.TRACE, deniedRoles: DENIED_ROLES },
|
||||
TRACE_DETAIL: {
|
||||
|
||||
@@ -2175,7 +2175,6 @@ export enum CoretypesKindDTO {
|
||||
'factor-api-key' = 'factor-api-key',
|
||||
license = 'license',
|
||||
subscription = 'subscription',
|
||||
'deployment-host' = 'deployment-host',
|
||||
logs = 'logs',
|
||||
traces = 'traces',
|
||||
metrics = 'metrics',
|
||||
@@ -3493,7 +3492,6 @@ export enum TelemetrytypesFieldContextDTO {
|
||||
span = 'span',
|
||||
trace = 'trace',
|
||||
resource = 'resource',
|
||||
scope = 'scope',
|
||||
attribute = 'attribute',
|
||||
body = 'body',
|
||||
'' = '',
|
||||
|
||||
@@ -241,29 +241,28 @@ export const QueryBuilderV2 = memo(function QueryBuilderV2({
|
||||
))
|
||||
)}
|
||||
|
||||
{!showOnlyWhereClause &&
|
||||
currentQuery.builder.queryFormulas?.length > 0 && (
|
||||
<div className="qb-formulas-container">
|
||||
{currentQuery.builder.queryFormulas.map((formula, index) => {
|
||||
const query =
|
||||
currentQuery.builder.queryData[index] ||
|
||||
currentQuery.builder.queryData[0];
|
||||
{!showOnlyWhereClause && currentQuery.builder.queryFormulas.length > 0 && (
|
||||
<div className="qb-formulas-container">
|
||||
{currentQuery.builder.queryFormulas.map((formula, index) => {
|
||||
const query =
|
||||
currentQuery.builder.queryData[index] ||
|
||||
currentQuery.builder.queryData[0];
|
||||
|
||||
return (
|
||||
<div key={formula.queryName} className="qb-formula">
|
||||
<Formula
|
||||
filterConfigs={filterConfigs}
|
||||
query={query}
|
||||
formula={formula}
|
||||
index={index}
|
||||
isAdditionalFilterEnable={false}
|
||||
isQBV2
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
return (
|
||||
<div key={formula.queryName} className="qb-formula">
|
||||
<Formula
|
||||
filterConfigs={filterConfigs}
|
||||
query={query}
|
||||
formula={formula}
|
||||
index={index}
|
||||
isAdditionalFilterEnable={false}
|
||||
isQBV2
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{shouldShowFooter && (
|
||||
<QueryFooter
|
||||
@@ -291,7 +290,7 @@ export const QueryBuilderV2 = memo(function QueryBuilderV2({
|
||||
</div>
|
||||
))}
|
||||
|
||||
{currentQuery.builder.queryFormulas?.map((formula) => (
|
||||
{currentQuery.builder.queryFormulas.map((formula) => (
|
||||
<div key={formula.queryName} className="formula-name">
|
||||
{formula.queryName}
|
||||
</div>
|
||||
|
||||
@@ -212,32 +212,6 @@ describe('QueryBuilderV2 + QueryV2 - base render', () => {
|
||||
expect(handleRunQueryMock).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('does not crash when builder.queryFormulas/queryTraceOperator are missing (partial/legacy query)', () => {
|
||||
const currentQueryBase = baseQBContext.currentQuery as Query;
|
||||
|
||||
mockedUseQueryBuilder.mockReturnValue({
|
||||
...baseQBContext,
|
||||
currentQuery: {
|
||||
...currentQueryBase,
|
||||
builder: {
|
||||
queryData: currentQueryBase.builder.queryData,
|
||||
queryFormulas: undefined as unknown as [],
|
||||
queryTraceOperator: undefined as unknown as [],
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
expect(() =>
|
||||
render(<QueryBuilderV2 panelType={PANEL_TYPES.TABLE} version="v4" />),
|
||||
).not.toThrow();
|
||||
|
||||
// query list still renders from queryData, formulas block is skipped
|
||||
expect(document.querySelector('.query-names-section')).toBeInTheDocument();
|
||||
expect(
|
||||
document.querySelector('.qb-formulas-container'),
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('fx button is disabled when functions already exist', () => {
|
||||
const currentQueryBase = baseQBContext.currentQuery as Query;
|
||||
const supersetQueryBase = baseQBContext.supersetQuery as Query;
|
||||
|
||||
@@ -4,7 +4,7 @@ import {
|
||||
} from 'mocks-server/__mockdata__/roles';
|
||||
import { rest, server } from 'mocks-server/server';
|
||||
import { NuqsTestingAdapter } from 'nuqs/adapters/testing';
|
||||
import { render, screen, waitFor } from 'tests/test-utils';
|
||||
import { fireEvent, render, screen, waitFor } from 'tests/test-utils';
|
||||
import {
|
||||
setupAuthzAdmin,
|
||||
setupAuthzDeny,
|
||||
@@ -110,7 +110,10 @@ describe('ServiceAccountDrawer — permissions', () => {
|
||||
it('shows PermissionDeniedCallout in Keys tab when list-keys permission is denied', async () => {
|
||||
server.use(setupAuthzDeny(APIKeyListPermission));
|
||||
|
||||
renderDrawer({ account: 'sa-1', tab: 'keys' });
|
||||
renderDrawer();
|
||||
await screen.findByDisplayValue('CI Bot');
|
||||
|
||||
fireEvent.click(screen.getByRole('radio', { name: /keys/i }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText(/list:factor-api-key/)).toBeInTheDocument();
|
||||
|
||||
@@ -10,7 +10,6 @@ const fieldContextToSuggestionMap: Record<
|
||||
[TelemetrytypesFieldContextDTO.attribute]: 'attribute',
|
||||
// no maps for the following values on suggestion context
|
||||
[TelemetrytypesFieldContextDTO.trace]: undefined,
|
||||
[TelemetrytypesFieldContextDTO.scope]: undefined,
|
||||
[TelemetrytypesFieldContextDTO.body]: undefined,
|
||||
[TelemetrytypesFieldContextDTO.metric]: undefined,
|
||||
[TelemetrytypesFieldContextDTO.log]: undefined,
|
||||
|
||||
@@ -53,24 +53,17 @@ export const getUpdatedStepInterval = (evalWindow?: string): number => {
|
||||
};
|
||||
|
||||
export const getSelectedQueryOptions = (
|
||||
queries:
|
||||
| Array<
|
||||
| IBuilderQuery
|
||||
| IBuilderTraceOperator
|
||||
| IBuilderFormula
|
||||
| IClickHouseQuery
|
||||
| IPromQLQuery
|
||||
>
|
||||
| undefined
|
||||
| null,
|
||||
): SelectProps['options'] => {
|
||||
if (!queries) {
|
||||
return [];
|
||||
}
|
||||
return queries
|
||||
queries: Array<
|
||||
| IBuilderQuery
|
||||
| IBuilderTraceOperator
|
||||
| IBuilderFormula
|
||||
| IClickHouseQuery
|
||||
| IPromQLQuery
|
||||
>,
|
||||
): SelectProps['options'] =>
|
||||
queries
|
||||
.filter((query) => !query.disabled)
|
||||
.map((query) => ({
|
||||
label: 'queryName' in query ? query.queryName : query.name,
|
||||
value: 'queryName' in query ? query.queryName : query.name,
|
||||
}));
|
||||
};
|
||||
|
||||
@@ -14,6 +14,8 @@ import {
|
||||
} from 'api/generated/services/sigNoz.schemas';
|
||||
import { AxiosError } from 'axios';
|
||||
import { FeatureKeys } from 'constants/features';
|
||||
import AuthZButton from 'lib/authz/components/AuthZButton/AuthZButton';
|
||||
import { buildAuthDomainUpdatePermission } from 'lib/authz/hooks/useAuthZ/permissions/auth-domain.permissions';
|
||||
import { defaultTo } from 'lodash-es';
|
||||
import { useAppContext } from 'providers/App/App';
|
||||
import { useErrorModal } from 'providers/ErrorModalProvider';
|
||||
@@ -209,7 +211,11 @@ function CreateOrEdit(props: CreateOrEditProps): JSX.Element {
|
||||
Cancel
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
<AuthZButton
|
||||
checks={
|
||||
isCreate ? [] : [buildAuthDomainUpdatePermission(record?.id ?? '')]
|
||||
}
|
||||
withPortal={false}
|
||||
onClick={onSubmitHandler}
|
||||
variant="solid"
|
||||
color="primary"
|
||||
@@ -217,7 +223,7 @@ function CreateOrEdit(props: CreateOrEditProps): JSX.Element {
|
||||
testId="auth-domain-save"
|
||||
>
|
||||
Save Changes
|
||||
</Button>
|
||||
</AuthZButton>
|
||||
</section>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -7,6 +7,8 @@ import {
|
||||
RenderErrorResponseDTO,
|
||||
} from 'api/generated/services/sigNoz.schemas';
|
||||
import { AxiosError } from 'axios';
|
||||
import AuthZTooltip from 'lib/authz/components/AuthZTooltip/AuthZTooltip';
|
||||
import { buildAuthDomainUpdatePermission } from 'lib/authz/hooks/useAuthZ/permissions/auth-domain.permissions';
|
||||
import { useErrorModal } from 'providers/ErrorModalProvider';
|
||||
import { ErrorV2Resp } from 'types/api';
|
||||
import APIError from 'types/api/error';
|
||||
@@ -60,12 +62,14 @@ function SSOEnforcementToggle({
|
||||
};
|
||||
|
||||
return (
|
||||
<Switch
|
||||
disabled={isLoading}
|
||||
value={isChecked}
|
||||
onChange={onChangeHandler}
|
||||
testId="auth-domain-enforce-sso"
|
||||
/>
|
||||
<AuthZTooltip checks={[buildAuthDomainUpdatePermission(record.id ?? '')]}>
|
||||
<Switch
|
||||
disabled={isLoading}
|
||||
value={isChecked}
|
||||
onChange={onChangeHandler}
|
||||
testId="auth-domain-enforce-sso"
|
||||
/>
|
||||
</AuthZTooltip>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,164 @@
|
||||
import {
|
||||
AuthDomainListPermission,
|
||||
buildAuthDomainDeletePermission,
|
||||
} from 'lib/authz/hooks/useAuthZ/permissions/auth-domain.permissions';
|
||||
import {
|
||||
AUTHZ_CHECK_URL,
|
||||
setupAuthzAdmin,
|
||||
setupAuthzAllow,
|
||||
setupAuthzDenyAll,
|
||||
setupAuthzGrantByPrefix,
|
||||
} from 'lib/authz/utils/authz-test-utils';
|
||||
import { rest, server } from 'mocks-server/server';
|
||||
import { render, screen, userEvent, waitFor } from 'tests/test-utils';
|
||||
|
||||
import AuthDomain from '../index';
|
||||
import { AUTH_DOMAINS_LIST_ENDPOINT, mockDomainsListResponse } from './mocks';
|
||||
|
||||
function setupListHandler(): void {
|
||||
server.use(
|
||||
rest.get(AUTH_DOMAINS_LIST_ENDPOINT, (_, res, ctx) =>
|
||||
res(ctx.status(200), ctx.json(mockDomainsListResponse)),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
describe('AuthDomain authz', () => {
|
||||
afterEach(() => {
|
||||
server.resetHandlers();
|
||||
});
|
||||
|
||||
describe('when all permissions are denied', () => {
|
||||
it('disables the add button and blocks the table with a callout', async () => {
|
||||
server.use(setupAuthzDenyAll());
|
||||
setupListHandler();
|
||||
|
||||
render(<AuthDomain />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('auth-domain-add')).toBeDisabled();
|
||||
});
|
||||
|
||||
await expect(
|
||||
screen.findByText(/is not authorized to perform/i),
|
||||
).resolves.toBeInTheDocument();
|
||||
expect(screen.getByText('list:auth-domain:*')).toBeInTheDocument();
|
||||
expect(screen.queryByText('signoz.io')).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('when only list is granted', () => {
|
||||
it('renders rows but disables the row actions and the add button', async () => {
|
||||
server.use(setupAuthzGrantByPrefix('list'));
|
||||
setupListHandler();
|
||||
|
||||
render(<AuthDomain />);
|
||||
|
||||
await expect(screen.findByText('signoz.io')).resolves.toBeInTheDocument();
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('auth-domain-add')).toBeDisabled();
|
||||
});
|
||||
|
||||
screen.getAllByTestId('auth-domain-configure').forEach((button) => {
|
||||
expect(button).toBeDisabled();
|
||||
});
|
||||
screen.getAllByTestId('auth-domain-delete').forEach((button) => {
|
||||
expect(button).toBeDisabled();
|
||||
});
|
||||
screen.getAllByRole('switch').forEach((toggle) => {
|
||||
expect(toggle).toBeDisabled();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('when all permissions are granted', () => {
|
||||
it('keeps every control interactive', async () => {
|
||||
server.use(setupAuthzAdmin());
|
||||
setupListHandler();
|
||||
|
||||
render(<AuthDomain />);
|
||||
|
||||
await expect(screen.findByText('signoz.io')).resolves.toBeInTheDocument();
|
||||
|
||||
expect(screen.getByTestId('auth-domain-add')).toBeEnabled();
|
||||
await waitFor(() => {
|
||||
screen.getAllByTestId('auth-domain-configure').forEach((button) => {
|
||||
expect(button).toBeEnabled();
|
||||
});
|
||||
});
|
||||
screen.getAllByTestId('auth-domain-delete').forEach((button) => {
|
||||
expect(button).toBeEnabled();
|
||||
});
|
||||
screen.getAllByRole('switch').forEach((toggle) => {
|
||||
expect(toggle).toBeEnabled();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('when read is granted but update is not', () => {
|
||||
it('keeps configure clickable and disables save inside the modal', async () => {
|
||||
const user = userEvent.setup({ pointerEventsCheck: 0 });
|
||||
server.use(setupAuthzGrantByPrefix('list', 'read'));
|
||||
setupListHandler();
|
||||
|
||||
render(<AuthDomain />);
|
||||
|
||||
await expect(screen.findByText('signoz.io')).resolves.toBeInTheDocument();
|
||||
|
||||
const configureButtons = screen.getAllByTestId('auth-domain-configure');
|
||||
await waitFor(() => {
|
||||
expect(configureButtons[0]).toBeEnabled();
|
||||
});
|
||||
await user.click(configureButtons[0]);
|
||||
|
||||
await screen.findByTestId('auth-domain-save');
|
||||
await waitFor(() => {
|
||||
const saveButton = screen.getByTestId('auth-domain-save');
|
||||
expect(saveButton).toBeDisabled();
|
||||
expect(saveButton).toHaveAttribute('data-denied-permissions');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('when delete is granted on a single domain', () => {
|
||||
it('enables delete only for that row', async () => {
|
||||
server.use(
|
||||
setupAuthzAllow(
|
||||
AuthDomainListPermission,
|
||||
buildAuthDomainDeletePermission('domain-1'),
|
||||
),
|
||||
);
|
||||
setupListHandler();
|
||||
|
||||
render(<AuthDomain />);
|
||||
|
||||
await expect(screen.findByText('signoz.io')).resolves.toBeInTheDocument();
|
||||
|
||||
const deleteButtons = screen.getAllByTestId('auth-domain-delete');
|
||||
expect(deleteButtons).toHaveLength(3);
|
||||
|
||||
// Row order follows mockDomainsListResponse: domain-1, domain-2, domain-3
|
||||
await waitFor(() => {
|
||||
expect(deleteButtons[0]).toBeEnabled();
|
||||
});
|
||||
expect(deleteButtons[1]).toBeDisabled();
|
||||
expect(deleteButtons[2]).toBeDisabled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('while permission checks are loading', () => {
|
||||
it('keeps the add button disabled', async () => {
|
||||
server.use(
|
||||
rest.post(AUTHZ_CHECK_URL, (_req, res, ctx) => res(ctx.delay('infinite'))),
|
||||
);
|
||||
setupListHandler();
|
||||
|
||||
render(<AuthDomain />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('auth-domain-add')).toBeDisabled();
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,3 +1,4 @@
|
||||
import { setupAuthzAdmin } from 'lib/authz/utils/authz-test-utils';
|
||||
import { rest, server } from 'mocks-server/server';
|
||||
import { render, screen, userEvent, waitFor } from 'tests/test-utils';
|
||||
|
||||
@@ -20,6 +21,7 @@ jest.mock('@signozhq/ui/sonner', () => ({
|
||||
describe('AuthDomain', () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
server.use(setupAuthzAdmin());
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
@@ -122,6 +124,9 @@ describe('AuthDomain', () => {
|
||||
render(<AuthDomain />);
|
||||
|
||||
const addButton = await screen.findByRole('button', { name: /add domain/i });
|
||||
await waitFor(() => {
|
||||
expect(addButton).toBeEnabled();
|
||||
});
|
||||
await user.click(addButton);
|
||||
|
||||
await waitFor(() => {
|
||||
@@ -148,8 +153,13 @@ describe('AuthDomain', () => {
|
||||
expect(screen.getByText('signoz.io')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
const configureLinks = await screen.findAllByText(/configure google auth/i);
|
||||
await user.click(configureLinks[0]);
|
||||
const configureButtons = await screen.findAllByTestId(
|
||||
'auth-domain-configure',
|
||||
);
|
||||
await waitFor(() => {
|
||||
expect(configureButtons[0]).toBeEnabled();
|
||||
});
|
||||
await user.click(configureButtons[0]);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText(/edit google authentication/i)).toBeInTheDocument();
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
import { fireEvent, render, screen, waitFor } from 'tests/test-utils';
|
||||
import { useAuthZ } from 'lib/authz/hooks/useAuthZ/useAuthZ';
|
||||
import { mockUseAuthZGrantAll } from 'lib/authz/utils/authz-test-utils';
|
||||
import { rest, server } from 'mocks-server/server';
|
||||
|
||||
import CreateEdit from '../CreateEdit/CreateEdit';
|
||||
@@ -9,6 +11,9 @@ import {
|
||||
mockUpdateSuccessResponse,
|
||||
} from './mocks';
|
||||
|
||||
jest.mock('lib/authz/hooks/useAuthZ/useAuthZ');
|
||||
const mockedUseAuthZ = useAuthZ as jest.MockedFunction<typeof useAuthZ>;
|
||||
|
||||
// TODO: https://github.com/SigNoz/platform-pod/issues/2602
|
||||
// The real @signozhq/ui/button has internal effects that prevent form.validateFields()
|
||||
// from resolving inside act(). Mirror the pattern from SSOEnforcementToggle.test.tsx
|
||||
@@ -45,7 +50,15 @@ jest.mock('@signozhq/ui/button', () => ({
|
||||
),
|
||||
}));
|
||||
|
||||
// Heavy real-timer integration tests (antd Collapse + form.validateFields() + a
|
||||
// react-query mutation); the default 5000ms budget flakes under parallel runs.
|
||||
jest.setTimeout(20000);
|
||||
|
||||
describe('CreateEdit — save payload correctness', () => {
|
||||
beforeEach(() => {
|
||||
mockedUseAuthZ.mockImplementation(mockUseAuthZGrantAll);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
server.resetHandlers();
|
||||
});
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
import { render, screen, userEvent, waitFor } from 'tests/test-utils';
|
||||
import { useAuthZ } from 'lib/authz/hooks/useAuthZ/useAuthZ';
|
||||
import { mockUseAuthZGrantAll } from 'lib/authz/utils/authz-test-utils';
|
||||
import { rest, server } from 'mocks-server/server';
|
||||
import {
|
||||
allRoles,
|
||||
@@ -15,6 +17,9 @@ import {
|
||||
mockUpdateSuccessResponse,
|
||||
} from './mocks';
|
||||
|
||||
jest.mock('lib/authz/hooks/useAuthZ/useAuthZ');
|
||||
const mockedUseAuthZ = useAuthZ as jest.MockedFunction<typeof useAuthZ>;
|
||||
|
||||
// TODO: https://github.com/SigNoz/platform-pod/issues/2602
|
||||
// The @signozhq/ui Button uses Radix Slot and has CSS infinite animations that
|
||||
// prevent form.validateFields() from resolving inside act(). Replacing with a
|
||||
@@ -112,6 +117,10 @@ const saveChanges = (user: User): Promise<void> =>
|
||||
user.click(screen.getByRole('button', { name: /save changes/i }));
|
||||
|
||||
describe('CreateEdit — role mapping uses API roles', () => {
|
||||
beforeEach(() => {
|
||||
mockedUseAuthZ.mockImplementation(mockUseAuthZGrantAll);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
server.resetHandlers();
|
||||
});
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
import { fireEvent, render, screen, waitFor } from 'tests/test-utils';
|
||||
import { useAuthZ } from 'lib/authz/hooks/useAuthZ/useAuthZ';
|
||||
import { mockUseAuthZGrantAll } from 'lib/authz/utils/authz-test-utils';
|
||||
import { rest, server } from 'mocks-server/server';
|
||||
import {
|
||||
AuthtypesAuthDomainConfigGoogleDTO,
|
||||
@@ -16,6 +18,13 @@ import {
|
||||
mockUpdateSuccessResponse,
|
||||
} from './mocks';
|
||||
|
||||
jest.mock('lib/authz/hooks/useAuthZ/useAuthZ');
|
||||
const mockedUseAuthZ = useAuthZ as jest.MockedFunction<typeof useAuthZ>;
|
||||
|
||||
beforeEach(() => {
|
||||
mockedUseAuthZ.mockImplementation(mockUseAuthZGrantAll);
|
||||
});
|
||||
|
||||
// @signozhq/ui/button internal effects block form.validateFields() in tests
|
||||
jest.mock('@signozhq/ui/button', () => ({
|
||||
...jest.requireActual('@signozhq/ui/button'),
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { setupAuthzAdmin } from 'lib/authz/utils/authz-test-utils';
|
||||
import { rest, server } from 'mocks-server/server';
|
||||
import { render, screen, userEvent, waitFor } from 'tests/test-utils';
|
||||
|
||||
@@ -34,6 +35,7 @@ import {
|
||||
describe('SSOEnforcementToggle', () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
server.use(setupAuthzAdmin());
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
@@ -87,6 +89,9 @@ describe('SSOEnforcementToggle', () => {
|
||||
);
|
||||
|
||||
const switchElement = screen.getByRole('switch');
|
||||
await waitFor(() => {
|
||||
expect(switchElement).toBeEnabled();
|
||||
});
|
||||
await user.click(switchElement);
|
||||
|
||||
await waitFor(() => {
|
||||
@@ -122,7 +127,11 @@ describe('SSOEnforcementToggle', () => {
|
||||
/>,
|
||||
);
|
||||
|
||||
await user.click(screen.getByRole('switch'));
|
||||
const switchElement = screen.getByRole('switch');
|
||||
await waitFor(() => {
|
||||
expect(switchElement).toBeEnabled();
|
||||
});
|
||||
await user.click(switchElement);
|
||||
|
||||
await waitFor(() => expect(mockUpdateAPI).toHaveBeenCalledTimes(1));
|
||||
expect(mockUpdateAPI).toHaveBeenCalledWith({
|
||||
@@ -149,6 +158,9 @@ describe('SSOEnforcementToggle', () => {
|
||||
);
|
||||
|
||||
const switchElement = screen.getByRole('switch');
|
||||
await waitFor(() => {
|
||||
expect(switchElement).toBeEnabled();
|
||||
});
|
||||
await user.click(switchElement);
|
||||
|
||||
await waitFor(() => {
|
||||
|
||||
@@ -14,6 +14,15 @@ import {
|
||||
} from 'api/generated/services/sigNoz.schemas';
|
||||
import { AxiosError } from 'axios';
|
||||
import ErrorContent from 'components/ErrorModal/components/ErrorContent';
|
||||
import AuthZButton from 'lib/authz/components/AuthZButton/AuthZButton';
|
||||
import { AuthZGuardContent } from 'lib/authz/components/AuthZGuard/AuthZGuardContent';
|
||||
import {
|
||||
AuthDomainCreatePermission,
|
||||
AuthDomainListPermission,
|
||||
buildAuthDomainDeletePermission,
|
||||
buildAuthDomainReadPermission,
|
||||
} from 'lib/authz/hooks/useAuthZ/permissions/auth-domain.permissions';
|
||||
import { useAuthZ } from 'lib/authz/hooks/useAuthZ/useAuthZ';
|
||||
import CopyToClipboard from 'periscope/components/CopyToClipboard';
|
||||
import { useErrorModal } from 'providers/ErrorModalProvider';
|
||||
import APIError from 'types/api/error';
|
||||
@@ -41,13 +50,17 @@ function AuthDomain(): JSX.Element {
|
||||
|
||||
const { showErrorModal } = useErrorModal();
|
||||
|
||||
const { permissions: authzPermissions } = useAuthZ([AuthDomainListPermission]);
|
||||
const canListAuthDomains =
|
||||
authzPermissions?.[AuthDomainListPermission]?.isGranted ?? false;
|
||||
|
||||
const {
|
||||
data: authDomainListResponse,
|
||||
isLoading: isLoadingAuthDomainListResponse,
|
||||
isFetching: isFetchingAuthDomainListResponse,
|
||||
error: errorFetchingAuthDomainListResponse,
|
||||
refetch: refetchAuthDomainListResponse,
|
||||
} = useListAuthDomains();
|
||||
} = useListAuthDomains({ query: { enabled: canListAuthDomains } });
|
||||
|
||||
const { mutate: deleteAuthDomain, isLoading } =
|
||||
useDeleteAuthDomain<AxiosError<RenderErrorResponseDTO>>();
|
||||
@@ -153,22 +166,24 @@ function AuthDomain(): JSX.Element {
|
||||
width: 100,
|
||||
render: (_, record: AuthtypesGettableAuthDomainDTO): JSX.Element => (
|
||||
<section className="auth-domain-list-column-action">
|
||||
<Button
|
||||
<AuthZButton
|
||||
checks={[buildAuthDomainReadPermission(record.id ?? '')]}
|
||||
className="auth-domain-list-action-link"
|
||||
onClick={(): void => setRecord(record)}
|
||||
variant="link"
|
||||
testId="auth-domain-configure"
|
||||
>
|
||||
Configure {SSOType.get(record.config?.kind || '')}
|
||||
</Button>
|
||||
<Button
|
||||
</AuthZButton>
|
||||
<AuthZButton
|
||||
checks={[buildAuthDomainDeletePermission(record.id ?? '')]}
|
||||
className="auth-domain-list-action-link delete"
|
||||
onClick={(): void => showDeleteModal(record)}
|
||||
variant="link"
|
||||
testId="auth-domain-delete"
|
||||
>
|
||||
Delete
|
||||
</Button>
|
||||
</AuthZButton>
|
||||
</section>
|
||||
),
|
||||
},
|
||||
@@ -182,7 +197,8 @@ function AuthDomain(): JSX.Element {
|
||||
<h3 className="auth-domain-title" data-testid="auth-domain-title">
|
||||
Authenticated Domains
|
||||
</h3>
|
||||
<Button
|
||||
<AuthZButton
|
||||
checks={[AuthDomainCreatePermission]}
|
||||
prefix={<Plus size="md" />}
|
||||
onClick={(): void => {
|
||||
setAddDomain(true);
|
||||
@@ -193,28 +209,32 @@ function AuthDomain(): JSX.Element {
|
||||
testId="auth-domain-add"
|
||||
>
|
||||
Add Domain
|
||||
</Button>
|
||||
</AuthZButton>
|
||||
</section>
|
||||
{formattedError && <ErrorContent error={formattedError} />}
|
||||
{!errorFetchingAuthDomainListResponse && (
|
||||
<Table
|
||||
columns={columns}
|
||||
dataSource={authDomainListResponse?.data}
|
||||
onRow={(
|
||||
record: AuthtypesGettableAuthDomainDTO,
|
||||
): HTMLAttributes<HTMLElement> =>
|
||||
// data-* attributes are valid row props but absent from the antd typing
|
||||
({
|
||||
'data-testid': `auth-domain-row-${record.name}`,
|
||||
}) as unknown as HTMLAttributes<HTMLElement>
|
||||
}
|
||||
loading={
|
||||
isLoadingAuthDomainListResponse || isFetchingAuthDomainListResponse
|
||||
}
|
||||
className="auth-domain-list"
|
||||
rowKey="id"
|
||||
/>
|
||||
)}
|
||||
<AuthZGuardContent checks={[AuthDomainListPermission]}>
|
||||
<>
|
||||
{formattedError && <ErrorContent error={formattedError} />}
|
||||
{!errorFetchingAuthDomainListResponse && (
|
||||
<Table
|
||||
columns={columns}
|
||||
dataSource={authDomainListResponse?.data}
|
||||
onRow={(
|
||||
record: AuthtypesGettableAuthDomainDTO,
|
||||
): HTMLAttributes<HTMLElement> =>
|
||||
// data-* attributes are valid row props but absent from the antd typing
|
||||
({
|
||||
'data-testid': `auth-domain-row-${record.name}`,
|
||||
}) as unknown as HTMLAttributes<HTMLElement>
|
||||
}
|
||||
loading={
|
||||
isLoadingAuthDomainListResponse || isFetchingAuthDomainListResponse
|
||||
}
|
||||
className="auth-domain-list"
|
||||
rowKey="id"
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
</AuthZGuardContent>
|
||||
{(addDomain || record) && (
|
||||
<CreateEdit
|
||||
isCreate={!record}
|
||||
|
||||
@@ -72,7 +72,8 @@ function DisplayName({ index, id: orgId }: DisplayNameProps): JSX.Element {
|
||||
await updateMyOrganization({ data: { id: orgId, displayName: name } });
|
||||
};
|
||||
|
||||
if (!org) {
|
||||
// The organization resource is not authz-backed yet, keep the legacy admin gate
|
||||
if (!org || !isAdmin) {
|
||||
return <div />;
|
||||
}
|
||||
|
||||
|
||||
@@ -329,21 +329,41 @@ describe('transformTransactionGroupsToResourcePermissions', () => {
|
||||
it('returns all resources from RESOURCE_ORDER even with empty transaction groups', () => {
|
||||
const result = transformTransactionGroupsToResourcePermissions([]);
|
||||
|
||||
expect(result).toHaveLength(7);
|
||||
expect(result).toHaveLength(8);
|
||||
expect(result.map((r) => r.resourceKind)).toStrictEqual([
|
||||
'auth-domain',
|
||||
'factor-api-key',
|
||||
'logs',
|
||||
'meter-metrics',
|
||||
'metrics',
|
||||
'role',
|
||||
'serviceaccount',
|
||||
'logs',
|
||||
'traces',
|
||||
'metrics',
|
||||
'meter-metrics',
|
||||
]);
|
||||
});
|
||||
|
||||
it('sets correct resource metadata from permissions config', () => {
|
||||
const result = transformTransactionGroupsToResourcePermissions([]);
|
||||
|
||||
const authDomainResource = result.find(
|
||||
(r) => r.resourceKind === 'auth-domain',
|
||||
);
|
||||
expect(authDomainResource).toMatchObject({
|
||||
resourceId: 'auth-domain',
|
||||
resourceKind: 'auth-domain',
|
||||
resourceType: CoretypesTypeDTO.metaresource,
|
||||
resourceLabel: 'Auth Domains',
|
||||
availableActions: [
|
||||
'attach',
|
||||
'create',
|
||||
'delete',
|
||||
'detach',
|
||||
'list',
|
||||
'read',
|
||||
'update',
|
||||
],
|
||||
});
|
||||
|
||||
const apiKeyResource = result.find(
|
||||
(r) => r.resourceKind === 'factor-api-key',
|
||||
);
|
||||
@@ -418,15 +438,16 @@ describe('createEmptyRolePermissions', () => {
|
||||
it('creates permissions for all resources in RESOURCE_ORDER', () => {
|
||||
const result = createEmptyRolePermissions();
|
||||
|
||||
expect(result).toHaveLength(7);
|
||||
expect(result).toHaveLength(8);
|
||||
expect(result.map((r) => r.resourceKind)).toStrictEqual([
|
||||
'auth-domain',
|
||||
'factor-api-key',
|
||||
'logs',
|
||||
'meter-metrics',
|
||||
'metrics',
|
||||
'role',
|
||||
'serviceaccount',
|
||||
'logs',
|
||||
'traces',
|
||||
'metrics',
|
||||
'meter-metrics',
|
||||
]);
|
||||
});
|
||||
|
||||
|
||||
@@ -3,6 +3,7 @@ import {
|
||||
ChartLine,
|
||||
DraftingCompass,
|
||||
Gauge,
|
||||
Globe,
|
||||
Key,
|
||||
Logs,
|
||||
Shield,
|
||||
@@ -38,7 +39,16 @@ export interface ResourcePanelConfig {
|
||||
* we want to add resource panel configs for only types we actually are using,
|
||||
* not all of them
|
||||
*/
|
||||
// Keys must stay alphabetically sorted — RESOURCE_ORDER derives the display order from them.
|
||||
export const RESOURCE_PANELS: Record<AuthZResource, ResourcePanelConfig> = {
|
||||
'auth-domain': {
|
||||
label: 'Auth Domains',
|
||||
description: 'Authenticated domains and their SSO configuration.',
|
||||
icon: Globe,
|
||||
selectorPlaceholder:
|
||||
'Type auth domain ID, separate multiple with comma or space',
|
||||
docsAnchor: 'auth-domain',
|
||||
},
|
||||
'factor-api-key': {
|
||||
label: 'API Keys',
|
||||
description: 'Programmatic access tokens for the workspace.',
|
||||
@@ -46,6 +56,33 @@ export const RESOURCE_PANELS: Record<AuthZResource, ResourcePanelConfig> = {
|
||||
selectorPlaceholder: 'Type API key ID, separate multiple with comma or space',
|
||||
docsAnchor: 'factor-api-key',
|
||||
},
|
||||
logs: {
|
||||
label: 'Logs',
|
||||
description: 'Log data collected across the workspace.',
|
||||
icon: Logs,
|
||||
selectorPlaceholder:
|
||||
'Enter selector as <query-type>/<key>/<value> or <query-type>/* or use wizard...',
|
||||
docsAnchor: 'logs',
|
||||
selectorType: 'telemetryBuilder',
|
||||
},
|
||||
'meter-metrics': {
|
||||
label: 'Meter Metrics',
|
||||
description: 'Usage metering data for the workspace.',
|
||||
icon: Gauge,
|
||||
selectorPlaceholder:
|
||||
'Enter selector as <query-type>/<key>/<value> or <query-type>/* or use wizard...',
|
||||
docsAnchor: 'meter-metrics',
|
||||
selectorType: 'telemetryBuilder',
|
||||
},
|
||||
metrics: {
|
||||
label: 'Metrics',
|
||||
description: 'Metric data collected across the workspace.',
|
||||
icon: ChartLine,
|
||||
selectorPlaceholder:
|
||||
'Enter selector as <query-type>/<key>/<value> or <query-type>/* or use wizard...',
|
||||
docsAnchor: 'metrics',
|
||||
selectorType: 'telemetryBuilder',
|
||||
},
|
||||
role: {
|
||||
label: 'Roles',
|
||||
description: 'Custom and managed roles and their assignments.',
|
||||
@@ -61,15 +98,6 @@ export const RESOURCE_PANELS: Record<AuthZResource, ResourcePanelConfig> = {
|
||||
'Type service account ID, separate multiple with comma or space',
|
||||
docsAnchor: 'service-account',
|
||||
},
|
||||
logs: {
|
||||
label: 'Logs',
|
||||
description: 'Log data collected across the workspace.',
|
||||
icon: Logs,
|
||||
selectorPlaceholder:
|
||||
'Enter selector as <query-type>/<key>/<value> or <query-type>/* or use wizard...',
|
||||
docsAnchor: 'logs',
|
||||
selectorType: 'telemetryBuilder',
|
||||
},
|
||||
traces: {
|
||||
label: 'Traces',
|
||||
description: 'Distributed tracing data collected across the workspace.',
|
||||
@@ -79,24 +107,6 @@ export const RESOURCE_PANELS: Record<AuthZResource, ResourcePanelConfig> = {
|
||||
docsAnchor: 'traces',
|
||||
selectorType: 'telemetryBuilder',
|
||||
},
|
||||
metrics: {
|
||||
label: 'Metrics',
|
||||
description: 'Metric data collected across the workspace.',
|
||||
icon: ChartLine,
|
||||
selectorPlaceholder:
|
||||
'Enter selector as <query-type>/<key>/<value> or <query-type>/* or use wizard...',
|
||||
docsAnchor: 'metrics',
|
||||
selectorType: 'telemetryBuilder',
|
||||
},
|
||||
'meter-metrics': {
|
||||
label: 'Meter Metrics',
|
||||
description: 'Usage metering data for the workspace.',
|
||||
icon: Gauge,
|
||||
selectorPlaceholder:
|
||||
'Enter selector as <query-type>/<key>/<value> or <query-type>/* or use wizard...',
|
||||
docsAnchor: 'meter-metrics',
|
||||
selectorType: 'telemetryBuilder',
|
||||
},
|
||||
};
|
||||
|
||||
export const RESOURCE_ORDER = Object.keys(RESOURCE_PANELS) as AuthZResource[];
|
||||
|
||||
@@ -27,14 +27,6 @@ export const useGetCompositeQueryParam = (): Query | null => {
|
||||
decodeURIComponent(compositeQuery.replace(/\+/g, ' ')),
|
||||
);
|
||||
|
||||
// Add default values for optional fields if empty
|
||||
if (parsedCompositeQuery?.builder) {
|
||||
parsedCompositeQuery.builder.queryFormulas =
|
||||
parsedCompositeQuery.builder.queryFormulas ?? [];
|
||||
parsedCompositeQuery.builder.queryTraceOperator =
|
||||
parsedCompositeQuery.builder.queryTraceOperator ?? [];
|
||||
}
|
||||
|
||||
// Convert old format to new format for each query in builder.queryData
|
||||
if (parsedCompositeQuery?.builder?.queryData) {
|
||||
parsedCompositeQuery.builder.queryData =
|
||||
|
||||
@@ -3,6 +3,19 @@ export default {
|
||||
status: 'success',
|
||||
data: {
|
||||
resources: [
|
||||
{
|
||||
kind: 'auth-domain',
|
||||
type: 'metaresource',
|
||||
allowedVerbs: [
|
||||
'attach',
|
||||
'create',
|
||||
'delete',
|
||||
'detach',
|
||||
'list',
|
||||
'read',
|
||||
'update',
|
||||
],
|
||||
},
|
||||
{
|
||||
kind: 'factor-api-key',
|
||||
type: 'metaresource',
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
import { buildPermission } from '../utils';
|
||||
import type { BrandedPermission } from '../types';
|
||||
|
||||
// Collection-level — wildcard selector required for correct response key matching
|
||||
export const AuthDomainListPermission = buildPermission(
|
||||
'list',
|
||||
'auth-domain:*',
|
||||
);
|
||||
export const AuthDomainCreatePermission = buildPermission(
|
||||
'create',
|
||||
'auth-domain:*',
|
||||
);
|
||||
|
||||
// Resource-level — require a specific auth domain id
|
||||
export const buildAuthDomainReadPermission = (id: string): BrandedPermission =>
|
||||
buildPermission('read', `auth-domain:${id}`);
|
||||
export const buildAuthDomainUpdatePermission = (
|
||||
id: string,
|
||||
): BrandedPermission => buildPermission('update', `auth-domain:${id}`);
|
||||
export const buildAuthDomainDeletePermission = (
|
||||
id: string,
|
||||
): BrandedPermission => buildPermission('delete', `auth-domain:${id}`);
|
||||
@@ -10,7 +10,6 @@ import { buildNavUrl, getQueryString } from 'container/SideNav/helper';
|
||||
import { settingsNavSections } from 'container/SideNav/menuItems';
|
||||
import NavItem from 'container/SideNav/NavItem/NavItem';
|
||||
import { SidebarItem } from 'container/SideNav/sideNav.types';
|
||||
import useComponentPermission from 'hooks/useComponentPermission';
|
||||
import { useGetTenantLicense } from 'hooks/useGetTenantLicense';
|
||||
import history from 'lib/history';
|
||||
import { Cog } from '@signozhq/icons';
|
||||
@@ -40,10 +39,6 @@ function SettingsPage(): JSX.Element {
|
||||
|
||||
const isWorkspaceBlocked = trialInfo?.workSpaceBlock || false;
|
||||
|
||||
const [isCurrentOrgSettings] = useComponentPermission(
|
||||
['current_org_settings'],
|
||||
user.role,
|
||||
);
|
||||
const { t } = useTranslation(['routes']);
|
||||
|
||||
const isGatewayEnabled =
|
||||
@@ -80,7 +75,8 @@ function SettingsPage(): JSX.Element {
|
||||
item.key === ROUTES.ROLE_CREATE ||
|
||||
item.key === ROUTES.ROLE_DETAILS ||
|
||||
item.key === ROUTES.ROLE_EDIT ||
|
||||
item.key === ROUTES.SERVICE_ACCOUNTS_SETTINGS
|
||||
item.key === ROUTES.SERVICE_ACCOUNTS_SETTINGS ||
|
||||
item.key === ROUTES.ORG_SETTINGS
|
||||
? true
|
||||
: item.isEnabled,
|
||||
}));
|
||||
@@ -92,7 +88,6 @@ function SettingsPage(): JSX.Element {
|
||||
item.key === ROUTES.BILLING ||
|
||||
item.key === ROUTES.INTEGRATIONS ||
|
||||
item.key === ROUTES.INGESTION_SETTINGS ||
|
||||
item.key === ROUTES.ORG_SETTINGS ||
|
||||
item.key === ROUTES.MEMBERS_SETTINGS ||
|
||||
item.key === ROUTES.SHORTCUTS ||
|
||||
item.key === ROUTES.MCP_SERVER
|
||||
@@ -131,7 +126,8 @@ function SettingsPage(): JSX.Element {
|
||||
item.key === ROUTES.ROLE_CREATE ||
|
||||
item.key === ROUTES.ROLE_DETAILS ||
|
||||
item.key === ROUTES.ROLE_EDIT ||
|
||||
item.key === ROUTES.SERVICE_ACCOUNTS_SETTINGS
|
||||
item.key === ROUTES.SERVICE_ACCOUNTS_SETTINGS ||
|
||||
item.key === ROUTES.ORG_SETTINGS
|
||||
? true
|
||||
: item.isEnabled,
|
||||
}));
|
||||
@@ -142,7 +138,6 @@ function SettingsPage(): JSX.Element {
|
||||
isEnabled:
|
||||
item.key === ROUTES.BILLING ||
|
||||
item.key === ROUTES.INTEGRATIONS ||
|
||||
item.key === ROUTES.ORG_SETTINGS ||
|
||||
item.key === ROUTES.MEMBERS_SETTINGS ||
|
||||
item.key === ROUTES.INGESTION_SETTINGS ||
|
||||
item.key === ROUTES.MCP_SERVER
|
||||
@@ -180,7 +175,8 @@ function SettingsPage(): JSX.Element {
|
||||
item.key === ROUTES.ROLE_CREATE ||
|
||||
item.key === ROUTES.ROLE_DETAILS ||
|
||||
item.key === ROUTES.ROLE_EDIT ||
|
||||
item.key === ROUTES.SERVICE_ACCOUNTS_SETTINGS
|
||||
item.key === ROUTES.SERVICE_ACCOUNTS_SETTINGS ||
|
||||
item.key === ROUTES.ORG_SETTINGS
|
||||
? true
|
||||
: item.isEnabled,
|
||||
}));
|
||||
@@ -188,10 +184,7 @@ function SettingsPage(): JSX.Element {
|
||||
if (isAdmin) {
|
||||
updatedItems = updatedItems.map((item) => ({
|
||||
...item,
|
||||
isEnabled:
|
||||
item.key === ROUTES.ORG_SETTINGS || item.key === ROUTES.MEMBERS_SETTINGS
|
||||
? true
|
||||
: item.isEnabled,
|
||||
isEnabled: item.key === ROUTES.MEMBERS_SETTINGS ? true : item.isEnabled,
|
||||
}));
|
||||
}
|
||||
|
||||
@@ -222,7 +215,6 @@ function SettingsPage(): JSX.Element {
|
||||
() =>
|
||||
getRoutes(
|
||||
user.role,
|
||||
isCurrentOrgSettings,
|
||||
isGatewayEnabled,
|
||||
isWorkspaceBlocked,
|
||||
isCloudUser,
|
||||
@@ -231,7 +223,6 @@ function SettingsPage(): JSX.Element {
|
||||
),
|
||||
[
|
||||
user.role,
|
||||
isCurrentOrgSettings,
|
||||
isGatewayEnabled,
|
||||
isWorkspaceBlocked,
|
||||
isCloudUser,
|
||||
|
||||
@@ -21,7 +21,6 @@ import {
|
||||
|
||||
export const getRoutes = (
|
||||
userRole: ROLES | null,
|
||||
isCurrentOrgSettings: boolean,
|
||||
isGatewayEnabled: boolean,
|
||||
isWorkspaceBlocked: boolean,
|
||||
isCloudUser: boolean,
|
||||
@@ -47,9 +46,8 @@ export const getRoutes = (
|
||||
|
||||
settings.push(...generalSettings(t));
|
||||
|
||||
if (isCurrentOrgSettings) {
|
||||
settings.push(...organizationSettings(t));
|
||||
}
|
||||
// Visible to all authenticated users — in-page authz gates the content
|
||||
settings.push(...organizationSettings(t));
|
||||
|
||||
if (isGatewayEnabled && (isAdmin || isEditor)) {
|
||||
settings.push(...multiIngestionSettings(t));
|
||||
|
||||
@@ -163,23 +163,20 @@ export function QueryBuilderProvider({
|
||||
const prepareQueryBuilderData = useCallback(
|
||||
(query: Query): Query => {
|
||||
const builder: QueryBuilderData = {
|
||||
queryData:
|
||||
query.builder.queryData?.map((item) => ({
|
||||
...initialQueryBuilderFormValuesMap[
|
||||
initialDataSource || DataSource.METRICS
|
||||
],
|
||||
...item,
|
||||
})) ?? [],
|
||||
queryFormulas:
|
||||
query.builder.queryFormulas?.map((item) => ({
|
||||
...initialFormulaBuilderFormValues,
|
||||
...item,
|
||||
})) ?? [],
|
||||
queryTraceOperator:
|
||||
query.builder.queryTraceOperator?.map((item) => ({
|
||||
...initialQueryBuilderFormTraceOperatorValues,
|
||||
...item,
|
||||
})) ?? [],
|
||||
queryData: query.builder.queryData?.map((item) => ({
|
||||
...initialQueryBuilderFormValuesMap[
|
||||
initialDataSource || DataSource.METRICS
|
||||
],
|
||||
...item,
|
||||
})),
|
||||
queryFormulas: query.builder.queryFormulas?.map((item) => ({
|
||||
...initialFormulaBuilderFormValues,
|
||||
...item,
|
||||
})),
|
||||
queryTraceOperator: query.builder.queryTraceOperator?.map((item) => ({
|
||||
...initialQueryBuilderFormTraceOperatorValues,
|
||||
...item,
|
||||
})),
|
||||
};
|
||||
|
||||
const setupedQueryData = builder.queryData.map((item) => {
|
||||
@@ -212,17 +209,15 @@ export function QueryBuilderProvider({
|
||||
return currentElement;
|
||||
});
|
||||
|
||||
const promql: IPromQLQuery[] =
|
||||
query.promql?.map((item) => ({
|
||||
...initialQueryPromQLData,
|
||||
...item,
|
||||
})) ?? [];
|
||||
const promql: IPromQLQuery[] = query.promql.map((item) => ({
|
||||
...initialQueryPromQLData,
|
||||
...item,
|
||||
}));
|
||||
|
||||
const clickHouse: IClickHouseQuery[] =
|
||||
query.clickhouse_sql?.map((item) => ({
|
||||
...initialClickHouseData,
|
||||
...item,
|
||||
})) ?? [];
|
||||
const clickHouse: IClickHouseQuery[] = query.clickhouse_sql.map((item) => ({
|
||||
...initialClickHouseData,
|
||||
...item,
|
||||
}));
|
||||
|
||||
const newQueryState: QueryState = {
|
||||
clickhouse_sql: clickHouse,
|
||||
|
||||
@@ -66,7 +66,6 @@
|
||||
"factor-api-key",
|
||||
"license",
|
||||
"subscription",
|
||||
"deployment-host",
|
||||
"logs",
|
||||
"traces",
|
||||
"metrics",
|
||||
|
||||
@@ -59,7 +59,7 @@ export const componentPermission: Record<ComponentTypes, ROLES[]> = {
|
||||
export const routePermission: Record<keyof typeof ROUTES, ROLES[]> = {
|
||||
HOME: ['ADMIN', 'EDITOR', 'VIEWER'],
|
||||
ALERTS_NEW: ['ADMIN', 'EDITOR'],
|
||||
ORG_SETTINGS: ['ADMIN'],
|
||||
ORG_SETTINGS: ['ADMIN', 'EDITOR', 'VIEWER'],
|
||||
MY_SETTINGS: ['ADMIN', 'EDITOR', 'VIEWER'],
|
||||
SERVICE_MAP: ['ADMIN', 'EDITOR', 'VIEWER'],
|
||||
ALL_CHANNELS: ['ADMIN', 'EDITOR', 'VIEWER'],
|
||||
@@ -172,6 +172,7 @@ export const routeWithInitialAuthZSupport = {
|
||||
LOGS: true,
|
||||
LOGS_EXPLORER: true,
|
||||
LIVE_LOGS: true,
|
||||
ORG_SETTINGS: true,
|
||||
ROLES_SETTINGS: true,
|
||||
ROLE_CREATE: true,
|
||||
ROLE_DETAILS: true,
|
||||
|
||||
@@ -77,7 +77,7 @@ func (provider *provider) addAuthDomainRoutes(router *mux.Router) error {
|
||||
SourceIDs: coretypes.OneID(coretypes.ResponseJSONPath("data.id")),
|
||||
SourceSelector: coretypes.WildcardSelector,
|
||||
TargetResource: coretypes.ResourceRole,
|
||||
TargetIDs: authDomainRoleNamesExtractor(),
|
||||
TargetIDs: provider.authDomainRoleNamesExtractor(),
|
||||
TargetSelector: coretypes.IDSelector,
|
||||
},
|
||||
),
|
||||
@@ -146,21 +146,23 @@ func (provider *provider) addAuthDomainRoutes(router *mux.Router) error {
|
||||
Verb: coretypes.VerbAttach,
|
||||
Category: coretypes.ActionCategoryAccessControl,
|
||||
SourceResource: coretypes.ResourceMetaResourceAuthDomain,
|
||||
SourceIDs: coretypes.OneID(coretypes.PathParam("id")),
|
||||
SourceIDs: provider.authDomainIDWhenRolesChangeExtractor(provider.authDomainAttachedRoleNames),
|
||||
SourceSelector: coretypes.IDSelector,
|
||||
TargetResource: coretypes.ResourceRole,
|
||||
TargetIDs: authDomainRoleNamesExtractor(),
|
||||
TargetIDs: coretypes.ResourceIDsExtractor{Phase: coretypes.PhaseRequest, Fn: provider.authDomainAttachedRoleNames},
|
||||
TargetSelector: coretypes.IDSelector,
|
||||
SkipIfNoIDs: true,
|
||||
},
|
||||
handler.AttachDetachSiblingResourceDef{
|
||||
Verb: coretypes.VerbDetach,
|
||||
Category: coretypes.ActionCategoryAccessControl,
|
||||
SourceResource: coretypes.ResourceMetaResourceAuthDomain,
|
||||
SourceIDs: coretypes.OneID(coretypes.PathParam("id")),
|
||||
SourceIDs: provider.authDomainIDWhenRolesChangeExtractor(provider.authDomainDetachedRoleNames),
|
||||
SourceSelector: coretypes.IDSelector,
|
||||
TargetResource: coretypes.ResourceRole,
|
||||
TargetIDs: provider.authDomainStoredRoleNamesExtractor(),
|
||||
TargetIDs: coretypes.ResourceIDsExtractor{Phase: coretypes.PhaseRequest, Fn: provider.authDomainDetachedRoleNames},
|
||||
TargetSelector: coretypes.IDSelector,
|
||||
SkipIfNoIDs: true,
|
||||
},
|
||||
),
|
||||
)).Methods(http.MethodPut).GetError(); err != nil {
|
||||
@@ -197,67 +199,119 @@ func (provider *provider) addAuthDomainRoutes(router *mux.Router) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// The extracted names are the roles the request body's mapping grants at SSO
|
||||
// login — see authDomainEffectiveRoleNames.
|
||||
func authDomainRoleNamesExtractor() coretypes.ResourceIDsExtractor {
|
||||
return coretypes.ResourceIDsExtractor{Phase: coretypes.PhaseRequest, Fn: func(ec coretypes.ExtractorContext) ([]string, error) {
|
||||
roleMappingJSON := gjson.GetBytes(ec.RequestBody, "roleMapping")
|
||||
if !roleMappingJSON.Exists() || roleMappingJSON.Type == gjson.Null {
|
||||
return authDomainEffectiveRoleNames(nil), nil
|
||||
}
|
||||
|
||||
roleMapping := new(authtypes.RoleMapping)
|
||||
if err := json.Unmarshal([]byte(roleMappingJSON.Raw), roleMapping); err != nil {
|
||||
return nil, errors.NewInvalidInputf(errors.CodeInvalidInput, "invalid role mapping: %v", err)
|
||||
}
|
||||
|
||||
return authDomainEffectiveRoleNames(roleMapping), nil
|
||||
}}
|
||||
func (provider *provider) authDomainRoleNamesExtractor() coretypes.ResourceIDsExtractor {
|
||||
return coretypes.ResourceIDsExtractor{Phase: coretypes.PhaseRequest, Fn: provider.authDomainRequestEffectiveRoleNames}
|
||||
}
|
||||
|
||||
// The extracted names are the roles the stored domain's mapping grants at SSO
|
||||
// login — an update replaces that mapping, so the caller must be able to detach
|
||||
// them.
|
||||
func (provider *provider) authDomainStoredRoleNamesExtractor() coretypes.ResourceIDsExtractor {
|
||||
func (provider *provider) authDomainIDWhenRolesChangeExtractor(roleNamesDiff func(coretypes.ExtractorContext) ([]string, error)) coretypes.ResourceIDsExtractor {
|
||||
return coretypes.ResourceIDsExtractor{Phase: coretypes.PhaseRequest, Fn: func(ec coretypes.ExtractorContext) ([]string, error) {
|
||||
if ec.Request == nil {
|
||||
diff, err := roleNamesDiff(ec)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if len(diff) == 0 || ec.Request == nil {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
claims, err := authtypes.ClaimsFromContext(ec.Request.Context())
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
orgID, err := valuer.NewUUID(claims.OrgID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
id, err := valuer.NewUUID(mux.Vars(ec.Request)["id"])
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
authDomain, err := provider.authDomainModule.GetByOrgIDAndID(ec.Request.Context(), orgID, id)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return authDomainEffectiveRoleNames(authDomain.RoleMapping()), nil
|
||||
return []string{mux.Vars(ec.Request)["id"]}, nil
|
||||
}}
|
||||
}
|
||||
|
||||
// The effective names are the roles a domain grants at SSO login: the mapped
|
||||
// roles plus the default (signoz-viewer when unset), or every role when the IDP
|
||||
// role attribute is trusted. Never empty — a check with no selectors is forbidden.
|
||||
func authDomainEffectiveRoleNames(roleMapping *authtypes.RoleMapping) []string {
|
||||
func (provider *provider) authDomainAttachedRoleNames(ec coretypes.ExtractorContext) ([]string, error) {
|
||||
requestRoleNames, err := provider.authDomainRequestEffectiveRoleNames(ec)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
storedRoleNames, err := provider.authDomainStoredEffectiveRoleNames(ec)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return provider.subtractRoleNames(requestRoleNames, storedRoleNames), nil
|
||||
}
|
||||
|
||||
func (provider *provider) authDomainDetachedRoleNames(ec coretypes.ExtractorContext) ([]string, error) {
|
||||
requestRoleNames, err := provider.authDomainRequestEffectiveRoleNames(ec)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
storedRoleNames, err := provider.authDomainStoredEffectiveRoleNames(ec)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return provider.subtractRoleNames(storedRoleNames, requestRoleNames), nil
|
||||
}
|
||||
|
||||
func (provider *provider) authDomainRequestEffectiveRoleNames(ec coretypes.ExtractorContext) ([]string, error) {
|
||||
roleMappingJSON := gjson.GetBytes(ec.RequestBody, "roleMapping")
|
||||
if !roleMappingJSON.Exists() || roleMappingJSON.Type == gjson.Null {
|
||||
return provider.authDomainEffectiveRoleNames(nil), nil
|
||||
}
|
||||
|
||||
roleMapping := new(authtypes.RoleMapping)
|
||||
if err := json.Unmarshal([]byte(roleMappingJSON.Raw), roleMapping); err != nil {
|
||||
return nil, errors.NewInvalidInputf(errors.CodeInvalidInput, "invalid role mapping: %v", err)
|
||||
}
|
||||
|
||||
return provider.authDomainEffectiveRoleNames(roleMapping), nil
|
||||
}
|
||||
|
||||
func (provider *provider) authDomainStoredEffectiveRoleNames(ec coretypes.ExtractorContext) ([]string, error) {
|
||||
if ec.Request == nil {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
claims, err := authtypes.ClaimsFromContext(ec.Request.Context())
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
orgID, err := valuer.NewUUID(claims.OrgID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
id, err := valuer.NewUUID(mux.Vars(ec.Request)["id"])
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
authDomain, err := provider.authDomainModule.GetByOrgIDAndID(ec.Request.Context(), orgID, id)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return provider.authDomainEffectiveRoleNames(authDomain.RoleMapping()), nil
|
||||
}
|
||||
|
||||
func (provider *provider) subtractRoleNames(roleNames []string, roleNamesToRemove []string) []string {
|
||||
removeSet := make(map[string]struct{}, len(roleNamesToRemove))
|
||||
for _, roleName := range roleNamesToRemove {
|
||||
removeSet[roleName] = struct{}{}
|
||||
}
|
||||
|
||||
remaining := make([]string, 0, len(roleNames))
|
||||
for _, roleName := range roleNames {
|
||||
if _, ok := removeSet[roleName]; !ok {
|
||||
remaining = append(remaining, roleName)
|
||||
}
|
||||
}
|
||||
|
||||
return remaining
|
||||
}
|
||||
|
||||
// Never empty — a check with no selectors is forbidden.
|
||||
func (provider *provider) authDomainEffectiveRoleNames(roleMapping *authtypes.RoleMapping) []string {
|
||||
if roleMapping == nil {
|
||||
return []string{authtypes.SigNozViewerRoleName}
|
||||
}
|
||||
|
||||
if roleMapping.UseRoleAttribute {
|
||||
return []string{coretypes.WildCardSelectorString}
|
||||
return []string{coretypes.WildCardSelectorString, authtypes.SigNozViewerRoleName}
|
||||
}
|
||||
|
||||
roleNames := roleMapping.RoleNames()
|
||||
|
||||
@@ -4,12 +4,13 @@ import (
|
||||
"net/http"
|
||||
|
||||
"github.com/SigNoz/signoz/pkg/http/handler"
|
||||
"github.com/SigNoz/signoz/pkg/types"
|
||||
"github.com/SigNoz/signoz/pkg/types/featuretypes"
|
||||
"github.com/gorilla/mux"
|
||||
)
|
||||
|
||||
func (provider *provider) addFlaggerRoutes(router *mux.Router) error {
|
||||
if err := router.Handle("/api/v2/features", handler.New(provider.authzMiddleware.OpenAccess(provider.flaggerHandler.GetFeatures), handler.OpenAPIDef{
|
||||
if err := router.Handle("/api/v2/features", handler.New(provider.authzMiddleware.ViewAccess(provider.flaggerHandler.GetFeatures), handler.OpenAPIDef{
|
||||
ID: "GetFeatures",
|
||||
Tags: []string{"features"},
|
||||
Summary: "Get features",
|
||||
@@ -21,7 +22,7 @@ func (provider *provider) addFlaggerRoutes(router *mux.Router) error {
|
||||
SuccessStatusCode: http.StatusOK,
|
||||
ErrorStatusCodes: []int{},
|
||||
Deprecated: false,
|
||||
SecuritySchemes: newScopedSecuritySchemes(nil),
|
||||
SecuritySchemes: newSecuritySchemes(types.RoleViewer),
|
||||
})).Methods(http.MethodGet).GetError(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -5,8 +5,6 @@ import (
|
||||
|
||||
"github.com/SigNoz/signoz/pkg/http/handler"
|
||||
"github.com/SigNoz/signoz/pkg/types"
|
||||
"github.com/SigNoz/signoz/pkg/types/authtypes"
|
||||
"github.com/SigNoz/signoz/pkg/types/coretypes"
|
||||
"github.com/SigNoz/signoz/pkg/types/zeustypes"
|
||||
"github.com/gorilla/mux"
|
||||
)
|
||||
@@ -29,7 +27,7 @@ func (provider *provider) addZeusRoutes(router *mux.Router) error {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := router.Handle("/api/v2/zeus/hosts", handler.New(provider.authzMiddleware.CheckResources(provider.zeusHandler.GetHosts, authtypes.SigNozAdminRoleName, authtypes.SigNozEditorRoleName, authtypes.SigNozViewerRoleName), handler.OpenAPIDef{
|
||||
if err := router.Handle("/api/v2/zeus/hosts", handler.New(provider.authzMiddleware.ViewAccess(provider.zeusHandler.GetHosts), handler.OpenAPIDef{
|
||||
ID: "GetHosts",
|
||||
Tags: []string{"zeus"},
|
||||
Summary: "Get host info from Zeus.",
|
||||
@@ -41,17 +39,12 @@ func (provider *provider) addZeusRoutes(router *mux.Router) error {
|
||||
SuccessStatusCode: http.StatusOK,
|
||||
ErrorStatusCodes: []int{http.StatusBadRequest, http.StatusUnauthorized, http.StatusForbidden, http.StatusNotFound},
|
||||
Deprecated: false,
|
||||
SecuritySchemes: newScopedSecuritySchemes([]string{coretypes.ResourceMetaResourceDeploymentHost.Scope(coretypes.VerbList)}),
|
||||
}, handler.WithResourceDefs(handler.BasicResourceDef{
|
||||
Resource: coretypes.ResourceMetaResourceDeploymentHost,
|
||||
Verb: coretypes.VerbList,
|
||||
Category: coretypes.ActionCategoryDataAccess,
|
||||
Selector: coretypes.WildcardSelector,
|
||||
}))).Methods(http.MethodGet).GetError(); err != nil {
|
||||
SecuritySchemes: newSecuritySchemes(types.RoleViewer),
|
||||
})).Methods(http.MethodGet).GetError(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := router.Handle("/api/v2/zeus/hosts", handler.New(provider.authzMiddleware.CheckResources(provider.zeusHandler.PutHost, authtypes.SigNozAdminRoleName), handler.OpenAPIDef{
|
||||
if err := router.Handle("/api/v2/zeus/hosts", handler.New(provider.authzMiddleware.AdminAccess(provider.zeusHandler.PutHost), handler.OpenAPIDef{
|
||||
ID: "PutHost",
|
||||
Tags: []string{"zeus"},
|
||||
Summary: "Put host in Zeus for a deployment.",
|
||||
@@ -63,14 +56,8 @@ func (provider *provider) addZeusRoutes(router *mux.Router) error {
|
||||
SuccessStatusCode: http.StatusNoContent,
|
||||
ErrorStatusCodes: []int{http.StatusBadRequest, http.StatusUnauthorized, http.StatusForbidden, http.StatusNotFound, http.StatusConflict},
|
||||
Deprecated: false,
|
||||
SecuritySchemes: newScopedSecuritySchemes([]string{coretypes.ResourceMetaResourceDeploymentHost.Scope(coretypes.VerbUpdate)}),
|
||||
}, handler.WithResourceDefs(handler.BasicResourceDef{
|
||||
Resource: coretypes.ResourceMetaResourceDeploymentHost,
|
||||
Verb: coretypes.VerbUpdate,
|
||||
Category: coretypes.ActionCategoryConfigurationChange,
|
||||
ID: coretypes.BodyJSONPath("name"),
|
||||
Selector: coretypes.WildcardSelector,
|
||||
}))).Methods(http.MethodPut).GetError(); err != nil {
|
||||
SecuritySchemes: newSecuritySchemes(types.RoleAdmin),
|
||||
})).Methods(http.MethodPut).GetError(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
|
||||
@@ -53,6 +53,9 @@ type AttachDetachSiblingResourceDef struct {
|
||||
TargetResource coretypes.Resource
|
||||
TargetIDs coretypes.ResourceIDsExtractor
|
||||
TargetSelector coretypes.SelectorFunc
|
||||
// SkipIfNoIDs skips the authz checks entirely when neither source nor target
|
||||
// ids resolve — an attach/detach of nothing authorizes nothing.
|
||||
SkipIfNoIDs bool
|
||||
}
|
||||
|
||||
func (def AttachDetachSiblingResourceDef) resolveRequest(ec coretypes.ExtractorContext) []coretypes.ResolvedResource {
|
||||
@@ -67,6 +70,7 @@ func (def AttachDetachSiblingResourceDef) resolveRequest(ec coretypes.ExtractorC
|
||||
def.TargetIDs,
|
||||
def.TargetSelector,
|
||||
false,
|
||||
def.SkipIfNoIDs,
|
||||
ec,
|
||||
),
|
||||
}
|
||||
@@ -96,6 +100,7 @@ func (def AttachDetachParentChildResourceDef) resolveRequest(ec coretypes.Extrac
|
||||
def.ChildIDs,
|
||||
nil,
|
||||
true,
|
||||
false,
|
||||
ec,
|
||||
),
|
||||
}
|
||||
|
||||
@@ -123,6 +123,10 @@ func (middleware *Audit) emitAuditEvent(req *http.Request, writer responseCaptur
|
||||
}
|
||||
|
||||
resource.ResolveResponse(extractorCtx)
|
||||
|
||||
if resource.Skip() {
|
||||
continue
|
||||
}
|
||||
verb, category := resource.Verb(), resource.Category()
|
||||
|
||||
switch typed := resource.(type) {
|
||||
|
||||
@@ -186,6 +186,10 @@ func (middleware *AuthZ) CheckResources(next http.HandlerFunc, roles ...string)
|
||||
return
|
||||
}
|
||||
|
||||
if resource.Skip() {
|
||||
continue
|
||||
}
|
||||
|
||||
if err := middleware.checkResource(ctx, claims, orgID, resource.Verb(), resource.SourceResource(), resource.SourceIDs(), resource.SourceSelector(), roleSelectors); err != nil {
|
||||
render.Error(rw, err)
|
||||
return
|
||||
|
||||
@@ -439,7 +439,7 @@ func (aH *APIHandler) RegisterRoutes(router *mux.Router, am *middleware.AuthZ) {
|
||||
router.HandleFunc("/api/v2/traces/fields", am.EditAccess(aH.updateTraceField)).Methods(http.MethodPost)
|
||||
|
||||
router.HandleFunc("/api/v1/version", am.OpenAccess(aH.getVersion)).Methods(http.MethodGet)
|
||||
router.HandleFunc("/api/v1/features", am.OpenAccess(aH.getFeatureFlags)).Methods(http.MethodGet)
|
||||
router.HandleFunc("/api/v1/features", am.ViewAccess(aH.getFeatureFlags)).Methods(http.MethodGet)
|
||||
router.HandleFunc("/api/v1/health", am.OpenAccess(aH.getHealth)).Methods(http.MethodGet)
|
||||
|
||||
router.HandleFunc("/api/v1/listErrors", am.ViewAccess(aH.listErrors)).Methods(http.MethodPost)
|
||||
@@ -1497,7 +1497,7 @@ func (aH *APIHandler) getFeatureFlags(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
claims, err := authtypes.ClaimsFromContext(r.Context())
|
||||
if err != nil {
|
||||
aH.HandleError(w, err, http.StatusUnauthorized)
|
||||
aH.HandleError(w, err, http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
|
||||
@@ -43,7 +43,7 @@ func ExistsExpression(columns []*schema.Column, key *telemetrytypes.TelemetryFie
|
||||
if len(evolutionsEntries) > 0 && evolutionsEntries[0] != nil {
|
||||
columnName = evolutionsEntries[0].ColumnName
|
||||
}
|
||||
rawPath := fmt.Sprintf("%s.%s", columnName, ClickHouseIdentifier(key.Name))
|
||||
rawPath := fmt.Sprintf("%s.`%s`", columnName, key.Name)
|
||||
if exists {
|
||||
return rawPath + " IS NOT NULL", nil
|
||||
}
|
||||
|
||||
@@ -107,8 +107,8 @@ func SynthesizeKeys(field *telemetrytypes.TelemetryFieldKey, value any) []*telem
|
||||
fieldContext = telemetrytypes.FieldContextAttribute
|
||||
}
|
||||
fieldDataType := field.FieldDataType
|
||||
// Resource and scope values are strings; pin the type so operand coercion applies.
|
||||
if (fieldContext == telemetrytypes.FieldContextResource || fieldContext == telemetrytypes.FieldContextScope) &&
|
||||
// Resource values are strings; pin the type so operand coercion applies.
|
||||
if fieldContext == telemetrytypes.FieldContextResource &&
|
||||
fieldDataType == telemetrytypes.FieldDataTypeUnspecified {
|
||||
fieldDataType = telemetrytypes.FieldDataTypeString
|
||||
}
|
||||
|
||||
@@ -56,17 +56,6 @@ func QueryStringToKeysSelectors(query string) []*telemetrytypes.FieldKeySelector
|
||||
FieldDataType: key.FieldDataType,
|
||||
})
|
||||
}
|
||||
|
||||
// todo(tushar): consider reverting changes done to this method in below PR to avoid scope specific checks
|
||||
// https://github.com/SigNoz/signoz/issues/11374
|
||||
if key.FieldContext == telemetrytypes.FieldContextScope {
|
||||
keys = append(keys, &telemetrytypes.FieldKeySelector{
|
||||
Name: key.FieldContext.StringValue() + "." + key.Name,
|
||||
Signal: key.Signal,
|
||||
FieldContext: telemetrytypes.FieldContextUnspecified, // this allows 'scope.' prefix for keys with other context as well
|
||||
FieldDataType: key.FieldDataType,
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -72,44 +72,6 @@ func TestQueryToKeys(t *testing.T) {
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
query: `scope.version = '1.0.0'`,
|
||||
expectedKeys: []telemetrytypes.FieldKeySelector{
|
||||
{
|
||||
Name: "version",
|
||||
Signal: telemetrytypes.SignalUnspecified,
|
||||
FieldContext: telemetrytypes.FieldContextScope,
|
||||
FieldDataType: telemetrytypes.FieldDataTypeUnspecified,
|
||||
},
|
||||
{
|
||||
Name: "scope.version",
|
||||
Signal: telemetrytypes.SignalUnspecified,
|
||||
FieldContext: telemetrytypes.FieldContextUnspecified,
|
||||
FieldDataType: telemetrytypes.FieldDataTypeUnspecified,
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
// A scope attribute whose own name carries a `scope.` prefix. `scope.prefixed`
|
||||
// normalizes to {prefixed, scope}; the second selector re-adds the prefix so the
|
||||
// metadata fetch can target the attribute's exact key `scope.prefixed` rather than
|
||||
// relying on the broad `%prefixed%` match.
|
||||
query: `scope.prefixed = 'x'`,
|
||||
expectedKeys: []telemetrytypes.FieldKeySelector{
|
||||
{
|
||||
Name: "prefixed",
|
||||
Signal: telemetrytypes.SignalUnspecified,
|
||||
FieldContext: telemetrytypes.FieldContextScope,
|
||||
FieldDataType: telemetrytypes.FieldDataTypeUnspecified,
|
||||
},
|
||||
{
|
||||
Name: "scope.prefixed",
|
||||
Signal: telemetrytypes.SignalUnspecified,
|
||||
FieldContext: telemetrytypes.FieldContextUnspecified,
|
||||
FieldDataType: telemetrytypes.FieldDataTypeUnspecified,
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
for _, testCase := range testCases {
|
||||
|
||||
@@ -244,7 +244,6 @@ func NewSQLMigrationProviderFactories(
|
||||
sqlmigration.NewDeleteOrphanUserRolesFactory(),
|
||||
sqlmigration.NewMigrateLambdaDashboardsFactory(),
|
||||
sqlmigration.NewAddAuthDomainTuplesFactory(sqlstore),
|
||||
sqlmigration.NewAddDeploymentHostTuplesFactory(sqlstore),
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -1,161 +0,0 @@
|
||||
package sqlmigration
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"time"
|
||||
|
||||
"github.com/SigNoz/signoz/pkg/factory"
|
||||
"github.com/SigNoz/signoz/pkg/sqlstore"
|
||||
"github.com/SigNoz/signoz/pkg/types/authtypes"
|
||||
"github.com/SigNoz/signoz/pkg/types/coretypes"
|
||||
"github.com/oklog/ulid/v2"
|
||||
"github.com/uptrace/bun"
|
||||
"github.com/uptrace/bun/dialect"
|
||||
"github.com/uptrace/bun/migrate"
|
||||
)
|
||||
|
||||
type addDeploymentHostTuples struct {
|
||||
sqlstore sqlstore.SQLStore
|
||||
}
|
||||
|
||||
func NewAddDeploymentHostTuplesFactory(sqlstore sqlstore.SQLStore) factory.ProviderFactory[SQLMigration, Config] {
|
||||
return factory.NewProviderFactory(factory.MustNewName("add_deployment_host_tuples"), func(ctx context.Context, ps factory.ProviderSettings, c Config) (SQLMigration, error) {
|
||||
return &addDeploymentHostTuples{sqlstore: sqlstore}, nil
|
||||
})
|
||||
}
|
||||
|
||||
func (migration *addDeploymentHostTuples) Register(migrations *migrate.Migrations) error {
|
||||
return migrations.Register(migration.Up, migration.Down)
|
||||
}
|
||||
|
||||
func (migration *addDeploymentHostTuples) Up(ctx context.Context, db *bun.DB) error {
|
||||
tx, err := db.BeginTx(ctx, nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer func() { _ = tx.Rollback() }()
|
||||
|
||||
var storeID string
|
||||
err = tx.QueryRowContext(ctx, `SELECT id FROM store WHERE name = ? LIMIT 1`, "signoz").Scan(&storeID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
var orgIDs []string
|
||||
err = tx.NewSelect().
|
||||
Table("organizations").
|
||||
Column("id").
|
||||
Scan(ctx, &orgIDs)
|
||||
if err != nil && err != sql.ErrNoRows {
|
||||
return err
|
||||
}
|
||||
|
||||
isPG := migration.sqlstore.BunDB().Dialect().Name() == dialect.PG
|
||||
|
||||
// zeus hosts moved from the legacy ViewAccess/AdminAccess role gates to
|
||||
// CheckResources, which on enterprise requires real tuples -- existing orgs
|
||||
// never had these written, only new orgs get them from the registry at bootstrap.
|
||||
tuples := []migrationTuple{
|
||||
{authtypes.SigNozAdminRoleName, "metaresource", "deployment-host", "list"},
|
||||
{authtypes.SigNozAdminRoleName, "metaresource", "deployment-host", "update"},
|
||||
{authtypes.SigNozEditorRoleName, "metaresource", "deployment-host", "list"},
|
||||
{authtypes.SigNozViewerRoleName, "metaresource", "deployment-host", "list"},
|
||||
}
|
||||
|
||||
for _, orgID := range orgIDs {
|
||||
for _, tuple := range tuples {
|
||||
entropy := ulid.DefaultEntropy()
|
||||
now := time.Now().UTC()
|
||||
tupleID := ulid.MustNew(ulid.Timestamp(now), entropy).String()
|
||||
|
||||
objectID := "organization/" + orgID + "/" + tuple.objectName + "/*"
|
||||
roleSubject := "organization/" + orgID + "/role/" + tuple.roleName
|
||||
|
||||
if isPG {
|
||||
user := "role:" + roleSubject + "#assignee"
|
||||
result, err := tx.ExecContext(ctx, `
|
||||
INSERT INTO tuple (store, object_type, object_id, relation, _user, user_type, ulid, inserted_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
|
||||
ON CONFLICT (store, object_type, object_id, relation, _user) DO NOTHING`,
|
||||
storeID, tuple.objectType, objectID, tuple.relation, user, "userset", tupleID, now,
|
||||
)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
rowsAffected, err := result.RowsAffected()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if rowsAffected == 0 {
|
||||
continue
|
||||
}
|
||||
_, err = tx.ExecContext(ctx, `
|
||||
INSERT INTO changelog (store, object_type, object_id, relation, _user, operation, ulid, inserted_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
|
||||
ON CONFLICT (store, ulid, object_type) DO NOTHING`,
|
||||
storeID, tuple.objectType, objectID, tuple.relation, user, 0, tupleID, now,
|
||||
)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
} else {
|
||||
result, err := tx.ExecContext(ctx, `
|
||||
INSERT INTO tuple (store, object_type, object_id, relation, user_object_type, user_object_id, user_relation, user_type, ulid, inserted_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
ON CONFLICT (store, object_type, object_id, relation, user_object_type, user_object_id, user_relation) DO NOTHING`,
|
||||
storeID, tuple.objectType, objectID, tuple.relation, "role", roleSubject, "assignee", "userset", tupleID, now,
|
||||
)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
rowsAffected, err := result.RowsAffected()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if rowsAffected == 0 {
|
||||
continue
|
||||
}
|
||||
_, err = tx.ExecContext(ctx, `
|
||||
INSERT INTO changelog (store, object_type, object_id, relation, user_object_type, user_object_id, user_relation, operation, ulid, inserted_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
ON CONFLICT (store, ulid, object_type) DO NOTHING`,
|
||||
storeID, tuple.objectType, objectID, tuple.relation, "role", roleSubject, "assignee", 0, tupleID, now,
|
||||
)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
managedRoleGroups := make(map[string]string, len(coretypes.ManagedRoleToTransactions))
|
||||
for roleName, transactions := range coretypes.ManagedRoleToTransactions {
|
||||
data, err := json.Marshal(authtypes.NewTransactionGroupsFromTransactions(transactions))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
managedRoleGroups[roleName] = string(data)
|
||||
}
|
||||
|
||||
for _, orgID := range orgIDs {
|
||||
for roleName, data := range managedRoleGroups {
|
||||
if _, err := tx.NewUpdate().
|
||||
Model(new(roles)).
|
||||
Set("transaction_groups = ?", data).
|
||||
Where("org_id = ?", orgID).
|
||||
Where("type = ?", authtypes.RoleTypeManaged.StringValue()).
|
||||
Where("name = ?", roleName).
|
||||
Exec(ctx); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return tx.Commit()
|
||||
}
|
||||
|
||||
func (migration *addDeploymentHostTuples) Down(context.Context, *bun.DB) error {
|
||||
return nil
|
||||
}
|
||||
@@ -204,15 +204,31 @@ func getKeySelectors(query qbtypes.QueryBuilderQuery[qbtypes.TraceAggregation])
|
||||
}
|
||||
|
||||
for idx := range query.GroupBy {
|
||||
keySelectors = append(keySelectors, keySelectorsForField(query.GroupBy[idx].TelemetryFieldKey)...)
|
||||
groupBy := query.GroupBy[idx]
|
||||
keySelectors = append(keySelectors, &telemetrytypes.FieldKeySelector{
|
||||
Name: groupBy.Name,
|
||||
Signal: telemetrytypes.SignalTraces,
|
||||
FieldContext: groupBy.FieldContext,
|
||||
FieldDataType: groupBy.FieldDataType,
|
||||
})
|
||||
}
|
||||
|
||||
for idx := range query.SelectFields {
|
||||
keySelectors = append(keySelectors, keySelectorsForField(query.SelectFields[idx])...)
|
||||
keySelectors = append(keySelectors, &telemetrytypes.FieldKeySelector{
|
||||
Name: query.SelectFields[idx].Name,
|
||||
Signal: telemetrytypes.SignalTraces,
|
||||
FieldContext: query.SelectFields[idx].FieldContext,
|
||||
FieldDataType: query.SelectFields[idx].FieldDataType,
|
||||
})
|
||||
}
|
||||
|
||||
for idx := range query.Order {
|
||||
keySelectors = append(keySelectors, keySelectorsForField(query.Order[idx].Key.TelemetryFieldKey)...)
|
||||
keySelectors = append(keySelectors, &telemetrytypes.FieldKeySelector{
|
||||
Name: query.Order[idx].Key.Name,
|
||||
Signal: telemetrytypes.SignalTraces,
|
||||
FieldContext: query.Order[idx].Key.FieldContext,
|
||||
FieldDataType: query.Order[idx].Key.FieldDataType,
|
||||
})
|
||||
}
|
||||
|
||||
for idx := range keySelectors {
|
||||
@@ -223,26 +239,6 @@ func getKeySelectors(query qbtypes.QueryBuilderQuery[qbtypes.TraceAggregation])
|
||||
return keySelectors
|
||||
}
|
||||
|
||||
func keySelectorsForField(key telemetrytypes.TelemetryFieldKey) []*telemetrytypes.FieldKeySelector {
|
||||
selectors := []*telemetrytypes.FieldKeySelector{
|
||||
{
|
||||
Name: key.Name,
|
||||
Signal: telemetrytypes.SignalTraces,
|
||||
FieldContext: key.FieldContext,
|
||||
FieldDataType: key.FieldDataType,
|
||||
},
|
||||
}
|
||||
if key.FieldContext != telemetrytypes.FieldContextUnspecified {
|
||||
selectors = append(selectors, &telemetrytypes.FieldKeySelector{
|
||||
Name: key.FieldContext.StringValue() + "." + key.Name,
|
||||
Signal: telemetrytypes.SignalTraces,
|
||||
FieldContext: telemetrytypes.FieldContextUnspecified,
|
||||
FieldDataType: key.FieldDataType,
|
||||
})
|
||||
}
|
||||
return selectors
|
||||
}
|
||||
|
||||
// mergeDeprecatedTraceKeys prepends deprecated intrinsic/calculated trace field
|
||||
// definitions to the keys map. We do this during statement building, not at
|
||||
// metadata fetch time, because:
|
||||
@@ -314,14 +310,20 @@ func adjustTraceKey(key *telemetrytypes.TelemetryFieldKey, keys map[string][]*te
|
||||
|
||||
For example: trace_id (intrinsic), response_status_code (calculated).
|
||||
*/
|
||||
// Resolve against the context-qualified name first, then the bare name since that can be instrinsic field e.g. scope.name.
|
||||
var isIntrinsicOrCalculatedField bool
|
||||
var intrinsicOrCalculatedField telemetrytypes.TelemetryFieldKey
|
||||
if key.FieldContext != telemetrytypes.FieldContextUnspecified {
|
||||
intrinsicOrCalculatedField, isIntrinsicOrCalculatedField = lookupIntrinsicOrCalculatedField(key.FieldContext.StringValue() + "." + key.Name)
|
||||
}
|
||||
if !isIntrinsicOrCalculatedField {
|
||||
intrinsicOrCalculatedField, isIntrinsicOrCalculatedField = lookupIntrinsicOrCalculatedField(key.Name)
|
||||
if _, ok := tracestelemetryschema.IntrinsicFields[key.Name]; ok {
|
||||
isIntrinsicOrCalculatedField = true
|
||||
intrinsicOrCalculatedField = tracestelemetryschema.IntrinsicFields[key.Name]
|
||||
} else if _, ok := tracestelemetryschema.CalculatedFields[key.Name]; ok {
|
||||
isIntrinsicOrCalculatedField = true
|
||||
intrinsicOrCalculatedField = tracestelemetryschema.CalculatedFields[key.Name]
|
||||
} else if _, ok := tracestelemetryschema.IntrinsicFieldsDeprecated[key.Name]; ok {
|
||||
isIntrinsicOrCalculatedField = true
|
||||
intrinsicOrCalculatedField = tracestelemetryschema.IntrinsicFieldsDeprecated[key.Name]
|
||||
} else if _, ok := tracestelemetryschema.CalculatedFieldsDeprecated[key.Name]; ok {
|
||||
isIntrinsicOrCalculatedField = true
|
||||
intrinsicOrCalculatedField = tracestelemetryschema.CalculatedFieldsDeprecated[key.Name]
|
||||
}
|
||||
|
||||
if isIntrinsicOrCalculatedField {
|
||||
@@ -333,24 +335,6 @@ func adjustTraceKey(key *telemetrytypes.TelemetryFieldKey, keys map[string][]*te
|
||||
return actions
|
||||
}
|
||||
|
||||
// lookupIntrinsicOrCalculatedField returns the intrinsic or calculated field registered under
|
||||
// name, across the current and deprecated tables.
|
||||
func lookupIntrinsicOrCalculatedField(name string) (telemetrytypes.TelemetryFieldKey, bool) {
|
||||
if f, ok := tracestelemetryschema.IntrinsicFields[name]; ok {
|
||||
return f, true
|
||||
}
|
||||
if f, ok := tracestelemetryschema.CalculatedFields[name]; ok {
|
||||
return f, true
|
||||
}
|
||||
if f, ok := tracestelemetryschema.IntrinsicFieldsDeprecated[name]; ok {
|
||||
return f, true
|
||||
}
|
||||
if f, ok := tracestelemetryschema.CalculatedFieldsDeprecated[name]; ok {
|
||||
return f, true
|
||||
}
|
||||
return telemetrytypes.TelemetryFieldKey{}, false
|
||||
}
|
||||
|
||||
// buildListQuery builds a query for list panel type.
|
||||
func (b *traceQueryStatementBuilder) buildListQuery(
|
||||
ctx context.Context,
|
||||
|
||||
@@ -374,94 +374,6 @@ func TestStatementBuilder(t *testing.T) {
|
||||
},
|
||||
expectedErr: nil,
|
||||
},
|
||||
{
|
||||
name: "scope.name filter and group by",
|
||||
requestType: qbtypes.RequestTypeTimeSeries,
|
||||
query: qbtypes.QueryBuilderQuery[qbtypes.TraceAggregation]{
|
||||
Signal: telemetrytypes.SignalTraces,
|
||||
StepInterval: qbtypes.Step{Duration: 30 * time.Second},
|
||||
Aggregations: []qbtypes.TraceAggregation{
|
||||
{
|
||||
Expression: "count()",
|
||||
},
|
||||
},
|
||||
Filter: &qbtypes.Filter{
|
||||
Expression: "scope.name = 'opentelemetry-io'",
|
||||
},
|
||||
Limit: 10,
|
||||
GroupBy: []qbtypes.GroupByKey{
|
||||
{
|
||||
TelemetryFieldKey: telemetrytypes.TelemetryFieldKey{
|
||||
Name: "scope.name",
|
||||
FieldContext: telemetrytypes.FieldContextScope,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
expected: qbtypes.Statement{
|
||||
Query: "WITH __limit_cte AS (SELECT toString(multiIf(scope.name::String <> '', scope.name::String, NULL)) AS `__GROUP_BY_KEY_0_scope.name`, count() AS __result_0 FROM signoz_traces.distributed_signoz_index_v3 WHERE (scope.name::String = ? AND scope.name::String <> '') AND timestamp >= ? AND timestamp < ? AND ts_bucket_start >= ? AND ts_bucket_start <= ? GROUP BY `__GROUP_BY_KEY_0_scope.name` ORDER BY __result_0 DESC LIMIT ?) SELECT toStartOfInterval(timestamp, INTERVAL 30 SECOND) AS ts, toString(multiIf(scope.name::String <> '', scope.name::String, NULL)) AS `__GROUP_BY_KEY_0_scope.name`, count() AS __result_0 FROM signoz_traces.distributed_signoz_index_v3 WHERE (scope.name::String = ? AND scope.name::String <> '') AND timestamp >= ? AND timestamp < ? AND ts_bucket_start >= ? AND ts_bucket_start <= ? AND (`__GROUP_BY_KEY_0_scope.name`) GLOBAL IN (SELECT `__GROUP_BY_KEY_0_scope.name` FROM __limit_cte) GROUP BY ts, `__GROUP_BY_KEY_0_scope.name`",
|
||||
Args: []any{"opentelemetry-io", "1747947419000000000", "1747983448000000000", uint64(1747945619), uint64(1747983448), 10, "opentelemetry-io", "1747947419000000000", "1747983448000000000", uint64(1747945619), uint64(1747983448)},
|
||||
},
|
||||
expectedErr: nil,
|
||||
},
|
||||
{
|
||||
name: "scope.version filter with scope.name group by",
|
||||
requestType: qbtypes.RequestTypeTimeSeries,
|
||||
query: qbtypes.QueryBuilderQuery[qbtypes.TraceAggregation]{
|
||||
Signal: telemetrytypes.SignalTraces,
|
||||
StepInterval: qbtypes.Step{Duration: 30 * time.Second},
|
||||
Aggregations: []qbtypes.TraceAggregation{
|
||||
{
|
||||
Expression: "count()",
|
||||
},
|
||||
},
|
||||
Filter: &qbtypes.Filter{
|
||||
Expression: "scope.version = '1.0.0'",
|
||||
},
|
||||
Limit: 10,
|
||||
GroupBy: []qbtypes.GroupByKey{
|
||||
{
|
||||
TelemetryFieldKey: telemetrytypes.TelemetryFieldKey{
|
||||
Name: "scope.name",
|
||||
FieldContext: telemetrytypes.FieldContextScope,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
expected: qbtypes.Statement{
|
||||
Query: "WITH __limit_cte AS (SELECT toString(multiIf(scope.name::String <> '', scope.name::String, NULL)) AS `__GROUP_BY_KEY_0_scope.name`, count() AS __result_0 FROM signoz_traces.distributed_signoz_index_v3 WHERE (scope.version::String = ? AND scope.version::String <> '') AND timestamp >= ? AND timestamp < ? AND ts_bucket_start >= ? AND ts_bucket_start <= ? GROUP BY `__GROUP_BY_KEY_0_scope.name` ORDER BY __result_0 DESC LIMIT ?) SELECT toStartOfInterval(timestamp, INTERVAL 30 SECOND) AS ts, toString(multiIf(scope.name::String <> '', scope.name::String, NULL)) AS `__GROUP_BY_KEY_0_scope.name`, count() AS __result_0 FROM signoz_traces.distributed_signoz_index_v3 WHERE (scope.version::String = ? AND scope.version::String <> '') AND timestamp >= ? AND timestamp < ? AND ts_bucket_start >= ? AND ts_bucket_start <= ? AND (`__GROUP_BY_KEY_0_scope.name`) GLOBAL IN (SELECT `__GROUP_BY_KEY_0_scope.name` FROM __limit_cte) GROUP BY ts, `__GROUP_BY_KEY_0_scope.name`",
|
||||
Args: []any{"1.0.0", "1747947419000000000", "1747983448000000000", uint64(1747945619), uint64(1747983448), 10, "1.0.0", "1747947419000000000", "1747983448000000000", uint64(1747945619), uint64(1747983448)},
|
||||
},
|
||||
expectedErr: nil,
|
||||
},
|
||||
{
|
||||
name: "scope.version filter only (no scope field in group by)",
|
||||
requestType: qbtypes.RequestTypeTimeSeries,
|
||||
query: qbtypes.QueryBuilderQuery[qbtypes.TraceAggregation]{
|
||||
Signal: telemetrytypes.SignalTraces,
|
||||
StepInterval: qbtypes.Step{Duration: 30 * time.Second},
|
||||
Aggregations: []qbtypes.TraceAggregation{
|
||||
{
|
||||
Expression: "count()",
|
||||
},
|
||||
},
|
||||
Filter: &qbtypes.Filter{
|
||||
Expression: "scope.version = '1.0.0'",
|
||||
},
|
||||
Limit: 10,
|
||||
GroupBy: []qbtypes.GroupByKey{
|
||||
{
|
||||
TelemetryFieldKey: telemetrytypes.TelemetryFieldKey{
|
||||
Name: "service.name",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
expected: qbtypes.Statement{
|
||||
Query: "WITH __limit_cte AS (SELECT toString(multiIf(multiIf(resource.`service.name` IS NOT NULL, resource.`service.name`::String, mapContains(resources_string, 'service.name'), resources_string['service.name'], NULL) IS NOT NULL, multiIf(resource.`service.name` IS NOT NULL, resource.`service.name`::String, mapContains(resources_string, 'service.name'), resources_string['service.name'], NULL), NULL)) AS `__GROUP_BY_KEY_0_service.name`, count() AS __result_0 FROM signoz_traces.distributed_signoz_index_v3 WHERE (scope.version::String = ? AND scope.version::String <> '') AND timestamp >= ? AND timestamp < ? AND ts_bucket_start >= ? AND ts_bucket_start <= ? GROUP BY `__GROUP_BY_KEY_0_service.name` ORDER BY __result_0 DESC LIMIT ?) SELECT toStartOfInterval(timestamp, INTERVAL 30 SECOND) AS ts, toString(multiIf(multiIf(resource.`service.name` IS NOT NULL, resource.`service.name`::String, mapContains(resources_string, 'service.name'), resources_string['service.name'], NULL) IS NOT NULL, multiIf(resource.`service.name` IS NOT NULL, resource.`service.name`::String, mapContains(resources_string, 'service.name'), resources_string['service.name'], NULL), NULL)) AS `__GROUP_BY_KEY_0_service.name`, count() AS __result_0 FROM signoz_traces.distributed_signoz_index_v3 WHERE (scope.version::String = ? AND scope.version::String <> '') AND timestamp >= ? AND timestamp < ? AND ts_bucket_start >= ? AND ts_bucket_start <= ? AND (`__GROUP_BY_KEY_0_service.name`) GLOBAL IN (SELECT `__GROUP_BY_KEY_0_service.name` FROM __limit_cte) GROUP BY ts, `__GROUP_BY_KEY_0_service.name`",
|
||||
Args: []any{"1.0.0", "1747947419000000000", "1747983448000000000", uint64(1747945619), uint64(1747983448), 10, "1.0.0", "1747947419000000000", "1747983448000000000", uint64(1747945619), uint64(1747983448)},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
fl := flaggertest.New(t)
|
||||
@@ -888,143 +800,6 @@ func TestStatementBuilderListQueryWithCorruptData(t *testing.T) {
|
||||
},
|
||||
expectedErr: nil,
|
||||
},
|
||||
{
|
||||
name: "List query with scope filter only (no scope in select or group by)",
|
||||
requestType: qbtypes.RequestTypeRaw,
|
||||
keysMap: map[string][]*telemetrytypes.TelemetryFieldKey{
|
||||
"scope.version": {
|
||||
{
|
||||
Name: "scope.version",
|
||||
Signal: telemetrytypes.SignalTraces,
|
||||
FieldContext: telemetrytypes.FieldContextScope,
|
||||
FieldDataType: telemetrytypes.FieldDataTypeString,
|
||||
},
|
||||
},
|
||||
},
|
||||
query: qbtypes.QueryBuilderQuery[qbtypes.TraceAggregation]{
|
||||
Signal: telemetrytypes.SignalTraces,
|
||||
StepInterval: qbtypes.Step{Duration: 30 * time.Second},
|
||||
Filter: &qbtypes.Filter{
|
||||
Expression: "scope.version = '1.0.0'",
|
||||
},
|
||||
Limit: 10,
|
||||
},
|
||||
expected: qbtypes.Statement{
|
||||
Query: "SELECT timestamp AS `__SELECT_KEY_0_timestamp`, trace_id AS `__SELECT_KEY_1_trace_id`, span_id AS `__SELECT_KEY_2_span_id`, trace_state AS `__SELECT_KEY_3_trace_state`, parent_span_id AS `__SELECT_KEY_4_parent_span_id`, flags AS `__SELECT_KEY_5_flags`, name AS `__SELECT_KEY_6_name`, kind AS `__SELECT_KEY_7_kind`, kind_string AS `__SELECT_KEY_8_kind_string`, duration_nano AS `__SELECT_KEY_9_duration_nano`, status_code AS `__SELECT_KEY_10_status_code`, status_message AS `__SELECT_KEY_11_status_message`, status_code_string AS `__SELECT_KEY_12_status_code_string`, events AS `__SELECT_KEY_13_events`, links AS `__SELECT_KEY_14_links`, response_status_code AS `__SELECT_KEY_15_response_status_code`, external_http_url AS `__SELECT_KEY_16_external_http_url`, http_url AS `__SELECT_KEY_17_http_url`, external_http_method AS `__SELECT_KEY_18_external_http_method`, http_method AS `__SELECT_KEY_19_http_method`, http_host AS `__SELECT_KEY_20_http_host`, db_name AS `__SELECT_KEY_21_db_name`, db_operation AS `__SELECT_KEY_22_db_operation`, has_error AS `__SELECT_KEY_23_has_error`, is_remote AS `__SELECT_KEY_24_is_remote`, attributes_string, attributes_number, attributes_bool, resources_string FROM signoz_traces.distributed_signoz_index_v3 WHERE (scope.version::String = ? AND scope.version::String <> '') AND timestamp >= ? AND timestamp < ? AND ts_bucket_start >= ? AND ts_bucket_start <= ? LIMIT ?",
|
||||
Args: []any{"1.0.0", "1747947419000000000", "1747983448000000000", uint64(1747945619), uint64(1747983448), 10},
|
||||
},
|
||||
},
|
||||
{
|
||||
// Regression test: scope.version in selectFields with no metadata (isColumn=true filters it out)
|
||||
// must still produce scope.version::String, not scope.attributes.version::String
|
||||
name: "scope.version in selectFields only, no metadata (intrinsic field fallback)",
|
||||
requestType: qbtypes.RequestTypeRaw,
|
||||
keysMap: map[string][]*telemetrytypes.TelemetryFieldKey{},
|
||||
query: qbtypes.QueryBuilderQuery[qbtypes.TraceAggregation]{
|
||||
Signal: telemetrytypes.SignalTraces,
|
||||
StepInterval: qbtypes.Step{Duration: 30 * time.Second},
|
||||
Filter: &qbtypes.Filter{},
|
||||
SelectFields: []telemetrytypes.TelemetryFieldKey{
|
||||
{Name: "scope.version", FieldContext: telemetrytypes.FieldContextUnspecified},
|
||||
},
|
||||
Limit: 10,
|
||||
},
|
||||
expected: qbtypes.Statement{
|
||||
Query: "SELECT timestamp AS `__SELECT_KEY_0_timestamp`, trace_id AS `__SELECT_KEY_1_trace_id`, span_id AS `__SELECT_KEY_2_span_id`, multiIf(scope.version::String <> '', scope.version::String, NULL) AS `__SELECT_KEY_3_scope.version` FROM signoz_traces.distributed_signoz_index_v3 WHERE timestamp >= ? AND timestamp < ? AND ts_bucket_start >= ? AND ts_bucket_start <= ? LIMIT ?",
|
||||
Args: []any{"1747947419000000000", "1747983448000000000", uint64(1747945619), uint64(1747983448), 10},
|
||||
},
|
||||
},
|
||||
{
|
||||
// A scope attribute whose own name literally carries a `scope.` prefix (`scope.prefixed`,
|
||||
// normalized to {prefixed, scope}) resolves to that attribute in a SELECT even without a
|
||||
// filter: getKeySelectors emits the reconstructed `scope.prefixed` selector so the metadata
|
||||
// fetch surfaces it and AdjustKey recovers the full name. Without it the `scope.` prefix is
|
||||
// lost and it wrongly reads `scope.attributes.prefixed`.
|
||||
name: "scope-prefixed attribute in selectFields resolves without a filter",
|
||||
requestType: qbtypes.RequestTypeRaw,
|
||||
keysMap: map[string][]*telemetrytypes.TelemetryFieldKey{
|
||||
"scope.prefixed": {
|
||||
{
|
||||
Name: "scope.prefixed",
|
||||
Signal: telemetrytypes.SignalTraces,
|
||||
FieldContext: telemetrytypes.FieldContextScope,
|
||||
FieldDataType: telemetrytypes.FieldDataTypeString,
|
||||
},
|
||||
},
|
||||
},
|
||||
query: qbtypes.QueryBuilderQuery[qbtypes.TraceAggregation]{
|
||||
Signal: telemetrytypes.SignalTraces,
|
||||
StepInterval: qbtypes.Step{Duration: 30 * time.Second},
|
||||
Filter: &qbtypes.Filter{},
|
||||
SelectFields: []telemetrytypes.TelemetryFieldKey{
|
||||
{Name: "prefixed", FieldContext: telemetrytypes.FieldContextScope},
|
||||
},
|
||||
Limit: 10,
|
||||
},
|
||||
expected: qbtypes.Statement{
|
||||
Query: "SELECT timestamp AS `__SELECT_KEY_0_timestamp`, trace_id AS `__SELECT_KEY_1_trace_id`, span_id AS `__SELECT_KEY_2_span_id`, multiIf(scope.attributes.`scope.prefixed` IS NOT NULL, scope.attributes.`scope.prefixed`::String, NULL) AS `__SELECT_KEY_3_scope.prefixed` FROM signoz_traces.distributed_signoz_index_v3 WHERE timestamp >= ? AND timestamp < ? AND ts_bucket_start >= ? AND ts_bucket_start <= ? LIMIT ?",
|
||||
Args: []any{"1747947419000000000", "1747983448000000000", uint64(1747945619), uint64(1747983448), 10},
|
||||
},
|
||||
},
|
||||
{
|
||||
// A scope-context key whose name matches a declared scope path resolves to that
|
||||
// declared path (scope.name), not the span `name` column and not an undeclared
|
||||
// scope attribute. getTracesKeys surfaces the declared path as an intrinsic key
|
||||
// (metadata.go), which shadows the same-named span intrinsic.
|
||||
name: "scope-context name resolves to the declared scope path",
|
||||
requestType: qbtypes.RequestTypeRaw,
|
||||
keysMap: map[string][]*telemetrytypes.TelemetryFieldKey{
|
||||
"scope.name": {
|
||||
{
|
||||
Name: "scope.name",
|
||||
Signal: telemetrytypes.SignalTraces,
|
||||
FieldContext: telemetrytypes.FieldContextScope,
|
||||
FieldDataType: telemetrytypes.FieldDataTypeString,
|
||||
},
|
||||
},
|
||||
"name": {
|
||||
{
|
||||
Name: "name",
|
||||
Signal: telemetrytypes.SignalTraces,
|
||||
FieldContext: telemetrytypes.FieldContextSpan,
|
||||
FieldDataType: telemetrytypes.FieldDataTypeString,
|
||||
},
|
||||
},
|
||||
},
|
||||
query: qbtypes.QueryBuilderQuery[qbtypes.TraceAggregation]{
|
||||
Signal: telemetrytypes.SignalTraces,
|
||||
StepInterval: qbtypes.Step{Duration: 30 * time.Second},
|
||||
Filter: &qbtypes.Filter{},
|
||||
SelectFields: []telemetrytypes.TelemetryFieldKey{
|
||||
{Name: "name", FieldContext: telemetrytypes.FieldContextScope},
|
||||
},
|
||||
Limit: 10,
|
||||
},
|
||||
expected: qbtypes.Statement{
|
||||
Query: "SELECT timestamp AS `__SELECT_KEY_0_timestamp`, trace_id AS `__SELECT_KEY_1_trace_id`, span_id AS `__SELECT_KEY_2_span_id`, multiIf(scope.name::String <> '', scope.name::String, NULL) AS `__SELECT_KEY_3_name` FROM signoz_traces.distributed_signoz_index_v3 WHERE timestamp >= ? AND timestamp < ? AND ts_bucket_start >= ? AND ts_bucket_start <= ? LIMIT ?",
|
||||
Args: []any{"1747947419000000000", "1747983448000000000", uint64(1747945619), uint64(1747983448), 10},
|
||||
},
|
||||
},
|
||||
{
|
||||
// span.scope.name (span context, name "scope.name") resolves to the declared
|
||||
// scope path scope.name, not a span attribute literally named scope.name.
|
||||
name: "span-context scope.name in selectFields resolves to the declared scope path",
|
||||
requestType: qbtypes.RequestTypeRaw,
|
||||
keysMap: map[string][]*telemetrytypes.TelemetryFieldKey{},
|
||||
query: qbtypes.QueryBuilderQuery[qbtypes.TraceAggregation]{
|
||||
Signal: telemetrytypes.SignalTraces,
|
||||
StepInterval: qbtypes.Step{Duration: 30 * time.Second},
|
||||
Filter: &qbtypes.Filter{},
|
||||
SelectFields: []telemetrytypes.TelemetryFieldKey{
|
||||
{Name: "scope.name", FieldContext: telemetrytypes.FieldContextSpan},
|
||||
},
|
||||
Limit: 10,
|
||||
},
|
||||
expected: qbtypes.Statement{
|
||||
Query: "SELECT timestamp AS `__SELECT_KEY_0_timestamp`, trace_id AS `__SELECT_KEY_1_trace_id`, span_id AS `__SELECT_KEY_2_span_id`, multiIf(scope.name::String <> '', scope.name::String, NULL) AS `__SELECT_KEY_3_scope.name` FROM signoz_traces.distributed_signoz_index_v3 WHERE timestamp >= ? AND timestamp < ? AND ts_bucket_start >= ? AND ts_bucket_start <= ? LIMIT ?",
|
||||
Args: []any{"1747947419000000000", "1747983448000000000", uint64(1747945619), uint64(1747983448), 10},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
for _, c := range cases {
|
||||
|
||||
@@ -180,7 +180,7 @@ func (t *telemetryMetaStore) getTracesKeys(ctx context.Context, fieldKeySelector
|
||||
`CASE
|
||||
// WHEN tagType = 'spanfield' THEN 1
|
||||
WHEN tagType = 'resource' THEN 2
|
||||
WHEN tagType = 'scope' THEN 3
|
||||
// WHEN tagType = 'scope' THEN 3
|
||||
WHEN tagType = 'tag' THEN 4
|
||||
ELSE 5
|
||||
END as priority`,
|
||||
|
||||
@@ -391,96 +391,6 @@ func TestConditionForResourceWithEvolution(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestConditionForScopeIntrinsicFields covers the scope.name/scope.version intrinsic
|
||||
// fields against the "scope" JSON column. These are *declared* String paths on that
|
||||
// column, so a row without a scope reads as ” and never NULL: presence must be an
|
||||
// empty-string check, since "IS NOT NULL" would hold for every row. That also rules
|
||||
// out treating them as nested attribute keys under scope.attributes, which are
|
||||
// undeclared (Dynamic) paths and genuinely NULL when absent.
|
||||
func TestConditionForScopeIntrinsicFields(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
fm := NewFieldMapper(flaggertest.New(t))
|
||||
conditionBuilder := NewConditionBuilder(fm, flaggertest.New(t))
|
||||
|
||||
testCases := []struct {
|
||||
name string
|
||||
key telemetrytypes.TelemetryFieldKey
|
||||
operator qbtypes.FilterOperator
|
||||
value any
|
||||
expectedSQL string
|
||||
}{
|
||||
{
|
||||
name: "Equal - scope.name",
|
||||
key: telemetrytypes.TelemetryFieldKey{
|
||||
Name: "scope.name",
|
||||
FieldContext: telemetrytypes.FieldContextScope,
|
||||
FieldDataType: telemetrytypes.FieldDataTypeString,
|
||||
},
|
||||
operator: qbtypes.FilterOperatorEqual,
|
||||
value: "io.signoz.payment",
|
||||
expectedSQL: "(scope.name::String = ? AND scope.name::String <> '')",
|
||||
},
|
||||
{
|
||||
name: "Equal - scope.version",
|
||||
key: telemetrytypes.TelemetryFieldKey{
|
||||
Name: "scope.version",
|
||||
FieldContext: telemetrytypes.FieldContextScope,
|
||||
FieldDataType: telemetrytypes.FieldDataTypeString,
|
||||
},
|
||||
operator: qbtypes.FilterOperatorEqual,
|
||||
value: "2.3.1",
|
||||
expectedSQL: "(scope.version::String = ? AND scope.version::String <> '')",
|
||||
},
|
||||
{
|
||||
name: "Exists - scope.name",
|
||||
key: telemetrytypes.TelemetryFieldKey{
|
||||
Name: "scope.name",
|
||||
FieldContext: telemetrytypes.FieldContextScope,
|
||||
FieldDataType: telemetrytypes.FieldDataTypeString,
|
||||
},
|
||||
operator: qbtypes.FilterOperatorExists,
|
||||
value: nil,
|
||||
expectedSQL: "scope.name::String <> ''",
|
||||
},
|
||||
{
|
||||
name: "NotExists - scope.version",
|
||||
key: telemetrytypes.TelemetryFieldKey{
|
||||
Name: "scope.version",
|
||||
FieldContext: telemetrytypes.FieldContextScope,
|
||||
FieldDataType: telemetrytypes.FieldDataTypeString,
|
||||
},
|
||||
operator: qbtypes.FilterOperatorNotExists,
|
||||
value: nil,
|
||||
expectedSQL: "scope.version::String = ''",
|
||||
},
|
||||
{
|
||||
// `scope.attribute.name` (normalized to {attribute.name, scope}) addresses the scope
|
||||
// attribute named `name` — the declared `scope.name` path is never reached this way.
|
||||
name: "Equal - scope.attribute.name reaches the named scope attribute",
|
||||
key: telemetrytypes.TelemetryFieldKey{
|
||||
Name: "attribute.name",
|
||||
FieldContext: telemetrytypes.FieldContextScope,
|
||||
FieldDataType: telemetrytypes.FieldDataTypeString,
|
||||
},
|
||||
operator: qbtypes.FilterOperatorEqual,
|
||||
value: "io.signoz.checkout",
|
||||
expectedSQL: "(scope.attributes.`name`::String = ? AND scope.attributes.`name` IS NOT NULL)",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range testCases {
|
||||
sb := sqlbuilder.NewSelectBuilder()
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
conds, _, err := conditionBuilder.ConditionFor(ctx, valuer.UUID{}, 0, 0, &tc.key, map[string][]*telemetrytypes.TelemetryFieldKey{tc.key.Name: {&tc.key}}, qbtypes.ConditionBuilderOptions{}, tc.operator, tc.value, sb)
|
||||
require.NoError(t, err)
|
||||
sb.Where(conds...)
|
||||
sql, _ := sb.BuildWithFlavor(sqlbuilder.ClickHouse)
|
||||
assert.Contains(t, sql, tc.expectedSQL)
|
||||
assert.NotContains(t, sql, "scope.`scope.", "must not double-prefix the scope JSON path")
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestConditionForSynthesizedKeys covers the KeyNotFound fallback: when a
|
||||
// referenced attribute key has no metadata match, the builder synthesizes key(s) from
|
||||
// user input and queries anyway, emitting a warning instead of failing.
|
||||
@@ -504,20 +414,6 @@ func TestConditionForSynthesizedKeys(t *testing.T) {
|
||||
assert.Contains(t, args, "timeout")
|
||||
})
|
||||
|
||||
t.Run("scope context with no metadata -> scope attribute", func(t *testing.T) {
|
||||
sb := sqlbuilder.NewSelectBuilder()
|
||||
key := telemetrytypes.TelemetryFieldKey{Name: "custom.attr", FieldContext: telemetrytypes.FieldContextScope}
|
||||
conds, warnings, err := cb.ConditionFor(ctx, valuer.UUID{}, 0, 0, &key, noMatches, qbtypes.ConditionBuilderOptions{}, qbtypes.FilterOperatorEqual, "v", sb)
|
||||
assert.NoError(t, err, "an undeclared scope attribute must still be filterable")
|
||||
assert.NotEmpty(t, warnings)
|
||||
sb.Where(conds...)
|
||||
sql, _ := sb.BuildWithFlavor(sqlbuilder.ClickHouse)
|
||||
assert.Contains(t, sql, "scope.attributes.`custom.attr`")
|
||||
// `scope.` can be part of the attribute's own name, so the literal spelling is a
|
||||
// candidate too — the caller ORs the two.
|
||||
assert.Contains(t, sql, "scope.attributes.`scope.custom.attr`")
|
||||
})
|
||||
|
||||
t.Run("bare key with number operand -> attribute number", func(t *testing.T) {
|
||||
sb := sqlbuilder.NewSelectBuilder()
|
||||
key := telemetrytypes.TelemetryFieldKey{Name: "http.status"}
|
||||
|
||||
@@ -43,7 +43,6 @@ const (
|
||||
SpanAttributesStringColumn = "attributes_string"
|
||||
SpanAttributesNumberColumn = "attributes_number"
|
||||
SpanAttributesBoolColumn = "attributes_bool"
|
||||
SpanAttributesColumn = "attributes"
|
||||
SpanResourcesStringColumn = "resources_string"
|
||||
)
|
||||
|
||||
@@ -122,20 +121,6 @@ var (
|
||||
FieldContext: telemetrytypes.FieldContextSpan,
|
||||
FieldDataType: telemetrytypes.FieldDataTypeString,
|
||||
},
|
||||
"scope.name": {
|
||||
Name: "scope.name",
|
||||
Description: "Instrumentation scope name",
|
||||
Signal: telemetrytypes.SignalTraces,
|
||||
FieldContext: telemetrytypes.FieldContextScope,
|
||||
FieldDataType: telemetrytypes.FieldDataTypeString,
|
||||
},
|
||||
"scope.version": {
|
||||
Name: "scope.version",
|
||||
Description: "Instrumentation scope version",
|
||||
Signal: telemetrytypes.SignalTraces,
|
||||
FieldContext: telemetrytypes.FieldContextScope,
|
||||
FieldDataType: telemetrytypes.FieldDataTypeString,
|
||||
},
|
||||
}
|
||||
IntrinsicFieldsDeprecated = map[string]telemetrytypes.TelemetryFieldKey{
|
||||
"traceID": {
|
||||
|
||||
@@ -52,9 +52,7 @@ var (
|
||||
KeyType: schema.LowCardinalityColumnType{ElementType: schema.ColumnTypeString},
|
||||
ValueType: schema.ColumnTypeString,
|
||||
}},
|
||||
"resource": {Name: "resource", Type: schema.JSONColumnType{}},
|
||||
"scope": {Name: "scope", Type: schema.JSONColumnType{}},
|
||||
"attributes": {Name: "attributes", Type: schema.JSONColumnType{}},
|
||||
"resource": {Name: "resource", Type: schema.JSONColumnType{}},
|
||||
|
||||
"events": {Name: "events", Type: schema.ArrayColumnType{
|
||||
ElementType: schema.ColumnTypeString,
|
||||
@@ -183,34 +181,18 @@ func (m *fieldMapper) getColumn(
|
||||
case telemetrytypes.FieldContextResource:
|
||||
return []*schema.Column{indexV3Columns["resource"], indexV3Columns["resources_string"]}, nil
|
||||
case telemetrytypes.FieldContextScope:
|
||||
return []*schema.Column{indexV3Columns["scope"]}, nil
|
||||
return []*schema.Column{}, qbtypes.ErrColumnNotFound
|
||||
case telemetrytypes.FieldContextAttribute:
|
||||
// Only typed keys resolve here: a data-type-unspecified attribute key
|
||||
// falls through to ErrColumnNotFound so bare keys keep taking the legacy
|
||||
// CandidateKeys/synthesis path rather than flipping to metadata-first.
|
||||
var mapCol *schema.Column
|
||||
switch key.FieldDataType {
|
||||
case telemetrytypes.FieldDataTypeString:
|
||||
mapCol = indexV3Columns["attributes_string"]
|
||||
return []*schema.Column{indexV3Columns["attributes_string"]}, nil
|
||||
case telemetrytypes.FieldDataTypeInt64,
|
||||
telemetrytypes.FieldDataTypeFloat64,
|
||||
telemetrytypes.FieldDataTypeNumber:
|
||||
mapCol = indexV3Columns["attributes_number"]
|
||||
return []*schema.Column{indexV3Columns["attributes_number"]}, nil
|
||||
case telemetrytypes.FieldDataTypeBool:
|
||||
mapCol = indexV3Columns["attributes_bool"]
|
||||
default:
|
||||
return nil, qbtypes.ErrColumnNotFound
|
||||
return []*schema.Column{indexV3Columns["attributes_bool"]}, nil
|
||||
}
|
||||
// Dual-read from the JSON column only once the attributes evolution entry
|
||||
// is registered for this key; without it, resolution is byte-for-byte the
|
||||
// legacy Map path. The JSON column is returned first so it wins the multiIf
|
||||
// when both homes hold the key; SelectEvolutionsForColumns narrows the pair
|
||||
// by the query's time range. This makes the evolution entry the rollout
|
||||
// control, exactly as it is for resource/scope.
|
||||
if attributeJSONEvolutionRegistered(key) {
|
||||
return []*schema.Column{indexV3Columns["attributes"], mapCol}, nil
|
||||
}
|
||||
return []*schema.Column{mapCol}, nil
|
||||
case telemetrytypes.FieldContextSpan:
|
||||
// Check if this is a span scope field
|
||||
if strings.ToLower(key.Name) == SpanSearchScopeRoot || strings.ToLower(key.Name) == SpanSearchScopeEntryPoint {
|
||||
@@ -296,7 +278,6 @@ func (m *fieldMapper) resolveColumnExprs(
|
||||
return nil, nil, nil, err
|
||||
}
|
||||
|
||||
key = narrowEvolutionsToColumns(key, columns)
|
||||
newColumns, evolutionsEntries, err := qbtypes.SelectEvolutionsForColumns(columns, key.Evolutions, startNs, endNs)
|
||||
if err != nil {
|
||||
return nil, nil, nil, err
|
||||
@@ -311,36 +292,14 @@ func (m *fieldMapper) resolveColumnExprs(
|
||||
|
||||
switch column.Type.GetType() {
|
||||
case schema.ColumnTypeEnumJSON:
|
||||
// json is only supported for resource context as of now
|
||||
if key.FieldContext != telemetrytypes.FieldContextResource {
|
||||
return nil, nil, nil, errors.Newf(errors.TypeInvalidInput, errors.CodeInvalidInput, "only resource context fields are supported for json columns, got %s", key.FieldContext.String)
|
||||
}
|
||||
// have to add ::string as clickHouse throws an error :- data types Variant/Dynamic are not allowed in GROUP BY
|
||||
// once clickHouse dependency is updated, we need to check if we can remove it.
|
||||
switch key.FieldContext {
|
||||
case telemetrytypes.FieldContextResource:
|
||||
exprs = append(exprs, fmt.Sprintf("%s.`%s`::String", columnName, key.Name))
|
||||
existExprs = append(existExprs, fmt.Sprintf("%s.`%s` IS NOT NULL", columnName, key.Name))
|
||||
case telemetrytypes.FieldContextScope:
|
||||
if f, ok := IntrinsicFields[key.Name]; ok && f.FieldContext == telemetrytypes.FieldContextScope {
|
||||
// declared String paths on the scope column read '' for the missing case
|
||||
exprs = append(exprs, fmt.Sprintf("%s::String", key.Name))
|
||||
existExprs = append(existExprs, fmt.Sprintf("%s <> ''", key.Name))
|
||||
} else {
|
||||
attributeName := strings.TrimPrefix(key.Name, "attribute.") // literal "attribute" prefix in attribute keys needs double prefix
|
||||
exprs = append(exprs, fmt.Sprintf("%s.attributes.%s::String", columnName, querybuilder.ClickHouseIdentifier(attributeName)))
|
||||
existExprs = append(existExprs, fmt.Sprintf("%s.attributes.%s IS NOT NULL", columnName, querybuilder.ClickHouseIdentifier(attributeName)))
|
||||
}
|
||||
case telemetrytypes.FieldContextAttribute:
|
||||
// Span attributes are flat shared-data paths keyed by the attribute name
|
||||
// verbatim (no nested object), so the name addresses the path directly.
|
||||
// String casts to ::String so an absent path folds to '' — matching the
|
||||
// Map column's default and preserving negative-operator parity; typed
|
||||
// numeric/bool cast to Nullable so a missing or wrong-typed path reads
|
||||
// NULL rather than 0/false. Existence tests the raw path (the cast folds
|
||||
// NULL) and is index-eligible via attributes_paths_tokenbf.
|
||||
path := fmt.Sprintf("%s.%s", columnName, querybuilder.ClickHouseIdentifier(key.Name))
|
||||
exprs = append(exprs, fmt.Sprintf("%s::%s", path, attributeJSONCast(key.FieldDataType)))
|
||||
existExprs = append(existExprs, fmt.Sprintf("%s IS NOT NULL", path))
|
||||
default:
|
||||
return nil, nil, nil, errors.NewInternalf(errors.CodeInternal, "only resource and scope context fields are supported for json columns, got %s", key.FieldContext.String)
|
||||
}
|
||||
exprs = append(exprs, fmt.Sprintf("%s.`%s`::String", columnName, key.Name))
|
||||
existExprs = append(existExprs, fmt.Sprintf("%s.`%s` IS NOT NULL", columnName, key.Name))
|
||||
case schema.ColumnTypeEnumString,
|
||||
schema.ColumnTypeEnumUInt64,
|
||||
schema.ColumnTypeEnumUInt32,
|
||||
@@ -382,64 +341,18 @@ func (m *fieldMapper) resolveColumnExprs(
|
||||
return exprs, existExprs, columns, nil
|
||||
}
|
||||
|
||||
// attributeJSONEvolutionRegistered reports whether the key carries an evolution entry for the
|
||||
// `attributes` JSON column. Until that entry is registered, attribute resolution stays on the
|
||||
// legacy Map column and the JSON column is never touched.
|
||||
func attributeJSONEvolutionRegistered(key *telemetrytypes.TelemetryFieldKey) bool {
|
||||
for _, e := range key.Evolutions {
|
||||
if e != nil && e.ColumnName == SpanAttributesColumn {
|
||||
return true
|
||||
// logicalForResolvedColumn upgrades a directly-resolvable key (the FieldFor
|
||||
// probe succeeded) to its family when the metadata map proves membership;
|
||||
// otherwise the key stays a single-member logical field.
|
||||
func (m *fieldMapper) logicalForResolvedColumn(ctx context.Context, orgID valuer.UUID, field *telemetrytypes.TelemetryFieldKey, keys map[string][]*telemetrytypes.TelemetryFieldKey) *telemetrytypes.LogicalField {
|
||||
for _, logical := range querybuilder.MatchingLogicalFields(ctx, orgID, m.fl, field, keys) {
|
||||
if logical.IsFamily() &&
|
||||
logical.FieldContext == field.FieldContext &&
|
||||
(field.FieldDataType == telemetrytypes.FieldDataTypeUnspecified || logical.FieldDataType == field.FieldDataType) {
|
||||
return logical
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// narrowEvolutionsToColumns returns key.Evolutions filtered to entries whose column is in cols.
|
||||
// A metadata attribute key carries the `__all__` evolutions for every attribute-context column
|
||||
// (all three legacy maps plus the JSON column), but getColumn resolves a typed key to only its
|
||||
// own map + the JSON column; without this filter SelectEvolutionsForColumns would reject the
|
||||
// in-range sibling-map entries as columns not present in the slice. A no-op for every other
|
||||
// context, where getColumn already returns exactly the columns the evolutions name.
|
||||
func narrowEvolutionsToColumns(key *telemetrytypes.TelemetryFieldKey, cols []*schema.Column) *telemetrytypes.TelemetryFieldKey {
|
||||
if len(key.Evolutions) == 0 {
|
||||
return key
|
||||
}
|
||||
allowed := make(map[string]struct{}, len(cols))
|
||||
for _, c := range cols {
|
||||
allowed[c.Name] = struct{}{}
|
||||
}
|
||||
filtered := make([]*telemetrytypes.EvolutionEntry, 0, len(key.Evolutions))
|
||||
for _, e := range key.Evolutions {
|
||||
if e == nil {
|
||||
continue
|
||||
}
|
||||
if _, ok := allowed[e.ColumnName]; ok {
|
||||
filtered = append(filtered, e)
|
||||
}
|
||||
}
|
||||
if len(filtered) == len(key.Evolutions) {
|
||||
return key
|
||||
}
|
||||
narrowed := *key
|
||||
narrowed.Evolutions = filtered
|
||||
return &narrowed
|
||||
}
|
||||
|
||||
// attributeJSONCast returns the ClickHouse cast target for a span attribute read from the
|
||||
// JSON column. String (and data-type-unspecified) casts to non-nullable String so an absent
|
||||
// path folds to ” the way the Map column's default does, keeping negative-operator parity;
|
||||
// numeric and bool cast to Nullable so an absent or wrong-typed path reads NULL instead of a
|
||||
// spurious 0/false. GROUP BY accepts these Nullable scalar casts (unlike a raw Dynamic).
|
||||
func attributeJSONCast(dataType telemetrytypes.FieldDataType) string {
|
||||
switch dataType {
|
||||
case telemetrytypes.FieldDataTypeInt64,
|
||||
telemetrytypes.FieldDataTypeFloat64,
|
||||
telemetrytypes.FieldDataTypeNumber,
|
||||
telemetrytypes.FieldDataTypeBool:
|
||||
return fmt.Sprintf("Nullable(%s)", telemetrytypes.MappingFieldDataTypeToJSONDataType[dataType].StringValue())
|
||||
default:
|
||||
return "String"
|
||||
}
|
||||
return telemetrytypes.SingleLogicalField(field.Name, field)
|
||||
}
|
||||
|
||||
// upgradeToFamilies swaps single-member candidates for their family when the
|
||||
@@ -506,11 +419,9 @@ func (m *fieldMapper) ColumnExpressionFor(
|
||||
var candidates []*telemetrytypes.LogicalField
|
||||
switch _, err := m.FieldFor(ctx, orgID, startNs, endNs, field); {
|
||||
case err == nil:
|
||||
// Every match from metadata is kept, similar to the filter path.
|
||||
candidates = querybuilder.MatchingLogicalFields(ctx, orgID, m.fl, field, keys)
|
||||
if len(candidates) == 0 {
|
||||
candidates = []*telemetrytypes.LogicalField{telemetrytypes.SingleLogicalField(field.Name, field)}
|
||||
}
|
||||
// A directly-resolvable key upgrades to its family when the metadata
|
||||
// map proves membership; otherwise it stays single-member.
|
||||
candidates = []*telemetrytypes.LogicalField{m.logicalForResolvedColumn(ctx, orgID, field, keys)}
|
||||
case errors.Is(err, qbtypes.ErrColumnNotFound):
|
||||
// The legacy candidate flow, unchanged: column (when the bare name is
|
||||
// one) plus metadata matches, else synthesized type-variant keys. The
|
||||
@@ -605,7 +516,6 @@ func (m *fieldMapper) columnIsTemporal(ctx context.Context, startNs, endNs uint6
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
key = narrowEvolutionsToColumns(key, columns)
|
||||
newColumns, _, err := qbtypes.SelectEvolutionsForColumns(columns, key.Evolutions, startNs, endNs)
|
||||
if err != nil {
|
||||
return false, err
|
||||
@@ -685,37 +595,15 @@ func (m *fieldMapper) CandidateKeys(ctx context.Context, _ valuer.UUID, field *t
|
||||
// honored as-is: the stripped name lives in the attribute maps
|
||||
stripped := telemetrytypes.NewTelemetryFieldKey(field.Name, telemetrytypes.FieldContextUnspecified, field.FieldDataType)
|
||||
return querybuilder.SynthesizeKeys(stripped, value)
|
||||
case telemetrytypes.FieldContextAttribute, telemetrytypes.FieldContextResource, telemetrytypes.FieldContextScope:
|
||||
case telemetrytypes.FieldContextAttribute, telemetrytypes.FieldContextResource:
|
||||
// strict context honored as-is: stripped interpretation first, literal spelling second
|
||||
literal := telemetrytypes.NewTelemetryFieldKey(field.FieldContext.StringValue()+"."+field.Name, field.FieldContext, field.FieldDataType)
|
||||
return append(querybuilder.SynthesizeKeys(field, value), querybuilder.SynthesizeKeys(literal, value)...)
|
||||
}
|
||||
// contexts that don't exist on spans (log, body, …) have nothing to synthesize
|
||||
// contexts that don't exist on spans (log, body, scope, …) have nothing to synthesize
|
||||
return nil
|
||||
}
|
||||
|
||||
// scopeJSONExistsExpression renders the existence predicate for the scope JSON column, the one
|
||||
// signal-specific case the generic querybuilder.ExistsExpression must not carry.
|
||||
func scopeJSONExistsExpression(key *telemetrytypes.TelemetryFieldKey, fieldExpression string, exists bool) (string, bool) {
|
||||
if key.FieldContext != telemetrytypes.FieldContextScope {
|
||||
return "", false
|
||||
}
|
||||
// Declared String paths are non-Nullable (absent reads '' not NULL).
|
||||
if f, ok := IntrinsicFields[key.Name]; ok && f.FieldContext == telemetrytypes.FieldContextScope {
|
||||
if exists {
|
||||
return fieldExpression + " <> ''", true
|
||||
}
|
||||
return fieldExpression + " = ''", true
|
||||
}
|
||||
// Scope attribute: the value expression casts the JSON path to String, which folds a missing
|
||||
// key's NULL to '', so presence must test the raw path — drop the ::String cast.
|
||||
path := strings.TrimSuffix(fieldExpression, "::String")
|
||||
if exists {
|
||||
return path + " IS NOT NULL", true
|
||||
}
|
||||
return path + " IS NULL", true
|
||||
}
|
||||
|
||||
// ExistsFor implements the per-key existence primitive of qbtypes.FieldMapper.
|
||||
func (m *fieldMapper) ExistsFor(
|
||||
ctx context.Context,
|
||||
@@ -728,13 +616,9 @@ func (m *fieldMapper) ExistsFor(
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
key = narrowEvolutionsToColumns(key, columns)
|
||||
fieldExpression, err := m.FieldFor(ctx, orgID, tsStart, tsEnd, key)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if expr, ok := scopeJSONExistsExpression(key, fieldExpression, exists); ok {
|
||||
return expr, nil
|
||||
}
|
||||
return querybuilder.ExistsExpression(columns, key, tsStart, tsEnd, fieldExpression, exists)
|
||||
}
|
||||
|
||||
@@ -1,254 +0,0 @@
|
||||
package tracestelemetryschema
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/SigNoz/signoz/pkg/flagger/flaggertest"
|
||||
qbtypes "github.com/SigNoz/signoz/pkg/types/querybuildertypes/querybuildertypesv5"
|
||||
"github.com/SigNoz/signoz/pkg/types/telemetrytypes"
|
||||
"github.com/SigNoz/signoz/pkg/valuer"
|
||||
"github.com/huandu/go-sqlbuilder"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
var (
|
||||
attrJSONRelease = time.Date(2025, 1, 1, 0, 0, 0, 0, time.UTC)
|
||||
|
||||
attrWindowBefore = [2]uint64{tsNano(2024, 1), tsNano(2024, 6)}
|
||||
attrWindowAfter = [2]uint64{tsNano(2025, 6), tsNano(2025, 7)}
|
||||
attrWindowStraddle = [2]uint64{tsNano(2024, 6), tsNano(2025, 6)}
|
||||
)
|
||||
|
||||
func tsNano(y int, m time.Month) uint64 {
|
||||
return uint64(time.Date(y, m, 1, 0, 0, 0, 0, time.UTC).UnixNano())
|
||||
}
|
||||
|
||||
func attrKey(name string, dt telemetrytypes.FieldDataType, evo []*telemetrytypes.EvolutionEntry) telemetrytypes.TelemetryFieldKey {
|
||||
return telemetrytypes.TelemetryFieldKey{
|
||||
Name: name,
|
||||
FieldContext: telemetrytypes.FieldContextAttribute,
|
||||
FieldDataType: dt,
|
||||
Evolutions: evo,
|
||||
}
|
||||
}
|
||||
|
||||
// TestFieldForAttributeJSONEvolution asserts the value expression across the rollout window:
|
||||
// before release the legacy Map lookup (byte-for-byte today), after release the type-aware JSON
|
||||
// cast, straddling a dual-read multiIf with the JSON column first.
|
||||
func TestFieldForAttributeJSONEvolution(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
fm := NewFieldMapper(flaggertest.New(t))
|
||||
evo := MockAttributeEvolutionData(attrJSONRelease)
|
||||
|
||||
testCases := []struct {
|
||||
name string
|
||||
dataType telemetrytypes.FieldDataType
|
||||
window [2]uint64
|
||||
expected string
|
||||
}{
|
||||
{"string before -> map", telemetrytypes.FieldDataTypeString, attrWindowBefore, "attributes_string['user.id']"},
|
||||
{"string after -> json", telemetrytypes.FieldDataTypeString, attrWindowAfter, "attributes.`user.id`::String"},
|
||||
{"string straddle -> dual", telemetrytypes.FieldDataTypeString, attrWindowStraddle, "multiIf(attributes.`user.id` IS NOT NULL, attributes.`user.id`::String, mapContains(attributes_string, 'user.id'), attributes_string['user.id'], NULL)"},
|
||||
{"number before -> map", telemetrytypes.FieldDataTypeNumber, attrWindowBefore, "attributes_number['user.id']"},
|
||||
{"number after -> json", telemetrytypes.FieldDataTypeNumber, attrWindowAfter, "attributes.`user.id`::Nullable(Float64)"},
|
||||
{"number straddle -> dual", telemetrytypes.FieldDataTypeNumber, attrWindowStraddle, "multiIf(attributes.`user.id` IS NOT NULL, attributes.`user.id`::Nullable(Float64), mapContains(attributes_number, 'user.id'), attributes_number['user.id'], NULL)"},
|
||||
{"int64 after -> json", telemetrytypes.FieldDataTypeInt64, attrWindowAfter, "attributes.`user.id`::Nullable(Int64)"},
|
||||
{"bool before -> map", telemetrytypes.FieldDataTypeBool, attrWindowBefore, "attributes_bool['user.id']"},
|
||||
{"bool after -> json", telemetrytypes.FieldDataTypeBool, attrWindowAfter, "attributes.`user.id`::Nullable(Bool)"},
|
||||
{"bool straddle -> dual", telemetrytypes.FieldDataTypeBool, attrWindowStraddle, "multiIf(attributes.`user.id` IS NOT NULL, attributes.`user.id`::Nullable(Bool), mapContains(attributes_bool, 'user.id'), attributes_bool['user.id'], NULL)"},
|
||||
}
|
||||
|
||||
for _, tc := range testCases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
key := attrKey("user.id", tc.dataType, evo)
|
||||
got, err := fm.FieldFor(ctx, valuer.UUID{}, tc.window[0], tc.window[1], &key)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, tc.expected, got)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestFieldForAttributeNoEvolutionParity proves the JSON column is untouched until the evolution
|
||||
// entry is registered: a key with no evolutions resolves to the Map column for every window.
|
||||
func TestFieldForAttributeNoEvolutionParity(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
fm := NewFieldMapper(flaggertest.New(t))
|
||||
|
||||
for _, dt := range []struct {
|
||||
dataType telemetrytypes.FieldDataType
|
||||
expected string
|
||||
}{
|
||||
{telemetrytypes.FieldDataTypeString, "attributes_string['user.id']"},
|
||||
{telemetrytypes.FieldDataTypeNumber, "attributes_number['user.id']"},
|
||||
{telemetrytypes.FieldDataTypeBool, "attributes_bool['user.id']"},
|
||||
} {
|
||||
key := attrKey("user.id", dt.dataType, nil)
|
||||
got, err := fm.FieldFor(ctx, valuer.UUID{}, attrWindowAfter[0], attrWindowAfter[1], &key)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, dt.expected, got, "no evolution entry must keep the Map path")
|
||||
}
|
||||
}
|
||||
|
||||
// TestConditionForAttributeJSON asserts the emitted WHERE fragment per operator against the JSON
|
||||
// column (window fully after release). Positive operators carry the raw-path existence guard;
|
||||
// numeric comparisons keep numeric semantics; existence never tests the ::String cast.
|
||||
func TestConditionForAttributeJSON(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
fm := NewFieldMapper(flaggertest.New(t))
|
||||
cb := NewConditionBuilder(fm, flaggertest.New(t))
|
||||
evo := MockAttributeEvolutionData(attrJSONRelease)
|
||||
|
||||
testCases := []struct {
|
||||
name string
|
||||
key telemetrytypes.TelemetryFieldKey
|
||||
operator qbtypes.FilterOperator
|
||||
value any
|
||||
expected string
|
||||
}{
|
||||
{
|
||||
name: "equal string",
|
||||
key: attrKey("user.id", telemetrytypes.FieldDataTypeString, evo),
|
||||
operator: qbtypes.FilterOperatorEqual, value: "admin",
|
||||
expected: "(attributes.`user.id`::String = ? AND attributes.`user.id` IS NOT NULL)",
|
||||
},
|
||||
{
|
||||
name: "not equal string has no exists guard",
|
||||
key: attrKey("user.id", telemetrytypes.FieldDataTypeString, evo),
|
||||
operator: qbtypes.FilterOperatorNotEqual, value: "admin",
|
||||
expected: "attributes.`user.id`::String <> ?",
|
||||
},
|
||||
{
|
||||
name: "greater than number",
|
||||
key: attrKey("http.status_code", telemetrytypes.FieldDataTypeInt64, evo),
|
||||
operator: qbtypes.FilterOperatorGreaterThan, value: float64(200),
|
||||
expected: "toFloat64(attributes.`http.status_code`::Nullable(Int64)) > ?",
|
||||
},
|
||||
{
|
||||
name: "ilike string",
|
||||
key: attrKey("user.id", telemetrytypes.FieldDataTypeString, evo),
|
||||
operator: qbtypes.FilterOperatorILike, value: "%adm%",
|
||||
expected: "LOWER(attributes.`user.id`::String) LIKE LOWER(?)",
|
||||
},
|
||||
{
|
||||
name: "exists uses raw path",
|
||||
key: attrKey("user.id", telemetrytypes.FieldDataTypeString, evo),
|
||||
operator: qbtypes.FilterOperatorExists, value: nil,
|
||||
expected: "attributes.`user.id` IS NOT NULL",
|
||||
},
|
||||
{
|
||||
name: "not exists uses raw path",
|
||||
key: attrKey("user.id", telemetrytypes.FieldDataTypeString, evo),
|
||||
operator: qbtypes.FilterOperatorNotExists, value: nil,
|
||||
expected: "attributes.`user.id` IS NULL",
|
||||
},
|
||||
{
|
||||
name: "in string",
|
||||
key: attrKey("user.id", telemetrytypes.FieldDataTypeString, evo),
|
||||
operator: qbtypes.FilterOperatorIn, value: []any{"a", "b"},
|
||||
expected: "((attributes.`user.id`::String = ? OR attributes.`user.id`::String = ?) AND attributes.`user.id` IS NOT NULL)",
|
||||
},
|
||||
{
|
||||
name: "not in string has no exists guard",
|
||||
key: attrKey("user.id", telemetrytypes.FieldDataTypeString, evo),
|
||||
operator: qbtypes.FilterOperatorNotIn, value: []any{"a", "b"},
|
||||
expected: "(attributes.`user.id`::String <> ? AND attributes.`user.id`::String <> ?)",
|
||||
},
|
||||
{
|
||||
name: "between number",
|
||||
key: attrKey("latency", telemetrytypes.FieldDataTypeNumber, evo),
|
||||
operator: qbtypes.FilterOperatorBetween, value: []any{float64(1), float64(9)},
|
||||
expected: "toFloat64(attributes.`latency`::Nullable(Float64)) BETWEEN ? AND ?",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range testCases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
sb := sqlbuilder.NewSelectBuilder()
|
||||
conds, _, err := cb.ConditionFor(ctx, valuer.UUID{}, attrWindowAfter[0], attrWindowAfter[1], &tc.key,
|
||||
map[string][]*telemetrytypes.TelemetryFieldKey{tc.key.Name: {&tc.key}}, qbtypes.ConditionBuilderOptions{}, tc.operator, tc.value, sb)
|
||||
require.NoError(t, err)
|
||||
sb.Where(conds...)
|
||||
sql, _ := sb.BuildWithFlavor(sqlbuilder.ClickHouse)
|
||||
assert.Contains(t, sql, tc.expected)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestConditionForAttributeJSONNotExistsDualRead covers NOT EXISTS across both homes during the
|
||||
// dual-read window: it must AND the JSON IS NULL with NOT mapContains so a row present in either
|
||||
// home is excluded (De Morgan), including rows that predate the JSON column.
|
||||
func TestConditionForAttributeJSONNotExistsDualRead(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
fm := NewFieldMapper(flaggertest.New(t))
|
||||
cb := NewConditionBuilder(fm, flaggertest.New(t))
|
||||
evo := MockAttributeEvolutionData(attrJSONRelease)
|
||||
|
||||
key := attrKey("user.id", telemetrytypes.FieldDataTypeString, evo)
|
||||
sb := sqlbuilder.NewSelectBuilder()
|
||||
conds, _, err := cb.ConditionFor(ctx, valuer.UUID{}, attrWindowStraddle[0], attrWindowStraddle[1], &key,
|
||||
map[string][]*telemetrytypes.TelemetryFieldKey{key.Name: {&key}}, qbtypes.ConditionBuilderOptions{}, qbtypes.FilterOperatorNotExists, nil, sb)
|
||||
require.NoError(t, err)
|
||||
sb.Where(conds...)
|
||||
sql, _ := sb.BuildWithFlavor(sqlbuilder.ClickHouse)
|
||||
// the value multiIf resolves the row's home; NOT EXISTS negates the whole thing to IS NULL
|
||||
assert.Contains(t, sql, "IS NULL")
|
||||
assert.Contains(t, sql, "attributes.`user.id` IS NOT NULL")
|
||||
assert.Contains(t, sql, "mapContains(attributes_string, 'user.id')")
|
||||
}
|
||||
|
||||
// TestColumnExpressionForAttributeJSON covers group-by (coerced to String) and aggregation
|
||||
// (coerced to Float64) over a JSON attribute after release: both are exists-guarded so an absent
|
||||
// path is NULL rather than a spurious ”/0, and the numeric branch keeps its toFloat64 coercion.
|
||||
func TestColumnExpressionForAttributeJSON(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
fm := NewFieldMapper(flaggertest.New(t))
|
||||
evo := MockAttributeEvolutionData(attrJSONRelease)
|
||||
|
||||
t.Run("group by string", func(t *testing.T) {
|
||||
key := attrKey("user.id", telemetrytypes.FieldDataTypeString, evo)
|
||||
got, err := fm.ColumnExpressionFor(ctx, valuer.UUID{}, attrWindowAfter[0], attrWindowAfter[1], &key, telemetrytypes.FieldDataTypeString, nil)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, "multiIf(attributes.`user.id` IS NOT NULL, attributes.`user.id`::String, NULL)", got)
|
||||
})
|
||||
|
||||
t.Run("aggregation numeric", func(t *testing.T) {
|
||||
key := attrKey("latency", telemetrytypes.FieldDataTypeNumber, evo)
|
||||
got, err := fm.ColumnExpressionFor(ctx, valuer.UUID{}, attrWindowAfter[0], attrWindowAfter[1], &key, telemetrytypes.FieldDataTypeFloat64, nil)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, "multiIf(attributes.`latency` IS NOT NULL, toFloat64(attributes.`latency`::Nullable(Float64)), NULL)", got)
|
||||
})
|
||||
}
|
||||
|
||||
// TestAttributeJSONNoAmbiguityWarning guards against a visible regression: the JSON column is a
|
||||
// second physical home for the same logical field, not a second logical field, so a plain
|
||||
// attribute filter must not emit the "ambiguous key" warning.
|
||||
func TestAttributeJSONNoAmbiguityWarning(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
fm := NewFieldMapper(flaggertest.New(t))
|
||||
cb := NewConditionBuilder(fm, flaggertest.New(t))
|
||||
evo := MockAttributeEvolutionData(attrJSONRelease)
|
||||
|
||||
key := attrKey("user.id", telemetrytypes.FieldDataTypeString, evo)
|
||||
sb := sqlbuilder.NewSelectBuilder()
|
||||
_, warnings, err := cb.ConditionFor(ctx, valuer.UUID{}, attrWindowAfter[0], attrWindowAfter[1], &key,
|
||||
map[string][]*telemetrytypes.TelemetryFieldKey{key.Name: {&key}}, qbtypes.ConditionBuilderOptions{}, qbtypes.FilterOperatorEqual, "x", sb)
|
||||
require.NoError(t, err)
|
||||
assert.Empty(t, warnings, "a plain attribute filter must not emit an ambiguity warning")
|
||||
}
|
||||
|
||||
// TestColumnForUnspecifiedAttributeNoBranchFlip pins the branch-flip decision: a
|
||||
// data-type-unspecified attribute key resolves to no column (even with the evolution present), so
|
||||
// bare attribute keys keep taking the legacy CandidateKeys/synthesis path rather than becoming
|
||||
// metadata-first resolvable.
|
||||
func TestColumnForUnspecifiedAttributeNoBranchFlip(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
fm := NewFieldMapper(flaggertest.New(t))
|
||||
evo := MockAttributeEvolutionData(attrJSONRelease)
|
||||
|
||||
key := attrKey("user.id", telemetrytypes.FieldDataTypeUnspecified, evo)
|
||||
_, err := fm.ColumnFor(ctx, valuer.UUID{}, attrWindowAfter[0], attrWindowAfter[1], &key)
|
||||
assert.ErrorIs(t, err, qbtypes.ErrColumnNotFound)
|
||||
}
|
||||
@@ -84,45 +84,6 @@ func TestGetFieldKeyName(t *testing.T) {
|
||||
expectedResult: "multiIf(resource.`deployment.environment` IS NOT NULL, resource.`deployment.environment`::String, `resource_string_deployment$$environment_exists`, `resource_string_deployment$$environment`, NULL)",
|
||||
expectedError: nil,
|
||||
},
|
||||
{
|
||||
name: "Scope field - scope.name",
|
||||
key: telemetrytypes.TelemetryFieldKey{
|
||||
Name: "scope.name",
|
||||
FieldContext: telemetrytypes.FieldContextScope,
|
||||
},
|
||||
expectedResult: "scope.name::String",
|
||||
expectedError: nil,
|
||||
},
|
||||
{
|
||||
name: "Scope field - scope.version",
|
||||
key: telemetrytypes.TelemetryFieldKey{
|
||||
Name: "scope.version",
|
||||
FieldContext: telemetrytypes.FieldContextScope,
|
||||
},
|
||||
expectedResult: "scope.version::String",
|
||||
expectedError: nil,
|
||||
},
|
||||
{
|
||||
name: "Scope field - custom attribute",
|
||||
key: telemetrytypes.TelemetryFieldKey{
|
||||
Name: "custom.attr",
|
||||
FieldContext: telemetrytypes.FieldContextScope,
|
||||
},
|
||||
expectedResult: "scope.attributes.`custom.attr`::String",
|
||||
expectedError: nil,
|
||||
},
|
||||
{
|
||||
// `scope.attribute.name` normalizes to {attribute.name, scope}; the literal
|
||||
// `attribute.` prefix is dropped so it addresses the scope attribute named `name`
|
||||
// (which the declared `scope.name` path deliberately does not).
|
||||
name: "Scope field - attribute prefix addresses the named scope attribute",
|
||||
key: telemetrytypes.TelemetryFieldKey{
|
||||
Name: "attribute.name",
|
||||
FieldContext: telemetrytypes.FieldContextScope,
|
||||
},
|
||||
expectedResult: "scope.attributes.`name`::String",
|
||||
expectedError: nil,
|
||||
},
|
||||
{
|
||||
// Query like `attribute.attribute_string:string` should resolve to `attributes_string['attribute_string']`.
|
||||
name: "Attribute key whose name collides with contextual map column resolves as a map lookup",
|
||||
@@ -343,99 +304,3 @@ func TestColumnExpressionForTimestampAttributeCollision(t *testing.T) {
|
||||
assert.Contains(t, result, "attributes_number['timestamp']")
|
||||
})
|
||||
}
|
||||
|
||||
// TestColumnExpressionForScopeDeclaredPath covers select-side resolution of scope names that
|
||||
// collide with a declared scope path. A short name under scope context (or the bare
|
||||
// `scope.<x>` spelling that normalizes to it) names both homes and coalesces them when
|
||||
// metadata knows a same-named scope attribute, and binds to the declared path alone when it
|
||||
// does not. The full `scope.<x>` name under explicit scope context addresses the declared
|
||||
// path alone; the explicit `scope.attribute.` prefix addresses the attribute alone.
|
||||
func TestColumnExpressionForScopeDeclaredPath(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
|
||||
scopeKey := func(name string) *telemetrytypes.TelemetryFieldKey {
|
||||
return &telemetrytypes.TelemetryFieldKey{
|
||||
Name: name,
|
||||
Signal: telemetrytypes.SignalTraces,
|
||||
FieldContext: telemetrytypes.FieldContextScope,
|
||||
FieldDataType: telemetrytypes.FieldDataTypeString,
|
||||
}
|
||||
}
|
||||
declaredOnly := map[string][]*telemetrytypes.TelemetryFieldKey{
|
||||
"scope.name": {scopeKey("scope.name")},
|
||||
"scope.version": {scopeKey("scope.version")},
|
||||
}
|
||||
withAttr := map[string][]*telemetrytypes.TelemetryFieldKey{
|
||||
"scope.name": {scopeKey("scope.name")},
|
||||
"scope.version": {scopeKey("scope.version")},
|
||||
"name": {scopeKey("name")},
|
||||
"version": {scopeKey("version")},
|
||||
}
|
||||
|
||||
testCases := []struct {
|
||||
name string
|
||||
key telemetrytypes.TelemetryFieldKey
|
||||
keys map[string][]*telemetrytypes.TelemetryFieldKey
|
||||
expectedResult string
|
||||
}{
|
||||
{
|
||||
name: "short name under scope context binds to the declared path",
|
||||
key: telemetrytypes.TelemetryFieldKey{Name: "version", FieldContext: telemetrytypes.FieldContextScope},
|
||||
keys: declaredOnly,
|
||||
expectedResult: "multiIf(scope.version::String <> '', scope.version::String, NULL)",
|
||||
},
|
||||
{
|
||||
name: "full scope.version name under scope context addresses the declared path alone",
|
||||
key: telemetrytypes.TelemetryFieldKey{Name: "scope.version", FieldContext: telemetrytypes.FieldContextScope},
|
||||
keys: withAttr,
|
||||
expectedResult: "multiIf(scope.version::String <> '', scope.version::String, NULL)",
|
||||
},
|
||||
{
|
||||
name: "full scope.name name under scope context addresses the declared path alone",
|
||||
key: telemetrytypes.TelemetryFieldKey{Name: "scope.name", FieldContext: telemetrytypes.FieldContextScope},
|
||||
keys: withAttr,
|
||||
expectedResult: "multiIf(scope.name::String <> '', scope.name::String, NULL)",
|
||||
},
|
||||
{
|
||||
// `scope.attribute.name` normalizes to {attribute.name, scope}; the `attribute.`
|
||||
// prefix is dropped so it addresses the scope attribute named `name` — the only
|
||||
// way to reach it, since `scope.name` is reserved for the declared path.
|
||||
name: "attribute prefix reaches the named scope attribute",
|
||||
key: telemetrytypes.TelemetryFieldKey{Name: "attribute.name", FieldContext: telemetrytypes.FieldContextScope},
|
||||
keys: withAttr,
|
||||
expectedResult: "multiIf(scope.attributes.`name` IS NOT NULL, scope.attributes.`name`::String, NULL)",
|
||||
},
|
||||
{
|
||||
// the caller supplied the context, so `scope.` is part of the name rather than a
|
||||
// prefix to strip: this addresses a scope attribute literally named
|
||||
// `scope.testing.env`, not the attribute `testing.env`
|
||||
name: "explicit context keeps a scope-prefixed name intact",
|
||||
key: telemetrytypes.TelemetryFieldKey{Name: "scope.testing.env", FieldContext: telemetrytypes.FieldContextScope},
|
||||
keys: declaredOnly,
|
||||
expectedResult: "multiIf(scope.attributes.`scope.testing.env` IS NOT NULL, scope.attributes.`scope.testing.env`::String, NULL)",
|
||||
},
|
||||
{
|
||||
// metadata knows both homes under this name, so the short spelling coalesces
|
||||
// them instead of being rejected as ambiguous
|
||||
name: "short name coalesces a known scope attribute with the declared path",
|
||||
key: telemetrytypes.TelemetryFieldKey{Name: "name", FieldContext: telemetrytypes.FieldContextScope},
|
||||
keys: withAttr,
|
||||
expectedResult: "multiIf(scope.attributes.`name` IS NOT NULL, toString(scope.attributes.`name`::String), scope.name::String <> '', toString(scope.name::String), NULL)",
|
||||
},
|
||||
{
|
||||
name: "short version coalesces a known scope attribute with the declared path",
|
||||
key: telemetrytypes.TelemetryFieldKey{Name: "version", FieldContext: telemetrytypes.FieldContextScope},
|
||||
keys: withAttr,
|
||||
expectedResult: "multiIf(scope.attributes.`version` IS NOT NULL, toString(scope.attributes.`version`::String), scope.version::String <> '', toString(scope.version::String), NULL)",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range testCases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
fm := NewFieldMapper(flaggertest.New(t))
|
||||
result, err := fm.ColumnExpressionFor(ctx, valuer.UUID{}, 0, 0, &tc.key, telemetrytypes.FieldDataTypeUnspecified, tc.keys)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, tc.expectedResult, result)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -113,20 +113,6 @@ func BuildCompleteFieldKeyMap(releaseTime time.Time) map[string][]*telemetrytype
|
||||
FieldDataType: telemetrytypes.FieldDataTypeBool,
|
||||
},
|
||||
},
|
||||
"scope.name": {
|
||||
{
|
||||
Name: "scope.name",
|
||||
FieldContext: telemetrytypes.FieldContextScope,
|
||||
FieldDataType: telemetrytypes.FieldDataTypeString,
|
||||
},
|
||||
},
|
||||
"scope.version": {
|
||||
{
|
||||
Name: "scope.version",
|
||||
FieldContext: telemetrytypes.FieldContextScope,
|
||||
FieldDataType: telemetrytypes.FieldDataTypeString,
|
||||
},
|
||||
},
|
||||
// both spellings of an enabled semantic-convention family
|
||||
"deployment.environment.name": {
|
||||
{
|
||||
@@ -154,29 +140,6 @@ func BuildCompleteFieldKeyMap(releaseTime time.Time) map[string][]*telemetrytype
|
||||
return keysMap
|
||||
}
|
||||
|
||||
// MockAttributeEvolutionData returns the attribute-context evolution timeline: the three legacy
|
||||
// map columns at epoch 0 and the JSON `attributes` column released at releaseTime. Every entry
|
||||
// is field_name "__all__", so a typed attribute key carries all four; getColumn keeps only its
|
||||
// own map plus the JSON column and narrowEvolutionsToColumns drops the rest before selection.
|
||||
func MockAttributeEvolutionData(releaseTime time.Time) []*telemetrytypes.EvolutionEntry {
|
||||
entry := func(col, typ string, rt time.Time) *telemetrytypes.EvolutionEntry {
|
||||
return &telemetrytypes.EvolutionEntry{
|
||||
Signal: telemetrytypes.SignalTraces,
|
||||
ColumnName: col,
|
||||
ColumnType: typ,
|
||||
FieldContext: telemetrytypes.FieldContextAttribute,
|
||||
FieldName: "__all__",
|
||||
ReleaseTime: rt,
|
||||
}
|
||||
}
|
||||
return []*telemetrytypes.EvolutionEntry{
|
||||
entry("attributes_string", "Map(LowCardinality(String), String)", time.Unix(0, 0)),
|
||||
entry("attributes_number", "Map(LowCardinality(String), Float64)", time.Unix(0, 0)),
|
||||
entry("attributes_bool", "Map(LowCardinality(String), Bool)", time.Unix(0, 0)),
|
||||
entry("attributes", "JSON()", releaseTime),
|
||||
}
|
||||
}
|
||||
|
||||
// MockEvolutionData returns the canonical resource-column evolution timeline used in tests:
|
||||
// the legacy resources_string map at epoch 0 and the JSON resource column released at releaseTime.
|
||||
func MockEvolutionData(releaseTime time.Time) []*telemetrytypes.EvolutionEntry {
|
||||
|
||||
@@ -71,7 +71,6 @@ func (name Kind) Enum() []any {
|
||||
KindFactorAPIKey,
|
||||
KindLicense,
|
||||
KindSubscription,
|
||||
KindDeploymentHost,
|
||||
KindLogs,
|
||||
KindTraces,
|
||||
KindMetrics,
|
||||
|
||||
@@ -31,7 +31,6 @@ var Kinds = []Kind{
|
||||
KindFactorAPIKey,
|
||||
KindLicense,
|
||||
KindSubscription,
|
||||
KindDeploymentHost,
|
||||
KindLogs,
|
||||
KindTraces,
|
||||
KindMetrics,
|
||||
@@ -72,7 +71,6 @@ var (
|
||||
KindFactorAPIKey = MustNewKind("factor-api-key")
|
||||
KindLicense = MustNewKind("license")
|
||||
KindSubscription = MustNewKind("subscription")
|
||||
KindDeploymentHost = MustNewKind("deployment-host")
|
||||
KindLogs = MustNewKind("logs")
|
||||
KindTraces = MustNewKind("traces")
|
||||
KindMetrics = MustNewKind("metrics")
|
||||
|
||||
@@ -191,9 +191,6 @@ var ManagedRoleToTransactions = map[string][]Transaction{
|
||||
{Verb: VerbRead, Object: *MustNewObject(ResourceRef{Type: TypeMetaResource, Kind: KindTTLSetting}, WildCardSelectorString)},
|
||||
{Verb: VerbUpdate, Object: *MustNewObject(ResourceRef{Type: TypeMetaResource, Kind: KindTTLSetting}, WildCardSelectorString)},
|
||||
{Verb: VerbList, Object: *MustNewObject(ResourceRef{Type: TypeMetaResource, Kind: KindTTLSetting}, WildCardSelectorString)},
|
||||
// deployment-host — admin updates, viewer lists
|
||||
{Verb: VerbList, Object: *MustNewObject(ResourceRef{Type: TypeMetaResource, Kind: KindDeploymentHost}, WildCardSelectorString)},
|
||||
{Verb: VerbUpdate, Object: *MustNewObject(ResourceRef{Type: TypeMetaResource, Kind: KindDeploymentHost}, WildCardSelectorString)},
|
||||
// user-preference — every authenticated user can read+update their own
|
||||
{Verb: VerbRead, Object: *MustNewObject(ResourceRef{Type: TypeMetaResource, Kind: KindUserPreference}, WildCardSelectorString)},
|
||||
{Verb: VerbUpdate, Object: *MustNewObject(ResourceRef{Type: TypeMetaResource, Kind: KindUserPreference}, WildCardSelectorString)},
|
||||
@@ -286,8 +283,6 @@ var ManagedRoleToTransactions = map[string][]Transaction{
|
||||
// ttl-setting — read only (admin updates)
|
||||
{Verb: VerbRead, Object: *MustNewObject(ResourceRef{Type: TypeMetaResource, Kind: KindTTLSetting}, WildCardSelectorString)},
|
||||
{Verb: VerbList, Object: *MustNewObject(ResourceRef{Type: TypeMetaResource, Kind: KindTTLSetting}, WildCardSelectorString)},
|
||||
// deployment-host — list only (admin updates)
|
||||
{Verb: VerbList, Object: *MustNewObject(ResourceRef{Type: TypeMetaResource, Kind: KindDeploymentHost}, WildCardSelectorString)},
|
||||
// user-preference — every authenticated user can read+update their own
|
||||
{Verb: VerbRead, Object: *MustNewObject(ResourceRef{Type: TypeMetaResource, Kind: KindUserPreference}, WildCardSelectorString)},
|
||||
{Verb: VerbUpdate, Object: *MustNewObject(ResourceRef{Type: TypeMetaResource, Kind: KindUserPreference}, WildCardSelectorString)},
|
||||
@@ -346,8 +341,6 @@ var ManagedRoleToTransactions = map[string][]Transaction{
|
||||
// ttl-setting — read only
|
||||
{Verb: VerbRead, Object: *MustNewObject(ResourceRef{Type: TypeMetaResource, Kind: KindTTLSetting}, WildCardSelectorString)},
|
||||
{Verb: VerbList, Object: *MustNewObject(ResourceRef{Type: TypeMetaResource, Kind: KindTTLSetting}, WildCardSelectorString)},
|
||||
// deployment-host — list only
|
||||
{Verb: VerbList, Object: *MustNewObject(ResourceRef{Type: TypeMetaResource, Kind: KindDeploymentHost}, WildCardSelectorString)},
|
||||
// user-preference — every authenticated user can read+update their own
|
||||
{Verb: VerbRead, Object: *MustNewObject(ResourceRef{Type: TypeMetaResource, Kind: KindUserPreference}, WildCardSelectorString)},
|
||||
{Verb: VerbUpdate, Object: *MustNewObject(ResourceRef{Type: TypeMetaResource, Kind: KindUserPreference}, WildCardSelectorString)},
|
||||
|
||||
@@ -31,7 +31,6 @@ var Resources = []Resource{
|
||||
ResourceMetaResourceFactorAPIKey,
|
||||
ResourceMetaResourceLicense,
|
||||
ResourceMetaResourceSubscription,
|
||||
ResourceMetaResourceDeploymentHost,
|
||||
ResourceTelemetryResourceLogs,
|
||||
ResourceTelemetryResourceTraces,
|
||||
ResourceTelemetryResourceMetrics,
|
||||
@@ -72,7 +71,6 @@ var (
|
||||
ResourceMetaResourceFactorAPIKey = NewResourceMetaResource(KindFactorAPIKey, VerbCreate, VerbList, VerbRead, VerbUpdate, VerbDelete)
|
||||
ResourceMetaResourceLicense = NewResourceMetaResource(KindLicense)
|
||||
ResourceMetaResourceSubscription = NewResourceMetaResource(KindSubscription)
|
||||
ResourceMetaResourceDeploymentHost = NewResourceMetaResource(KindDeploymentHost, VerbList, VerbUpdate)
|
||||
ResourceTelemetryResourceLogs = NewResourceTelemetryResource(KindLogs)
|
||||
ResourceTelemetryResourceTraces = NewResourceTelemetryResource(KindTraces)
|
||||
ResourceTelemetryResourceMetrics = NewResourceTelemetryResource(KindMetrics)
|
||||
|
||||
@@ -19,6 +19,7 @@ type ResolvedResource interface {
|
||||
SourceIDs() []string
|
||||
SourceSelector() SelectorFunc
|
||||
Err() error
|
||||
Skip() bool
|
||||
ResolveResponse(ec ExtractorContext)
|
||||
hasResponsePhase() bool
|
||||
}
|
||||
|
||||
@@ -59,6 +59,10 @@ func (resolved *resolvedResource) fill(phase ExtractPhase, ec ExtractorContext)
|
||||
}
|
||||
}
|
||||
|
||||
func (resolved *resolvedResource) Skip() bool {
|
||||
return false
|
||||
}
|
||||
|
||||
func (resolved *resolvedResource) Err() error {
|
||||
return resolved.err
|
||||
}
|
||||
|
||||
@@ -12,6 +12,7 @@ type resolvedResourceWithTarget struct {
|
||||
targetExtractor ResourceIDsExtractor
|
||||
targetIDs []string
|
||||
parentChild bool
|
||||
skipIfNoIDs bool
|
||||
err error
|
||||
}
|
||||
|
||||
@@ -25,6 +26,7 @@ func NewResolvedResourceWithTarget(
|
||||
targetExtractor ResourceIDsExtractor,
|
||||
targetSelector SelectorFunc,
|
||||
parentChild bool,
|
||||
skipIfNoIDs bool,
|
||||
ec ExtractorContext,
|
||||
) ResolvedResourceWithTargetResource {
|
||||
resolved := &resolvedResourceWithTarget{
|
||||
@@ -37,6 +39,7 @@ func NewResolvedResourceWithTarget(
|
||||
targetSelector: targetSelector,
|
||||
targetExtractor: targetExtractor,
|
||||
parentChild: parentChild,
|
||||
skipIfNoIDs: skipIfNoIDs,
|
||||
}
|
||||
resolved.fill(PhaseRequest, ec)
|
||||
|
||||
@@ -69,6 +72,10 @@ func (resolved *resolvedResourceWithTarget) fill(phase ExtractPhase, ec Extracto
|
||||
}
|
||||
}
|
||||
|
||||
func (resolved *resolvedResourceWithTarget) Skip() bool {
|
||||
return resolved.skipIfNoIDs && len(resolved.sourceIDs) == 0 && len(resolved.targetIDs) == 0
|
||||
}
|
||||
|
||||
func (resolved *resolvedResourceWithTarget) Err() error {
|
||||
return resolved.err
|
||||
}
|
||||
|
||||
@@ -18,7 +18,7 @@ import (
|
||||
// - Use `scope.` prefix to explicitly indicate and enforce scope context. Example
|
||||
// - `scope.name`
|
||||
// - `scope.version`
|
||||
// - `scope.my.custom.attribute` resolves to the `my.custom.attribute` scope attribute
|
||||
// - `scope.my.custom.attribute` and `scope.attribute.my.custom.attribute` resolve to same attribute
|
||||
//
|
||||
// - Use `attribute.` to explicitly indicate and enforce attribute context. Example
|
||||
// - `attribute.http.method`
|
||||
@@ -190,7 +190,7 @@ func (FieldContext) Enum() []any {
|
||||
FieldContextSpan,
|
||||
FieldContextTrace,
|
||||
FieldContextResource,
|
||||
FieldContextScope,
|
||||
// FieldContextScope,
|
||||
FieldContextAttribute,
|
||||
// FieldContextEvent,
|
||||
FieldContextBody,
|
||||
|
||||
@@ -294,17 +294,6 @@ func TestNormalize(t *testing.T) {
|
||||
FieldDataType: FieldDataTypeString,
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "Normalize keeps a prefix that does not match the set context",
|
||||
input: TelemetryFieldKey{
|
||||
Name: "scope.name",
|
||||
FieldContext: FieldContextAttribute,
|
||||
},
|
||||
expected: TelemetryFieldKey{
|
||||
Name: "scope.name",
|
||||
FieldContext: FieldContextAttribute,
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "Normalize body field",
|
||||
input: TelemetryFieldKey{
|
||||
|
||||
24
tests/fixtures/querier.py
vendored
24
tests/fixtures/querier.py
vendored
@@ -999,8 +999,6 @@ def generate_traces_with_corrupt_metadata() -> list[Traces]:
|
||||
"cloud.provider": "integration",
|
||||
"cloud.account.id": "000",
|
||||
"trace_id": "corrupt_data",
|
||||
"scope_name": "corrupt_data",
|
||||
"scope.scope.name": "corrupt_data",
|
||||
},
|
||||
attributes={
|
||||
"net.transport": "IP.TCP",
|
||||
@@ -1009,10 +1007,7 @@ def generate_traces_with_corrupt_metadata() -> list[Traces]:
|
||||
"http.request.method": "POST",
|
||||
"http.response.status_code": "200",
|
||||
"timestamp": "corrupt_data",
|
||||
"version": "1.0.0",
|
||||
"scope.scope.version": "1.0.0",
|
||||
},
|
||||
scope={"name": "io.signoz.http.server", "version": "2.0.0"},
|
||||
),
|
||||
Traces(
|
||||
timestamp=now - timedelta(seconds=3.5),
|
||||
@@ -1032,24 +1027,12 @@ def generate_traces_with_corrupt_metadata() -> list[Traces]:
|
||||
"cloud.provider": "integration",
|
||||
"cloud.account.id": "000",
|
||||
"timestamp": "corrupt_data",
|
||||
"scope.attributes.name": "corrupt_data",
|
||||
},
|
||||
attributes={
|
||||
"db.name": "integration",
|
||||
"db.operation": "SELECT",
|
||||
"db.statement": "SELECT * FROM integration",
|
||||
"trace_d": "corrupt_data",
|
||||
"scope.attributes.version": "corrupt_data",
|
||||
},
|
||||
scope={
|
||||
"name": "io.opentelemetry.contrib.http",
|
||||
"version": "1.0.0",
|
||||
"attributes": {
|
||||
"telemetry.sdk.language": "cpp",
|
||||
"name": "not-the-real-name",
|
||||
"version": "not-the-real-version",
|
||||
"attributes": "literally-a-key-named-attributes",
|
||||
},
|
||||
},
|
||||
),
|
||||
Traces(
|
||||
@@ -1070,15 +1053,12 @@ def generate_traces_with_corrupt_metadata() -> list[Traces]:
|
||||
"cloud.provider": "integration",
|
||||
"cloud.account.id": "000",
|
||||
"duration_nano": "corrupt_data",
|
||||
"scope.scope.attributes.version": "corrupt_data",
|
||||
},
|
||||
attributes={
|
||||
"http.request.method": "PATCH",
|
||||
"http.status_code": "404",
|
||||
"id": "1",
|
||||
"scope.scope.version": "corrupt_data",
|
||||
},
|
||||
scope={"name": "io.signoz.http.client", "version": "2.0.0"},
|
||||
),
|
||||
Traces(
|
||||
timestamp=now - timedelta(seconds=1),
|
||||
@@ -1097,7 +1077,6 @@ def generate_traces_with_corrupt_metadata() -> list[Traces]:
|
||||
"host.name": "linux-001",
|
||||
"cloud.provider": "integration",
|
||||
"cloud.account.id": "001",
|
||||
"scope.scope.version": "corrupt_data",
|
||||
},
|
||||
attributes={
|
||||
"message.type": "SENT",
|
||||
@@ -1105,10 +1084,7 @@ def generate_traces_with_corrupt_metadata() -> list[Traces]:
|
||||
"messaging.message.id": "001",
|
||||
"duration_nano": "corrupt_data",
|
||||
"id": 1,
|
||||
"scope": "corrupt_data",
|
||||
"scope.attributes.name": "corrupt_data",
|
||||
},
|
||||
scope={"name": "io.signoz.messaging", "version": "3.0.0"},
|
||||
),
|
||||
]
|
||||
|
||||
|
||||
32
tests/fixtures/traces.py
vendored
32
tests/fixtures/traces.py
vendored
@@ -302,7 +302,6 @@ class Traces(ABC):
|
||||
db_operation: str
|
||||
has_error: bool
|
||||
is_remote: str
|
||||
scope_json: dict[str, Any]
|
||||
|
||||
resource: list[TracesResource]
|
||||
tag_attributes: list[TracesTagAttributes]
|
||||
@@ -328,7 +327,6 @@ class Traces(ABC):
|
||||
links: list[TracesLink] = [],
|
||||
trace_state: str = "",
|
||||
flags: np.uint32 = 0,
|
||||
scope: dict[str, Any] = {},
|
||||
resource_write_mode: Literal["legacy_only", "dual_write"] = "dual_write",
|
||||
) -> None:
|
||||
if timestamp is None:
|
||||
@@ -410,33 +408,6 @@ class Traces(ABC):
|
||||
# Calculate resource fingerprint
|
||||
self.resource_fingerprint = LogsOrTracesFingerprint(self.resources_string).calculate()
|
||||
|
||||
# Process scope mirroring the InstrumentationScope on the OTLP span.
|
||||
scope_name = scope.get("name", "")
|
||||
scope_version = scope.get("version", "")
|
||||
scope_string = {k: str(v) for k, v in scope.get("attributes", {}).items()}
|
||||
self.scope_json = {
|
||||
"name": scope_name,
|
||||
"version": scope_version,
|
||||
"attributes": scope_string,
|
||||
}
|
||||
|
||||
scope_keys = {"scope.name": scope_name, "scope.version": scope_version}
|
||||
scope_keys.update(scope_string)
|
||||
for k, v in scope_keys.items():
|
||||
if v == "":
|
||||
continue
|
||||
self.tag_attributes.append(
|
||||
TracesTagAttributes(
|
||||
timestamp=timestamp,
|
||||
tag_key=k,
|
||||
tag_type="scope",
|
||||
tag_data_type="string",
|
||||
string_value=v,
|
||||
number_value=None,
|
||||
)
|
||||
)
|
||||
self.attribute_keys.append(TracesResourceOrAttributeKeys(name=k, datatype="string", tag_type="scope"))
|
||||
|
||||
# Process attributes by type and populate custom fields
|
||||
self.attribute_string = {}
|
||||
self.attributes_number = {}
|
||||
@@ -688,7 +659,6 @@ class Traces(ABC):
|
||||
self.has_error,
|
||||
self.is_remote,
|
||||
self.resource_json,
|
||||
self.scope_json,
|
||||
],
|
||||
dtype=object,
|
||||
)
|
||||
@@ -719,7 +689,6 @@ class Traces(ABC):
|
||||
attributes=data.get("attributes", {}),
|
||||
trace_state=data.get("trace_state", ""),
|
||||
flags=data.get("flags", 0),
|
||||
scope=data.get("scope", {}),
|
||||
)
|
||||
|
||||
@classmethod
|
||||
@@ -859,7 +828,6 @@ def insert_traces_to_clickhouse(conn, traces: list[Traces]) -> None:
|
||||
"has_error",
|
||||
"is_remote",
|
||||
"resource",
|
||||
"scope",
|
||||
],
|
||||
data=[trace.np_arr() for trace in traces],
|
||||
)
|
||||
|
||||
@@ -295,14 +295,6 @@
|
||||
"list"
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "metaresource",
|
||||
"kind": "deployment-host",
|
||||
"verbs": [
|
||||
"list",
|
||||
"update"
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "metaresource",
|
||||
"kind": "user-preference",
|
||||
@@ -506,13 +498,6 @@
|
||||
"list"
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "metaresource",
|
||||
"kind": "deployment-host",
|
||||
"verbs": [
|
||||
"list"
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "metaresource",
|
||||
"kind": "user-preference",
|
||||
@@ -669,13 +654,6 @@
|
||||
"list"
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "metaresource",
|
||||
"kind": "deployment-host",
|
||||
"verbs": [
|
||||
"list"
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "metaresource",
|
||||
"kind": "user-preference",
|
||||
|
||||
@@ -28,6 +28,7 @@ _TARGET_A = "target-a-authdomain.integration.test"
|
||||
_TARGET_B = "target-b-authdomain.integration.test"
|
||||
_ADMIN_DOMAIN = "admin-crud-authdomain.integration.test"
|
||||
_ACTOR_DOMAIN = "actor-crud-authdomain.integration.test"
|
||||
_DIFF_DOMAIN = "diff-crud-authdomain.integration.test"
|
||||
|
||||
_SAML_CONFIG = {
|
||||
"kind": "saml",
|
||||
@@ -428,6 +429,75 @@ def test_update_requires_detach_on_stored_roles(
|
||||
assert response.status_code == HTTPStatus.NO_CONTENT, response.text
|
||||
|
||||
|
||||
def test_update_with_unchanged_mapping_needs_only_update(
|
||||
signoz: types.SigNoz,
|
||||
create_user_admin: types.Operation, # pylint: disable=unused-argument
|
||||
get_token: Callable[[str, str], str],
|
||||
):
|
||||
admin_token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
|
||||
actor_id = find_role_by_name(signoz, admin_token, _ACTOR_ROLE_NAME)
|
||||
|
||||
response = requests.post(
|
||||
signoz.self.host_configs["8080"].get("/api/v2/auth_domains"),
|
||||
json={
|
||||
"name": _DIFF_DOMAIN,
|
||||
"enabled": True,
|
||||
"config": _SAML_CONFIG,
|
||||
"roleMapping": {"defaultRole": "EDITOR"},
|
||||
},
|
||||
headers={"Authorization": f"Bearer {admin_token}"},
|
||||
timeout=5,
|
||||
)
|
||||
assert response.status_code == HTTPStatus.CREATED, response.text
|
||||
domain_id = response.json()["data"]["id"]
|
||||
|
||||
response = requests.put(
|
||||
signoz.self.host_configs["8080"].get(f"/api/v1/roles/{actor_id}"),
|
||||
json={
|
||||
"description": "",
|
||||
"transactionGroups": [
|
||||
transaction_group("update", "metaresource", "auth-domain", [domain_id]),
|
||||
],
|
||||
},
|
||||
headers={"Authorization": f"Bearer {admin_token}"},
|
||||
timeout=5,
|
||||
)
|
||||
assert response.status_code == HTTPStatus.NO_CONTENT, response.text
|
||||
|
||||
token = get_token(_ACTOR_EMAIL, _ACTOR_PASSWORD)
|
||||
|
||||
# The mapping is echoed back unchanged, so the attach/detach checks are
|
||||
# skipped and update alone suffices.
|
||||
response = requests.put(
|
||||
signoz.self.host_configs["8080"].get(f"/api/v2/auth_domains/{domain_id}"),
|
||||
json={
|
||||
"enabled": False,
|
||||
"config": _SAML_CONFIG,
|
||||
"roleMapping": {"defaultRole": "EDITOR"},
|
||||
},
|
||||
headers={"Authorization": f"Bearer {token}"},
|
||||
timeout=5,
|
||||
)
|
||||
assert response.status_code == HTTPStatus.NO_CONTENT, f"unchanged mapping with update only: {response.text}"
|
||||
|
||||
# Dropping the mapping attaches signoz-viewer and detaches signoz-editor,
|
||||
# neither of which the actor can do.
|
||||
response = requests.put(
|
||||
signoz.self.host_configs["8080"].get(f"/api/v2/auth_domains/{domain_id}"),
|
||||
json={"enabled": False, "config": _SAML_CONFIG},
|
||||
headers={"Authorization": f"Bearer {token}"},
|
||||
timeout=5,
|
||||
)
|
||||
assert response.status_code == HTTPStatus.FORBIDDEN, f"changed mapping without attach/detach: expected 403, got {response.status_code}: {response.text}"
|
||||
|
||||
response = requests.delete(
|
||||
signoz.self.host_configs["8080"].get(f"/api/v2/auth_domains/{domain_id}"),
|
||||
headers={"Authorization": f"Bearer {admin_token}"},
|
||||
timeout=5,
|
||||
)
|
||||
assert response.status_code == HTTPStatus.NO_CONTENT, response.text
|
||||
|
||||
|
||||
def test_instance_verbs_scoped_to_granted_domain(
|
||||
signoz: types.SigNoz,
|
||||
create_user_admin: types.Operation, # pylint: disable=unused-argument
|
||||
|
||||
@@ -1240,13 +1240,6 @@ def test_traces_list_span_scope(
|
||||
lambda x: {"duration_nano": int(x[1].duration_nano), "span_id": x[1].span_id, "timestamp": format_timestamp(x[1].timestamp), "trace_id": x[1].trace_id},
|
||||
id="select_attribute_duration_order_intrinsic",
|
||||
),
|
||||
# Case 9: filter on the intrinsic scope.version. Only x[1] should match.
|
||||
pytest.param(
|
||||
BuilderQuery(signal="traces", name="A", select_fields=[TelemetryFieldKey("timestamp")], filter_expression="scope.version = '1.0.0'", limit=1),
|
||||
HTTPStatus.OK,
|
||||
lambda x: {"span_id": x[1].span_id, "timestamp": format_timestamp(x[1].timestamp), "trace_id": x[1].trace_id},
|
||||
id="filter_scope_version",
|
||||
),
|
||||
],
|
||||
)
|
||||
def test_traces_list_with_corrupt_data(
|
||||
@@ -1290,168 +1283,6 @@ def test_traces_list_with_corrupt_data(
|
||||
assert get_rows(response)[0]["data"] == expected(traces)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"filter_expression,expected_indices",
|
||||
[
|
||||
# Intrinsic scope.name / scope.version resolve to the JSON sub-columns.
|
||||
pytest.param("scope.name = 'io.signoz.payment'", [1], id="intrinsic_scope_name"),
|
||||
pytest.param("scope.version = '2.3.1'", [0], id="intrinsic_scope_version"),
|
||||
# A scope attribute resolves against the scope JSON column's attributes.
|
||||
pytest.param("scope.telemetry.sdk.language = 'python'", [1], id="scope_attribute"),
|
||||
# A scope attribute whose own name carries a `scope.` prefix. `scope.prefixed`
|
||||
# normalizes to {prefixed, scope} and must still resolve to the attribute.
|
||||
pytest.param("scope.prefixed = 'prefixed-val'", [0], id="scope_prefixed_attribute"),
|
||||
# `env.tier` is a span attribute on span 0 and a scope attribute on
|
||||
# span 1. Unprefixed -> no explicit context, so it is checked in every
|
||||
# applicable context (attribute OR scope) and both spans match.
|
||||
pytest.param("env.tier = 'gold'", [0, 1], id="bare_cross_context"),
|
||||
# The explicit `scope.` prefix forces scope context only, so span 0's
|
||||
# span attribute is ignored — only span 1 matches.
|
||||
pytest.param("scope.env.tier = 'gold'", [1], id="scope_prefixed_cross_context"),
|
||||
# `scope.name` names both homes it can resolve to: the declared scope.name field
|
||||
# (span 0) and a same-named `name` scope attribute (span 1), the same way any
|
||||
# other name colliding across contexts unions. `scope.attribute.name` addresses
|
||||
# the attribute alone.
|
||||
pytest.param("scope.name = 'io.signoz.checkout'", [0, 1], id="scope_name_unions_attribute"),
|
||||
# The `scope.name` spelling is also a real stored key: span 2 carries a span
|
||||
# attribute literally named `scope.name`, so it matches too.
|
||||
pytest.param("scope.name = 'attr-scope-name'", [2], id="scope_name_matches_stored_spelling"),
|
||||
# The explicit `scope.attribute.` prefix addresses the scope attribute alone, without
|
||||
# the declared path. Span 1 has a `name` scope attribute = 'io.signoz.checkout'.
|
||||
pytest.param("scope.attribute.name = 'io.signoz.checkout'", [1], id="scope_attribute_name"),
|
||||
# `version` as a scope attribute: no span carries one (span 1's 4.5.6 is the declared
|
||||
# scope.version, not a scope attribute), so this matches nothing.
|
||||
pytest.param("scope.attribute.version = '4.5.6'", [], id="scope_attribute_version_none"),
|
||||
# An unprefixed `name` is checked in every applicable context: the span `name`
|
||||
# column (span 2) and a `name` scope attribute (span 1). It does not reach the
|
||||
# declared scope.name field (span 0), which only the `scope.` prefix addresses.
|
||||
pytest.param("name = 'io.signoz.checkout'", [1, 2], id="bare_name_unions_scope_attribute"),
|
||||
# A value that no resolvable key holds (scope.name/scope.version field,
|
||||
# a `name`/`version` scope attribute, or a same-named attribute/resource)
|
||||
# returns nothing.
|
||||
pytest.param("scope.version = 'corrupt_data'", [], id="scope_version_no_match"),
|
||||
pytest.param("scope.name = 'corrupt_data'", [], id="scope_name_no_match"),
|
||||
],
|
||||
)
|
||||
def test_traces_list_with_scope_filter(
|
||||
signoz: types.SigNoz,
|
||||
create_user_admin: None, # pylint: disable=unused-argument
|
||||
get_token: Callable[[str, str], str],
|
||||
insert_traces: Callable[[list[Traces]], None],
|
||||
filter_expression: str,
|
||||
expected_indices: list[int],
|
||||
) -> None:
|
||||
"""
|
||||
Setup three spans with different scope key resolution:
|
||||
- x[0]: scope.name/version 'io.signoz.checkout'/'2.3.1'; span attribute
|
||||
env.tier='gold'.
|
||||
- x[1]: scope.name/version 'io.signoz.payment'/'4.5.6'; scope attributes
|
||||
telemetry.sdk.language='python', env.tier='gold', and a `name` scope
|
||||
attribute colliding with x[0]'s scope.name value.
|
||||
- x[2]: span name 'io.signoz.checkout' (colliding with x[0]'s scope.name
|
||||
value) and a span attribute literally named `scope.name`.
|
||||
|
||||
Tests:
|
||||
- Filtering on scope.name / scope.version / a scope attribute.
|
||||
- An unprefixed key is resolved across contexts (scope checked alongside
|
||||
attribute / intrinsic), while a `scope.`-prefixed key is scope-only.
|
||||
- `scope.name`/`scope.version` name every home they resolve to: the declared JSON
|
||||
sub-column and a same-named `name`/`version` scope attribute. The explicit
|
||||
`scope.attribute.` prefix addresses the attribute alone.
|
||||
- a bare `name` reaches the span `name` column and a `name` scope attribute, but
|
||||
never the declared scope.name field.
|
||||
"""
|
||||
now = datetime.now(tz=UTC).replace(microsecond=0)
|
||||
trace_id = TraceIdGenerator.trace_id()
|
||||
span_ids = [TraceIdGenerator.span_id() for _ in range(3)]
|
||||
|
||||
traces = [
|
||||
Traces(
|
||||
timestamp=now - timedelta(seconds=4),
|
||||
duration=timedelta(seconds=2),
|
||||
trace_id=trace_id,
|
||||
span_id=span_ids[0],
|
||||
parent_span_id="",
|
||||
name="GET /checkout",
|
||||
kind=TracesKind.SPAN_KIND_SERVER,
|
||||
status_code=TracesStatusCode.STATUS_CODE_OK,
|
||||
resources={"service.name": "checkout"},
|
||||
attributes={"http.request.method": "GET", "env.tier": "gold"},
|
||||
scope={
|
||||
"name": "io.signoz.checkout",
|
||||
"version": "2.3.1",
|
||||
# a scope attribute whose own name carries a `scope.` prefix
|
||||
"attributes": {"telemetry.sdk.language": "go", "scope.prefixed": "prefixed-val"},
|
||||
},
|
||||
),
|
||||
Traces(
|
||||
timestamp=now - timedelta(seconds=2),
|
||||
duration=timedelta(seconds=1),
|
||||
trace_id=trace_id,
|
||||
span_id=span_ids[1],
|
||||
parent_span_id="",
|
||||
name="POST /pay",
|
||||
kind=TracesKind.SPAN_KIND_SERVER,
|
||||
status_code=TracesStatusCode.STATUS_CODE_OK,
|
||||
resources={"service.name": "payment"},
|
||||
attributes={"http.request.method": "POST"},
|
||||
# env.tier is a scope attribute here (cross-context with span 0);
|
||||
# `name` is a scope attribute colliding with span 0's scope.name.
|
||||
scope={
|
||||
"name": "io.signoz.payment",
|
||||
"version": "4.5.6",
|
||||
"attributes": {
|
||||
"telemetry.sdk.language": "python",
|
||||
"env.tier": "gold",
|
||||
"name": "io.signoz.checkout",
|
||||
},
|
||||
},
|
||||
),
|
||||
Traces(
|
||||
timestamp=now - timedelta(seconds=1),
|
||||
duration=timedelta(seconds=1),
|
||||
trace_id=trace_id,
|
||||
span_id=span_ids[2],
|
||||
parent_span_id="",
|
||||
# span name collides with span 0's scope.name value
|
||||
name="io.signoz.checkout",
|
||||
kind=TracesKind.SPAN_KIND_SERVER,
|
||||
status_code=TracesStatusCode.STATUS_CODE_OK,
|
||||
resources={"service.name": "probe"},
|
||||
# a span attribute named `scope.name`
|
||||
attributes={"scope.name": "attr-scope-name"},
|
||||
scope={"name": "span-gamma", "version": "9.9.9"},
|
||||
),
|
||||
]
|
||||
insert_traces(traces)
|
||||
|
||||
token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
|
||||
start_ms = int((now - timedelta(minutes=1)).timestamp() * 1000)
|
||||
end_ms = int((now + timedelta(seconds=1)).timestamp() * 1000)
|
||||
|
||||
response = make_query_request(
|
||||
signoz,
|
||||
token,
|
||||
start_ms=start_ms,
|
||||
end_ms=end_ms,
|
||||
request_type=RequestType.RAW,
|
||||
queries=[
|
||||
BuilderQuery(
|
||||
signal="traces",
|
||||
name="A",
|
||||
select_fields=[TelemetryFieldKey("timestamp")],
|
||||
filter_expression=filter_expression,
|
||||
limit=10,
|
||||
).to_dict()
|
||||
],
|
||||
)
|
||||
|
||||
assert response.status_code == HTTPStatus.OK, response.text
|
||||
got_span_ids = {row["data"]["span_id"] for row in get_rows(response)}
|
||||
expected_span_ids = {traces[i].span_id for i in expected_indices}
|
||||
assert got_span_ids == expected_span_ids
|
||||
|
||||
|
||||
@pytest.mark.parametrize("surface", ["filter", "select", "order"])
|
||||
def test_traces_list_unknown_span_context_synthesizes(
|
||||
signoz: types.SigNoz,
|
||||
|
||||
Reference in New Issue
Block a user